Last Updated: 2026-06-28 Status: Active Companion docs: architecture.md · api-spec.md
1. Permission Strings
| Permission | Grants access to |
|---|---|
forms:read |
List forms, get a form, per-form analytics, tenant overview, list submissions (all statuses), get a submission, list webhook deliveries |
forms:write |
Create / update / delete / duplicate forms; update submission status + note; delete submissions; reply to a submission; retry webhook delivery |
forms:submissions.export |
GET /admin/forms/:id/submissions/export (CSV / JSON download) |
system:owner |
Bypasses all permission checks; also bypasses credit gates (unlimited submissions) |
2. Permission Matrix
| Operation | forms:read |
forms:write |
forms:submissions.export |
system:owner |
|---|---|---|---|---|
GET /admin/forms |
✅ | ✅ | — | ✅ |
GET /admin/forms/overview |
✅ | ✅ | — | ✅ |
GET /admin/forms/:id |
✅ | ✅ | — | ✅ |
GET /admin/forms/:id/analytics |
✅ | ✅ | — | ✅ |
POST /admin/forms |
— | ✅ | — | ✅ |
PUT /admin/forms/:id |
— | ✅ | — | ✅ |
DELETE /admin/forms/:id |
— | ✅ | — | ✅ |
POST /admin/forms/:id/duplicate |
— | ✅ | — | ✅ |
GET /admin/forms/:id/submissions |
✅ | ✅ | — | ✅ |
GET /admin/submissions (inbox) |
✅ | ✅ | — | ✅ |
GET /admin/submissions/:id |
✅ | ✅ | — | ✅ |
PATCH /admin/submissions/:id |
— | ✅ | — | ✅ |
DELETE /admin/submissions/:id |
— | ✅ | — | ✅ |
POST /admin/submissions/:id/reply |
— | ✅ | — | ✅ |
GET /admin/forms/:id/webhook-deliveries |
✅ | ✅ | — | ✅ |
GET /admin/forms/:id/submissions/export |
— | — | ✅ | ✅ |
| MCP tools (read) | ✅ | ✅ | — | ✅ |
| MCP tools (write) | — | ✅ | — | ✅ |
MCP export-submissions |
— | — | ✅ | ✅ |
Public POST /f/:form_id |
no auth required | no auth required | no auth required | — |
3. Gateway Headers
The gateway injects these headers for all admin and MCP requests:
| Header | Set by | Contains |
|---|---|---|
x-gateway-key |
Gateway | Shared secret (validated against GATEWAY_SECRET). Present on all requests including public. |
x-tenant-id |
Gateway | Owning tenant's ID (admin/MCP only) |
x-user-id |
Gateway | Authenticated user's ID (admin/MCP only) |
x-user-role |
Gateway | User role string (admin/MCP only) |
x-user-permissions |
Gateway | Comma-separated permission strings (admin/MCP only) |
x-request-id |
Gateway | Unique request ID for tracing |
The forms-service reads x-user-permissions and checks for the required permission strings via requirePermission().
4. Public Submit Path
POST /f/:form_id, GET /f/:form_id/schema, GET /f/:form_id/page, and GET /f/embed.js require no auth header. The form_id in the URL is the only credential.
The gateway still injects x-gateway-key on the public path (required by the worker's gateway guard), but does not inject tenant headers. The forms-service derives the tenant from the form row itself.
Abuse prevention on the public path:
allowed_originsCORS check (per-form)- Honeypot field (always active, default field name
botcheck) - Cloudflare Turnstile (per-form opt-in,
captchaRequired=true) - Native rate limiter: 20 requests per 60 seconds per
(form_id, IP)
5. Role Bundles
forms:read and forms:write are bundled into the tenant owner, admin, and editor roles. forms:submissions.export is bundled into owner and admin. system:owner bypasses all checks including credit gates.
Permission strings are defined in @repo/core-types and managed in the manager service's entitlement catalog.