diff --git a/api_tests/api_test_lock_caller_caller/client/app.mjs b/api_tests/api_test_lock_caller_caller/client/app.mjs new file mode 100644 index 000000000..12473315f --- /dev/null +++ b/api_tests/api_test_lock_caller_caller/client/app.mjs @@ -0,0 +1,174 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lock_caller_caller/client/app.mjs + * @Date: 2026-09-28 00:00:00 -07:00 (1790578800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Client module for the `lockCaller.caller()` tests (#477). Schedules jobs on the + * scheduler service; each job calls a probe target, so the target sees whichever identity the job + * runs as. + * + * @module api_tests/api_test_lock_caller_caller/client/app + */ + +import { self } from "@cldmv/slothlet/runtime"; + +/** + * Schedule a job that reports who the target sees calling it, pinned to this client. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleWhoami(); + */ +export function scheduleWhoami() { + return self.scheduler.service.every(() => self.target.probe.whoami()); +} + +/** + * Same job, but the scheduler pins itself (`lockCaller`), for comparison. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleWhoamiAsService(); + */ +export function scheduleWhoamiAsService() { + return self.scheduler.service.everyAsSelf(() => self.target.probe.whoami()); +} + +/** + * Schedule a job that calls the client-only guarded route. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleGuarded(); + */ +export function scheduleGuarded() { + return self.scheduler.service.every(() => self.target.probe.guarded()); +} + +/** + * Schedule the guarded job with the scheduler pinning itself, for comparison. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleGuardedAsService(); + */ +export function scheduleGuardedAsService() { + return self.scheduler.service.everyAsSelf(() => self.target.probe.guarded()); +} + +/** + * Schedule an async job that awaits a timer before reporting who the target sees. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleAsyncWhoami(); + */ +export function scheduleAsyncWhoami() { + return self.scheduler.service.every(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return self.target.probe.whoami(); + }); +} + +/** + * Schedule a job that throws a TypeError. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleThrow(); + */ +export function scheduleThrow() { + return self.scheduler.service.every(() => { + throw new TypeError("job-boom"); + }); +} + +/** + * Schedule a job that returns its own `this` and arguments. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleThisAndArgs(); + */ +export function scheduleThisAndArgs() { + return self.scheduler.service.every(function thisAndArgs(...args) { + return { self: this, args }; + }); +} + +/** + * Schedule a job that calls the principal-gated route for the project it is fired with. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleFiles(); + */ +export function scheduleFiles() { + return self.scheduler.service.every((projectId) => self.target.probe.files(projectId)); +} + +/** + * Call the principal-gated route directly as this client. + * @param {string} projectId - Project id. + * @returns {{projectId: string}} The project marker. + * @public + * @example + * api.client.app.readFiles("p1"); + */ +export function readFiles(projectId) { + return self.target.probe.files(projectId); +} + +/** + * The principal-gated job with the scheduler pinning itself, for comparison. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleFilesAsService(); + */ +export function scheduleFilesAsService() { + return self.scheduler.service.everyAsSelf((projectId) => self.target.probe.files(projectId)); +} + +/** + * Ask the scheduler for a pinned wrapper and hand back both it and the original callback. + * @returns {{wrapper: Function, original: Function}} The wrapper and the callback it pins. + * @public + * @example + * api.client.app.pinThroughScheduler(); + */ +export function pinThroughScheduler() { + const original = () => "original"; + return { wrapper: self.scheduler.service.pin(original), original }; +} + +/** + * Call `lockCaller.caller` directly from this (ungranted) client. + * @returns {Function} The pinned wrapper (only when granted). + * @public + * @example + * api.client.app.pinDirectly(); + */ +export function pinDirectly() { + return self.slothlet.lockCaller.caller(() => 0); +} + +/** + * Fire the scheduler from inside this client, so the client is the ambient identity. + * @returns {Promise>} The jobs' results. + * @public + * @example + * await api.client.app.fireFromClient(); + */ +export function fireFromClient() { + return self.scheduler.service.fire(); +} diff --git a/api_tests/api_test_lock_caller_caller/roles/registry.mjs b/api_tests/api_test_lock_caller_caller/roles/registry.mjs new file mode 100644 index 000000000..8a075a906 --- /dev/null +++ b/api_tests/api_test_lock_caller_caller/roles/registry.mjs @@ -0,0 +1,51 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lock_caller_caller/roles/registry.mjs + * @Date: 2026-09-28 00:00:00 -07:00 (1790578800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Owner of the "roles" principal (#459) for the `lockCaller.caller()` tests (#477). + * Grants are per user, per project. + * + * @module api_tests/api_test_lock_caller_caller/roles/registry + */ + +import { self } from "@cldmv/slothlet/runtime"; + +const grants = new Map(); + +/** + * Register the "roles" principal, keyed by `ctx.user`. + * @returns {void} + * @public + * @example + * api.roles.registry.setup(); + */ +export function setup() { + self.slothlet.permissions.principal.register("roles", { + key: (ctx) => ctx.user, + resolve: async (user) => ({ projects: { ...(grants.get(user) ?? {}) } }) + }); +} + +/** + * Grant a user permissions on a project. + * @param {string} user - User id. + * @param {string} projectId - Project id. + * @param {string[]} perms - Permissions. + * @returns {void} + * @public + * @example + * api.roles.registry.grant("u1", "p1", ["read"]); + */ +export function grant(user, projectId, perms) { + grants.set(user, { ...(grants.get(user) ?? {}), [projectId]: perms }); +} diff --git a/api_tests/api_test_lock_caller_caller/scheduler/service.mjs b/api_tests/api_test_lock_caller_caller/scheduler/service.mjs new file mode 100644 index 000000000..8faa6f7d1 --- /dev/null +++ b/api_tests/api_test_lock_caller_caller/scheduler/service.mjs @@ -0,0 +1,126 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lock_caller_caller/scheduler/service.mjs + * @Date: 2026-09-28 00:00:00 -07:00 (1790578800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Scheduler-style service for the `lockCaller.caller()` tests (#477). + * + * @description + * Accepts callbacks on behalf of whoever calls it and runs them later from {@link fire}. `every` + * pins the CALLER of `every` onto the job (`self.slothlet.lockCaller.caller`); `everyAsSelf` pins the + * service itself (`self.slothlet.lockCaller`) for comparison. + * + * @module api_tests/api_test_lock_caller_caller/scheduler/service + */ + +import { self } from "@cldmv/slothlet/runtime"; + +let jobs = []; + +/** + * Store a job pinned to the identity of whoever called this leaf. + * @param {Function} job - Callback to run on {@link fire}. + * @returns {number} The number of stored jobs. + * @public + * @example + * api.scheduler.service.every(() => 1); + */ +export function every(job) { + jobs.push(self.slothlet.lockCaller.caller(job)); + return jobs.length; +} + +/** + * Store a job pinned to this service (plain `lockCaller`). + * @param {Function} job - Callback to run on {@link fire}. + * @returns {number} The number of stored jobs. + * @public + * @example + * api.scheduler.service.everyAsSelf(() => 1); + */ +export function everyAsSelf(job) { + jobs.push(self.slothlet.lockCaller(job)); + return jobs.length; +} + +/** + * Return the `lockCaller.caller()` wrapper for `job` without storing it. + * @param {Function} job - Callback to pin. + * @returns {Function} The pinned wrapper. + * @public + * @example + * api.scheduler.service.pin(() => 1); + */ +export function pin(job) { + return self.slothlet.lockCaller.caller(job); +} + +/** + * Run every stored job, sequentially, forwarding `args`. + * @param {...*} args - Arguments forwarded to each job. + * @returns {Promise>} Each job's (awaited) result. + * @public + * @example + * await api.scheduler.service.fire(); + */ +export async function fire(...args) { + const results = []; + for (const job of jobs) results.push(await job(...args)); + return results; +} + +/** + * Run every stored job concurrently, forwarding `args`. + * @param {...*} args - Arguments forwarded to each job. + * @returns {Promise>} Each job's result. + * @public + * @example + * await api.scheduler.service.fireAll(); + */ +export function fireAll(...args) { + return Promise.all(jobs.map((job) => job(...args))); +} + +/** + * Run every stored job synchronously with an explicit `this`, returning the raw results. + * @param {*} thisArg - `this` binding for each job. + * @param {...*} args - Arguments forwarded to each job. + * @returns {Array<*>} Each job's result. + * @public + * @example + * api.scheduler.service.fireWith({ tag: 1 }); + */ +export function fireWith(thisArg, ...args) { + return jobs.map((job) => job.apply(thisArg, args)); +} + +/** + * The raw stored job wrappers (for metadata inspection). + * @returns {Function[]} The stored wrappers. + * @public + * @example + * api.scheduler.service.list(); + */ +export function list() { + return [...jobs]; +} + +/** + * Drop every stored job. + * @returns {void} + * @public + * @example + * api.scheduler.service.clear(); + */ +export function clear() { + jobs = []; +} diff --git a/api_tests/api_test_lock_caller_caller/target/probe.mjs b/api_tests/api_test_lock_caller_caller/target/probe.mjs new file mode 100644 index 000000000..e9789449e --- /dev/null +++ b/api_tests/api_test_lock_caller_caller/target/probe.mjs @@ -0,0 +1,55 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lock_caller_caller/target/probe.mjs + * @Date: 2026-09-28 00:00:00 -07:00 (1790578800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Probe targets for the `lockCaller.caller()` tests (#477): report who called them, and + * permission-gated routes keyed to the caller. + * + * @module api_tests/api_test_lock_caller_caller/target/probe + */ + +import { self } from "@cldmv/slothlet/runtime"; + +/** + * Report the api path of the module that called this leaf. + * @returns {string|null} The caller's api path, or null when called from the host. + * @public + * @example + * api.target.probe.whoami(); // null (host) + */ +export function whoami() { + return self.slothlet.metadata.caller()?.apiPath ?? null; +} + +/** + * Permission-gated route: rules allow it for the client and deny it for the scheduler. + * @returns {string} A constant marker. + * @public + * @example + * api.target.probe.guarded(); + */ +export function guarded() { + return "guarded-ok"; +} + +/** + * Principal-gated route (#459): allowed for the client when its roles grant "read" on `projectId`. + * @param {string} projectId - Project id. + * @returns {{projectId: string}} The project marker. + * @public + * @example + * api.target.probe.files("p1"); + */ +export function files(projectId) { + return { projectId }; +} diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 83f2b4e51..c3683f5db 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -746,6 +746,31 @@ server.addHook( - Called with no active context (no module wrapper to capture), `lockCaller` is a no-op passthrough — it is meaningful only when called from inside a module. - **Runtime mode matters for async callbacks.** In **async** runtime mode the pinned identity propagates through `AsyncLocalStorage`, so `self.*` calls after an `await` still resolve to the registering module. In **live** runtime mode the caller is pinned only for the **synchronous** portion of the callback — the live context manager restores the previous wrapper as soon as the callback returns its promise. Once the synchronous `runInContext()` stack has unwound there may be **no slothlet caller at all**, so an `async` callback that `await`s before calling `self.*` resumes with whatever context is then active — typically none, and an identity probe sees `unknown`. Live mode keeps no per-async-task context; use **async** runtime mode for `async` hooks (like the example above) that must keep locked identity past their first `await`. `bind` is **not** an escape hatch here — it has the same live-mode limitation (see below); async runtime mode is the only fix. +### `self.slothlet.lockCaller.caller(fn)` — pin the leaf's caller + +`lockCaller` pins the leaf that calls it. A service that accepts a callback on behalf of whoever called it — a scheduler's `every(interval, fn)`, a registry — can therefore only pin **itself**, and the callback runs as the service rather than the module that handed it over. `lockCaller.caller` pins the current leaf's **caller** instead: exactly the identity `self.slothlet.metadata.caller()` reports at that moment. + +```javascript +// scheduler/service.mjs +import { self } from "@cldmv/slothlet/runtime"; + +export function every(ms, job) { + // Runs each job as the module that called every(), not as the scheduler. + setInterval(self.slothlet.lockCaller.caller(job), ms); +} +``` + +```javascript +// client/app.mjs — the job runs as client.app.*, so rules keyed to the client apply inside it. +self.scheduler.service.every(60_000, () => self.reports.refresh()); +``` + +- **It is a privilege.** Acting as your caller is not something a module may do by default: `slothlet.lockCaller.caller` is its own api path, denied to every module by a built-in rule. The host grants it to the services that run callbacks on their callers' behalf — `{ caller: "scheduler.**", target: "slothlet.lockCaller.caller", effect: "allow" }`. See [Permissions](PERMISSIONS.md#other-slothlet-routes-are-gated-too). The check runs when the wrapper is created. +- **No module caller → runs as the host.** When the leaf was itself called from outside any module (the host, a transport edge), the callback is pinned to "no module caller": it runs as the host, and `metadata.caller()` returns `null` inside it. This is a real pin, not a passthrough of `fn`, and it does not throw. Because a host-pinned callback carries the host's exemption from rules, grant `lockCaller.caller` only to modules trusted to act for any caller, the host included. +- **Otherwise identical to `lockCaller`**: the identity is captured once, at call time, and cannot be changed afterwards; `this` and arguments are forwarded; errors thrown by `fn` propagate unchanged; the wrapper exposes `_slothletOriginal`; a callback held across `reload()` runs against the current instance. +- **Principals compose.** Calls made inside the callback are enforced for the pinned identity, so rules keyed to the caller — including [`requires` principals](PERMISSIONS.md#principals) — resolve exactly as they would for a direct call from that caller. +- **Runtime mode** carries the same caveat as `lockCaller`: in **async** runtime mode the pinned identity propagates through `AsyncLocalStorage` across every `await`. In **live** runtime mode there is one identity slot per instance, so while an `async` callback is parked at an `await` alongside other suspended calls (for example the service that fired it and is awaiting it), its resumed calls are attributed from the call stack and can resolve to an awaiting module frame instead of the pin. That fails closed — never to the host — but it is not the pin; use async runtime mode for `async` callbacks that must keep the pinned caller past an `await`. + ### `self.slothlet.bind(fn)` — freeze the whole async context `bind` is a convenience re-export of Node's `AsyncResource.bind`. Unlike `lockCaller`, it freezes the **entire** async context captured at registration time — every `AsyncLocalStorage`, including slothlet's caller and request context: @@ -758,7 +783,7 @@ Reach for `lockCaller` when you want the caller pinned but request-scoped contex Both utilities share the same live-mode limitation: `AsyncResource.bind` only meaningfully captures slothlet's caller/context in **async** runtime mode. In live mode the slothlet store is kept off the `AsyncLocalStorage`, so `bind` degrades to binding whatever other async context exists and does **not** preserve slothlet caller identity past an `await`. `bind` is therefore not a workaround for the live-mode `lockCaller` caveat above — if an `async` callback must keep slothlet identity across awaits, run the instance in **async** runtime mode. -> `slothlet.lockCaller` and `slothlet.bind` are permission-gated routes like every other `slothlet.*` member — see [Permissions](PERMISSIONS.md#other-slothlet-routes-are-gated-too). +> `slothlet.lockCaller`, `slothlet.lockCaller.caller`, and `slothlet.bind` are permission-gated routes like every other `slothlet.*` member — see [Permissions](PERMISSIONS.md#other-slothlet-routes-are-gated-too). `lockCaller.caller` is denied to modules by default. --- diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index e91cf2924..e578e342a 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -727,6 +727,18 @@ The entire `slothlet` namespace is wrapped by an internal route proxy, so every A more specific user rule can still deny them for a particular module if needed. The whole point of `lockCaller` is downstream of this: the callback it returns runs with the caller identity set to the **registering** module, so permission rules keyed to that module match instead of failing against whatever module's async context happened to be ambient when the callback fired. +`slothlet.lockCaller.caller` is the exception: it is **denied by default**. It pins the current leaf's _caller_ onto a callback, so the callback acts as another module (or, when the leaf was called from the host, as the host) — a privilege, not a strengthening. The built-in allow on `slothlet.lockCaller` is an exact-path rule and does not cover it. The host grants it to the service modules that run callbacks on their callers' behalf; this holds under both `defaultPolicy: "allow"` and `"deny"`: + +```javascript +// Built-in rule registered for every instance: +{ caller: "**", target: "slothlet.lockCaller.caller", effect: "deny" } + +// Host grant — let the scheduler run jobs as the modules that scheduled them: +{ caller: "scheduler.**", target: "slothlet.lockCaller.caller", effect: "allow" } +``` + +A module without the grant gets `PERMISSION_DENIED`. See [`lockCaller.caller`](HOOKS.md#selfslothletlockcallercallerfn--pin-the-leafs-caller). + `slothlet.metadata.caller` and `slothlet.metadata.self` are **allowed by default** for the same reason. They reveal identity only — which module is calling this one, and which module this is — and grant no data or control access; they are what a module needs to [authorize or scope by its caller](./METADATA.md#selfslothletmetadatacaller) under a `defaultPolicy: "deny"` configuration: ```javascript diff --git a/src/lib/builders/api_builder.mjs b/src/lib/builders/api_builder.mjs index e5b857ede..46f8ade76 100644 --- a/src/lib/builders/api_builder.mjs +++ b/src/lib/builders/api_builder.mjs @@ -3517,6 +3517,82 @@ export class ApiBuilder extends ComponentBase { }; } + /** + * Freeze the current leaf's CALLER identity onto a callback (#477). + * @param {Function} fn - The callback whose caller identity should be pinned. + * @returns {Function} A wrapper that invokes `fn` as the current leaf's caller. + * @throws {SlothletError} PERMISSION_DENIED when the calling module has not been granted + * `slothlet.lockCaller.caller`; INVALID_ARGUMENT when `fn` is not a function. + * @public + * + * @description + * `lockCaller` pins the leaf that calls it, so a service leaf accepting a callback on + * behalf of whoever called it (a scheduler's `every(interval, fn)`, a registry) can only + * pin itself — the callback would run as the service. `lockCaller.caller` pins the + * identity `self.slothlet.metadata.caller()` reports at the moment of the call (the same + * resolution, shared through the metadata handler), so the callback runs as the module + * that called the service. + * + * Acting as your caller is a privilege: the api path `slothlet.lockCaller.caller` is + * denied to every module by a built-in rule, and the host grants it, e.g. + * `{ caller: "scheduler.**", target: "slothlet.lockCaller.caller", effect: "allow" }`. + * The check runs when the wrapper is created, not when it is invoked. + * + * When the leaf itself was called from outside any module (the host, a transport edge), + * the callback is pinned to **no module caller**: it runs as the host, and + * `metadata.caller()` returns null inside it. This is a real pin, not a passthrough. + * + * Otherwise identical to `lockCaller`: captured once at call time and immutable after; + * `this` and arguments are forwarded; errors from `fn` propagate unchanged; the returned + * wrapper exposes `_slothletOriginal`; instanceID/contextManager are resolved live so a + * callback held across `reload()` targets the current instance. Runtime-mode behaviour is + * the same as `lockCaller`'s — in **async** mode the identity propagates through + * `AsyncLocalStorage` across every `await`; in **live** mode there is one identity slot per + * instance, so an async callback resumed while other calls are also suspended is attributed + * from the call stack (failing closed, never to the host) — see docs/HOOKS.md. + * + * @example + * // scheduler/service.mjs — runs each job as the module that scheduled it. + * export function every(ms, job) { + * setInterval(self.slothlet.lockCaller.caller(job), ms); + * } + */ + const lockCallerCaller = function slothlet_lockCaller_caller(fn) { + // Enforced here as well as on the route: the function is handed out as a plain value + // (`caller` is a meta-property of the route proxy), so a reference passed on to another + // module must still be refused for that module. + enforceInternalPermission("slothlet.lockCaller.caller"); + if (typeof fn !== "function") { + throw new slothlet.SlothletError("INVALID_ARGUMENT", { + argument: "fn", + expected: "function", + received: typeof fn, + validationError: true + }); + } + // The identity metadata.caller() reports right now — null when the current leaf was + // called from outside any module, which pins "no module caller" (run as the host). + const capturedCaller = slothlet.handlers.metadata.callerWrapper(); + const locked = function slothlet_lockedCallerCaller(...args) { + // rawErrors: surface fn's own errors unchanged. instanceID/contextManager resolved live + // so a callback held across a reload() targets the current instance. + return capturedCaller + ? slothlet.contextManager.runInContext(slothlet.instanceID, fn, this, args, capturedCaller, true) + : slothlet.contextManager.runInContext(slothlet.instanceID, fn, this, args, null, true, true); + }; + // Parity with lockCaller / the EventEmitter patch metadata. + locked._slothletOriginal = fn; + return locked; + }; + // `lockCaller.caller` is its own api path (`slothlet.lockCaller.caller`), so the permission + // system gates it independently of `lockCaller`, whose call behaviour is unchanged. + Object.defineProperty(namespace.lockCaller, "caller", { + value: lockCallerCaller, + enumerable: true, + writable: false, + configurable: false + }); + return createInternalRouteProxy(namespace, "slothlet"); } diff --git a/src/lib/handlers/context-async.mjs b/src/lib/handlers/context-async.mjs index 9e879ff18..68fdd8f67 100644 --- a/src/lib/handlers/context-async.mjs +++ b/src/lib/handlers/context-async.mjs @@ -22,6 +22,7 @@ import { AsyncLocalStorage } from "@cldmv/slothlet/helpers/platform"; import { SlothletError } from "@cldmv/slothlet/errors"; import { runtime_isClassInstance, runtime_wrapClassInstance } from "@cldmv/slothlet/helpers/class-instance-wrapper"; import { setApiContextChecker } from "@cldmv/slothlet/helpers/eventemitter-context"; +import { TRUSTED_ROOT } from "#handlers/trusted-root"; /** * AsyncLocalStorage-based context manager for async runtime @@ -84,10 +85,14 @@ export class AsyncContextManager { * `fn` propagate unchanged instead of wrapping it as `CONTEXT_EXECUTION_FAILED`. Used * for framework callbacks (`lockCaller`, pinned hooks) where the caller expects the * original error type/code/status. + * @param {boolean} [asHost=false] - When `true`, run `fn` with **no module caller**: both + * `currentWrapper` and `callerWrapper` are cleared for the execution, so `fn` runs as the host + * (`metadata.caller()` returns null inside it). `currentWrapper` is ignored. Used by + * `lockCaller.caller()` when the pinned caller is the host. * @returns {*} Result of function execution * @public */ - runInContext(instanceID, fn, thisArg, args, currentWrapper, rawErrors = false) { + runInContext(instanceID, fn, thisArg, args, currentWrapper, rawErrors = false, asHost = false) { // Check if we're already in an active ALS context const activeStore = this.als.getStore(); let baseStore; @@ -111,7 +116,15 @@ export class AsyncContextManager { // Create a new store with currentWrapper for this execution const executionStore = { ...baseStore }; - if (currentWrapper) { + if (asHost) { + // Pinned to "no module caller": the flow runs as the host, not as whichever module is ambient. + executionStore.callerWrapper = null; + executionStore.currentWrapper = null; + // Running as the host means enforcement must see a host-initiated flow. The marker is + // non-enumerable (never spread onto a module's execution store), so apply it explicitly — the + // same marker the instance's base store carries for a genuinely host-initiated call. + Object.defineProperty(executionStore, TRUSTED_ROOT, { value: true, configurable: true }); + } else if (currentWrapper) { executionStore.callerWrapper = baseStore.currentWrapper; executionStore.currentWrapper = currentWrapper; } diff --git a/src/lib/handlers/context-live.mjs b/src/lib/handlers/context-live.mjs index 8fa0e6165..a9bbf558f 100644 --- a/src/lib/handlers/context-live.mjs +++ b/src/lib/handlers/context-live.mjs @@ -207,8 +207,9 @@ export class LiveContextManager { * @private */ #resolveSuspendedFromStack(suspended) { + // Host-pinned calls (`lockCaller.caller()` with no module caller) are tracked with no filePath: + // they have no module frame to match. When only those are suspended, no candidate can match. const candidates = [...suspended].filter((entry) => entry.filePath); - /* v8 ignore next — entries always carry a filePath; guards a partial mock. */ if (!candidates.length) return null; // Raise the frame budget for this capture only: the caller's frame sits below slothlet's own @@ -367,10 +368,14 @@ export class LiveContextManager { * `fn` propagate unchanged instead of wrapping it as `CONTEXT_EXECUTION_FAILED`. Used * for framework callbacks (`lockCaller`, pinned hooks) where the caller expects the * original error type/code/status. + * @param {boolean} [asHost=false] - When `true`, run `fn` with **no module caller**: both + * `currentWrapper` and `callerWrapper` are cleared for the execution, so `fn` runs as the host + * (`metadata.caller()` returns null inside it). `currentWrapper` is ignored. Used by + * `lockCaller.caller()` when the pinned caller is the host. * @returns {*} Result of function execution * @public */ - runInContext(instanceID, fn, thisArg, args, currentWrapper, rawErrors = false) { + runInContext(instanceID, fn, thisArg, args, currentWrapper, rawErrors = false, asHost = false) { // CHILD INSTANCE APPROACH: Check if current is this instance OR a child of this instance const currentID = this.currentInstanceID; const isAlreadyInContext = this.#flowBelongsToInstance(currentID, instanceID); @@ -392,6 +397,11 @@ export class LiveContextManager { const previousCallerWrapper = store.callerWrapper; this.currentInstanceID = targetInstanceID; + if (asHost) { + // Pinned to "no module caller": the call runs as the host, not as whichever module is ambient. + store.callerWrapper = null; + store.currentWrapper = null; + } // currentWrapper is optional; false branch is covered directly in context-live-branches tests // but v8 hit-counter overflows to -255 in the parallel matrix, appearing uncovered. /* v8 ignore next */ @@ -429,7 +439,9 @@ export class LiveContextManager { // thenable and hand back a plain promise in its place. Those reads carry their own // caller snapshot taken when the proxy was created, so they stay attributed anyway. if (result instanceof Promise) { - /* v8 ignore next — filePath/apiPath are set on every live wrapper; ?? guards a partial mock. */ + // A host-pinned call (`asHost`) has no wrapper, so it is tracked with an empty api path and + // no filePath (see #resolveSuspendedFromStack). + /* v8 ignore next — a live wrapper always carries filePath/apiPath; the ?? on a present wrapper guards a partial mock. */ const apiPath = currentWrapper?.____slothletInternal?.apiPath ?? ""; const entry = { currentWrapper, @@ -444,8 +456,13 @@ export class LiveContextManager { // identity to disambiguate later, so there is nothing to record. Every promise-returning call // reaching here carries the wrapper being invoked, so the skip arm guards a caller-less entry // this path is not handed. + // + // A call pinned to the host (`asHost`) is the exception: it deliberately holds the shared + // field at null for its whole async lifetime. Were it untracked, a single suspended module + // call resuming meanwhile would trust that null field and be treated as the host — failing + // open. Tracking it keeps the count honest, so that module is resolved from its stack instead. /* v8 ignore next */ - if (currentWrapper) this.#suspendedFor(store).add(entry); + if (currentWrapper || asHost) this.#suspendedFor(store).add(entry); const settle = () => { this.#suspendedFor(store).delete(entry); restore(); diff --git a/src/lib/handlers/metadata.mjs b/src/lib/handlers/metadata.mjs index a30e8278a..210f71f17 100644 --- a/src/lib/handlers/metadata.mjs +++ b/src/lib/handlers/metadata.mjs @@ -855,8 +855,24 @@ export class Metadata extends ComponentBase { * @public */ caller() { + const callerWrapper = this.callerWrapper(); + if (!callerWrapper) return null; + return this.getMetadata(callerWrapper); + } + + /** + * Resolve the wrapper of the API function that called the current function. + * + * The single resolution behind {@link Metadata#caller}: reads `callerWrapper` from the active + * context-manager store. `self.slothlet.lockCaller.caller()` pins exactly this identity, so the + * two can never disagree about who "the caller" is. + * + * @returns {object|null} The calling function's wrapper, or null when there is no module caller + * in context (the function was invoked from the host or outside the API). + * @public + */ + callerWrapper() { const ctx = this.slothlet.contextManager?.tryGetContext(); - if (!ctx || !ctx.callerWrapper) return null; - return this.getMetadata(ctx.callerWrapper); + return ctx?.callerWrapper ?? null; } } diff --git a/src/lib/handlers/permission-manager.mjs b/src/lib/handlers/permission-manager.mjs index 2033d4d55..c75d292f4 100644 --- a/src/lib/handlers/permission-manager.mjs +++ b/src/lib/handlers/permission-manager.mjs @@ -386,6 +386,14 @@ export class PermissionManager extends ComponentBase { this.addRule({ caller: "**", target: "slothlet.lockCaller", effect: "allow" }, "__builtin__"); this.addRule({ caller: "**", target: "slothlet.bind", effect: "allow" }, "__builtin__"); + // Built-in deny rule (#477): `slothlet.lockCaller.caller` pins the current leaf's CALLER onto + // a callback, so the callback acts as another module — a privilege, unlike `lockCaller`, which + // only pins the module itself. Host-only by default; the host grants it to the service modules + // that run callbacks on their callers' behalf (e.g. `{ caller: "scheduler.**", target: + // "slothlet.lockCaller.caller", effect: "allow" }`). The exact-path allow on `slothlet.lockCaller` + // above does not match this deeper path. + this.addRule({ caller: "**", target: "slothlet.lockCaller.caller", effect: "deny" }, "__builtin__"); + // Built-in allow rules (#468): `slothlet.metadata.caller` / `slothlet.metadata.self` reveal // identity only — who is calling this module, and which module this is — which is what a module // needs to enforce its own policy (e.g. scoping records by caller). They grant no data or diff --git a/src/slothlet.mjs b/src/slothlet.mjs index 83e53f0b6..97c824b60 100644 --- a/src/slothlet.mjs +++ b/src/slothlet.mjs @@ -1717,7 +1717,7 @@ export default slothlet; * @property {Function} slothlet.versioning.unregister - Unregister a specific version from a logical API path. Removes the versioned namespace (`vX.path`) and updates or tears down the dispatcher. If the logical path/version is not registered, this resolves without removing anything. %%sig: (logicalPath: string, versionTag: string): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api', versionDispatcher: "version" });|await api.slothlet.api.add('auth', './api/v1', {}, { version: 'v1' });|await api.slothlet.api.add('auth', './api/v2', {}, { version: 'v2' });|const removed = await api.slothlet.versioning.unregister('auth', 'v2'); // true%% * @property {Function} slothlet.versioning.getVersionMetadata - Retrieve the VersionManager-only metadata stored at registration time via `versionConfig.metadata`. Returns `undefined` if the path or version is not registered, or if versioning has not yet been used. %%sig: (logicalPath: string, versionTag: string): object|undefined%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api', versionDispatcher: "version" });|await api.slothlet.api.add('auth', './api/v1', {}, { version: 'v1', metadata: { stable: true } });|const meta = api.slothlet.versioning.getVersionMetadata('auth', 'v1'); // { stable: true, version: 'v1', logicalPath: 'auth' }%% * @property {Function} slothlet.versioning.setVersionMetadata - Patch (merge) the VersionManager-only metadata for a registered version at runtime. The injected `version` and `logicalPath` keys always win over supplied patch fields. Throws `VERSION_NOT_FOUND` if the path or version is not registered. %%sig: (logicalPath: string, versionTag: string, patch: object): void%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api', versionDispatcher: "version" });|await api.slothlet.api.add('auth', './api/v1', {}, { version: 'v1' });|api.slothlet.versioning.setVersionMetadata('auth', 'v1', { stable: true });%% - * @property {Function} slothlet.lockCaller - Freeze the registering module's caller identity onto a callback. The returned wrapper overrides only the caller identity when invoked; request-scoped context stays live. In **async** runtime mode the pinned identity propagates through `AsyncLocalStorage` for the callback's full async lifetime. In **live** runtime mode identity is pinned only for the synchronous portion — once `fn` returns its promise the live context is restored, so an async callback that `await`s before calling `self.*` resumes with no slothlet caller at all (an identity check sees `unknown`); use async runtime mode for async hooks (e.g. Fastify `addHook`) that must keep identity past the first `await`. %%sig: (fn: function): function%% %%example: // Inside a module's init()|server.addHook("onRequest", self.slothlet.lockCaller(handler));%% + * @property {Function} slothlet.lockCaller - Freeze the registering module's caller identity onto a callback. The returned wrapper overrides only the caller identity when invoked; request-scoped context stays live. In **async** runtime mode the pinned identity propagates through `AsyncLocalStorage` for the callback's full async lifetime. In **live** runtime mode identity is pinned only for the synchronous portion — once `fn` returns its promise the live context is restored, so an async callback that `await`s before calling `self.*` resumes with no slothlet caller at all (an identity check sees `unknown`); use async runtime mode for async hooks (e.g. Fastify `addHook`) that must keep identity past the first `await`. **`slothlet.lockCaller.caller(fn)`** (a property of this function, same signature) pins the current leaf's **caller** instead — the identity `slothlet.metadata.caller()` reports — so a service that runs callbacks on behalf of whoever called it (a scheduler, a registry) runs each one as that caller rather than as itself. It is denied to modules by a built-in rule on `slothlet.lockCaller.caller`; the host grants it (e.g. `{ caller: "scheduler.**", target: "slothlet.lockCaller.caller", effect: "allow" }`). When the leaf was called from outside any module the callback is pinned to no module caller and runs as the host (`metadata.caller()` returns null inside it). Otherwise it behaves like `lockCaller`, including the runtime-mode caveat. %%sig: (fn: function): function%% %%example: // Inside a module's init()|server.addHook("onRequest", self.slothlet.lockCaller(handler));%% %%example: // Inside a scheduler leaf (host-granted `slothlet.lockCaller.caller`)|export function every(ms, job) {| setInterval(self.slothlet.lockCaller.caller(job), ms);|}%% * @property {Function} slothlet.bind - Freeze the entire async context onto a callback at registration time (convenience re-export of `AsyncResource.bind`). Unlike `lockCaller`, which overrides only caller identity and leaves request-scoped context live, `bind` freezes every `AsyncLocalStorage`, including slothlet's caller and context. Meaningful in async runtime mode; degrades to non-slothlet ALS context in live mode. %%sig: (fn: function): function%% %%example: // Inside a module's init()|server.addHook("onRequest", self.slothlet.bind(handler));%% * @property {object} [slothlet.reference] - The `reference` object from config, merged onto the root API and accessible here. * @property {Function} slothlet.reload - Reload the entire instance (re-scans the directory and recreates all module references). Accepts `{ keepInstanceID: boolean }`. %%sig: ([options]: Object): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|await api.slothlet.reload(); // full reload%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| await api.slothlet.reload(); // full reload|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| await api.slothlet.reload(); // full reload|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|await api.slothlet.reload(); // full reload%% diff --git a/tests/vitests/setup/vitest-helper.mjs b/tests/vitests/setup/vitest-helper.mjs index bfc2d9bbc..a57da9566 100644 --- a/tests/vitests/setup/vitest-helper.mjs +++ b/tests/vitests/setup/vitest-helper.mjs @@ -67,6 +67,7 @@ export const TEST_DIRS = { API_TEST_EVENTS: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_events`), API_TEST_CROSS_INSTANCE: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_cross_instance`), API_TEST_LOCK_CALLER: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_lock_caller`), + API_TEST_LOCK_CALLER_CALLER: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_lock_caller_caller`), API_TEST_BROWSER: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_browser`), API_TEST_MULTI_ROOT_FN: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_multi_root_fn`), API_TEST_LIFECYCLE_HOOKS: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_lifecycle_hooks`), diff --git a/tests/vitests/suites/permissions/lock-caller-caller.test.vitest.mjs b/tests/vitests/suites/permissions/lock-caller-caller.test.vitest.mjs new file mode 100644 index 000000000..9b224fb30 --- /dev/null +++ b/tests/vitests/suites/permissions/lock-caller-caller.test.vitest.mjs @@ -0,0 +1,399 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/permissions/lock-caller-caller.test.vitest.mjs + * @Date: 2026-09-28 00:00:00 -07:00 (1790578800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview #477 — `self.slothlet.lockCaller.caller(fn)` pins the current leaf's CALLER onto a + * callback, so a service that runs callbacks on behalf of whoever called it (a scheduler, a registry) + * runs each one as that caller rather than as itself. Acting as your caller is a privilege: the path + * `slothlet.lockCaller.caller` is denied to every module by a built-in rule and granted by the host. + * + * Fixture: `scheduler.service` stores jobs (`every` pins the caller, `everyAsSelf` pins itself via + * plain `lockCaller`); `client.app` schedules jobs that call `target.probe.*`, which reports / gates on + * the identity it is called with; `roles.registry` owns a #459 principal. + * + * @module tests/vitests/suites/permissions/lock-caller-caller + */ + +import { describe, it, expect, afterEach } from "vitest"; +import slothlet from "@cldmv/slothlet"; +import { getMatrixConfigs, TEST_DIRS, withSuppressedSlothletErrorOutput } from "../../setup/vitest-helper.mjs"; + +const BASE = TEST_DIRS.API_TEST_LOCK_CALLER_CALLER; + +/** The host grant that lets the scheduler pin its callers. */ +const GRANT = { caller: "scheduler.**", target: "slothlet.lockCaller.caller", effect: "allow" }; + +/** `target.probe.guarded` is for the client only. */ +const GUARD_RULES = [ + { caller: "scheduler.**", target: "target.probe.guarded", effect: "deny" }, + { caller: "client.**", target: "target.probe.guarded", effect: "allow" } +]; + +/** Under `defaultPolicy: "deny"`, the calls the fixture makes besides the one under test. */ +const DENY_POLICY_BASE_RULES = [ + { caller: "client.**", target: "scheduler.**", effect: "allow" }, + { caller: "client.**", target: "target.probe.whoami", effect: "allow" }, + { caller: "scheduler.**", target: "target.probe.whoami", effect: "allow" } +]; + +/** The job each client scheduling leaf pins — the identity a pinned job runs as. */ +const CLIENT_WHOAMI = "client.app.scheduleWhoami"; + +/** + * Assert an invocation fails with `PERMISSION_DENIED`, whether it throws synchronously (eager) or + * rejects (lazy first call). + * @param {() => unknown} invoke - Zero-arg thunk. + * @returns {Promise} + */ +const expectDenied = (invoke) => + withSuppressedSlothletErrorOutput(async () => { + let error; + try { + await invoke(); + } catch (err) { + error = err; + } + expect(error).toBeDefined(); + expect(error.code).toBe("PERMISSION_DENIED"); + }); + +describe.each(getMatrixConfigs())("Permissions > lockCaller.caller (#477) > $name", ({ config }) => { + let api; + + afterEach(async () => { + if (api) { + await api.scheduler.service.clear(); + await api.shutdown(); + } + api = null; + }); + + /** + * Build the fixture api. + * @param {object[]} rules - Permission rules. + * @param {"allow"|"deny"} [defaultPolicy="allow"] - Default policy. + * @returns {Promise} The api. + */ + const build = async (rules, defaultPolicy = "allow") => { + api = await slothlet({ ...config, base: BASE, permissions: { defaultPolicy, rules } }); + await api.scheduler.service.clear(); + return api; + }; + + // ── Pinning the caller ────────────────────────────────────────────────────── + + it("pins the leaf's caller, not the leaf: the target sees the client that scheduled the job", async () => { + await build([GRANT]); + await api.client.app.scheduleWhoami(); + expect(await api.scheduler.service.fire()).toEqual([CLIENT_WHOAMI]); + }); + + it("plain lockCaller in the same leaf pins the service itself (the problem lockCaller.caller solves)", async () => { + await build([GRANT]); + await api.client.app.scheduleWhoamiAsService(); + expect(await api.scheduler.service.fire()).toEqual(["scheduler.service.everyAsSelf"]); + }); + + it("a permission rule keyed to the caller applies inside the pinned job", async () => { + await build([GRANT, ...GUARD_RULES]); + await api.client.app.scheduleGuarded(); + expect(await api.scheduler.service.fire()).toEqual(["guarded-ok"]); + + // Pinned to the scheduler instead, the same job is denied. + await api.scheduler.service.clear(); + await api.client.app.scheduleGuardedAsService(); + await expectDenied(() => api.scheduler.service.fire()); + }); + + it("the pinned identity holds regardless of which module fires the job", async () => { + await build([GRANT]); + await api.client.app.scheduleWhoami(); + // Fired from inside the client (ambient client.app.fireFromClient → scheduler.service.fire). + expect(await api.client.app.fireFromClient()).toEqual([CLIENT_WHOAMI]); + }); + + // ── No module caller ──────────────────────────────────────────────────────── + + it("a leaf called from the host pins no module caller: the job runs as the host", async () => { + await build([GRANT]); + const job = () => ({ seen: api.target.probe.whoami(), caller: api.slothlet.metadata.caller() }); + await api.scheduler.service.every(job); + + // Fired from inside a module — a passthrough would run the job as scheduler.service.fire. + const [result] = await api.client.app.fireFromClient(); + expect(await result.seen).toBeNull(); + expect(result.caller).toBeNull(); + }); + + it("the host pin is a real wrapper, not a passthrough of fn", async () => { + await build([GRANT]); + const job = () => 1; + await api.scheduler.service.every(job); + const [wrapper] = await api.scheduler.service.list(); + expect(wrapper).not.toBe(job); + expect(wrapper._slothletOriginal).toBe(job); + expect(wrapper()).toBe(1); + }); + + it("host code calling lockCaller.caller directly gets a host pin and does not throw", async () => { + await build([]); + const job = () => api.target.probe.whoami(); + const pinned = api.slothlet.lockCaller.caller(job); + expect(pinned).not.toBe(job); + expect(await pinned()).toBeNull(); + }); + + it("an async host-pinned job keeps running as the host across an await", async () => { + await build([GRANT]); + await api.scheduler.service.every(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return api.target.probe.whoami(); + }); + const [job] = await api.scheduler.service.list(); + expect(await job()).toBeNull(); + }); + + // Async runtime only — the same caveat lockCaller carries. The live runtime keeps one identity slot + // per instance, so while the job is parked at its await, the modules awaiting it (client → scheduler) + // are suspended too; the job's resumed call is then attributed from the stack, which names the + // awaiting scheduler frame. That fails closed (a module identity, not the host), but it is not the pin. + it.skipIf(config.runtime === "live")( + "async runtime: a host-pinned job fired from inside a module runs as the host across an await", + async () => { + await build([GRANT]); + await api.scheduler.service.every(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return api.target.probe.whoami(); + }); + expect(await api.client.app.fireFromClient()).toEqual([null]); + } + ); + + it("concurrent async host-pinned jobs all run as the host", async () => { + await build([GRANT]); + const job = async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return api.target.probe.whoami(); + }; + await api.scheduler.service.every(job); + await api.scheduler.service.every(job); + const [first, second] = await api.scheduler.service.list(); + // Invoked straight from the host so only the two host-pinned jobs are in flight. + expect(await Promise.all([first(), second()])).toEqual([null, null]); + }); + + // ── Permission gate ───────────────────────────────────────────────────────── + + it("is denied to a module without a grant under defaultPolicy allow", async () => { + await build([]); + await expectDenied(() => api.client.app.scheduleWhoami()); + await expectDenied(() => api.client.app.pinDirectly()); + }); + + it("is denied to a module without a grant under defaultPolicy deny", async () => { + await build(DENY_POLICY_BASE_RULES, "deny"); + await expectDenied(() => api.client.app.scheduleWhoami()); + await expectDenied(() => api.client.app.pinDirectly()); + }); + + it("is allowed with a host grant under defaultPolicy deny", async () => { + await build([...DENY_POLICY_BASE_RULES, GRANT], "deny"); + await api.client.app.scheduleWhoami(); + expect(await api.scheduler.service.fire()).toEqual([CLIENT_WHOAMI]); + // The grant is scoped: the client itself still may not pin its caller. + await expectDenied(() => api.client.app.pinDirectly()); + }); + + it("the built-in exact allow on slothlet.lockCaller does not cover slothlet.lockCaller.caller", async () => { + await build([GRANT], "deny"); + const global = api.slothlet.permissions.global; + expect(global.checkAccess("client.app", "slothlet.lockCaller")).toBe(true); + expect(global.checkAccess("client.app", "slothlet.lockCaller.caller")).toBe(false); + expect(global.checkAccess("scheduler.service", "slothlet.lockCaller.caller")).toBe(true); + }); + + it("the built-in deny applies under defaultPolicy allow too", async () => { + await build([]); + const global = api.slothlet.permissions.global; + expect(global.checkAccess("anyModule", "slothlet.lockCaller")).toBe(true); + expect(global.checkAccess("anyModule", "slothlet.lockCaller.caller")).toBe(false); + }); + + // ── Principals (#459) ─────────────────────────────────────────────────────── + + /** + * Rules for the principals tests: the client may read a project its roles grant "read" on. + * @type {object[]} + */ + const PRINCIPAL_RULES = [ + GRANT, + { caller: "client.**", target: "scheduler.**", effect: "allow" }, + { caller: "roles.**", target: "slothlet.permissions.principal.**", effect: "allow" }, + { + caller: "client.**", + target: "target.probe.files", + effect: "allow", + requires: ["roles"], + condition: (ctx, { args, principals }) => principals.roles.projects[args[0]]?.includes("read") === true + } + ]; + + /** + * Build the principals fixture: the roles principal registered and u1 granted read on p1. + * @returns {Promise<(ctx: object, fn: () => unknown) => unknown>} A per-request context runner. + */ + const buildPrincipals = async () => { + await build(PRINCIPAL_RULES, "deny"); + await api.roles.registry.setup(); + await api.roles.registry.grant("u1", "p1", ["read"]); + return (ctx, fn) => api.slothlet.context.run(ctx, fn); + }; + + it("principals: the pinned caller's principal-gated rule applies inside the job", async () => { + const as = await buildPrincipals(); + // Resolve u1's roles once through a direct client call, so the job takes the synchronous path. + expect(await as({ user: "u1" }, () => api.client.app.readFiles("p1"))).toEqual({ projectId: "p1" }); + + await api.client.app.scheduleFiles(); + expect(await as({ user: "u1" }, () => api.scheduler.service.fire("p1"))).toEqual([{ projectId: "p1" }]); + // Same identity, a project its roles grant nothing on: the resolved facts deny it. + await expectDenied(() => as({ user: "u1" }, () => api.scheduler.service.fire("p2"))); + + // Pinned to the scheduler, the client rule never matches. + await api.scheduler.service.clear(); + await api.client.app.scheduleFilesAsService(); + await expectDenied(() => as({ user: "u1" }, () => api.scheduler.service.fire("p1"))); + }); + + // Async runtime only: a stale principal promotes the call past an await, and in the live runtime the + // deferred enforcement is attributed from the stack while the scheduler is suspended awaiting the job — + // the same caveat lockCaller carries (see the async host-pin test above). + it.skipIf(config.runtime === "live")("async runtime: a principal is resolved for the pinned caller from inside the job", async () => { + const as = await buildPrincipals(); + await api.client.app.scheduleFiles(); + // No prior resolve: the job's call is promoted to resolve u1's roles, then gated on them. + expect(await as({ user: "u1" }, () => api.scheduler.service.fire("p1"))).toEqual([{ projectId: "p1" }]); + await expectDenied(() => as({ user: "u2" }, () => api.scheduler.service.fire("p1"))); + }); + + // ── lockCaller parity ─────────────────────────────────────────────────────── + + it("errors thrown by the job propagate unchanged", async () => { + await build([GRANT]); + await api.client.app.scheduleThrow(); + const [wrapper] = await api.scheduler.service.list(); + let error; + try { + wrapper(); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(TypeError); + expect(error.message).toBe("job-boom"); + + const hostPinned = api.slothlet.lockCaller.caller(() => { + throw new RangeError("host-boom"); + }); + expect(() => hostPinned()).toThrow(RangeError); + }); + + it("forwards `this` and arguments into the job", async () => { + await build([GRANT]); + await api.client.app.scheduleThisAndArgs(); + const thisArg = { tag: "the-this" }; + const [result] = await api.scheduler.service.fireWith(thisArg, 1, 2); + expect(result.self).toBe(thisArg); + expect(result.args).toEqual([1, 2]); + }); + + it("exposes the original function via `_slothletOriginal`", async () => { + await build([GRANT]); + const { wrapper, original } = await api.client.app.pinThroughScheduler(); + expect(wrapper._slothletOriginal).toBe(original); + expect(wrapper()).toBe("original"); + }); + + it("the identity is captured at call time and cannot be changed afterwards", async () => { + await build([GRANT]); + await api.client.app.scheduleWhoami(); + const [wrapper] = await api.scheduler.service.list(); + // Invoked from the host, from another module, and again — always the client. + expect(await wrapper()).toBe(CLIENT_WHOAMI); + expect(await api.client.app.fireFromClient()).toEqual([CLIENT_WHOAMI]); + expect(await wrapper()).toBe(CLIENT_WHOAMI); + }); + + it("rejects a non-function argument", async () => { + await build([]); + let error; + try { + api.slothlet.lockCaller.caller(123); + } catch (err) { + error = err; + } + expect(error?.code).toBe("INVALID_ARGUMENT"); + }); + + it("a pinned job still targets the live instance after a full reload", async () => { + // Identity marker: only the client is denied the guarded route, so a denial naming the client proves + // the job still runs as the client (the host and the scheduler would both be allowed). + await build([GRANT, { caller: "client.**", target: "target.probe.guarded", effect: "deny" }]); + await api.client.app.scheduleGuarded(); + const [wrapper] = await api.scheduler.service.list(); + /** + * Invoke the pinned job and return the error it raised. + * @returns {Promise} The error. + */ + const invoke = () => + withSuppressedSlothletErrorOutput(async () => { + try { + await wrapper(); + } catch (err) { + return err; + } + return undefined; + }); + const before = await invoke(); + expect(before?.code).toBe("PERMISSION_DENIED"); + expect(before?.message).toContain("client.app.scheduleGuarded"); + + await api.slothlet.reload(); + const after = await invoke(); + expect(after?.code).toBe("PERMISSION_DENIED"); + expect(after?.message).toContain("client.app.scheduleGuarded"); + }); + + // Same runtime-mode behaviour as lockCaller (see the lock-caller runtime suite): the identity is held + // across the job's await in both runtimes. + it("an async job keeps the pinned caller across an await", async () => { + await build([GRANT]); + await api.client.app.scheduleAsyncWhoami(); + expect(await api.scheduler.service.fire()).toEqual(["client.app.scheduleAsyncWhoami"]); + }); + + it("plain lockCaller is unchanged: a function, host passthrough, and reachable .caller", async () => { + await build([]); + expect(typeof api.slothlet.lockCaller).toBe("function"); + expect(typeof api.slothlet.lockCaller.caller).toBe("function"); + const fn = () => 42; + expect(api.slothlet.lockCaller(fn)).toBe(fn); + expect(api.slothlet.lockCaller(fn)()).toBe(42); + }); + + it("lockCaller.caller is reachable through self for a granted module", async () => { + await build([{ caller: "client.**", target: "slothlet.lockCaller.caller", effect: "allow" }]); + const pinned = await api.client.app.pinDirectly(); + expect(typeof pinned).toBe("function"); + expect(pinned()).toBe(0); + }); +}); diff --git a/types/src/lib/builders/api_builder.d.mts.map b/types/src/lib/builders/api_builder.d.mts.map index 302e76339..3f1178f0e 100644 --- a/types/src/lib/builders/api_builder.d.mts.map +++ b/types/src/lib/builders/api_builder.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"api_builder.d.mts","sourceRoot":"","sources":["../../../../src/lib/builders/api_builder.mjs"],"names":[],"mappings":"AAmMA;;;;;;;;;;;;;GAaG;AACH;IACC,gCAAuC;IAEvC;;;;;;;OAOG;IACH,sBANW,MAAM,EAQhB;IAED;;;;;OAKG;IACH,8BAJW,MAAM,GACJ,OAAO,CAAC,MAAM,CAAC,CA6G3B;IAED;;;;;;;;;;;OAWG;IACH,gCA6jGC;IAED;;;;OAIG;IACH,+BA4BC;IAED;;;;OAIG;IACH,0BAgCC;IAED;;;;OAIG;IACH,4BAsTC;IAED;;;;;OAKG;IACH,8BAmEC;IAED;;;;;OAKG;IACH,uBAgCC;CACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;8BAv3H6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"api_builder.d.mts","sourceRoot":"","sources":["../../../../src/lib/builders/api_builder.mjs"],"names":[],"mappings":"AAmMA;;;;;;;;;;;;;GAaG;AACH;IACC,gCAAuC;IAEvC;;;;;;;OAOG;IACH,sBANW,MAAM,EAQhB;IAED;;;;;OAKG;IACH,8BAJW,MAAM,GACJ,OAAO,CAAC,MAAM,CAAC,CA6G3B;IAED;;;;;;;;;;;OAWG;IACH,gCAyoGC;IAED;;;;OAIG;IACH,+BA4BC;IAED;;;;OAIG;IACH,0BAgCC;IAED;;;;OAIG;IACH,4BAsTC;IAED;;;;;OAKG;IACH,8BAmEC;IAED;;;;;OAKG;IACH,uBAgCC;CACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;8BAn8H6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/handlers/context-async.d.mts b/types/src/lib/handlers/context-async.d.mts index 6d800a2c8..0b8dcec69 100644 --- a/types/src/lib/handlers/context-async.d.mts +++ b/types/src/lib/handlers/context-async.d.mts @@ -31,10 +31,14 @@ export class AsyncContextManager { * `fn` propagate unchanged instead of wrapping it as `CONTEXT_EXECUTION_FAILED`. Used * for framework callbacks (`lockCaller`, pinned hooks) where the caller expects the * original error type/code/status. + * @param {boolean} [asHost=false] - When `true`, run `fn` with **no module caller**: both + * `currentWrapper` and `callerWrapper` are cleared for the execution, so `fn` runs as the host + * (`metadata.caller()` returns null inside it). `currentWrapper` is ignored. Used by + * `lockCaller.caller()` when the pinned caller is the host. * @returns {*} Result of function execution * @public */ - public runInContext(instanceID: string, fn: Function, thisArg: any, args: any[], currentWrapper?: Object, rawErrors?: boolean): any; + public runInContext(instanceID: string, fn: Function, thisArg: any, args: any[], currentWrapper?: Object, rawErrors?: boolean, asHost?: boolean): any; /** * Get current active context * @returns {Object} Current context store diff --git a/types/src/lib/handlers/context-async.d.mts.map b/types/src/lib/handlers/context-async.d.mts.map index a3dffb814..80f3f4537 100644 --- a/types/src/lib/handlers/context-async.d.mts.map +++ b/types/src/lib/handlers/context-async.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"context-async.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/context-async.mjs"],"names":[],"mappings":"AAyBA;;;;GAIG;AACH;IAKE,SAA6D;IAC7D,yBAA0B;IAG3B;;;;OAIG;IACH,kDAKC;IAED;;;;;;OAMG;IACH,8BALW,MAAM,WACN,MAAM,GACJ,MAAM,CAkBlB;IAED;;;;;;;;;;;;;OAaG;IACH,gCAZW,MAAM,yBAEN,GAAC,gCAED,MAAM,cACN,OAAO,GAIL,GAAC,CA4Fb;IAqBD;;;;;OAKG;IACH,qBAJa,MAAM,CAUlB;IAED;;;;;;;;;;;;OAYG;IACH,kCAVW,MAAM,GAOJ,MAAM,GAAC,SAAS,CAe5B;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,sCAVW,MAAM,GAMJ;QAAC,cAAc,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAC,GAAC,SAAS,CAQrE;IAED;;;;OAIG;IACH,2BAHW,MAAM,QAoBhB;IAED;;;;OAIG;IACH,yBAHa,MAAM,CAclB;;CACD;AAED;;;GAGG;AACH,sDAA6D"} \ No newline at end of file +{"version":3,"file":"context-async.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/context-async.mjs"],"names":[],"mappings":"AA0BA;;;;GAIG;AACH;IAKE,SAA6D;IAC7D,yBAA0B;IAG3B;;;;OAIG;IACH,kDAKC;IAED;;;;;;OAMG;IACH,8BALW,MAAM,WACN,MAAM,GACJ,MAAM,CAkBlB;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,gCAhBW,MAAM,yBAEN,GAAC,gCAED,MAAM,cACN,OAAO,WAIP,OAAO,GAIL,GAAC,CAoGb;IAqBD;;;;;OAKG;IACH,qBAJa,MAAM,CAUlB;IAED;;;;;;;;;;;;OAYG;IACH,kCAVW,MAAM,GAOJ,MAAM,GAAC,SAAS,CAe5B;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,sCAVW,MAAM,GAMJ;QAAC,cAAc,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAC,GAAC,SAAS,CAQrE;IAED;;;;OAIG;IACH,2BAHW,MAAM,QAoBhB;IAED;;;;OAIG;IACH,yBAHa,MAAM,CAclB;;CACD;AAED;;;GAGG;AACH,sDAA6D"} \ No newline at end of file diff --git a/types/src/lib/handlers/context-live.d.mts b/types/src/lib/handlers/context-live.d.mts index a71c47842..e28db87cc 100644 --- a/types/src/lib/handlers/context-live.d.mts +++ b/types/src/lib/handlers/context-live.d.mts @@ -79,10 +79,14 @@ export class LiveContextManager { * `fn` propagate unchanged instead of wrapping it as `CONTEXT_EXECUTION_FAILED`. Used * for framework callbacks (`lockCaller`, pinned hooks) where the caller expects the * original error type/code/status. + * @param {boolean} [asHost=false] - When `true`, run `fn` with **no module caller**: both + * `currentWrapper` and `callerWrapper` are cleared for the execution, so `fn` runs as the host + * (`metadata.caller()` returns null inside it). `currentWrapper` is ignored. Used by + * `lockCaller.caller()` when the pinned caller is the host. * @returns {*} Result of function execution * @public */ - public runInContext(instanceID: string, fn: Function, thisArg: any, args: any[], currentWrapper?: Object, rawErrors?: boolean): any; + public runInContext(instanceID: string, fn: Function, thisArg: any, args: any[], currentWrapper?: Object, rawErrors?: boolean, asHost?: boolean): any; /** * Get current active context * @returns {Object} Current context store diff --git a/types/src/lib/handlers/context-live.d.mts.map b/types/src/lib/handlers/context-live.d.mts.map index a9a9b3f60..4609c1ee4 100644 --- a/types/src/lib/handlers/context-live.d.mts.map +++ b/types/src/lib/handlers/context-live.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"context-live.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/context-live.mjs"],"names":[],"mappings":"AAiEA;;;;;;;;;;;;;GAaG;AACH;IAEE,yBAA0B;IAC1B,uBAA6B;IA2B9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,sCAZW,MAAM,GAQJ;QAAC,cAAc,EAAE,MAAM,GAAC,IAAI,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,OAAO,CAAA;KAAC,GAAC,SAAS,CAkDhG;IAoGD;;;;OAIG;IACH,kDAgCC;IAED;;;;;;OAMG;IACH,8BALW,MAAM,WACN,MAAM,GACJ,MAAM,CAwBlB;IAED;;;;;;;;;;;;;OAaG;IACH,gCAZW,MAAM,yBAEN,GAAC,gCAED,MAAM,cACN,OAAO,GAIL,GAAC,CA8Gb;IAED;;;;;OAKG;IACH,qBAJa,MAAM,CAuBlB;IAED;;;;;;;;;;;OAWG;IACH,kCATW,MAAM,GAMJ,MAAM,GAAC,SAAS,CAc5B;IAED;;;;OAIG;IACH,2BAHW,MAAM,QAyBhB;IAED;;;;OAIG;IACH,yBAHa,MAAM,CAclB;;CACD;AAED;;;GAGG;AACH,oDAA2D"} \ No newline at end of file +{"version":3,"file":"context-live.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/context-live.mjs"],"names":[],"mappings":"AAiEA;;;;;;;;;;;;;GAaG;AACH;IAEE,yBAA0B;IAC1B,uBAA6B;IA2B9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,sCAZW,MAAM,GAQJ;QAAC,cAAc,EAAE,MAAM,GAAC,IAAI,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,OAAO,CAAA;KAAC,GAAC,SAAS,CAkDhG;IAqGD;;;;OAIG;IACH,kDAgCC;IAED;;;;;;OAMG;IACH,8BALW,MAAM,WACN,MAAM,GACJ,MAAM,CAwBlB;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,gCAhBW,MAAM,yBAEN,GAAC,gCAED,MAAM,cACN,OAAO,WAIP,OAAO,GAIL,GAAC,CA0Hb;IAED;;;;;OAKG;IACH,qBAJa,MAAM,CAuBlB;IAED;;;;;;;;;;;OAWG;IACH,kCATW,MAAM,GAMJ,MAAM,GAAC,SAAS,CAc5B;IAED;;;;OAIG;IACH,2BAHW,MAAM,QAyBhB;IAED;;;;OAIG;IACH,yBAHa,MAAM,CAclB;;CACD;AAED;;;GAGG;AACH,oDAA2D"} \ No newline at end of file diff --git a/types/src/lib/handlers/metadata.d.mts b/types/src/lib/handlers/metadata.d.mts index 4e5c99e63..82e96766f 100644 --- a/types/src/lib/handlers/metadata.d.mts +++ b/types/src/lib/handlers/metadata.d.mts @@ -241,6 +241,18 @@ export class Metadata extends ComponentBase { * @public */ public caller(): object | null; + /** + * Resolve the wrapper of the API function that called the current function. + * + * The single resolution behind {@link Metadata#caller}: reads `callerWrapper` from the active + * context-manager store. `self.slothlet.lockCaller.caller()` pins exactly this identity, so the + * two can never disagree about who "the caller" is. + * + * @returns {object|null} The calling function's wrapper, or null when there is no module caller + * in context (the function was invoked from the host or outside the API). + * @public + */ + public callerWrapper(): object | null; #private; } import { ComponentBase } from "#factories/component-base"; diff --git a/types/src/lib/handlers/metadata.d.mts.map b/types/src/lib/handlers/metadata.d.mts.map index a72ba89dd..2b8f21078 100644 --- a/types/src/lib/handlers/metadata.d.mts.map +++ b/types/src/lib/handlers/metadata.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"metadata.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/metadata.mjs"],"names":[],"mappings":"AAwBA;;;;;;;;;GASG;AACH,kCAHU,MAAM,CAGsC;AAEtD;;;;;GAKG;AACH;IACC,gCAAqC;IAKrC;;;OAGG;IACH,sBAFW,MAAM,EAKhB;IAVD,4BAA4B;IAC5B,aADW,MAAM,GAAG,IAAI,CACL;IA6KnB;;;;;;;;;;;;;;;OAeG;IACH,0BA+CC;IAED;;;;;OAKG;IACH,0BAJW,WAAS,MAAM,GACb,MAAM,GAAC,IAAI,CAYvB;IAED;;;;;;OAMG;IACH,2BAJW,WAAS,MAAM,GACb,MAAM,CAmElB;IAED;;;;;OAKG;IACH,8BAJW,MAAM,SACN,OAAO,QAKjB;IAED;;;;;;OAMG;IACH,8CAJW,MAAM,SACN,OAAO,QA+CjB;IAED;;;;;;;;OAQG;IACH,kDANW,MAAM,GAAC,MAAM,EAAE,GAAC;YAAO,MAAM,GAAE,MAAM,EAAE;KAAC,QAkFlD;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,iCAJW,MAAM,YACN,MAAM,QA0BhB;IAED;;;;;;;;;OASG;IACH,qCAHW,MAAM,QAMhB;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCALW,MAAM,YACN,MAAM,GAAC,MAAM,UACb,OAAO,QAyBjB;IAED;;;;;;;;;;;;;OAaG;IACH,gCANW,MAAM,GACJ,MAAM,CAmBlB;IAED;;;;;;;;;;;;OAYG;IACH,mCAJW,MAAM,QACN,MAAM,GAAC,MAAM,EAAE,QA+BzB;IAED;;;;;;;;;;OAUG;IACH,mBAHa;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,iBAAiB,gBAAK;KAAE,CAe9D;IAED;;;;;;;;;;;;;;;OAeG;IACH,uBAHW;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,iBAAiB,gBAAK;KAAE,QAgC5D;IAED;;;;;;;;;;;;;OAaG;IACH,iBAJW,MAAM,GACJ,OAAO,CAAC,MAAM,GAAC,IAAI,CAAC,CAkChC;IAED;;;;;;;;;;;;;OAaG;IACH,eAHa,MAAM,CAalB;IAED;;;;;;;;;;;;OAYG;IACH,iBAHa,MAAM,GAAC,IAAI,CAOvB;;CACD;8BAz0B6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"metadata.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/metadata.mjs"],"names":[],"mappings":"AAwBA;;;;;;;;;GASG;AACH,kCAHU,MAAM,CAGsC;AAEtD;;;;;GAKG;AACH;IACC,gCAAqC;IAKrC;;;OAGG;IACH,sBAFW,MAAM,EAKhB;IAVD,4BAA4B;IAC5B,aADW,MAAM,GAAG,IAAI,CACL;IA6KnB;;;;;;;;;;;;;;;OAeG;IACH,0BA+CC;IAED;;;;;OAKG;IACH,0BAJW,WAAS,MAAM,GACb,MAAM,GAAC,IAAI,CAYvB;IAED;;;;;;OAMG;IACH,2BAJW,WAAS,MAAM,GACb,MAAM,CAmElB;IAED;;;;;OAKG;IACH,8BAJW,MAAM,SACN,OAAO,QAKjB;IAED;;;;;;OAMG;IACH,8CAJW,MAAM,SACN,OAAO,QA+CjB;IAED;;;;;;;;OAQG;IACH,kDANW,MAAM,GAAC,MAAM,EAAE,GAAC;YAAO,MAAM,GAAE,MAAM,EAAE;KAAC,QAkFlD;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,iCAJW,MAAM,YACN,MAAM,QA0BhB;IAED;;;;;;;;;OASG;IACH,qCAHW,MAAM,QAMhB;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCALW,MAAM,YACN,MAAM,GAAC,MAAM,UACb,OAAO,QAyBjB;IAED;;;;;;;;;;;;;OAaG;IACH,gCANW,MAAM,GACJ,MAAM,CAmBlB;IAED;;;;;;;;;;;;OAYG;IACH,mCAJW,MAAM,QACN,MAAM,GAAC,MAAM,EAAE,QA+BzB;IAED;;;;;;;;;;OAUG;IACH,mBAHa;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,iBAAiB,gBAAK;KAAE,CAe9D;IAED;;;;;;;;;;;;;;;OAeG;IACH,uBAHW;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,iBAAiB,gBAAK;KAAE,QAgC5D;IAED;;;;;;;;;;;;;OAaG;IACH,iBAJW,MAAM,GACJ,OAAO,CAAC,MAAM,GAAC,IAAI,CAAC,CAkChC;IAED;;;;;;;;;;;;;OAaG;IACH,eAHa,MAAM,CAalB;IAED;;;;;;;;;;;;OAYG;IACH,iBAHa,MAAM,GAAC,IAAI,CAOvB;IAED;;;;;;;;;;OAUG;IACH,wBAJa,MAAM,GAAC,IAAI,CAOvB;;CACD;8BAz1B6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/handlers/permission-manager.d.mts.map b/types/src/lib/handlers/permission-manager.d.mts.map index 9d515e7f0..42a04f6e6 100644 --- a/types/src/lib/handlers/permission-manager.d.mts.map +++ b/types/src/lib/handlers/permission-manager.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"permission-manager.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/permission-manager.mjs"],"names":[],"mappings":"AA8KA;;;;;;;GAOG;AACH;IACC;;;;OAIG;IACH,yBAHU,MAAM,CAG8B;IA0I9C;;;;;;OAMG;IACH,sBAJW,MAAM,EAsFhB;IAED;;;;;;;;;;;;;OAaG;IACH,cAVG;QAAqB,MAAM,EAAnB,MAAM;QACO,MAAM,EAAnB,MAAM;QACO,MAAM,EAAnB,MAAM;KACd,kBAAQ,MAAM,GAAC,IAAI,WACX,MAAM,GAAC,IAAI,iBACT,MAAM,CAiDlB;IAED;;;;;;;;;;OAUG;IACH,mBAPW,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,OAAO,CAmCnB;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,mBAZG;QAAqB,MAAM,EAAnB,MAAM;QACO,KAAK,EAAlB,MAAM;QACwB,MAAM,EAApC,MAAM,GAAC,QAAQ,GAAC,OAAO;QACuB,SAAS;KAC/D,kBAAQ,MAAM,GAAC,IAAI,WACX,MAAM,GAAC,IAAI,UACX,MAAM,GAAC,IAAI,GACT,MAAM,CAqClB;IAED;;;;;;;;OAQG;IACH,wBALW,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,OAAO,CAiBnB;IAED;;;;;;;;;;OAUG;IACH,kCALW,MAAM,GAAC,IAAI,aACX,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,MAAM,GAAC,QAAQ,GAAC,OAAO,CA0BnC;IAED;;;;OAIG;IACH,uBAFa,MAAM,CAIlB;IAED;;;;;OAKG;IACH,gCAFa,OAAO,CAOnB;IAuCD;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,wBAlBW,MAAM,cAEd;QAAwC,GAAG,EAAnC,CAAS,IAAM,EAAN,MAAM,KAAG,GAAC;QAGQ,OAAO,EAAlC,CAAS,IAAC,EAAD,GAAC,KAAG,GAAC;QACM,MAAM;KAClC,kBAAQ,MAAM,GAAC,IAAI,WACX,CAAS,IAAQ,YAAE,IAAQ,EAAR,KAAK,CAAC,GAAC,CAAC,KAAG,GAAC,GAG7B,IAAI,CA0ChB;IAED;;;;;;;;;;;OAWG;IACH,0BARW,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,OAAO,CAanB;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,0BARW,MAAM,gBACN,GAAC,mBACD,MAAM,GAAC,IAAI,GACT,OAAO,CAiBnB;IAED;;;;;;;OAOG;IACH,mBALW,MAAM,GACJ,OAAO,CAMnB;IAED;;;;;;;;OAQG;IACH,0BALW,MAAM,GACJ,IAAI,CAUhB;IAED;;;;;;;;;;;OAWG;IACH,2BALW,MAAM,GACJ,IAAI,CAUhB;IAED;;;;;;;;;;;OAWG;IACH,uBANW,MAAM,iBACN,MAAM,GACJ,IAAI,CAShB;IAED;;;;;;;;;;;;;;;OAeG;IACH,4BATW,MAAM,cACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,GACT,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,GAAC,CAAA;KAAE,CAAC,CAkCnD;IAED;;;;;;;;;OASG;IACH,yBALW,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,GAAC,CAAA;KAAE,CAAC,GACrC,OAAO,CAAC,IAAI,CAAC,CAMzB;IA4SD;;;;;;;;;;;;;;;OAeG;IACH,wBAXW,MAAM,cACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,YACX;QAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;KAAE,GAAC,IAAI,GAEzB,OAAO,CA2BnB;IAED;;;;;;;;;OASG;IACH,4BANW,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,cAAU,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WAAS,CAAC,GAAC,IAAI,GAAC,SAAS,mBACvF,MAAM,GAAC,IAAI,GACT,OAAO,CASnB;IAyBD;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,0BAjBW,MAAM,cACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,aACX,kBAAkB,GAAC,IAAI,YAGvB;QAAE,cAAc,CAAC,EAAE,OAAO,CAAA;KAAE,GAAC,IAAI,GAG/B,OAAO,CA+BnB;IAED;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,8BAbW,MAAM,GAAC,IAAI,YAEX,MAAM,YACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,GACT,OAAO,CAenB;IAED;;;;;;;;;;;;;;;OAeG;IACH,4BAZW,MAAM,GAAC,IAAI,YACX,MAAM,YACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBAGX,MAAM,GAAC,IAAI,GACT,OAAO,CAMnB;IAED;;;;;;;OAOG;IACH,4BALW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAazB;IAED;;;;;;;OAOG;IACH,2BALW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAYzB;IAED;;;;;;;;OAQG;IACH,8BALW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAazB;IAED;;;;;;OAMG;IACH,UAJa,IAAI,CAQhB;IAcD;;;;;;;;OAQG;IACH,QAJa,IAAI,CAMhB;IAED;;;;;OAKG;IACH,YAJa,OAAO,CAMnB;IAED;;;;;;OAMG;IACH,WAJa,IAAI,CAQhB;IAED;;;;;;OAMG;IACH,aAJa,OAAO,CAMnB;IAED;;;;;;;;OAQG;IACH,uBAJa,OAAO,CAMnB;IAED;;;;;;;;;OASG;IACH,oBAJa,OAAO,CAMnB;IAED;;;;;;;;;;;;OAYG;IACH,mCAVW,MAAM,GAAC,IAAI,GAAC,SAAS,GACnB,OAAO,CAWnB;IAED;;;;;;;;;;;OAWG;IACH,qBANW,OAAO,GACL,IAAI,CAgBhB;IAED;;;;;;OAMG;IAIH,eAPa,KAAK,CAAC,MAAM,CAAC,CAgBzB;IAED;;;;;;;;OAQG;IACH,2BALW,KAAK,CAAC,MAAM,CAAC,GACX,IAAI,CAShB;IAGD;;;;;;;OAOG;IACH,YAJa,OAAO,CAAC,IAAI,CAAC,CAczB;IA4kBD;;;;;;;OAOG;IACH,cAEC;;CACD;;;;;;;;;;;;;;;UAplEa,KAAK,CAAC,GAAC,CAAC,GAAC,IAAI;;;;YACb,MAAM,GAAC,IAAI;;8BAtHK,2BAA2B"} \ No newline at end of file +{"version":3,"file":"permission-manager.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/permission-manager.mjs"],"names":[],"mappings":"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"} \ No newline at end of file