API Design
Last Updated: 2026-04-25
Overview
Porta exposes three distinct API surfaces:
- Admin API (
/api/admin/*) — RESTful management API for organizations, applications, clients, users, RBAC, and system configuration - OIDC Endpoints (
/:orgSlug/*) — OpenID Connect protocol endpoints powered by node-oidc-provider - Admin GUI BFF (
/api/*,/auth/*) — Backend-for-frontend proxy that wraps the Admin API with session-based auth, CSRF, and security headers (see Admin GUI architecture)
This document covers the design principles, conventions, and patterns used in the Admin API. For OIDC protocol details, see the node-oidc-provider documentation. For BFF details, see the Security Architecture.
REST Conventions
URL Structure
All admin endpoints follow a consistent RESTful pattern:
/api/admin/{resource} # Collection
/api/admin/{resource}/:id # Single resource
/api/admin/{resource}/:id/{sub-resource} # Nested resourceNested resources are used for tenant-scoped or application-scoped entities:
/api/admin/organizations/:orgId/users # Users within an org
/api/admin/organizations/:orgId/users/:userId/roles # User's role assignments
/api/admin/applications/:appId/roles # Roles within an app
/api/admin/applications/:appId/permissions # Permissions within an app
/api/admin/applications/:appId/claims # Claim definitions within an appHTTP Methods
| Method | Purpose | Idempotent | Response |
|---|---|---|---|
GET | Retrieve resource(s) | Yes | 200 + body |
POST | Create resource | No | 201 + body |
PUT | Full update | Yes | 200 + body |
PATCH | Partial update / status change | Yes | 200 + body |
DELETE | Remove resource | Yes | 204 (no body) |
Endpoint Inventory
| Route File | Base Path | Endpoints | Description |
|---|---|---|---|
organizations.ts | /api/admin/organizations | 10 | Org CRUD + status lifecycle + branding |
applications.ts | /api/admin/applications | 11 | App CRUD + status + modules |
clients.ts | /api/admin/clients | 10 | Client CRUD + status + secrets |
users.ts | /api/admin/organizations/:orgId/users | 13 | User CRUD + status + password + login tracking |
roles.ts | /api/admin/applications/:appId/roles | 9 | Role CRUD + permission assignment |
permissions.ts | /api/admin/applications/:appId/permissions | 6 | Permission CRUD |
user-roles.ts | /api/admin/organizations/:orgId/users/:userId/roles | 4 | User-role assignment |
custom-claims.ts | /api/admin/applications/:appId/claims | 9 | Claim definitions + user values |
config.ts | /api/admin/config | — | System configuration management |
keys.ts | /api/admin/keys | — | Signing key management |
audit.ts | /api/admin/audit | — | Audit log viewer with filters |
stats.ts | /api/admin/stats | — | Dashboard statistics (6 aggregate queries) |
sessions.ts | /api/admin/sessions | — | Session management + revocation |
bulk.ts | /api/admin/bulk | — | Bulk status operations |
branding.ts | /api/admin/organizations/:orgId/branding | — | Logo/favicon upload (bytea) |
exports.ts | /api/admin/export/:entityType | — | CSV/JSON data export |
Authentication
Admin API Authentication
All /api/admin/* routes (except the metadata endpoint) are protected by the admin-auth middleware (src/middleware/admin-auth.ts):
Key properties:
- Self-authentication — Porta validates tokens signed by its own keys
- ES256 only — No algorithm negotiation; ECDSA P-256 is enforced
- Role-based — Requires
porta-adminrole in the super-admin organization - Metadata endpoint —
GET /api/admin/metadatais unauthenticated (for CLI login discovery)
OIDC Authentication
OIDC endpoints use standard OpenID Connect mechanisms:
- Authorization Code + PKCE for public clients (SPAs, CLI)
- Client Secret Post for confidential clients (with SHA-256 pre-hash)
- Client Credentials for machine-to-machine
Request Validation
All request bodies are validated using Zod schemas defined inline in route handlers:
// Example: Create organization
const schema = z.object({
name: z.string().min(1).max(255),
slug: z.string().min(2).max(63).regex(/^[a-z0-9-]+$/).optional(),
defaultLocale: z.string().max(10).optional(),
});
const body = schema.parse(ctx.request.body);Validation principles:
- Every request body field is validated before reaching the service layer
- Zod parse errors are caught by the error handler and returned as 400 responses
- Path parameters (UUIDs) are validated with
z.string().uuid() - Query parameters for pagination/filtering are validated with optional schemas
Pagination
Offset-Based Pagination (Legacy)
Used on some list endpoints:
GET /api/admin/organizations?page=1&limit=20Response includes pagination metadata:
{
"data": [...],
"pagination": {
"page": 1,
"limit": 20,
"total": 150,
"totalPages": 8
}
}Cursor-Based Keyset Pagination (Preferred)
All entity repositories support cursor-based pagination for consistent performance on large datasets:
GET /api/admin/organizations?cursor=<opaque>&limit=20&sort=name&order=ascResponse:
{
"data": [...],
"pagination": {
"hasMore": true,
"nextCursor": "<opaque>",
"limit": 20
}
}Implementation: Keyset pagination uses WHERE (sort_column, id) > (last_value, last_id) for O(1) page access regardless of offset.
Optimistic Concurrency (ETag)
Entity updates support optimistic concurrency via ETag/If-Match headers:
GET /api/admin/organizations/:id
→ ETag: "abc123"
PUT /api/admin/organizations/:id
If-Match: "abc123"
→ 200 OK (if unchanged)
→ 412 Precondition Failed (if modified by another client)ETags are computed from the entity's updated_at timestamp.
Error Handling
Error Response Format
All errors follow a consistent JSON format:
{
"error": "Human-readable error message",
"status": 400
}HTTP Status Codes
| Code | Meaning | When Used |
|---|---|---|
| 200 | OK | Successful read or update |
| 201 | Created | Successful resource creation |
| 204 | No Content | Successful deletion |
| 400 | Bad Request | Validation failure (Zod parse error) |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Insufficient permissions or suspended tenant |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Duplicate slug, email uniqueness violation |
| 410 | Gone | Archived tenant |
| 412 | Precondition Failed | ETag mismatch |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unhandled error (details hidden) |
Domain Error Classes
Each module defines typed error classes that the error handler maps to HTTP status codes:
OrganizationNotFoundError → 404
OrganizationValidationError → 400
UserNotFoundError → 404
UserValidationError → 400
ClientNotFoundError → 404
RoleNotFoundError → 404
ClaimNotFoundError → 404
RbacValidationError → 400Service Layer Pattern
All route handlers follow a consistent pattern:
- Route handler validates input with Zod
- Service function orchestrates business logic
- Repository executes parameterized SQL queries
- Cache is invalidated after writes
- Audit log records the action (fire-and-forget)
- Response is returned as JSON
Functional Style
Porta uses standalone exported functions rather than classes for services:
// ✅ Porta's style
export async function createOrganization(input: CreateOrganizationInput): Promise<Organization> { ... }
// ❌ Not used
class OrganizationService { create(input) { ... } }Data Export
The export API supports bulk data extraction:
GET /api/admin/export/organizations?format=csv
GET /api/admin/export/users?format=json&orgId=<uuid>Supported entity types: organizations, applications, clients, users. Supported formats: CSV, JSON.
Related Documentation
- System Overview — Architecture and middleware stack
- Data Model — Database schema
- Security — Authentication and authorization details
- Admin API Reference — Product documentation for API consumers