logicspike/docs

Engagement

Engagement — Permissions

Last Updated: 2026-09-05 · Status: Active

The permission set

Defined in packages/core-access/src/permission-catalogue.ts. Owner, admin and viewer roles derive from it automatically; nothing hardcodes a role's engagement permissions.

Permission readOnly Owner / Admin Member Viewer Group
engagement:stats.read yes ✓ ✓ ✓ Analytics
engagement:sites.read yes ✓ ✓ ✓ Sites
engagement:sites.write ✓ ✓ Sites
engagement:settings.admin ✓ ✗ Settings
engagement:comments.read yes ✓ ✓ ✓ Comments
engagement:comments.moderate ✓ ✓ Comments
engagement:comments.reply ✓ ✓ Comments
engagement:comments.delete ✓ ✓ Comments
engagement:bans.manage ✓ ✓ Comments
engagement:members.read yes ✓ ✓ ✓ Audience
engagement:members.manage ✓ ✓ Audience

Three things worth knowing

settings.admin, not settings.write. The .admin suffix is what excludes a permission from the Member role (rbac.manager.ts). Engagement settings decide whether comments are open at all, what the moderation policy is, and where notifications go — editor-level access is not the right bar for that. Precedent: media:files.admin.

The suffix must be spelled the same everywhere — catalogue, worker route guards, MCP catalog, dashboard. sanitizeKeyPermissions silently drops permission ids that aren't in the catalogue when minting an API key, so a settings.write typo wouldn't error; it would just make update_settings quietly unreachable for that key. packages/core-access/test/catalogue.test.ts catches this class of typo.

comments.read exposes commenter email addresses. Treat it like newsletter:subscribers.read. It is marked readOnly and reaches viewers, which is the same posture — but be aware that "read-only" here still means "can see the addresses of everyone who commented".

Members can moderate. engagement is in MEMBER_WRITE_SERVICES, so an editor can approve, reply, delete and ban. That is the intended division: moderation is editorial work; changing the policy is not.

How a request gets authorised

  1. Gateway validates the JWT or API key, derives the PBAC floor from the catalogue, and forwards x-user-permissions as a JSON array.
  2. app.on(METHOD, [paths], requirePermission("engagement:…")) in src/index.ts gates every admin and MCP route.
  3. test/route-coverage.test.ts fails the build on any ungated mutation that isn't in its explicit allowlist.

The allowlist holds exactly the routes that authenticate some other way — the public door (opaque site_id), /internal/* (a shared token), and /cron/* (the gateway key). Listing them explicitly rather than excluding them by pattern means a route accidentally moved out of /e/* trips the test.

API keys

Preset Gets engagement?
Editor ✓ writes, via EDITOR_WRITE_SERVICES
Publishable / public-read ✗ — PUBLIC_READ_SERVICES is blog-only
Custom whatever you tick

A publishable key can therefore read blog posts but not engagement stats unless you build a Custom key. Publishable keys are refused on MCP entirely (mcp-core/src/auth.ts).

The public door has no permissions at all

/e/* routes are ungated by design and are listed in the route-coverage allowlist. Their security model is entirely different — see architecture.md. If you find yourself wanting to add a permission check to a public route, the route is probably in the wrong place.

The platform blocklist is not a permission

/internal/platform-blocklist acts across every tenant at once, so it is deliberately outside the permission system: no tenant role, however privileged, can reach it. It is guarded by PLATFORM_ADMIN_TOKEN, held only by apps/internal-api, and every write goes to the staff audit log with the blocked value attached.

Adding a permission

  1. Add it to the catalogue with the right group and readOnly.
  2. Guard the route in src/index.ts.
  3. Set requiredPermission on the MCP tool descriptor, if it has one.
  4. Gate the dashboard control with usePermission(...) / <PermissionGate>.

Steps 1 and 2 are what the tests enforce; 3 and 4 fail silently if you skip them (a dropped key permission, an enabled button that 403s).

Step 5, easy to miss: existing keys don't retroactively gain a new permission

Owner/admin dashboard sessions re-derive their permission set live from the catalogue on every mint (rbac.manager.ts's ALL_PERMISSION_IDS), so a brand-new permission group shows up for them immediately. A secret API key is different: its permissions column is a snapshot written once, at mint or edit time (apps/manager/src/lib/mint-api-key.ts, apps/manager/src/routes/api-keys.ts), and it is never recomputed just because the catalogue grew. A key minted before this file's permission set existed will never see engagement:* — or any permission added after it was created — until someone re-saves or regenerates it from the dashboard.

This is a real, easy-to-hit trap: adding a new service to the catalogue does not retroactively unlock it for existing keys, even ones assigned a role/preset that is supposed to include it. If an MCP client reports missing tools right after a catalogue change, check whether its key predates that change before assuming the tools themselves are broken.

Engagement