⚠️ Porta is in beta — APIs and features may change before v1.0
Skip to content

Architecture Decision Log

Last Updated: 2026-04-25

Overview

This page tracks all significant architecture decisions made during Porta's development. Each decision follows the ADR (Architecture Decision Record) format: context, decision, and consequences.

Decision Log

#DecisionStatusDateSummary
ADR-001Koa over ExpressAcceptedKoa required for node-oidc-provider compatibility
ADR-002Path-Based Multi-TenancyAcceptedOrganization slug in URL path for OIDC issuer isolation
ADR-003Hybrid OIDC AdaptersAcceptedRedis for short-lived, PostgreSQL for long-lived OIDC artifacts
ADR-004ES256 Token SigningAcceptedECDSA P-256 for all JWT signing, no algorithm negotiation
ADR-005Argon2id for Password HashingAcceptedArgon2id over bcrypt/scrypt for memory-hard hashing
ADR-006Functional Code StyleAcceptedStandalone functions over classes for services
ADR-007Zod for Config and Input ValidationAcceptedZod schemas for fail-fast config and request validation
ADR-008Dual-Mode CLI BootstrapAcceptedDirect-DB for init/migrate, HTTP for all other commands
ADR-009Self-Authentication for Admin APIAcceptedPorta validates its own tokens for admin API access
ADR-010Domain Module StructureAcceptedConsistent module layout: types, repository, cache, service
ADR-011Login Methods ResolutionAcceptedPer-client override with org-level default inheritance
ADR-012Client Secret Two-Layer HashingAcceptedSHA-256 pre-hash + Argon2id for OIDC compatibility
ADR-013Admin GUI: React SPA + Koa BFFAccepted2026-04BFF pattern with confidential client for admin dashboard

ADR-001: Koa over Express

Context: Porta needs an HTTP framework for its web server. node-oidc-provider, the OIDC engine, is designed for and tested with Koa.

Decision: Use Koa 2.x as the HTTP framework.

Consequences:

  • ✅ Native compatibility with node-oidc-provider (no adapter needed)
  • ✅ Clean async/await middleware pipeline
  • ✅ Lightweight core with explicit middleware composition
  • ⚠️ Smaller ecosystem than Express, fewer third-party middleware options

ADR-002: Path-Based Multi-Tenancy

Context: Porta must serve multiple organizations, each with their own OIDC issuer. Options considered: subdomain-based (orgSlug.auth.example.com), path-based (auth.example.com/orgSlug), or header-based.

Decision: Use path-based multi-tenancy with the organization slug as the first URL path segment: /:orgSlug/.well-known/openid-configuration.

Consequences:

  • ✅ Simple deployment — single DNS record, single TLS certificate
  • ✅ Works behind any reverse proxy without wildcard DNS
  • ✅ Tenant isolation via URL parsing (no header trust required)
  • ✅ OIDC issuer URL naturally includes the tenant identifier
  • ⚠️ All OIDC endpoints must be mounted under /:orgSlug/ prefix
  • ⚠️ Requires tenant resolver middleware before OIDC processing

ADR-003: Hybrid OIDC Adapters (Redis + PostgreSQL)

Context: node-oidc-provider requires storage adapters for various OIDC artifacts (sessions, tokens, grants). Different artifacts have different durability and performance requirements.

Decision: Use a hybrid adapter strategy — Redis for short-lived artifacts, PostgreSQL for long-lived artifacts.

StorageArtifactsRationale
RedisSession, Interaction, AuthorizationCode, ReplayDetection, ClientCredentials, PushedAuthorizationRequestHigh throughput, short TTL, acceptable data loss
PostgreSQLAccessToken, RefreshToken, GrantDurability required, survives Redis restart

Consequences:

  • ✅ Fast session lookups via Redis
  • ✅ Durable tokens survive cache eviction
  • ✅ Session destroy() cascades grant/token deletion across both stores
  • ✅ Natural session expiry preserves tokens (enables refresh flows)
  • ⚠️ Two adapter implementations to maintain
  • ⚠️ Cascade deletion requires cross-store coordination

ADR-004: ES256 Token Signing

Context: JWT tokens need a signing algorithm. Options: RS256 (RSA), ES256 (ECDSA), HS256 (HMAC), or EdDSA.

Decision: Use ES256 (ECDSA P-256) exclusively for all token signing. No algorithm negotiation.

Consequences:

  • ✅ Smaller keys and signatures than RSA (faster verification)
  • ✅ Well-supported across all JWT libraries
  • ✅ No algorithm confusion attacks (single algorithm enforced)
  • ✅ FIPS 186-4 compliant
  • ⚠️ Requires native crypto module for key generation

ADR-005: Argon2id for Password Hashing

Context: Passwords need to be hashed with a modern, memory-hard algorithm. Options: bcrypt, scrypt, Argon2id.

Decision: Use Argon2id for all password hashing, compliant with NIST SP 800-63B.

Consequences:

  • ✅ Memory-hard — resistant to GPU/ASIC attacks
  • ✅ OWASP recommended, NIST compliant
  • ✅ Configurable memory/time/parallelism parameters
  • ⚠️ Requires native C binding (argon2 npm package) — needs build tools in Docker
  • ⚠️ Also used for recovery code hashing and client secret verification

ADR-006: Functional Code Style

Context: The codebase needs a consistent code organization pattern. Options: class-based services with DI, or standalone functions.

Decision: Use standalone exported functions for all service, repository, and utility code. No classes for business logic.

Consequences:

  • ✅ Simpler to test (no instantiation, no mocking constructors)
  • ✅ Tree-shakeable imports
  • ✅ No dependency injection framework needed
  • ✅ Module-level imports for dependencies (database pool, Redis client)
  • ⚠️ Global state accessed via module imports (pool, Redis client)

ADR-007: Zod for Config and Input Validation

Context: Environment variables and API request bodies need validation. Options: joi, yup, zod, class-validator.

Decision: Use Zod for both configuration validation (fail-fast on startup) and API request validation.

Consequences:

  • ✅ TypeScript-native with excellent type inference
  • ✅ Single library for both config and request validation
  • ✅ Fail-fast config validation prevents runtime errors
  • ✅ Production safety checks via Zod's superRefine (e.g., secure cookies require HTTPS)
  • ⚠️ Config validated once at startup; system_config has its own runtime cache

ADR-008: Dual-Mode CLI Bootstrap

Context: The CLI needs to work in two scenarios: (1) initial setup before any admin user exists, and (2) normal administration after setup.

Decision: Implement dual-mode bootstrap:

  • withBootstrap() — Direct database access for porta init, porta migrate, porta seed
  • withHttpClient() — HTTP-based access (via OIDC auth) for all other commands

Consequences:

  • porta init can bootstrap the admin infrastructure without a pre-existing admin account
  • ✅ Normal commands use the same auth flow as any other client (OIDC + PKCE)
  • ✅ CLI credentials stored securely at ~/.porta/credentials.json (0600 permissions)
  • ⚠️ Two code paths to maintain (bootstrap vs HTTP)
  • ⚠️ porta login requires a browser for the OIDC auth code flow

ADR-009: Self-Authentication for Admin API

Context: The admin API needs authentication. Options: separate admin auth system, API keys, or use Porta's own OIDC tokens.

Decision: Porta authenticates its own admin API using tokens it issues to the super-admin organization. The admin-auth middleware validates ES256 JWTs against Porta's own signing keys.

Consequences:

  • ✅ No external auth dependency
  • ✅ Single source of truth for admin identity
  • ✅ Leverages existing OIDC infrastructure (keys, token issuance, RBAC)
  • porta-admin role provides granular access control
  • ⚠️ Bootstrap requires porta init to create the super-admin org and first user

ADR-010: Domain Module Structure

Context: The codebase is growing with multiple business domains. Need a consistent structure for each domain.

Decision: Each domain module follows a standard layout: index.ts (barrel), types.ts, errors.ts, repository.ts, cache.ts, service.ts, plus optional slugs.ts and validators.ts.

Consequences:

  • ✅ Predictable file locations — developers know where to find things
  • ✅ Clear separation of concerns within each module
  • ✅ Barrel exports control the public API surface
  • ✅ Easy to add new modules following the template
  • ⚠️ Some modules have more files than strictly needed (not all need slugs.ts)

ADR-011: Login Methods Resolution

Context: Different organizations and clients may support different authentication methods (password, magic link). Need a flexible inheritance model.

Decision: Per-client login_methods override (NULL = inherit from org) + per-org default_login_methods (NOT NULL, DB DEFAULT {password,magic_link}). Resolution via resolveLoginMethods(org, client).

Consequences:

  • ✅ Org-level default covers most cases
  • ✅ Per-client override for special clients (e.g., passwordless-only SPA)
  • ✅ NULL semantics = "inherit" is intuitive
  • ✅ Enforced before authentication processing (early rejection)
  • ⚠️ Template must handle 4 rendering modes (both/password-only/magic-link-only/empty)

ADR-012: Client Secret Two-Layer Hashing

Context: node-oidc-provider uses SHA-256 to compare client secrets during client_secret_post authentication. But SHA-256 alone is insufficient for secret storage.

Decision: Store both a SHA-256 pre-hash (secret_sha256) for OIDC runtime comparison and a full Argon2id hash (secret_hash) for offline verification.

Consequences:

  • ✅ Compatible with node-oidc-provider's SHA-256 comparison
  • ✅ Full Argon2id protection for stored secrets
  • ✅ SHA-256 pre-hash computed via middleware before reaching the provider
  • ⚠️ Two hash values stored per secret (marginal storage overhead)

ADR-013: Admin GUI: React SPA + Koa BFF

Context: Porta needs a web-based admin dashboard for managing organizations, applications, clients, users, RBAC, and system configuration. The dashboard must be secure (handles admin tokens), integrate with Porta's OIDC auth, and provide a modern UI. Options considered: (a) server-rendered pages (Handlebars), (b) SPA calling Admin API directly from the browser, (c) SPA with a Backend-for-Frontend (BFF) proxy.

Decision: Use a React SPA with FluentUI v9 served through a Koa BFF (Backend-for-Frontend). The BFF handles OIDC authentication as a confidential client, stores tokens in a server-side Redis session, and proxies API requests with Bearer token injection. The SPA never sees admin tokens.

Technology choices:

  • React 19 + FluentUI v9 — Microsoft's enterprise design system, consistent component library
  • React Query (TanStack Query) — Server state management with caching, retry, and cache invalidation
  • React Router — Client-side routing with breadcrumb support
  • Vite — Fast build tooling for development and production
  • Koa BFF — Matches the main Porta server's framework (Koa), reuses patterns
  • koa-session + ioredis — Server-side session store in Redis (DB index 1)

Consequences:

  • ✅ Admin tokens never reach the browser — immune to XSS token theft
  • ✅ Confidential client auth (client_secret_post) — stronger than public client PKCE
  • ✅ CSRF protection via double-submit cookie pattern
  • ✅ FluentUI v9 provides accessible, enterprise-grade components out of the box
  • ✅ React Query reduces boilerplate for data fetching and keeps UI in sync
  • ✅ Self-contained module (admin-gui/) with own package.json, tests, and build
  • ✅ Shares Docker image with Porta server (PORTA_SERVICE=admin mode)
  • ⚠️ Additional service to deploy (port 4002)
  • ⚠️ Separate dependency tree from the main Porta server
  • ⚠️ BFF adds a network hop between browser and Admin API

Adding New ADRs

When making a significant architecture decision, add an entry to this log with:

  1. Context — Why is this decision needed?
  2. Decision — What was decided?
  3. Consequences — What are the trade-offs?

Decisions that warrant an ADR:

  • Technology choices (frameworks, libraries, databases)
  • Architectural patterns (how modules interact)
  • Security mechanisms (crypto algorithms, auth flows)
  • Data model design (schema patterns, storage strategies)
  • API design conventions (pagination, error handling)

Released under the MIT License.