Skip to content
Back to skills

iii-worker-manager

ASecurity

WebSocket listener that SDK workers connect to. Use to expose the engine to trusted (internal) and untrusted (RBAC-protected) workers, gate function and trigger registration, and audit or transform every invocation through a middleware function. Mounts channel WebSocket endpoints on every listener.

  • 18,821 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 3, 2026
developmenttypescriptrustapisecurity

Works with

  • api

Security analysis

A100/100

Scanned September 24, 2026

npx -y skills add iii-hq/iii --skill skills --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of iii-worker-manager?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for iii-worker-manager
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/iii-hq-iii-worker-manager/badge)](https://www.skillsdirectory.com/skills/iii-hq-iii-worker-manager)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: iii-worker-manager
description: >-
  WebSocket listener that SDK workers connect to. Use to expose the engine to
  trusted (internal) and untrusted (RBAC-protected) workers, gate function and
  trigger registration, and audit or transform every invocation through a
  middleware function. Mounts channel WebSocket endpoints on every listener.
---

# iii-worker-manager

The `iii-worker-manager` is a mandatory engine worker that opens WebSocket listeners. The base
`engine.workers.iii-worker-manager` entry sets the main port (`49134` by default); `#instance`
entries open separate listeners with their own RBAC, middleware, and registration hooks. Channels
are mounted on every listener at `/ws/channels/{channel_id}`.

## When to Use

- Exposing the engine to an untrusted network — RBAC is mandatory, never open the engine port itself. Use the standalone [`rbac-proxy` worker](https://workers.iii.dev/workers/rbac-proxy) (`iii trigger compose::add worker=rbac-proxy`), which applies these RBAC rules on its own port out of process. A second `#instance` listener with an `rbac` block and an `auth_function_id` is the in-engine alternative.
- Restricting which functions a connected worker can invoke — combine `expose_functions` filters (operator-side) with `forbidden_functions` from `AuthResult` (per-session, hard deny).
- Auditing, rate-limiting, or enriching every invocation — set `middleware_function_id` on the listener; the middleware decides whether to call the target and what to return.
- Per-tenant or per-session namespace isolation — return `function_registration_prefix` from the auth function so every function/trigger this session registers is transparently prefixed without the worker code knowing.
- Gating dynamic registration — wire `on_function_registration_function_id`, `on_trigger_registration_function_id`, or `on_trigger_type_registration_function_id` to validate or rewrite registrations.

## Boundaries

- The infrastructure carve-out (`engine::channels::create`, `engine::workers::register`, `engine::log::*`, `engine::baggage::*`) is always allowed on RBAC listeners regardless of `expose_functions`. Adding one of those IDs to `forbidden_functions` denies it but logs a warning — workers may behave unpredictably (broken setup, lost logs, missing context).
- The engine port must remain internal. Only `rbac-proxy` or an RBAC-protected `#instance` listener may face an external network.
- The middleware is **not** a pre-handler — it must invoke the target function itself (typically via `iii.trigger`) and return its result. Returning early without invoking simply skips the call.
- `forbidden_functions` from the auth result wins over both `allowed_functions` and `expose_functions`. There is no way for a session to override an operator's deny list.
- Registration hooks return mapped fields or **throw** to deny. Omitted result fields keep their original values; returning `{}` is a no-op (allow as-is).
- Triggers and functions registered through an RBAC session are scoped to that session and cleaned up automatically on disconnect.

## Configuration

Two listeners — one internal, one external with in-engine RBAC (the `rbac-proxy` worker accepts the same `rbac` block through the `configuration` worker instead):

```yaml
engine:
  workers:
    iii-worker-manager:
      port: 49134

    "iii-worker-manager#rbac":
      host: 0.0.0.0
      port: 49135
      middleware_function_id: my-project::middleware
      rbac:
        auth_function_id: my-project::auth
        on_function_registration_function_id: my-project::on-fn-reg
        on_trigger_registration_function_id: my-project::on-trig-reg
        on_trigger_type_registration_function_id: my-project::on-trig-type-reg
        expose_functions:
          - match("api::*")
          - match("*::public")
          - metadata:
              public: true
```

## Auth function

Receives `AuthInput { headers, query_params, ip_address }`, returns `AuthResult`. Throw to reject the connection.

```typescript
import type { AuthInput, AuthResult } from '@iii-dev/helpers/worker-connection-manager'

iii.registerFunction(
  'my-project::auth',
  async (input: AuthInput): Promise<AuthResult> => {
    const token = input.headers?.['authorization']?.replace(/^Bearer\s+/i, '')
    if (!token) throw new Error('Missing credentials')

    const user = await validateToken(token)

    return {
      allowed_functions: [],
      forbidden_functions: user.role === 'readonly'
        ? ['api::users::delete', 'api::users::update']
        : [],
      allowed_trigger_types: user.role === 'admin' ? ['cron', 'webhook'] : undefined,
      allow_trigger_type_registration: user.role === 'admin',
      function_registration_prefix: `tenant-${user.tenant_id}`,
      context: { user_id: user.id, role: user.role, tenant_id: user.tenant_id },
    }
  },
)
```

`AuthResult` defaults: `allow_function_registration: true`, `allow_trigger_type_registration: false`, `allowed_trigger_types: undefined` (permissive — every type allowed). Setting `function_registration_prefix` makes the engine transparently rewrite IDs in both directions; the worker SDK never sees the prefix.

## Middleware function

Runs on every invocation through this listener. Receives `MiddlewareFunctionInput { function_id, payload, action, context }`. Must call the target function itself.

```typescript
import type { MiddlewareFunctionInput } from 'iii-sdk'

iii.registerFunction(
  'my-project::middleware',
  async (input: MiddlewareFunctionInput) => {
    console.log(`[audit] user=${input.context.user_id} → ${input.function_id}`)

    return iii.trigger({
      function_id: input.function_id,
      payload: {
        ...input.payload,
        _caller_id: input.context.user_id,
        _caller_role: input.context.role,
      },
    })
  },
)
```

Skipping the `iii.trigger` call short-circuits the request — useful for rate-limit rejections (return a structured error envelope instead of invoking the target).

## Registration hooks

Each hook receives the registration details plus `AuthResult.context`. Return mapped fields or throw to deny. Omitted fields keep their original value.

```typescript
import type {
  OnFunctionRegistrationInput,
  OnTriggerRegistrationInput,
  OnTriggerTypeRegistrationInput,
} from '@iii-dev/helpers/worker-connection-manager'

iii.registerFunction(
  'my-project::on-fn-reg',
  async (input: OnFunctionRegistrationInput) => {
    if (input.function_id.startsWith('internal::')) {
      throw new Error('Cannot register internal functions')
    }
    return { function_id: input.function_id }
  },
)

iii.registerFunction(
  'my-project::on-trig-reg',
  async (input: OnTriggerRegistrationInput) => {
    const role = input.context.role as string
    if (!input.function_id.startsWith(`${role}::`)) {
      throw new Error('Function ID must be prefixed with the role')
    }
    return { function_id: `${role}::${input.function_id}` }
  },
)

iii.registerFunction(
  'my-project::on-trig-type-reg',
  async (input: OnTriggerTypeRegistrationInput) => {
    if (input.context.role !== 'admin') {
      throw new Error('Only admins can register trigger types')
    }
    return {}
  },
)
```

A worker can register a trigger type only when `allow_trigger_type_registration: true` AND (if configured) the hook returns a result. A worker can register a trigger only when its `trigger_type` is in `allowed_trigger_types` (or the field is omitted) AND the hook returns a result.

## Function filters

`expose_functions` accepts wildcard and metadata filters; multiple filters are OR'd. Metadata filters AND across keys.

```yaml
expose_functions:
  - match("api::*")             # ID glob: * matches any chars
  - match("*::public")          # suffix match
  - match("api::*::read")       # multi-segment match
  - metadata:                   # all keys must match
      public: true
      tier: free
  - metadata:
      name: match("*public*")   # metadata values can be wildcards too
```

A function is exposed if **any** filter matches. Empty `expose_functions` = no functions exposed (other than the infrastructure carve-out).

## Channels on RBAC ports

Channels are mounted at `/ws/channels/{channel_id}` on the same port the worker connected through. `engine::channels::create` is always allowed (infrastructure carve-out), so RBAC listeners can hand out channel refs even with empty `expose_functions`. SDK `createChannel()` works unmodified — channel data flows through whichever listener the worker is on, never the main engine port. Access to a channel WebSocket is independently validated by the `access_key` capability token in the `StreamChannelRef`.

## Access decision flow

```mermaid
flowchart TD
  Start["invokeFunction"] --> Forbidden{"in forbidden_functions?"}
  Forbidden -- yes --> Deny["DENY"]
  Forbidden -- no --> Allowed{"in allowed_functions?"}
  Allowed -- yes --> Allow["ALLOW"]
  Allowed -- no --> Infra{"infrastructure ID?"}
  Infra -- yes --> Allow
  Infra -- no --> Expose{"any expose_functions filter matches?"}
  Expose -- yes --> Allow
  Expose -- no --> Deny
```

Rule of thumb: deny by default (rule 5). Operators allow via `expose_functions`; auth functions augment with `allowed_functions` and tighten with `forbidden_functions`. The infrastructure carve-out is the only path that ignores `expose_functions` entirely.

## Security notes

- Always set `auth_function_id` on listeners that face untrusted networks. A listener with no `rbac` block authenticates nothing — every connection is allowed and `expose_functions` alone gates access.
- Prefer narrow `expose_functions` over `match("*")`. Audit the list whenever a new namespace lands in the engine.
- The middleware is the right place for request validation, rate limiting, and audit logging. Keep it idempotent — invocations may be retried.
- Use `function_registration_prefix` for multi-tenant isolation rather than asking each tenant to register pre-prefixed function IDs. The prefix is invisible to the worker SDK.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…