# fulla — full documentation > All English documentation pages concatenated for LLM consumption. Source of truth: https://fulla.dev/docs --- # Getting Started Source: https://fulla.dev/docs/intro # Getting Started **fulla** is a high-performance, open-source identity and access management (IAM) core built in C++17: a production-grade OAuth2/OIDC authorization server with full coverage of user authentication, MFA, WebAuthn, RBAC, and multi-tenancy — it can be deployed as an **out-of-the-box product** (Docker/Helm) or integrated as an **embeddable C++ SDK** (`find_package(fulla-*)`). Quick entry points: | If you want to… | Go to | |---|---| | Understand the architecture in five minutes | [Architecture Overview](architecture/architecture-overview) | | Get it running | [Docker Deployment](operate/docker-deployment) · [Quick Start](https://github.com/voidvec/fulla#quick-start) in the README | | Embed the OAuth2 engine in your C++ project | [SDK Integration Guide](sdk/sdk-integration-guide) | | Call the HTTP API from any language | [API Reference](domains/api-reference) · [OIDC Integration](domains/oidc-guide) | | Deploy to production | [Production Deployment](operate/deployment) · [Configuration Guide](operate/configuration-guide) | | Understand why a design is the way it is | [ADR Decision Records](adr/ADR-0001.md) | | Contribute to the project | [Contribute](contribute/testing-guide) | The content on this site comes directly from [the repository's docs/ directory](https://github.com/voidvec/fulla/tree/master/docs) (single source of truth, zero copying); if you find an error, please open a PR against the repository documentation — the site is rebuilt automatically from master. --- # Automating with Fulla (no human in the loop) Source: https://fulla.dev/docs/guides/automation # Automating with Fulla (no human in the loop) Fulla speaks standard OAuth 2.0, so scripts, CLIs and service-to-service integrations use the same protocols browsers use. This guide covers the two supported automation paths. For letting your *users* sign in, see [Build an app on Fulla](build-an-app.md). ## 1. Scripts and CLIs — Device Authorization Grant (device flow) Best when a tool runs where a browser may or may not be available (a laptop CLI, a CI job with a browser, a TV/terminal). 1. Ask an administrator (or, where enabled, self-register via Portal → **My Applications**) to create a client for your tool. Device flow needs the `urn:ietf:params:oauth:grant-type:device_code` grant type. 2. Start the flow: ```bash curl -X POST https://your-fulla.example/oauth2/device_authorization \ -d "client_id=YOUR_CLIENT_ID" # -> { "device_code": "...", "user_code": "ABCD-EFGH", # "verification_uri": "https://your-fulla.example/oauth2/device", # "interval": 5, "expires_in": 600 } ``` 3. Show `verification_uri` + `user_code` to the user; they approve in the browser. 4. Poll the token endpoint (respect `interval` and `slow_down`): ```bash curl -X POST https://your-fulla.example/oauth2/token \ -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \ -d "device_code=DEVICE_CODE" \ -d "client_id=YOUR_CLIENT_ID" ``` While pending you get `authorization_pending`; after approval you get an `access_token` (+ `refresh_token` with the `offline_access`-style scope). ## 2. Service to service — client credentials grant For machine-only integrations with no user context (no end-user consent, no OIDC identity). Requires a **CONFIDENTIAL** client with `client_credentials` in its grant types. ```bash curl -X POST https://your-fulla.example/oauth2/token \ -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \ -d "grant_type=client_credentials" \ -d "scope=read" ``` Notes: - The resulting token represents the *client*, not any user (`sub` is the client identity). - Scope is bounded by the scopes registered on the client; request only what you need. - Rotate secrets from the management UI (or Portal → My Applications for self-registered apps). Rotation invalidates the previous secret immediately. ## Why there are no personal API tokens Personal long-lived API tokens would bypass scope governance, revocation and audit paths that the standard grants already provide. Device flow + client_credentials cover the same use cases with better security characteristics; see the v1.4.0 design notes in `docs-local/productization-evolution/` for the trade-off discussion. --- # Build an app on Fulla (OIDC sign-in) Source: https://fulla.dev/docs/guides/build-an-app # Build an app on Fulla (OIDC sign-in) Let your web/mobile/CLI application use Fulla as its identity provider: standard OpenID Connect, any standards-compliant library, no custom protocol. ## 1. Register your application - **Where enabled**, self-register: Portal → **My Applications** → *Register application*. Choose: - `PUBLIC` for browser/mobile apps (authorization code + PKCE, no secret); - `CONFIDENTIAL` for server-side apps (client secret, shown exactly once — store it in a secret manager). - Otherwise ask your Fulla administrator to create the client. - Register your exact redirect URI(s) (`https://` required outside development). - Pick scopes from the self-service allowlist (`openid`, `profile`, `email` by default). ## 2. Point your OIDC library at Fulla Discovery does the rest: ``` https://your-fulla.example/.well-known/openid-configuration ``` Standard settings: authorization endpoint `/oauth2/authorize`, token endpoint `/oauth2/token`, JWKS `/.well-known/jwks.json`, scopes `openid profile email`. ## 3. Sign users in Authorization code + PKCE (works for PUBLIC and CONFIDENTIAL clients): ``` GET /oauth2/authorize? client_id=YOUR_CLIENT_ID &redirect_uri=https://your-app.example/callback &response_type=code &scope=openid profile email &state= &code_challenge= &code_challenge_method=S256 ``` Exchange the code at `/oauth2/token`, verify the `id_token` against the JWKS, and read profile claims from `/oauth2/userinfo`. ## 4. Automation (no user present) Scripts and service-to-service integrations do not sign users in — see [Automating with Fulla](automation.md) for device flow and client_credentials. ## 5. Operations - Rotating a secret (Portal → My Applications → *Rotate secret*) invalidates the previous secret immediately. - Deleting an application revokes its consents and stops token issuance. - Administrators can suspend abusive applications; suspended clients fail client validation immediately. --- # Architecture Overview Source: https://fulla.dev/docs/architecture/architecture-overview # Architecture Overview This page summarizes the overall architecture of fulla from four perspectives: technology stack, module layout, request flow, and deployment topology. ## 1. Technology Stack | Layer | Technology | Purpose | |---|---|---| | Web framework | Drogon | High-performance asynchronous C++ HTTP services | | Primary database | PostgreSQL 17 | Persistence for users, roles, clients, authorization codes, and tokens (deployment default since 2026-08-18; the server also runs on 15 — see [PG Major-Version Upgrade](../operate/postgresql-major-upgrade.md) for the upgrade path) | | Cache / KV | Redis | Optional L2 cache in front of Postgres (client and access-token read paths); the standalone Redis store is deprecated (rate limiting is implemented in-process and does not depend on Redis) | | Frontend | Vue 3 + Vite | SPA client for the OAuth2 authorization-code flow | | Deployment | Docker Compose | Local and production-like full-stack deployment | | Observability | Prometheus | Metrics collected via the Drogon PromExporter | ## 2. Module Layout To keep the system genuinely pluggable, the project is structured as two tiers: a core plugin library plus a demo server: ```text HTTP 请求 | |-- fulla-server(apps/server —— 演示服务器二进制) | `-- AuthService:本地应用认证(libs/drogon/src/AuthService.cc) | `-- fulla::drogon(libs/drogon —— 独立 SDK 包,target fulla::drogon) |-- 插件核心 | `-- OAuth2Plugin:初始化与生命周期管理 | |-- 协议控制器与过滤器(自动注册) | |-- AuthorizationEndpointController / TokenEndpointController / DiscoveryController: | | 处理 /oauth2/authorize、/oauth2/token、/.well-known/*、/userinfo | `-- 过滤器:AuthorizationFilter、OAuth2AuthFilter | |-- 服务层(核心业务逻辑,libs/oauth2) | |-- TokenService:PKCE、授权码/令牌的生成与交换 | |-- ClientService:客户端凭据与 redirect URI 校验 | `-- IdentityService:RBAC、subject 映射与用户同意 | `-- 存储层(按仓储定义端口,按后端装配 Bundle) |-- I{Client,Grant,Token,Consent,UserInfo}Repository:存储端口(libs/oauth2) |-- MemoryRepositoryBundle:进程内测试存储(libs/storage-memory) |-- PostgresRepositoryBundle:持久化存储(libs/storage-postgres) `-- RedisRepositoryBundle:Redis 存储(libs/storage-redis) ``` ## 3. Authorization-Code Flow ```mermaid sequenceDiagram participant SPA as Vue SPA participant App as fulla-server (App) participant Core as OAuth2Plugin (Core) participant Store as Storage SPA->>Core: GET /oauth2/authorize Core->>Store: validate client Core-->>SPA: 302 → App /login SPA->>App: POST /api/login App->>Store: AuthService::validateUser App-->>SPA: 302 /callback?code=... SPA->>Core: POST /oauth2/token Core->>Store: consume auth code Core->>Store: save access token Core-->>SPA: access_token JSON SPA->>Core: GET /oauth2/userinfo Core->>Store: validate token Core-->>SPA: userinfo JSON ``` ## 4. Storage Strategy `OAuth2Plugin` selects the backend according to `storage_type` in `config.json`. | storage_type | Implementation | Typical use | |---|---|---| | memory | `MemoryRepositoryBundle` | Unit tests and fast local demos | | redis | `RedisRepositoryBundle` | **Deprecated** — logs an ERROR at startup and rejects the `refresh_token` grant with `unsupported_grant_type`; do not use. Redis now serves only as an optional cache layer in front of Postgres (see [Configuration Guide §3](../operate/configuration-guide.md)) | | postgres | `PostgresRepositoryBundle` | Production persistent storage | > Each `*RepositoryBundle` assembles all five repository implementations for its backend — client / grant / token / consent / userinfo (see the headers under each `libs/storage-*/include`). ## 5. Frontend–Backend Integration The frontend starts the OAuth2 authorization-code flow from the login page: it stores the CSRF `state` in localStorage, handles `/callback`, exchanges the authorization code for tokens, and then calls `/oauth2/userinfo`. For third-party social login, the frontend receives the external authorization code and submits it to backend endpoints: - `/api/google/login` - `/api/wechat/login` Token exchange with the provider happens on the server side; provider secrets are never exposed to the browser. ## 6. Deployment Topology Docker Compose starts the following by default: - `fulla-frontend`: port `8080` - `fulla-admin` (admin console): port `8081` - `fulla-backend`: port `5555` - PostgreSQL: host port `5433` - Redis: host port `6380` - Prometheus: port `9090` For production, terminate TLS at a reverse proxy and proxy API requests to the Drogon backend (see [Production Deployment](../operate/deployment.md) for the full procedure). --- # OAuth2 Data Persistence Source: https://fulla.dev/docs/architecture/data-persistence # OAuth2 Data Persistence This document describes the OAuth2 plugin's persistence layer design, database schema, Redis key-value structure, and security hardening. ## 1. Design Goals - **Storage decoupling**: repository interfaces (`IClientRepository`, `IGrantRepository`, `ITokenRepository`, etc. under `libs/oauth2/include/fulla/oauth2/repository/`) abstract over multiple storage backends such as memory, PostgreSQL, and Redis; each backend is assembled as a `*RepositoryBundle` implementation. - **Data durability**: ensure that critical data — client information, tokens, auth codes — is never lost. - **Security hardening**: client secrets are never stored in plaintext; salted SHA256 hashing is mandatory. - **Asynchronous, high performance**: all low-level operations use `execSqlAsync` and `execCommandAsync` on a callback basis, fully exploiting Drogon's non-blocking I/O. --- ## 2. PostgreSQL Storage Suited to production environments; provides strict, full relational data consistency. ### 2.1 Database Schema Created by the migration script `apps/server/migrations/V002__oauth2_core.sql` (idempotent, `IF NOT EXISTS`; subsequent migrations add scopes, device codes, lockout, and other columns). The core tables: #### Client table (`oauth2_clients`) Stores information about registered client applications. ```sql CREATE TABLE IF NOT EXISTS oauth2_clients ( client_id VARCHAR(50) PRIMARY KEY, client_type VARCHAR(20) NOT NULL DEFAULT 'CONFIDENTIAL', client_secret VARCHAR(100) NOT NULL, -- 存储 SHA256(secret + salt) 的 Hex 字符串 salt VARCHAR(50) NOT NULL, -- 随机盐值 name VARCHAR(100), redirect_uris TEXT, -- 逗号分隔或 JSON 数组 allowed_grant_types TEXT -- 允许的 grant_type 列表 ); ``` #### Authorization-code table (`oauth2_codes`) Short-lived authorization credentials. ```sql CREATE TABLE IF NOT EXISTS oauth2_codes ( code VARCHAR(100) PRIMARY KEY, client_id VARCHAR(50) NOT NULL REFERENCES oauth2_clients(client_id), user_id VARCHAR(50), scope TEXT, redirect_uri TEXT, code_challenge VARCHAR(128), -- PKCE 支持 code_challenge_method VARCHAR(10), -- S256 / plain expires_at BIGINT NOT NULL, -- Unix Timestamp used BOOLEAN DEFAULT FALSE -- 防重放攻击 ); ``` #### Access-token table (`oauth2_access_tokens`) ```sql CREATE TABLE IF NOT EXISTS oauth2_access_tokens ( token VARCHAR(100) PRIMARY KEY, -- 存 SHA-256(token) 哈希(64 hex),非明文(ADR-0004) client_id VARCHAR(50) NOT NULL REFERENCES oauth2_clients(client_id), user_id VARCHAR(50), scope TEXT, expires_at BIGINT NOT NULL, revoked BOOLEAN DEFAULT FALSE, issued_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT, issuer VARCHAR(255) NOT NULL DEFAULT '', audience VARCHAR(255), not_before BIGINT DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT, introspect_count INTEGER DEFAULT 0, revoked_at BIGINT, revoked_by VARCHAR(50) ); ``` #### Refresh-token table (`oauth2_refresh_tokens`) ```sql CREATE TABLE IF NOT EXISTS oauth2_refresh_tokens ( token VARCHAR(100) PRIMARY KEY, -- 存 SHA-256(token) 哈希,非明文(ADR-0004) access_token VARCHAR(100) NOT NULL, -- 关联的访问令牌哈希(无外键约束,按值引用) client_id VARCHAR(50) NOT NULL REFERENCES oauth2_clients(client_id), user_id VARCHAR(50), scope TEXT, expires_at BIGINT NOT NULL, revoked BOOLEAN DEFAULT FALSE, revoked_at BIGINT, revoked_by VARCHAR(50) ); ``` --- ## 3. Redis Storage (Deprecated) > **⚠️ The standalone Redis storage mode is deprecated (F-005)**: in this mode the server logs an > ERROR at startup and rejects the `refresh_token` grant with `unsupported_grant_type`; > historically, refresh tokens were never persisted in this mode. New deployments must use > `postgres` plus the optional Redis cache layer (§ Cache Consistency). The key space below is > kept only as a reference for existing deployments. ### 3.1 Key Pattern Design All keys are prefixed with `oauth2:` (the cache layer has its own separate `fulla:cache:` prefix; the transaction-coordination key family `oauth2:transaction:*` is not listed in the table below). | Entity | Key format | Type | TTL | Notes | |------|-------------|------|-----|------| | **Client** | `oauth2:client:{client_id}` | Hash | none | Fields: `secret` (hash), `salt`, `redirect_uris` (JSON), `allowed_scopes` (JSON) | | **Auth Code** | `oauth2:code:{code}` | String | 10 minutes | Value: JSON-serialized object | | **Access Token** | `oauth2:token:{token}` | String | 1 hour | Value: JSON-serialized object | | **Refresh Token**| `oauth2:refresh:{token}` | String | 30 days | Value: JSON-serialized object | ### 3.2 Sample Data **Client (Hash structure)**: ```bash HSET oauth2:client:fulla-portal secret "42a121b66fb9f1d4f73125788f42eb6799110c6aeae5a9a12a2fed5307a0088d" salt "random_salt" redirect_uris "[\"http://localhost:5173/callback\"]" ``` **Auth Code (String value)**: ```json { "client_id": "fulla-portal", "user_id": "admin", "scope": "openid", "redirect_uri": "http://localhost:5173/callback", "expires_at": 1735689000, "used": false } ``` --- ## 4. Security Hardening To prevent client-secret exposure in the event of a database breach, the system enforces a strict hashing policy. ### 4.1 Algorithm and Flow 1. **On storage**: - Generate a random `salt` (optional, but recommended to reserve in the Postgres schema). - Compute `Hash = SHA256(raw_secret + salt)`. - Store `Hash` (hex string) and `salt` in the database. 2. **On validation**: - The client submits `input_secret`. - The system reads `stored_hash` and `salt` from the database. - Compute `CheckHash = SHA256(input_secret + salt)`. - Compare `CheckHash` with `stored_hash` (case-insensitive). ### 4.2 Code Implemented in `RedisClientRepository::validateClient` and `PostgresClientRepository::validateClient`. ```cpp // 核心逻辑示例 std::string input = clientSecret + client->salt; std::string calculatedHash = drogon::utils::getSha256(input.data(), input.length()); return lower(calculatedHash) == lower(storedHash); ``` --- ## 5. Data Lifecycle Management To keep the database from growing without bound, the system implements an automated expired-data cleanup mechanism. ### 5.1 Policy Overview | Storage backend | Cleanup strategy | Mechanism | Frequency | |----------|----------|----------|------| | **Redis** | **TTL auto-expiry** | Relies on Redis-native `SETEX`/`EXPIRE`; no application-layer involvement. | Real time | | **PostgreSQL**| **Periodic deletion** | `OAuth2CleanupService` calls the cleanup methods of `IGrantRepository` / `ITokenRepository` to delete expired auth codes and access/refresh tokens. | Default: every 1 hour | | **Memory** | **Periodic scan** | Same as above; `OAuth2CleanupService` triggers each repository's expiry cleanup. | Default: every 1 hour | ### 5.2 Scheduler Implementation Cleanup is performed by a dedicated `OAuth2CleanupService` (`libs/drogon/src/plugin/OAuth2CleanupService.cc`), created and started in `OAuth2Plugin::initAndStart`; the interval is controlled by the plugin configuration key `cleanup_interval_seconds` (default `3600`; see `config.json`): ```cpp cleanupService_ = std::make_shared(grantRepo_, tokenRepo_); double cleanupInterval = config.get("cleanup_interval_seconds", 3600.0).asDouble(); cleanupService_->start(cleanupInterval); ``` Internally the service uses `drogon::app().getLoop()->runEvery(interval, ...)` for periodic firing, and `weak_from_this()` to guard against callbacks after destruction. ### 5.3 Interface Definition Cleanup is no longer concentrated in a single `IOAuth2Storage::deleteExpiredData`; it is split per repository — `IGrantRepository` (auth codes) and `ITokenRepository` (access/refresh tokens) each expose their own expired-deletion methods, orchestrated by `OAuth2CleanupService`. ## 6. Storage Backend Selection and the Memory-Backend Warning (F-031) > **⚠️ The memory storage backend is for testing/development only; it must not be used in production.** `storage_type="memory"` (see `config.ci.json`) keeps all client / token / code / consent data in process memory, with **secrets (client_secret) stored in plaintext** (no SHA-256 salted hashing), and: - All data is lost on process restart (no persistence); - No multi-user / multi-instance sharing (each process holds an independent copy of the state); - No transactions, no atomic CAS guarantees (test-stub implementation); - The memory identity repository always returns `nullopt` from `findByUsername`, so the admin login path is unavailable in this mode (`loginAsAdmin()` returns `nullopt`; integration tests that depend on it skip cleanly). **Production deployments must use `storage_type="postgres"`** (Postgres is the only supported production storage backend; the standalone Redis storage mode is deprecated — see F-005 / [Configuration Guide §3](../operate/configuration-guide.md)). The only reason the memory backend exists is to let Windows/macOS CI environments run the DB-independent test cases (contract tests, pure unit tests, protocol error-envelope tests, etc.) when no Postgres is available. ## Data Consistency Notes ### Authorization-code single use (anti double-spend) `consumeAuthCode` guarantees atomicity at the storage layer: PostgreSQL uses `UPDATE ... WHERE consumed = false ... RETURNING` (a raw-SQL exemption); the Redis backend uses a Lua script; the memory backend uses a mutex. The contract is covered for all three implementations by `tests/contract/GrantRepositoryContractTest.cc`. ### Cache consistency: delayed double-delete Write-path invalidation for the Redis L2 cache (key prefix `fulla:cache:`) uses a **delayed double-delete**: an immediate DEL plus a second, delayed DEL on the event loop (default 200ms, configurable via `cache.invalidation_double_delete_delay_ms`, clamped to [50,2000]). This covers the race window where "a reader thread backfills a stale value just before the DEL" (issue #79). Second-DEL failures are observable via the `fulla_cache_invalidation_failures_total{kind}` counter (issue #80). The read path is cache-aside: on a miss it falls through to PostgreSQL and backfills (with TTL as the eventual-consistency backstop). ### Refresh-token families and cascading revocation Refresh tokens store a family identifier (V008); on detected replay the entire family is revoked. Revocation can be initiated at three granularities — per token / per client / per user (admin API and `/oauth2/revoke`). --- # OAuth2 Security Architecture Source: https://fulla.dev/docs/architecture/security-architecture # OAuth2 Security Architecture This document describes the system's security threat model and the corresponding defense mechanisms, covering token lifecycle management, secret storage, and anti-attack strategies. ## 1. Threat Model | Threat | Description | Defense | Related docs | |----------|------|----------|----------| | **Replay Attack** | An attacker intercepts an auth code and attempts to redeem it before or after the legitimate client. | **Atomic Consume** + **One-Time Use Enforcement**. | [Data Consistency](data-persistence) | | **Credential Leakage** | A database breach leaks client secrets. | **SHA256 Salted Hash**. The database stores only hashes, never plaintext. | [Data Persistence](data-persistence) | | **Token Theft** | An access token is intercepted. | **Short-lived Token** (1 hour) + **Refresh Token Rotation**. | This document | | **CSRF** | An attacker tricks a user into an unintended authorization. | Mandatory validation of the **state** parameter (recommended for clients to implement). | [API Reference](../domains/api-reference.md) | ## 2. Token Lifecycle Management ### 2.1 Access Token - **Lifetime**: 1 hour. - **Purpose**: Access to protected resources (e.g. `/userinfo`). - **Validation**: stateless (JWT) or stateful (DB lookup). This project uses **stateful** validation, which supports immediate revocation. ### 2.2 Refresh Token - **Lifetime**: 30 days. - **Purpose**: Exchanged for a new token once the access token has expired. - **Security mechanism: Rotation** - On every refresh, the server issues not only a new access token but also **a new refresh token**. - The old refresh token is invalidated immediately. - **Detection**: If an old refresh token is presented again, the system treats it as token theft and cascades revocation of every token under that `token_family` (implemented; see §7.2). ## 3. Secrets Management ### 3.1 Client Secrets - **Storage**: `sha256(secret + salt)` - **Transport**: Only over HTTPS, in the POST body. ### 3.2 Configuration Files - Sensitive values (such as DB and Redis passwords) should be injected via **environment variables** rather than hardcoded in `config.json`. - In production deployments, configuration file permissions should be strictly restricted. ## 4. Best-Practice Recommendations - **HTTPS**: Production **must** enable HTTPS/TLS; without it, OAuth2 offers no security whatsoever. - **PKCE**: Mobile/SPA clients should enable PKCE (Proof Key for Code Exchange) — this backend implements it and enforces it by default for PUBLIC clients, supporting both `plain` and `S256`. - **IP allowlist**: For high-privilege clients, restrict the source IPs allowed to redeem tokens. ## 5. Token Storage Security All tokens (access tokens, refresh tokens) are stored in the database **as SHA-256 hashes only** — never in plaintext. - **Storage format**: `SHA-256(token_value)` - **Validation flow**: client submits token → server computes the hash → compares against the stored hash - **Benefit**: Even if the database leaks, an attacker cannot recover a valid token ## 6. Password Hashing Policy ### 6.1 Current Standard (OWASP 2023) - **Algorithm**: PBKDF2-SHA256 - **Iterations**: 310,000 (per the OWASP 2023 recommendation) - **Salt**: A unique random salt per user (16 bytes) - **Output**: 32-byte key ### 6.2 Legacy Password Migration The system supports gradual migration from the legacy single-iteration SHA-256 hashing to PBKDF2: 1. On login, the system detects the password hash format 2. If it is the legacy format (SHA-256), the hash is automatically upgraded to PBKDF2 after successful verification 3. The migration is transparent to users; no password reset is required ## 7. Refresh Token Rotation and Family Tracking ### 7.1 Family-Based Tracking Every refresh-token chain shares a `token_family` identifier: - A unique `token_family` ID is generated when the first RT is issued - Subsequent rotated RTs inherit the same `token_family` - The system can track the lifecycle of the entire token chain ### 7.2 Reuse Detection and Cascading Revocation When a revoked refresh token is presented again: 1. **Detection**: The received RT is marked revoked in the database 2. **Verdict**: Treated as token theft (the attacker holds an old RT) 3. **Response**: Cascading revocation of **all** tokens under that `token_family` 4. **Outcome**: Both the legitimate user and the attacker must re-authenticate ### 7.3 Sequence Diagram ```mermaid sequenceDiagram participant U as 用户 participant S as 服务器 participant A as 攻击者 U->>S: 使用 RT-1 刷新 S-->>U: 撤销 RT-1,颁发 RT-2(同 family) A->>S: 使用 RT-1 刷新(重用!) S->>S: 检测到 RT-1 已撤销 S->>S: 级联撤销 family 下所有 Token U--xA: 下次请求失败,需重新登录 ``` ## 8. Subject Privacy ### 8.1 UUID public_sub - **External identifier**: UUID v4 is used as the `public_sub` (public subject identifier) - **Internal identifier**: The database auto-increment ID is used only for internal joins - **Anti-enumeration**: UUIDs are unpredictable; attackers cannot enumerate users by incrementing IDs - **OIDC compatibility**: The `sub` claim in `id_token` uses `public_sub` ### 8.2 Comparison | Approach | Enumerable | Information leakage | OIDC compatible | |------|--------|----------|-----------| | Auto-increment ID | ✗ predictable | Leaks the user count | ✓ | | UUID public_sub | ✓ unpredictable | No information leakage | ✓ | ## HTTP Security Response Headers A global middleware attaches the following to every response: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, and `Content-Security-Policy` (a restrictive policy for the API domain). ## Global Rate Limiting: Hodor (enabled in production configuration only) Global-side rate limiting is handled by Drogon's official **Hodor** plugin (token bucket + in-process CacheMap, at the IP/user/global levels), and it is **mounted only in `config.prod.json`** (the development configuration does not include the plugin). Current production thresholds (authoritative source is `config.prod.json`; this is a snapshot): `/oauth2/login` IP capacity 3 / user capacity 2; `/oauth2/token` 5/min; all other endpoints 5000 global and 30 per IP. Rejected responses are returned via the error envelope `VALIDATION_RATE_LIMITED` (429). > Note on the relationship to F-018: this is **global-side** rate limiting (any request, > anti-scanning); the in-process failure-count rate limiting in `configuration-guide` §8 is > **authentication-side** brute-force protection (login/token failure counts). The two coexist > and cover different surfaces. ## Security Operations Checklist **Routine verification**: no secrets committed to the repository (`git grep` spot checks + the Secret Hygiene CI gate); all `.env*` files ignored; frontend production builds contain no embedded credentials. **Key rotation**: JWKS currently uses a single static `kid` (F-029 is a follow-up operations task; the rotation procedure is not automated); rotating DB/SMTP/social credentials = change env vars + rolling restart. **Incident response**: suspected leak → immediately rotate the affected credentials → if historical commits are involved, rewrite them with `git filter-repo` (not the deprecated filter-branch) and force-push → notify. **pre-commit hook template** (optional): ```bash #!/bin/sh if git diff --cached | grep -qiE '(api[_-]?key|secret|password)\s*[:=]'; then echo "possible credential in commit"; exit 1 fi ``` --- # fulla Competitor Performance Benchmark Comparison Design Source: https://fulla.dev/docs/benchmark/competitor-benchmark-design # fulla Competitor Performance Benchmark Comparison Design > **Version**: v1.2 (2026-08-28 revision: GC-jitter root cause analysis — WSL2 I/O scheduling pauses, not runtime GC; adds `--discard-spikes` / `--env-monitor` to run-gc-jitter.sh; Cleaned P99 stats in COMPARISON.md) > **Date**: 2026-08-15 > **Document type**: Technical design (Phase 0.5 implementation blueprint, **not code** — for implementation see the §7 milestones) > **Upstream planning**: [Evolution Plan §3 Phase 0] (productization-evolution-plan: locally maintained archive), item P0 "self-hosted competitor comparison benchmark" > **Prerequisites**: Benchmark infrastructure design (internal archive, moved to local maintenance along with the productization-evolution directory), M1–M4 delivered (S1–S6 self-test data committed under `benchmarks/results/`) > **Verification target**: The competitor column (Keycloak/Ory) order-of-magnitude reference numbers in survey report §3.1 (internal archive, moved to local maintenance) --- ## 0. TL;DR - **What**: Load-test Keycloak / Ory Hydra / Zitadel on the **same machine, with the same wrk ladder and the same PostgreSQL backend**, producing a like-for-like comparison table against fulla's committed self-test data (`benchmarks/results/SUMMARY.md`). - **What gets compared**: The five single-step scenarios S1 discovery / S2 client_credentials / S3 introspect / S5 refresh_token / S6 userinfo, plus cold start, steady-state RSS, and the **GC-jitter long run** (5-minute P99 time series — root cause: WSL2 I/O scheduling pauses, not runtime GC; Cleaned P99 after env-noise removal shows fulla 3.0ms < Keycloak 4.6ms < Zitadel 18.1ms < Ory 24.1ms). - **What does not get compared**: S4 auth_code (each product's login/consent flows cannot be driven uniformly by wrk); Auth0 (SaaS, cannot be self-hosted); competitors' extreme tuned configurations (official recommended production configurations are used throughout). - **Core principle**: Fairness over flattering numbers. Competitor communities will challenge the results, so the methodology must be beyond reproach — same hardware, same concurrency ladder, same backend, each product's officially recommended configuration, all scripts committed and reproducible. - **Acceptance**: A four-product like-for-like comparison table (QPS / P99 / steady-state RSS / cold start / GC jitter) landed in `benchmarks/competitors/results/COMPARISON.md`; a third party can reproduce it one-command-style from the README. --- ## 1. Goals and Non-Goals ### 1.1 Goals | # | Goal | Measured by | |---|------|------| | G1 | **Same-environment competitor data**: replace the survey report §3.1 competitor column ("from each product community's public benchmarks, not a same-environment comparison") with same-environment measurements | §6 acceptance ✅ COMPARISON.md | | G2 | **Validate the differentiation narrative**: whether the C++ low-tail-latency claims are supported by same-environment data (note: the original "no-GC" narrative was falsified — all four products show identical WSL2-environment spikes; the real differentiation is in Cleaned P99) | GC-jitter long run + steady-state RSS comparison | | G3 | **Reproducible**: a third party following `benchmarks/competitors/README.md` on an identically specced machine obtains data within <15% deviation | Reproduction threshold (reusing the self-test infrastructure's AC1) | | G4 | **Honest revision**: if fulla does not lead on some dimension, revise the claim ordering in survey report §3.1/§3.2 accordingly | Report update | ### 1.2 Non-Goals | # | Non-Goal | Why / owner | |---|--------|--------------| | N1 | Hosted competitors such as Auth0 / Okta | SaaS cannot be self-hosted and the environment cannot be controlled; their public numbers are not reproducible. They remain only as order-of-magnitude references in the survey report | | N2 | S4 auth_code scenario comparison | Each product's login page/redirect/consent interaction flows are completely different; wrk cannot drive them uniformly (see §4 D4) | | N3 | Competitor extreme tuning | Officially recommended production configurations only. Extreme-tuning comparisons are an arms race with no credibility; official documentation links are attached alongside the results | | N4 | Feature/protocol coverage comparison | That belongs to a product capability audit (iam-architecture-audit.md) and is unrelated to performance | | N5 | Rerunning the fulla ladder data | fulla is still rerun in the same session (see the §5.1 v1.1 revision: gcjitter/RSS/cold start are mandatory + R7 eliminates drift); "reuse" only means reusing the same runner/methodology — the 2026-08-12 in-repo data is demoted to a historical baseline | --- ## 2. Background: Why Now ### 2.1 Phase 0 self-testing is done; the comparison is the last gap Phase 0 (benchmark M1–M4) was delivered on 2026-08-12: six scenarios S1–S6, 40 JSON files, and a load-bearing validation report (`benchmarks/results/SUMMARY.md`). fulla's own numbers are now credible. The survey report §3.1 competitor column (Keycloak ~10–20k QPS, Ory ~30–50k QPS) is annotated "public community benchmarks, not a same-environment comparison, order-of-magnitude reference only". **Without same-environment comparison data, "N× faster than Keycloak" must not appear in any external material.** ### 2.2 fulla self-test baseline (the comparison baseline, measured 2026-08-12) | Scenario | Steady-state QPS | Steady-state P99 | Error rate | |------|---------|---------|--------| | S1 discovery | 86,332 | — (low concurrency <1 ms) | 0.006% | | S2 client_credentials | 8,915 | 8 ms | 0.000% | | S3 introspect | 17,132 | 12 ms | 0.000% | | S5 refresh_token | 1,982 | 26 ms | 0.000% | | S6 userinfo | 16,674 | 12 ms | 0.000% | Environment: WSL2 8 vCPU / 16GB / PG connection pool 25 / Redis 20 / wrk 4.1.0 / ladder 2→128. See `benchmarks/results/SUMMARY.md` for details. ### 2.3 Existing reusable assets | Asset | Reuse approach | |------|---------| | `run-scenario.sh` ladder runner (warmup→measure→JSON) | Competitor scenarios simply pass different `.lua` files; the runner gains **optional** parameters (`READY_PATH`/`RESULTS_DIR`/`WRK_LIB_DIR`/`--reissue` hook, see M0.1) — a single implementation, no second copy | | `parse-wrk.py` (wrk text→schema v1 JSON) | Scenario-agnostic; only gains `--product/--product-version` pass-through parameters (M0.2) | | `observe/docker-stats.sh` + `scrape-metrics.sh` | docker-stats gains a `CONTAINER_GLOB` parameter (M0.3) to sample competitor container RSS/CPU; scrape-metrics remains fulla-only (competitors have no `/metrics` endpoint; observation items are split) | | `measure-cold-start.sh` pattern | One equivalent cold-start timing script per competitor | | `lib/gen-tokens.py` idea | Competitor token pool generation (each product's introspect tokens must be issued by the product itself, see D5) | | docker-compose base (PG15) | Competitor composes reuse the same PG version and connection pool configuration | --- ## 3. Key Constraints and Design Decisions ### D1 — Fairness: the three-same principle (same hardware / same tool / same backend) **This is the credibility foundation of the entire plan.** | Dimension | Unified value | Notes | |------|--------|------| | Hardware | The same machine (the WSL2 8 vCPU/16GB used by Phase 0, or a later dedicated bare-metal box) | The four products run sequentially, with `docker compose down -v` cleanup in between | | OS/kernel | The same WSL2 Ubuntu | — | | Load tool | wrk 4.1.0, the same ladder parameters (2→4→8→16→32→64→128), warmup 5 s / measure 10 s (aligned with the in-repo self-test methodology; Keycloak warmup 60 s, see the D2 exemption) | Reuses `run-scenario.sh`; no second runner is written | | Backend storage | PostgreSQL 15 (the same image tag), connection pool aligned at 25 | A competitor's supported pool ceiling may be <25; use `min(25, official ceiling)` and note it in the results | | Network topology | localhost cross-container (wrk on the host) | Same as the self-test | | Result format | schema v1 JSON (`parse-wrk.py`) | All four products' data are isomorphic, so `run-comparison.sh` can aggregate them | | Ports | Fixed ports per stack (fulla 5555 / Keycloak 8080 / Hydra 4444+4445 / Zitadel 8080) | Serial execution avoids conflicts; each setup asserts up front that the ports are free | ### D2 — Competitor configuration = officially recommended production configuration (no tuning up, no tuning down) **Rationale**: Extreme-tuning comparisons have no credibility; deliberately tuning down is academic fraud. | Competitor | Configuration baseline | Official source | |------|---------|---------| | Keycloak | `start --optimized` + PostgreSQL, memory per the official 2GB limit recommendation | [Running Keycloak in a container](https://www.keycloak.org/server/containers) | | Ory Hydra | Official docker-compose + PostgreSQL DSN | [Ory Hydra docs](https://www.ory.sh/docs/hydra/self-hosted/deploy-hydra) | | Zitadel | Official compose (`setup mode` initialization + PostgreSQL) | [Set up ZITADEL with Docker Compose](https://zitadel.com/docs/self-hosting/deploy/compose) | Each competitor's `setup.sh` header comment must link the official documentation; every deviation from the official defaults (e.g., connection pool alignment) is annotated individually with its rationale. **JVM warmup exemption**: Keycloak's JIT/GC needs a longer warmup. Warmup is extended to 60 s for Keycloak (the other three keep 5 s, aligned with fulla's self-test methodology; the measurement duration is a uniform 10 s) and noted in the results — this is not favoritism, it is giving the JIT time to compile; otherwise what gets measured is "uncompiled interpreted execution". ### D3 — Scenario mapping: functional equivalence, not path equivalence Endpoint paths differ per product; map to endpoints with the **same function** (§4 matrix). Key constraints: - **S3 introspect, the Ory special case**: Hydra's introspect sits on the admin port (4445) rather than the public port, and in production deployments the admin port is usually not exposed. For fairness, all four products test introspect, but **COMPARISON.md annotates Ory's admin-port semantic difference**. - **Authentication method**: client_credentials uses each product's standard client authentication (Basic or post, per its declaration). - **Zitadel S2 special case (JWT profile)**: Zitadel's official M2M path is Service User + private_key_jwt (the token endpoint does **not** support client_credentials with Basic authentication). wrk Lua cannot sign JWTs, so the setup phase pre-signs a client_assertion pool with python (pyjwt + cryptography, already available in WSL), with exp covering the entire run window; if Zitadel enforces single-use jti, re-sign per step (equivalent to S5's --reissue mechanism). A COMPARISON.md appendix notes "the JWT profile is Zitadel's officially recommended machine authentication; it is functionally equivalent". ### D4 — Excluding S4 auth_code: not wrk-drivable fulla's S4 is a two-step form POST `login → token` (headless-drivable). But: - **Keycloak** auth_code requires its login page's HTML form + JS - **Ory Hydra** requires an external login/consent app (Hydra itself has no login UI) - **Zitadel** has its own login session flow Driving all of these uniformly would require a real browser (Playwright), and what would be measured is "login page rendering", not "token issuance". **S4 is excluded from the first iteration, with the limitation noted in COMPARISON.md**; if needed later, approximate it with "pre-issued code + single-step token exchange" (each product's pre-issuance method differs and the complexity is high — revisit in Phase 0.6). ### D5 — Competitor token pools must be issued by the products themselves (no direct SQL inserts) fulla's self-tests pre-seed tokens for S3/S6 via SQL (`gen-tokens.py`). **Not possible for competitors**: - Keycloak's access tokens are signed JWTs with private hash/key formats - Hydra/Zitadel likewise **Approach**: Each competitor's `setup.sh` issues tokens in bulk through **its own token endpoint**, writes the live tokens into a token-pool file, and the Lua scenario scripts reuse the same token-pool logic (thread slicing, reusing the `s3-introspect.lua` pattern). **Pool size (v1.1 revision — the two pool types have different bases)**: - **S3/S6 pool (reusable)**: Tokens are only read-validated, never consumed; N=2000 suffices; bulk issuance itself takes ~1–2 minutes (parallel xargs -P8 is faster). - **S5 pool (single-issued, single-consumed)**: Every refresh token is invalidated after a single use; the pool must be ≥ that step's QPS × measurement duration × a 1.3 margin. fulla's self-test measured basis: 20,000 pool ÷ 10 s ≈ 1,982 QPS (the pool exactly covers the measurement window). Competitors must **re-issue the pool** before every step (`run-scenario.sh` gains a `--reissue ""` hook, equivalent to the self-test's SQL `--reseed` but going through each product's API). - **RTs cannot come from client_credentials**: RFC 6749 §4.4.3 forbids issuing refresh tokens under that grant. Competitor RT pools must be obtained through user-context flows — Keycloak via ROPC (direct access grants); Hydra via the accept flow (see M2); Zitadel via the Session API/auth_code (see M2). ### D6 — Long-run P99 time series (env-noise-aware tail-latency comparison) A single 30 s run cannot reveal tail-latency patterns. A dedicated **long-run test** is designed: ``` Per product: c=32 fixed, sustained for 5 minutes, scenario pinned to S6 userinfo (v1.1 revision: S5 is unusable — the pool would be exhausted; S2 has write amplification — fulla persists one token row to the DB per request, so at 5 minutes ~8k QPS ≈ 2.4M rows, an asymmetric workload across the four products; S6 is a read path, its token pool is reusable, and all four products are functionally equivalent — the fairest carrier. If S6 is marked N/A for Hydra, fall back to S2 and note it in the results.) Sampling: record each 10 s window's P99 once (wrk has no native segmentation → approximated by 30 serial 10 s segments) Output: a P99-over-time curve (JSON array) Expected (original, falsified): Keycloak shows periodic P99 spikes (GC STW); Ory shows small Go GC spikes; fulla stays flat. Actual (2026-08-28 root cause analysis): ALL four products show identical ~30s period, ~1.8s P99-ceiling spikes — caused by WSL2 virtio-blk I/O scheduling pauses, not any runtime GC. Cleaned P99 (after removing contaminated segments) is the comparable metric: fulla 3.0ms, Keycloak 4.6ms, Zitadel 18.1ms, Ory 24.1ms ``` Implementation: `benchmarks/competitors/run-gc-jitter.sh` — loops wrk `-d10s` 30 times in series, parsing each segment's P99 into an array. **This is the single most compelling chart for the external narrative** (visual evidence of tail-latency stability; use `--discard-spikes 5.0` to filter WSL2 env-noise spikes and report Cleaned P99). ### D7 — Unified memory basis: full-stack container RSS + annotated logical layer Phase 0 load-bearing validation found fulla's container RSS is ~2.4GB (including the Drogon connection pool/shared-library COW), which does not match the "50–120MB" claimed figure. The comparison plan unifies the basis: | Basis | Collection | Use | |------|------|------| | Full-stack container RSS | steady-state `docker stats` sampling (reusing the observe scripts) | **Primary basis for the comparison table** — comparable across all four products on the same basis | | Process PSS (optional) | `smem` / `/proc/*/smaps_rollup` | Supplementary basis eliminating double counting of COW shared pages | COMPARISON.md explicitly states "full-stack container RSS, including each product's own runtime + connection pool" to avoid basis disputes. --- ## 4. Comparison Scenario Matrix ### 4.1 Scenario × competitor endpoint mapping (verified against official documentation) | Scenario | Function | fulla | Keycloak | Ory Hydra | Zitadel | |------|------|-----------|----------|-----------|---------| | **S1** discovery | OIDC discovery document | `GET /.well-known/openid-configuration` | `GET /realms/{r}/.well-known/openid-configuration` | `GET /.well-known/openid-configuration` (public :4444) | `GET /.well-known/openid-configuration` | | **S2** client_credentials | machine-to-machine token | `POST /oauth2/token` | `POST /realms/{r}/protocol/openid-connect/token` | `POST /oauth2/token` (public :4444) | `POST /oauth/v2/token` | | **S3** introspect | token introspection | `POST /oauth2/introspect` | `POST /realms/{r}/protocol/openid-connect/token/introspect` | `POST /admin/oauth2/introspect` (admin :4445) ⚠ | `POST /oauth/v2/introspect` | | **S5** refresh_token | token refresh | `POST /oauth2/token` (refresh) | `POST /realms/{r}/protocol/openid-connect/token` (refresh) | `POST /oauth2/token` (refresh) | `POST /oauth/v2/token` (refresh) | | **S6** userinfo | user information | `GET /oauth2/userinfo` | `GET /realms/{r}/protocol/openid-connect/userinfo` | `GET /userinfo` (public :4444) | `GET /oidc/v1/userinfo` | | ~~S4~~ auth_code | user login | ~~`login→token`~~ | ~~login page not headless-drivable~~ | ~~external consent app required~~ | ~~login session flow~~ | > ⚠ Ory's admin-port semantic difference for introspect is noted in the results. The Keycloak realm name, Hydra's public/admin dual ports, and Zitadel's instance domain are all fixed as constants in their respective setup scripts. Endpoint sources: - Keycloak: [OpenID Connect endpoints](https://www.keycloak.org/docs/latest/securing_apps/) (`/realms/{realm}/protocol/openid-connect/*`) - Ory Hydra: [API docs](https://www.ory.sh/docs/hydra/reference/api) (public :4444 / admin :4445) - Zitadel: [OpenID Connect Endpoints](https://zitadel.com/docs/apis/openidoauth/endpoints) ### 4.2 Metric matrix | Metric | Collection method | Comparison-table column | |------|---------|---------| | Steady-state QPS (highest step with err<0.01%) | wrk ladder + parse-wrk.py | ✅ | | P50 / P95 / P99 (steady-state step) | wrk `--latency` | ✅ | | Error rate | wrk non-2xx | ✅ (threshold column) | | Cold start (→health 200) | equivalent timing script per product | ✅ | | Steady-state container RSS | docker stats sampling | ✅ | | GC jitter (5-min P99 curve) | run-gc-jitter.sh | ✅ (dedicated section) | | Driver CPU | existing run-scenario.sh sampling | ✅ (credibility annotation) | --- ## 5. Test Strategy ### 5.1 Execution order (single machine, serial, to prevent mutual interference) ``` 1. fulla — rerun in the same session (v1.1 revision: gcjitter/RSS/cold start are mandatory items for AC1/AC2; Phase 0 never sampled gcjitter; and R7 requires all four products to run back-to-back in the same session to eliminate cross-day environment drift. The 2026-08-12 in-repo data is kept as a historical baseline; fresh same-session data is used for the comparison) 2. Keycloak — setup → ladder → long run → cold start → teardown 3. Ory Hydra — same as above 4. Zitadel — same as above Between products: docker compose down -v + assert no leftover containers/volumes/networks ``` `run-comparison.sh --fresh` runs everything serially; `--only keycloak` supports re-running a single product. ### 5.2 Competitor setup conventions (one setup.sh per product) Every `setup.sh` must: 1. Start the competitor + PostgreSQL (connection pool aligned, see D1) 2. Wait for health (each product's ready probe: Keycloak `/realms/master`, Hydra `/health/ready`, Zitadel `/debug/healthz`) 3. Initialize configuration (realm/client/user — via each product's CLI or admin API) 4. **Bulk-issue the token pool** (D5: N client_credentials requests → `access_tokens.txt`) 5. Warmup validation (one token request to confirm the pipeline works) ### 5.3 Long-run P99 time series (D6) Parameters: `c = the concurrency step at each product's steady-state knee`, 30 × 10 s segments (5 minutes total), no gaps between segments. Output: `results/---gcjitter.json` (P99 array + segment timestamps, schema v2 with `env_noise` + `cleaned_stats` fields). Spike detection: `--discard-spikes 5.0` marks segments with P99 > 5×median as contaminated (WSL2 env noise). Cleaned P99 is computed from remaining segments. Env monitoring: `--env-monitor` collects vmstat + /proc/meminfo for post-hoc correlation. ### 5.4 Cold start Timed independently per product: `docker compose up -d ` → poll the ready probe for 200. Recorded in two modes (with/without DB warm-up), aligned with the self-test `measure-cold-start.sh`. ### 5.5 Environment metadata The `env` block of the result JSONs follows schema v1, gaining `product` / `product_version` fields (parse-wrk.py gains one pass-through parameter); the COMPARISON.md table header lists the versions. --- ## 6. Acceptance Criteria (checkable) > ✅ All passed 2026-08-17 (M0–M3 delivered; the four products measured same-session on 2026-08-17; COMPARISON.md generated by gen-comparison.py). > 🔄 Full rerun refresh 2026-08-21 (same infrastructure, fulla on an optimized baseline step — wave-1/2 + LTO, see the G1 delivery note): **all five scenarios ahead** (previously behind on S5/S6 — S5's measurement-budget artifact has had its basis fixed, and S6 overtook via the wave-2 user/role cache); the GC section adds cross-product environmental-noise cross-validation (identical spikes on all four). Survey report §3.1 now cites the new table. | # | Acceptance item | Measured by | Status | |---|--------|------|------| | AC1 | **Four-product like-for-like comparison table**: S1/S2/S3/S5/S6 × \{QPS, P99, RSS, cold start\} committed to `benchmarks/competitors/results/COMPARISON.md`, each row carrying the product version | COMPARISON.md | ✅ | | AC2 | **GC jitter curves**: four products' 5-minute P99 time-series JSON + comparison section (is fulla flat; does Keycloak show periodic spikes) | gcjitter JSON + section | ✅ (conclusion opposite to expectation: all four products show identical ~30s-period ~1.8s-ceiling spikes caused by WSL2 I/O scheduling pauses, not runtime GC — see the G4 note and `GC_JITTER_ROOT_CAUSE_ANALYSIS.md`; Cleaned P99 after env-noise removal: fulla 3.0ms best) | | AC3 | **Reproducible**: `run-comparison.sh` runs all four products serially with one command; the README covers environment requirements and reproduction steps | Reproduction guide | ✅ (`benchmarks/competitors/README.md`) | | AC4 | **Fairness statement**: each competitor's configuration source (official documentation links), every deviation from the defaults, and the warmup difference (Keycloak 60 s) are all explicitly annotated | COMPARISON.md appendix + setup.sh comments | ✅ | | AC5 | **Honest revision**: survey report §3.1 competitor column updated to "same-environment measurements"; §3.2 claims converge if falsified | research.md update | ✅ (the S5/S6 and GC-jitter claims converged per the measurements, see the §3.1/3.2 revisions) | | AC6 | **S4 exclusion statement**: COMPARISON.md notes the method limitation for the auth_code scenario | Limitations section | ✅ (appendix B.1) | ### Implementation errata (v1.2, revised during the 2026-08-17 implementation; v1.3 adds two items on 2026-08-18) Decisions made during implementation that deviate from this design v1.1 — all of them fairness/feasibility corrections: 1. **Zitadel version v2.71.19 → v4.17.1**: v2.71 was two major versions behind (the current stable line is v4, the same generation as Keycloak 26 / Hydra 26), and v4 is an eventstore/projection performance rewrite. Testing the old version would distort and disparage Zitadel — indefensible. The first-attempted `v1.80.0-v2.9-amd64` tag turned out to be a v1-era CockroachDB-only image; discarded. 2. **Zitadel S2 authentication path corrected**: Design D3's original wording "client_assertion (JWT profile)" did not work in v2.71/v4 testing — Zitadel's token endpoint handles client_credentials for machine users via Basic-secret only (password hashing per request); the official M2M path is the **RFC 7523 jwt-bearer grant** (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=…`). The S2/S3 equivalence annotations were updated accordingly. 3. **Zitadel S3 authentication switched to a private key**: official advice in #6220 — secret authentication password-hashes on every request (a CPU bottleneck); switched to an OIDC app + private_key_jwt. Measured comparison: the Basic path could not pass S3's error gate; the private-key path reached 2.9k QPS with zero errors. 4. **Projection settling gate**: Starting the load immediately after minting ~2000 tokens lets Zitadel's CQRS projections fall behind, causing an S1 500-storm (up to 99.99% errors at c=16). run-all now has a discovery smoke gate up front (only starts when clean), eliminating the artifact. 5. **S5 Zitadel = N/A** (DG-2 early ruling): machine users have no refresh tokens (RFC 6749 §4.4.3), the password grant has been removed, and Session API→auth_code is a user-interaction flow (the same exclusion rationale as D4). Noted in the limitations section. 6. **docker-stats.sh pipe-glob regression fixed**: under bash 5.2, `case $x in $GLOB)` (with `|` inside GLOB) no longer expands into multiple branches — fulla-side RSS sampling had been silently empty since the M0 parameterization; changed to split-and-match individually. This regression also explains the missing fulla RSS data after v1.1. 7. **fulla S2 scope follows #43**: the seed drops legacy `read/write`; the bench validation and the s2 lua use `tokens:read` (same code, same test — not a basis change). 8. **(v1.3) Four products upgraded PG 15 → 17 in sync** (2026-08-18, f789bda): The design baseline was "the same `postgres:15-alpine` tag as the self-test". Deviation motive: D1 same-environment fairness — keeping all four products on the same PG major version; the fulla server's libpq was already 17.x, removing the client 17/server 15 skew. fulla's own 15 vs 17 A/B is within the noise band (no self-interested throughput motive). deploy/'s compose and Helm moved to 17 in sync (the existing-volume upgrade runbook is at `docs/operate/postgresql-major-upgrade.md`; the CHANGELOG is marked BREAKING). 9. **(v1.3) fulla Redis pool 25 → 64** (2026-08-18, quick win 8838ac6): The design's §5.2 connection-pool basis was aligned at pool=25; during implementation, cache-on required pool ≥ the expected concurrency (with pool 20, S6 hit all-connection timeouts, -18%), so the bench overlay was raised to 64. Competitors run at their own official default pool basis (noted in the COMPARISON.md fairness appendix). --- ## 7. Implementation Plan (4 milestones, each step with acceptance criteria) > v1.1: M0 added at review — parameterization of the shared infrastructure (backward compatible; behavior is unchanged when the new env vars are absent). ### M0 — Shared infrastructure parameterization (prerequisite, ~0.5 day) | # | Step | Content | Acceptance criteria | |---|------|------|---------| | M0.1 | `run-scenario.sh` parameterization | (a) `READY_PATH` env (default `/health/ready`) — the health gate's probe can be swapped for a competitor probe; (b) `RESULTS_DIR` env (default `benchmarks/results`); (c) the `WRK_LIB_DIR` export becomes `${WRK_LIB_DIR:-$BENCH_DIR/lib}` (competitor luas point at their own lib); (d) `BENCH_PRODUCT`/`BENCH_PRODUCT_VERSION` env passed through to parse-wrk.py; (e) a generic `--reissue ""` hook: executed once before each step's warmup and once before measured (competitor S5 re-issues the token pool via API, replacing fulla's SQL `--reseed`); (f) `--observe` split into `--observe-stats` (docker-stats only) and `--observe-metrics` (scrape-metrics only; unused for competitors without `/metrics`) | **AC-M0.1a** Running fulla S1 as a single step (c=2, -d10s) without any new env behaves identically to before the change (JSON schema, defaults, and output paths all unchanged); **AC-M0.1b** With `READY_PATH= RESULTS_DIR= BENCH_PRODUCT=x`, the health gate uses the new path, the JSON lands in the new directory, and env.product=x; **AC-M0.1c** `--reissue "touch $TMP/marker"` smoke: two markers per step (pre-warmup + pre-measured) | | M0.2 | `parse-wrk.py` pass-through | `--product`/`--product-version` CLI args → new `product`/`product_version` fields in the env block (defaults `fulla`/empty) | **AC-M0.2** A wrk sample text parsed via pipe yields a JSON containing both fields with correct values; without the args, the defaults do not break the existing aggregation | | M0.3 | `docker-stats.sh` container-filter parameterization | `CONTAINER_GLOB` env (default keeps the `*fulla-backend*|*fulla-postgres*|*fulla-redis*` semantics) | **AC-M0.3** Sampling with `CONTAINER_GLOB='*keycloak*'` outputs keycloak container rows and no fulla rows | | M0.4 | Schema documentation sync | the `result-schema.md` env block gains `product`/`product_version` field descriptions | The documentation's field table matches the implementation (cross-checked) | ### M1 — Keycloak comparison (the heaviest; hardest nut first, ~2 days) Configuration baseline (D2): `quay.io/keycloak/keycloak:` + `start --optimized` + `--memory 2G` (the official container recommendation), PostgreSQL on the **same image tag** as the self-test — `postgres:15-alpine`, port 8080. | # | Step | Content | Acceptance criteria | |---|------|------|---------| | M1.1 | `docker-compose.yml` | keycloak + postgres; healthcheck hits `/realms/master`; volumes/networks explicitly prefixed `kc-bench-` | **AC-M1.1a** After `up -d`, the probe returns 200; **AC-M1.1b** After `down -v`, `docker ps -a`/`docker volume ls`/`docker network ls` show no kc-bench leftovers | | M1.2 | `setup.sh` | Wait for health → `kcadm.sh` creates realm `bench`, client `bench-svc` (confidential + service account + introspection permissions), user `bench-user` (direct grants) → **calibration run** (c=8 single-step S2/S5/S6 to estimate QPS) → generate the RT pool per `pool = QPS×10s×1.3` (bulk-issued via ROPC, parallel with xargs -P8) + AT pool ≥2000 (S3/S6 reuse) → warmup: verify one token request | **AC-M1.2a** `set -euo pipefail`; any kcadm/curl failure exits non-zero; **AC-M1.2b** Final self-check: both pool files have ≥ the expected line counts and a single client_credentials returns 200; **AC-M1.2c** Idempotent: after `down -v`, rerunning setup succeeds | | M1.3 | `scenarios/` (5 luas) | s1/s2/s3/s5/s6 rewritten per the §4.1 endpoints; S2 uses Basic (bench-svc); S3 Basic + AT pool (thread slicing, reusing the s3 pattern); S5 RT one-shot pool; S6 Bearer AT pool | **AC-M1.3** Each scenario `wrk c=2 -d5s` smoke: non-2xx=0, socket errors=0 (S5 tolerates pool-tail nil-closed connections, but there must be no invalid_grant) | | M1.4 | Ladder data | `WARMUP_S=60 DURATION_S=10`, ladder 2→128 × 5 scenarios → 35 JSONs; the S2 step attaches `--observe-stats` for RSS | **AC-M1.4a** All 35 JSONs carry `product=keycloak` + the version; **AC-M1.4b** Per step, driver CPU <80% or the JSON is flagged `limited=true`; **AC-M1.4c** The RSS tsv is written and contains keycloak+postgres rows | | M1.5 | GC jitter | `run-gc-jitter.sh`: S6 c=32, 30×10 s segments (D6) | **AC-M1.5** The JSON contains 30 P99 data points + segment start timestamps; no failed segments | | M1.6 | Cold start | Two modes: A = full initialization on a fresh volume (including realm/client creation); B = pre-initialized volume, restarting only the keycloak container | **AC-M1.6** 2 JSONs (modes A/B), containing seconds and peak RSS | | M1.7 | `teardown.sh` | down -v + leftover assertion | Same as AC-M1.1b | | M1.8 | First two-way comparison | fulla same-session rerun (§5.1) + Keycloak data → draft comparison table | **AC-M1.8** 5 scenarios × `QPS / P50/P95/P99 / RSS / cold start` rows complete, version columns complete | ### M2 — Ory Hydra + Zitadel (~3 days) **Hydra**: No built-in user system. User tokens for S5/S6 are driven headless via the official mock pattern: `GET /oauth2/auth` (login redirect) → admin API `POST /admin/oauth2/auth/requests/login/accept` + `consent/accept` → code → token; drivable with pure curl (no browser needed). **Zitadel**: S2 uses the JWT-profile pre-signed assertion pool (D3 special case); S3 uses an API client + Basic; S5/S6 prefer the v2 Session API to create a password session → auth_code exchanged for user tokens. | # | Step | Acceptance criteria | |---|------|---------| | M2.1 | Hydra compose (hydra v2 + PG, public 4444 / admin 4445) + setup (client create + accept-flow-driven pool issuance)| Same pattern as M1.1/M1.2: `/health/ready` probe 200; pool-file line-count self-check; any curl/jq failure exits non-zero | | M2.2 | Hydra scenarios + ladder + gcjitter + cold start | Same acceptance as M1.3–M1.6; the S3 JSON/results carry the admin-port annotation | | M2.3 | Zitadel compose (`setup` mode initialization + `start` mode run) + setup (machine-user JSON key, API client, human user) | Same as M1.1/M1.2; the setup's two phases (init/start) are individually re-entrant | | M2.4 | Zitadel scenarios + ladder + gcjitter + cold start | Same as M1.3–M1.6; the S2 results note the JWT-profile authentication equivalence | **Decision gates (within M2)**: - **DG-1**: If Hydra's accept flow cannot be curl-driven (e.g., mandatory JS), S5/S6 are marked N/A. Criterion: the setup can reliably obtain a user token carrying the `openid` scope. - **DG-2**: If Zitadel's Session API→auth_code cannot be driven within 2 working days, S5/S6 are marked N/A (S1/S2/S3 as the fallback), noted in the limitations section. ### M3 — Aggregation + honest revision (~1 day) | # | Step | Acceptance criteria | |---|------|---------| | M3.1 | `gen-comparison.py` aggregator | Reads all four products' JSONs and fully auto-generates COMPARISON.md: main table (5 scenarios × QPS/P50/P95/P99/RSS/cold start × version columns), GC-jitter section, fairness appendix (configuration sources + deviation items + warmup differences), limitations section (S4 exclusion, Ory admin-port, Zitadel JWT-profile, WSL2 statement). **AC-M3.1**: no hand-entered numbers; a missing product/scenario renders an explicit N/A, never a missing row | | M3.2 | `run-comparison.sh` orchestrator | `--fresh` runs everything serially (cleanup + leftover assertion between products) + `--only ` re-runs + auto-invokes the aggregator at the end. **AC-M3.2**: one command from an empty environment to COMPARISON.md | | M3.3 | research.md §3.1/§3.2 revision | The competitor column becomes "same-environment measurements"; the claims converge per the measurements. **AC-M3.3**: every number is traceable to an in-repo JSON | | M3.4 | Documentation wrap-up | benchmarks/README.md gains the competitors guide; this document's §6 acceptance checked off | AC1–AC6 all checked | --- ## 8. Directory Layout Design (design only) ``` benchmarks/competitors/ ├── README.md # Reproduction guide (environment/order/limitations) ├── run-comparison.sh # Runs everything serially (or --only ) ├── run-gc-jitter.sh # 5-minute P99 time series (D6) ├── keycloak/ │ ├── docker-compose.yml # Keycloak + PG (aligned per D1) │ ├── setup.sh # start --optimized + kcadm init + token pool │ ├── teardown.sh │ ├── lib/generated/ # setup outputs: token pool files (WRK_LIB_DIR points here) │ └── scenarios/ # s1/s2/s3/s5/s6.lua (endpoints per §4.1) ├── ory/ │ ├── docker-compose.yml # Hydra(+Kratos if needed) + PG │ ├── setup.sh # hydra client create + token pool │ ├── teardown.sh │ └── scenarios/ ├── zitadel/ │ ├── docker-compose.yml │ ├── setup.sh # setup mode init + token pool │ ├── teardown.sh │ └── scenarios/ └── results/ ├── ----c.json # schema v1 ├── ---gcjitter.json └── COMPARISON.md # Aggregated comparison table (generated by gen-comparison.py, M3) # The aggregator lives in the shared reporting/ (same level as parse-wrk.py): benchmarks/reporting/gen-comparison.py ``` **Reuse, don't duplicate**: `run-scenario.sh` / `parse-wrk.py` / `observe/` directly reference the existing implementations in `benchmarks/fulla/` and `benchmarks/reporting/` (via path parameters or environment variables); no second runner copy is made for competitors. --- ## 9. Risks and Mitigations | Risk | Level | Impact | Mitigation | |------|------|------|------| | **Competitor communities challenge the configuration as unfair** | High | External data overturned, reputation damaged | D2 official recommended configuration + AC4 full annotation of deviation items; competitor communities can be invited to review the setup scripts before publication | | **Keycloak JIT/GC under-warmed, low numbers called unfair** | High | Keycloak's numbers artificially low | Warmup extended to 60 s + the long run discards the first minute of segments | | **Hydra has no built-in users, S6 untestable** | Medium | Scenario coverage gap | Equip Hydra with a minimal login/consent mock app (the official brownfield pattern); if the complexity exceeds expectations, S6 is marked N/A for Hydra | | **S5 competitor pool undersized / reissue too slow** | Medium | S5 data distorted or the setup times out | The calibration run sizes the pool (QPS×10s×1.3); parallel issuance via xargs -P8; --reissue re-issues per step (D5 v1.1) | | **Zitadel token endpoint lacks Basic client_credentials support** | Medium | S2 cannot be measured on the unified Basic basis | JWT-profile pre-signed assertion pool (the officially recommended path, signed with pyjwt); COMPARISON notes the authentication equivalence (D3 v1.1) | | **Zitadel Session API→auth_code driving fails** | Medium | S5/S6 gap | Decision gate DG-2: mark N/A if not working within 2 working days; S1/S2/S3 as the fallback | | **Slow competitor token pool issuance (2000 API calls)** | Low | Long setup time | Parallel issuance (xargs -P8); or reduce the pool to 500 (the S3/S6 pool is reusable — that is enough) | | **fulla not leading on some dimension** | Medium | Selling-point narrative damaged | That is precisely the infrastructure's value — converge honestly onto the leading dimensions (the established contingency in Evolution Plan §2 principle 1) | | **Single-machine serial runs, environment drift (OS updates/temperature)** | Medium | The four products' data come from different batches and are not comparable | Run back-to-back within one session; timestamp every product's results; take the median across reruns | | **wrk cannot saturate the Go/Java services (driver-limited)** | Low | The numbers are lower bounds | Keep the AC4 driver CPU gate; annotate above 80% | --- ## Appendix A: Relationship to Upstream Documents | Upstream | Relationship | |------|------| | Benchmark infrastructure design (internal archive, moved to local maintenance with the productization-evolution directory) | This document expands its N1 (Phase 0.5 competitor comparison); it reuses that design's runner/parser/schema/observe | | [Evolution Plan] (productization-evolution-plan: locally maintained archive) §3 Phase 0 P0 item 2 | This document is the implementation design for that work item | | Survey report (internal archive) §3.1 | The **replacement data source** for the competitor column; M3 revises it per the facts | | `benchmarks/results/SUMMARY.md` | fulla-side baseline (same-environment self-test, 2026-08-12) | ## Appendix B: Decision Record | Decision | Choice | Alternatives and rejection rationale | |------|------|---------------| | Comparison targets | Keycloak / Ory / Zitadel | Auth0 (SaaS, not self-hostable) rejected | | Scenario scope | S1/S2/S3/S5/S6 | S4 (login flow not headless-drivable) excluded, see D4 | | Configuration baseline | Officially recommended production configuration | Extreme tuning (an arms race with no credibility) and default dev configuration (unfair) both rejected | | Token pool | Self-issued via each product's API | Direct SQL insertion (private signature formats) rejected, see D5 | | Memory basis | Full-stack container RSS (primary) + PSS (supplementary) | A single basis necessarily disadvantages one side; both bases are presented side by side with annotation | | Warmup/measure basis (v1.1) | 5 s/10 s, Keycloak warmup 60 s | Aligned with the in-repo self-test data; the original text's "10 s for the other four" did not match the actual data (5 s/10 s) and was corrected | | GC-jitter carrier scenario (v1.1) | S6 userinfo, c=32 fixed | S5 (pool exhaustion) and S2 (fulla writes to the DB per request; asymmetric workload) rejected, see D6 | | fulla data basis (v1.1) | Same-session rerun | "Reusing the old data" contradicted AC2 (gcjitter mandatory) + R7 (same session eliminates drift) and was corrected | | S5 competitor pool (v1.1) | API reissue per step (--reissue), pool = QPS×10s×1.3 | A fixed 2000 pool (single-use, single-consumption — would be exhausted) rejected; RTs not taken from client_credentials (forbidden by RFC 6749 §4.4.3) | --- # OAuth2 API Reference Source: https://fulla.dev/docs/domains/api-reference # OAuth2 API Reference > **Complete API specification**: The hand-maintained OpenAPI source file is [`apps/server/openapi.yaml`](https://github.com/voidvec/fulla/blob/master/apps/server/openapi.yaml) (the **single source of truth**; CI validates three-layer consistency and version synchronization via `openapi-spec-validator` plus a governance gate). The Swagger UI (`/docs/api`) browses `apps/server/docs/api/openapi.json`, a derived artifact generated at runtime from Controller code. This service provides authentication and authorization based on the OAuth 2.0 standard (RFC 6749). ## Endpoint Category Overview | Category | Description | Prefix | |------|------|------| | **Password Reset** | Password reset requests and confirmation (based on email verification codes) | `/api/password-reset` | | **Email Verification** | Email verification sending and confirmation | `/api/email/verify` | | **MFA (Multi-Factor Auth)** | TOTP setup, verification, and recovery code management | `/api/me/mfa` (login completion at `/oauth2/mfa/verify`) | | **Admin API** | User management, client management, audit logs (requires the admin role) | `/api/admin` | | **User Self-Service** | User profile updates, password changes, session management | `/api/user` | | **OIDC Discovery** | OpenID Connect discovery endpoints and JWKS | `/.well-known/openid-configuration`, `/oauth2/jwks` | --- ## 1. Authorization Endpoint Used to request user authorization and obtain an authorization code. - **URL**: `/oauth2/authorize` - **Method**: `GET` - **Access**: Public (requires login) ### Request Parameters (Query Parameters) | Parameter | Required | Description | Example | |---|---|---|---| | `response_type` | Yes | Must be `code` | `code` | | `client_id` | Yes | Client ID | `fulla-portal` | | `redirect_uri` | Yes | Callback URL (must match exactly) | `http://localhost:5173/callback` | | `scope` | No | Requested scope | `openid profile` | | `state` | Recommended | Random string for CSRF protection | `xyz123` | | `code_challenge` | No | PKCE code challenge (mandatory by default for PUBLIC clients) | `dBjftJeZ4CVK...` | | `code_challenge_method` | No | `plain` or `S256` (defaults to `plain` when a challenge is provided) | `S256` | | `nonce` | No | OIDC nonce (replay protection); echoed into the id_token when the openid scope is requested | `n-0S6_WzA2Mj` | | `prompt` | No | OIDC prompt values, space-separated: `none`/`login`/`consent`/`select_account` (§3.1.2.1). `none` forbids any UI; `login` forces re-authentication; `consent` forces the consent page. Combining `none` with other values → 400 | `none` | | `max_age` | No | Maximum allowable age of authentication (seconds). If the session auth_time exceeds the limit → forced re-authentication | `3600` | ### Response **Success**: Redirects to `redirect_uri` with `code` and `state` attached. ```http HTTP/1.1 302 Found Location: http://localhost:5173/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz123 ``` **Error**: Returns a JSON error directly, or redirects with an error parameter. ```json { "error": "invalid_client", "error_description": "Unknown client_id" } ``` --- ## 2. Token Endpoint Used to exchange an authorization code for an access token. - **URL**: `/oauth2/token` - **Method**: `POST` - **Access**: Public (requires client authentication) - **Content-Type**: `application/x-www-form-urlencoded` ### Request Parameters (Form Data) | Parameter | Required | Description | Example | |---|---|---|---| | `grant_type` | Yes | Must be `authorization_code` | `authorization_code` | | `code` | Yes | The code obtained in the previous step | `SplxlOBeZQQYbYS6WxSbIA` | | `redirect_uri` | Yes | Must be identical to the one used to obtain the code | `http://localhost:5173/callback` | | `client_id` | Yes | Client ID | `fulla-portal` | | `client_secret` | Yes | Client secret (used for authentication) | `vue-secret` | ### Response **Success (200 OK)**: ```json { "access_token": "2YotnFZFEjr1zCsicMWpAA", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "scope": "openid profile" } ``` **Response headers (F-019, RFC 6749 §5.1 / RFC 7009 §2.2.1)**: All successful token / introspect / revoke responses carry `Cache-Control: no-store` and `Pragma: no-cache`, forbidding intermediary proxies from caching response bodies that contain credentials. *(Note: `grant_type=refresh_token` requires prior client authentication (F-003/F-017, RFC 6749 §3.2.1/§6): the authentication method must match the client's registered `token_endpoint_auth_method` — `client_secret_basic` (default) accepts only HTTP Basic (a `client_secret` in the body is rejected); `client_secret_post` accepts only form fields; PUBLIC clients send only `client_id` (including a secret is rejected). Missing or incorrect credentials return 401 `invalid_client`. Refresh token persistence is supported only on the Postgres backend; `storage_type="redis"` is deprecated, and in that mode the refresh grant returns `unsupported_grant_type` (F-005).)* **Failure (400/401)**: ```json { "error": "invalid_grant", "error_description": "Authorization code has expired" } ``` **Failure (429 Too Many Requests)** — F-018 rate limiting: `/oauth2/token`, `/oauth2/introspect`, `/oauth2/revoke`, and device_code polling share a single in-process sliding-window rate limiter, bucketed by `(client_ip, client_id)`. Within a window (default 60 s), once **failed** attempts reach the threshold (default 30; configurable via `custom_config["auth"]["rate_limit"]` with `max_failures` / `window_seconds`), subsequent requests return 429. Only failures (authentication / validation failures) are counted; a success resets the counter. ```http HTTP/1.1 429 Too Many Requests Retry-After: 42 Content-Type: application/json { "error": "invalid_request", "error_description": "Too many failed attempts; please retry later" } ``` --- ## 3. UserInfo Endpoint Used to validate an access token and retrieve user information. - **URL**: `/oauth2/userinfo` - **Method**: `GET` - **Access**: Protected (Bearer token) ### Request Headers Authorization: `Bearer {access_token}` ### Response **Success (200 OK)**: ```json { "sub": "admin", "name": "admin", "email": "admin@example.com", "email_verified": true, "picture": "..." } ``` **Failure (401 Unauthorized)**: ```json { "error": "invalid_token" } ``` **Failure (403 Forbidden)** — F-023: the access token's scope does not include `openid`, or the token is an M2M token (subject `client:*`). The response carries `WWW-Authenticate: Bearer error="insufficient_scope"`: ```json { "error": "insufficient_scope", "error_description": "The access token does not have the openid scope required for userinfo" } ``` ### 3.x Path → required-scope mapping (F-010 minimal resource-scope model) After an access token passes validation, `OAuth2AuthFilter` / `AuthorizationFilter` enforce a minimal required scope based on the request path (RFC 6750 §3.1). When the token's scope is insufficient, a 403 is returned with `WWW-Authenticate: Bearer realm="fulla", error="insufficient_scope", scope=""`, where the `scope` attribute names the scope required to unlock the resource. | Path | Required Scope | Notes | |---|---|---| | `/oauth2/userinfo` | `openid` | Coexists with the F-023 check inside the userinfo handler (defense-in-depth) | | `/api/me`, `/api/me/*` | `profile` | Enforced via `OAuth2AuthFilter` | | `/api/admin/*` | `admin` | Enforced via `AuthorizationFilter`, **layered on top of the existing RBAC role check** (the scope gate runs first, then the role gate; both must pass) | > **A complete resource-scope authorization model is future work** (separate issue > "Complete resource-scope authorization model"). Only the minimal mapping above > applies today; all other `/api/*` paths remain guarded solely by the existing RBAC > rules (`rbac_rules`), with no additional scope requirement. Scope matching is exact > matching against space-separated tokens (`fulla::drogon::utils::hasScope()`), > preventing `openidprofile` from erroneously passing `openid`/`profile`. ### 3.y Client management (F-030: admin-only, no RFC 7592 self-management) Client registration and management are available **only** via the admin API `/api/admin/clients/*` (requires the admin scope + admin role). This service does **not** implement the `registration_access_token` self-management endpoints of RFC 7592 dynamic client management — clients cannot view or modify their own registration information. Clients that require changes must contact an administrator to process them via the admin API. ### 3.z Nonce replay protection (F-026: client responsibility) OIDC Core §15.5.2 makes nonce replay checking a **client-side MUST**: the server **echoes** the client-submitted nonce in the id_token but does not store it or perform server-side replay checks. Clients must (1) generate a unique nonce for every authentication request, (2) after receiving an id_token, compare the echoed value against the locally stored nonce, and (3) reject any id_token with a duplicated or missing nonce. This service follows that division of responsibility and provides no server-side nonce replay protection. --- ## 3.1 RP-Initiated Logout Endpoint (End Session Endpoint) OIDC RP-Initiated Logout 1.0 §2 — terminates the user's server-side session and (optionally) redirects to the client's registered `post_logout_redirect_uri`. - **URL**: `/oauth2/end_session` - **Method**: `GET` (link-style) or `POST` (form-style) - **Access**: Public (no Bearer token required) ### Request Parameters (Query/Form) | Parameter | Required | Description | |---|---|---| | `id_token_hint` | No* | A previously issued id_token whose `aud` claim identifies the client, used to validate `post_logout_redirect_uri` (signature verification is mandatory: RS256 + kid match + iss/exp/sub policy; `aud` supports string or array per RFC 7519 §4.1.3, server tries each candidate). Verification failure returns 400 `AUTH_INVALID_ID_TOKEN_HINT` (error code 4006); unregistered `post_logout_redirect_uri` returns 400 `VALIDATION_REDIRECT_URI_NOT_REGISTERED` (error code 3013). *Required when `post_logout_redirect_uri` is provided | | `post_logout_redirect_uri` | No | Post-logout redirect URI; must be a redirect_uri registered by the `id_token_hint` client, otherwise 400 | | `state` | No | Opaque value echoed verbatim into the redirect URI | ### Response - **200 OK**: When no `post_logout_redirect_uri` is provided, returns `{ "message": "Logged out successfully" }`; the session has been cleared. - **302 Found**: A provided and successfully validated `post_logout_redirect_uri` (with `state` attached). - **400 Bad Request**: `post_logout_redirect_uri` not registered (`VALIDATION_REDIRECT_URI_NOT_REGISTERED`, error code 3013) / missing `id_token_hint` so the client cannot be identified (`AUTH_INVALID_ID_TOKEN_HINT`, 4006) / `id_token_hint` signature verification failed (expired, issuer mismatch, invalid signature; `AUTH_INVALID_ID_TOKEN_HINT`, 4006). --- ## 4. Helper Endpoints ### Login Submission (Internal) - **URL**: `/oauth2/login` - **Method**: `POST` - **Desc**: An internal form-submission endpoint used for session login and redirect. ### WeChat Login (Optional) - **URL**: `/api/wechat/login` - **Method**: `POST` - **Desc**: Handles WeChat Mini Program / QR-code login (for demonstration purposes). ### Google Login Callback (Optional) - **URL**: `/api/google/login` - **Method**: `POST` - **Desc**: Receives the Google authorization code from the frontend, the server exchanges it with Google for an access token and calls the UserInfo API, returning filtered user information (`sub`, `name`, `email`, `picture`). - **Request parameters**: - `code` (required): The authorization code returned by Google - **Success (200 OK)**: ```json {"sub": "1234567890", "name": "John Doe", "email": "john@gmail.com", "picture": "..."} ``` - **Failure (400/502)**: Invalid code or the Google API is unreachable. ### User Registration - **URL**: `/api/register` - **Method**: `POST` - **Content-Type**: `application/x-www-form-urlencoded` - **Rate limit**: 5 requests per minute per IP, 5000 per minute globally (Hodor plugin) #### Request Parameters (Form Data) | Parameter | Required | Description | |---|---|---| | `username` | Yes | Username | | `password` | Yes | Password (plaintext; stored server-side as SHA256 + salt) | | `email` | No | Email address | #### Response - **Success (200 OK)**: `User Registered` - **Failure (400 Bad Request)**: Missing username or password - **Failure (500 Internal Server Error)**: Username already exists, etc. ### Admin Dashboard (RBAC Protected) - **URL**: `/api/admin/dashboard` - **Method**: `GET` - **Access**: Protected; requires the `admin` role (Header: `Authorization: Bearer `) #### Response - **Success (200 OK)**: ```json {"message": "Welcome to Admin Dashboard", "status": "success"} ``` - **Failure (401)**: Token invalid or missing - **Failure (403)**: User is authenticated but does not hold the `admin` role --- ## 5. Common Error Codes > **Single source of truth**: The tables in 5.1 and 5.2 below are generated from `allEntries()` / `allOAuthEntries()` of the backend `ErrorCatalog` (`libs/common/include/fulla/common/error/ErrorCatalog.h`) and verified by an automated test — do not modify table rows by hand. > Any inconsistency (missing/extra entries, HTTP status code or Error_Category mismatch) fails the verification test: `fulla-tests -r ErrorCatalogDoc`. ### 5.1 Application Error Codes > **Language note**: the `Default Message` / `Description` columns quote the **exact strings the server emits** (registered in `ErrorCatalog`); they are kept verbatim — currently Chinese — because a client matching on `error_description` must see precisely what the catalog defines. A CI test (`Unit_P0_ErrorCatalogDoc_*`) fails if these tables drift from the catalog. Business endpoints (Application_Endpoint) return a uniform error envelope whose `error.code` values belong to the Error_Code set registered in the table below; `numeric_code` and `category` likewise come from the table, and the HTTP status code maps consistently by Error_Category (the NETWORK category distinguishes 502/504 by numeric_code). A few resource-semantics VALIDATION codes retain their pre-migration HTTP status codes via entry-level explicit overrides (Option A / requirement 11.4): `VALIDATION_RESOURCE_NOT_FOUND` → 404, resource-already-exists/conflict codes (`VALIDATION_RESOURCE_CONFLICT`, `VALIDATION_USERNAME_TAKEN`, `VALIDATION_EMAIL_TAKEN`, `VALIDATION_CREDENTIAL_ALREADY_REGISTERED`) → 409, `VALIDATION_RATE_LIMITED` → 429; all other VALIDATION codes remain 400. | Error_Code | numeric_code | Error_Category | HTTP Status | Default Message (Client_Safe_Message) | |---|---|---|---|---| | `NET_CONNECTION_FAILED` | 1001 | NETWORK | 502 | 上游连接失败 | | `NET_TIMEOUT` | 1002 | NETWORK | 504 | 请求超时 | | `DB_CONNECTION_ERROR` | 2001 | DATABASE | 500 | 服务暂时不可用 | | `DB_QUERY_ERROR` | 2002 | DATABASE | 500 | 服务暂时不可用 | | `DB_CONSTRAINT_VIOLATION` | 2003 | DATABASE | 500 | 数据冲突 | | `VALIDATION_INVALID_INPUT` | 3001 | VALIDATION | 400 | 输入参数有误 | | `VALIDATION_MISSING_REQUIRED_FIELD` | 3002 | VALIDATION | 400 | 缺少必填字段 | | `VALIDATION_FORMAT_ERROR` | 3003 | VALIDATION | 400 | 格式不正确 | | `VALIDATION_PASSWORD_TOO_SHORT` | 3014 | VALIDATION | 400 | 密码长度不足 | | `VALIDATION_RESOURCE_NOT_FOUND` | 3004 | VALIDATION | 404 | 资源不存在 | | `VALIDATION_RESOURCE_CONFLICT` | 3005 | VALIDATION | 409 | 资源已存在或冲突 | | `VALIDATION_USERNAME_TAKEN` | 3006 | VALIDATION | 409 | 该用户名已被注册 | | `VALIDATION_EMAIL_TAKEN` | 3007 | VALIDATION | 409 | 该邮箱已被注册 | | `VALIDATION_CREDENTIAL_ALREADY_REGISTERED` | 3008 | VALIDATION | 409 | 该安全密钥已注册,无需重复添加 | | `WEBAUTHN_INVALID_ATTESTATION` | 3015 | VALIDATION | 400 | 注册声明无法通过验证 | | `WEBAUTHN_CHALLENGE_MISMATCH` | 3016 | VALIDATION | 400 | 注册挑战校验失败 | | `VALIDATION_RESET_TOKEN_INVALID` | 3009 | VALIDATION | 400 | 重置链接已失效,请重新申请 | | `VALIDATION_VERIFICATION_TOKEN_INVALID` | 3010 | VALIDATION | 400 | 验证链接已失效,请重新发送邮件 | | `VALIDATION_DEVICE_CODE_INVALID` | 3011 | VALIDATION | 400 | 设备码无效、已过期或已被处理 | | `VALIDATION_RATE_LIMITED` | 3012 | VALIDATION | 429 | 请求过于频繁,请稍后重试 | | `VALIDATION_REDIRECT_URI_NOT_REGISTERED` | 3013 | VALIDATION | 400 | 登出重定向地址未注册 | | `AUTH_INVALID_CREDENTIALS` | 4001 | AUTHENTICATION | 401 | 用户名或密码错误 | | `AUTH_TOKEN_EXPIRED` | 4002 | AUTHENTICATION | 401 | 登录已过期 | | `AUTH_TOKEN_INVALID` | 4003 | AUTHENTICATION | 401 | 登录凭证无效 | | `AUTH_SESSION_REQUIRED` | 4007 | AUTHENTICATION | 401 | 需要先登录 | | `AUTH_MFA_CODE_INVALID` | 4004 | AUTHENTICATION | 401 | 验证码不正确 | | `AUTH_MFA_NOT_CONFIGURED` | 4005 | AUTHENTICATION | 401 | 尚未设置双重验证,请先完成设置 | | `AUTH_INVALID_ID_TOKEN_HINT` | 4006 | AUTHENTICATION | 400 | 登录令牌提示无效 | | `AUTH_MFA_REQUIRED` | 4008 | AUTHENTICATION | 401 | 需要完成 MFA 验证 | | `AUTH_PASSWORD_CHANGE_REQUIRED` | 4009 | AUTHENTICATION | 403 | 必须先修改密码 | | `AUTHZ_ACCESS_DENIED` | 5001 | AUTHORIZATION | 403 | 没有访问权限 | | `AUTHZ_INSUFFICIENT_PERMISSIONS` | 5002 | AUTHORIZATION | 403 | 权限不足 | | `AUTH_SOCIAL_ACCOUNT_NOT_LINKED` | 5003 | AUTHORIZATION | 403 | 该第三方账号尚未绑定本地账户 | | `INTERNAL_ERROR` | 6001 | INTERNAL | 500 | 服务器内部错误 | ### 5.2 OAuth2 Protocol Error Codes (RFC 6749 §5.2 / RFC 7009 / RFC 8628) OAuth2 protocol endpoints (OAuth2_Protocol_Endpoint) keep the RFC 6749 §5.2 error body structure `{ "error", "error_description", "error_uri" }`; the `error` values and HTTP status codes are taken from the table below. | error | HTTP Status | Default error_description | |---|---|---| | `invalid_request` | 400 | 请求参数缺失或无效 | | `invalid_client` | 401 | 客户端认证失败 | | `invalid_grant` | 400 | 授权许可无效或已过期 | | `unauthorized_client` | 400 | 客户端无权使用该授权类型 | | `unsupported_grant_type` | 400 | 不支持的授权类型 | | `invalid_scope` | 400 | 请求的 scope 无效 | | `server_error` | 500 | 服务器内部错误 | | `temporarily_unavailable` | 503 | 服务暂时不可用 | | `access_denied` | 403 | 授权请求被拒绝(用户无权或拒绝授权) | | `unsupported_token_type` | 400 | 不支持的令牌类型 | | `authorization_pending` | 400 | 授权尚未完成,请稍后重试 | | `slow_down` | 400 | 轮询过于频繁,请降低频率 | | `expired_token` | 400 | 设备码已过期,请重新发起授权 | ### 5.3 HTTP Status Code Quick Reference | HTTP Status | Description | Example Causes | |---|---|---| | `200` | OK | Request succeeded | | `302` | Found | Redirect (e.g., the OAuth2 authorization jump) | | `400` | Bad Request | Invalid parameters, `invalid_grant`, `unauthorized_client` | | `401` | Unauthorized | Token invalid or expired, `invalid_client` | | `403` | Forbidden | **RBAC block**: user is authenticated but lacks a required role, `access_denied` | | `429` | Too Many Requests | Rate limit triggered (Rate Limiting) | | `500` | Internal Server Error | Internal server error | --- ## 6. API Contract Maintenance Process (OpenAPI Governance) The **single source of truth for the HTTP API contract is [`apps/server/openapi.yaml`](https://github.com/voidvec/fulla/blob/master/apps/server/openapi.yaml)**; this document is its guided introduction and complement (covering content the yaml does not carry, such as the error code tables). ### 6.1 Change Process 1. When changing endpoint behavior, update `apps/server/openapi.yaml` in sync (new endpoints / parameters / responses). 2. Metadata registered via `OpenApiGenerator::addEndpoint()` inside Controllers must stay consistent (`fulla-tests -r OpenApiGenerator` verifies registration completeness). 3. Swagger UI (`http://localhost:5555/docs/api/`) is hosted by the server for manual review. ### 6.2 Breaking-Change Gate (CI) The `OpenAPI Governance` workflow runs an **oasdiff breaking** gate on PRs: it compares the PR's `openapi.yaml` against master, and any breaking change (removed paths, tightened request bodies, narrowed responses, etc.) fails CI unless: - The change is accompanied by a major version bump; or - It is explicitly exempted in `tools/openapi-governance/oasdiff-breaking-ignore.md` with a documented rationale. The same command can be reproduced locally (see the header comment in `.github/workflows/openapi-governance.yml`). ### 6.3 Quality Standards * **Required fields**: `path`, `method`, `summary`, `description`, `tags`, `responses`, `requiresAuth`. * **Recommended practice**: provide `responseExamples` for every response code, and fully define each parameter's `type` and `location`. ### 6.4 Troubleshooting * **Swagger UI inaccessible**: check whether static assets are shipped with the server and confirm that static file serving is enabled. * **Registration validation fails**: run the `fulla-tests -r OpenApiGenerator` unit test and inspect the specific registration errors. * **Governance-gate false positive**: verify that the `oasdiff breaking` output and the exemption-list entry refer to the same path/operation. --- # Multi-Tenancy (Organizations) Source: https://fulla.dev/docs/domains/multi-tenancy # Multi-Tenancy (Organizations) fulla's multi-tenancy today is an **organizational layer**: organizations group users and clients, carry branding fields, and are managed through admin APIs. This page documents exactly what exists, what it does **not** do yet, and how to use it without over-assuming isolation. > **Read this first**: organizations are metadata and ownership grouping, > not a hard isolation boundary. Authorization is enforced by RBAC + scopes > (see [RBAC Guide](rbac-guide.md)); today an org-scoped principal is not > automatically fenced off from other orgs' data. ## 1. Model (V017) ```sql organizations ( id SERIAL PRIMARY KEY, slug VARCHAR(50) UNIQUE, -- 3–50 chars, lowercase name VARCHAR(200), logo_uri VARCHAR(512), -- branding primary_color VARCHAR(7), -- branding issuer_override VARCHAR(512), -- stored; see §4 roadmap created_at / updated_at ) ``` Two nullable foreign keys attach entities to an organization: | Column | On | Semantics | |---|---|---| | `org_id` | `users` | The user belongs to the org; `NULL` = unassigned (backwards compatible) | | `org_id` | `oauth2_clients` | The client is owned by the org; `NULL` = global/ownerless | Both are nullable by design: pre-V017 data and platform-level principals (the seed `admin`, the `fulla-admin-console` client) simply have no org. ## 2. Admin API surface All routes require an admin-scope token (`AuthorizationFilter`; `impliedBy: admin`) — [API Reference](api-reference.md) §client management: | Method & path | Purpose | |---|---| | `GET /api/admin/organizations` | List organizations (id, slug, name, branding) | | `POST /api/admin/organizations` | Create (slug: 3–50 lowercase chars, unique) | | `GET /api/admin/organizations/{slug}` | Fetch one | Additionally: - `POST/PATCH /api/admin/users` accepts `org_id` — an integer assigns the user to an org; JSON `null` **clears** the assignment. Example: ```bash # Create an organization (token: admin scope) curl -X POST http://localhost:5555/api/admin/organizations \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"slug":"acme","name":"ACME Corp","logo_uri":"https://acme.example/logo.svg","primary_color":"#5b2fd1"}' # Attach a user to it curl -X PATCH http://localhost:5555/api/admin/users/42 \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"org_id": 1}' ``` ## 3. What this buys you today - **Ownership bookkeeping**: which human belongs to which company, which client application belongs to which company — queryable via the admin API and SQL (`users.org_id`, `oauth2_clients.org_id`). - **Branding catalog**: per-org logo and primary color for frontends that want to skin the login experience per tenant. - **No migration cliff**: everything is optional and additive; deployments that don't care about orgs never touch it. ## 4. What it does NOT do yet (roadmap) Be explicit with stakeholders — these are **not** implemented: 1. **`issuer_override` is stored but not applied**: per-org issuer in the discovery document and in issued tokens is schema-ready, not runtime-live. 2. **No org-scoped filtering/isolation** on user or client listings; an admin sees across orgs. 3. **No org-scoped roles**: roles are global (RBAC), not per-org. 4. **No update/delete** endpoints for organizations (create/list/get only). 5. **No per-org rate limits, quotas, or keys**. If you need hard tenant isolation today, run one fulla stack per tenant — the Docker Compose / Helm paths make that cheap ([Deployment](../operate/deployment.md)). ## 5. Schema reference The authoritative DDL is [`V017__multi_tenant.sql`](https://github.com/voidvec/fulla/blob/master/apps/server/migrations/V017__multi_tenant.sql) (indexes on `users(org_id)`, `oauth2_clients(org_id)`, `organizations(slug)`). Storage-layer details: [Data Persistence](../architecture/data-persistence.md). --- # OpenID Connect (OIDC) Integration Guide Source: https://fulla.dev/docs/domains/oidc-guide # OpenID Connect (OIDC) Integration Guide This guide describes how to integrate this OAuth2 service into your application as an OIDC Provider. ## 1. Discovery Endpoint The OIDC discovery endpoint provides all configuration information about the Provider: ``` GET /.well-known/openid-configuration ``` Example response: ```json { "issuer": "https://your-domain.com", "authorization_endpoint": "https://your-domain.com/oauth2/authorize", "token_endpoint": "https://your-domain.com/oauth2/token", "userinfo_endpoint": "https://your-domain.com/oauth2/userinfo", "jwks_uri": "https://your-domain.com/.well-known/jwks.json", "response_types_supported": ["code"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], "scopes_supported": ["openid", "profile", "email"] } ``` ## 2. JWKS Endpoint The JSON Web Key Set endpoint provides the public keys used to verify `id_token` signatures: ``` GET /.well-known/jwks.json ``` Example response: ```json { "keys": [ { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "default-key-id", "n": "", "e": "AQAB" } ] } ``` ## 3. id_token Format The `id_token` is an RS256-signed JWT containing the following standard claims: | Claim | Description | Example | |-------|------|------| | `iss` | Issuer | `https://your-domain.com` | | `sub` | Unique user identifier (UUID public_sub) | `550e8400-e29b-41d4-a716-446655440000` | | `aud` | Audience (Client ID) | `your-client-id` | | `exp` | Expiration time (Unix timestamp) | `1700000000` | | `iat` | Issued-at time (Unix timestamp) | `1699996400` | | `nonce` | The nonce value sent in the request | `abc123` | Depending on the requested scopes, it may also contain: - **profile scope**: `name`, `preferred_username` - **email scope**: `email`, `email_verified` ## 4. Verifying the id_token ### 4.1 Verification Steps 1. **Decode the JWT header**: extract `kid` (Key ID) and `alg` (should be RS256) 2. **Fetch the public key**: retrieve the public key matching the `kid` from the JWKS endpoint 3. **Verify the signature**: verify the JWT signature with the RSA public key 4. **Verify the claims**: - `iss` must match your configured Issuer URL - `aud` must include your Client ID - `exp` must be in the future - `nonce` must match the value you sent in the authorization request ### 4.2 Security Considerations - **Always verify the signature**; never trust an unverified JWT - **Cache the JWKS**, but with a sensible refresh interval (24 hours is recommended, or follow the Cache-Control header) - **Check the `alg` header** and reject the `none` algorithm (prevents algorithm downgrade attacks) ## 5. Supported Scopes and Claims | Scope | Returned Claims | |-------|---------------| | `openid` | `sub` (required scope; enables OIDC) | | `profile` | `name`, `preferred_username` | | `email` | `email`, `email_verified` | ## 6. Integration Examples ### 6.1 Using a Standard OIDC Client Library (Node.js) ```javascript const { Issuer } = require('openid-client'); // Discover the Provider configuration automatically const issuer = await Issuer.discover('https://your-domain.com'); const client = new issuer.Client({ client_id: 'your-client-id', client_secret: 'your-client-secret', redirect_uris: ['http://localhost:3000/callback'], response_types: ['code'], }); // Generate the authorization URL const authUrl = client.authorizationUrl({ scope: 'openid profile email', state: 'random-state-value', nonce: 'random-nonce-value', }); // Handle the callback const params = client.callbackParams(req); const tokenSet = await client.callback('http://localhost:3000/callback', params, { state: 'random-state-value', nonce: 'random-nonce-value', }); console.log('ID Token claims:', tokenSet.claims()); console.log('Access Token:', tokenSet.access_token); ``` ### 6.2 Using Python (authlib) ```python from authlib.integrations.requests_client import OAuth2Session client = OAuth2Session( client_id='your-client-id', client_secret='your-client-secret', redirect_uri='http://localhost:8000/callback', scope='openid profile email' ) # Build the authorization URL uri, state = client.create_authorization_url( 'https://your-domain.com/oauth2/authorize' ) # Handle the callback and exchange for tokens token = client.fetch_token( 'https://your-domain.com/oauth2/token', authorization_response=callback_url ) # Fetch user information userinfo = client.get('https://your-domain.com/oauth2/userinfo').json() ``` ### 6.3 Using Go (coreos/go-oidc) ```go provider, err := oidc.NewProvider(ctx, "https://your-domain.com") oauth2Config := oauth2.Config{ ClientID: "your-client-id", ClientSecret: "your-client-secret", RedirectURL: "http://localhost:8080/callback", Endpoint: provider.Endpoint(), Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, } // Verify the id_token verifier := provider.Verifier(&oidc.Config{ClientID: "your-client-id"}) idToken, err := verifier.Verify(ctx, rawIDToken) ``` ## 7. FAQ **Q: How long is the id_token valid?** A: The same as the access token — 1 hour by default. **Q: How should key rotation be handled?** A: Refresh the public keys from the JWKS endpoint periodically. When verification fails, refresh the JWKS first, then retry the verification. **Q: Is PKCE supported?** A: Yes. PKCE (RFC 7636) is implemented and enforced by default for PUBLIC clients (both `plain` and `S256` are supported). SPA and mobile clients should use `S256`. --- # RBAC Access Control System (Role-Based Access Control) Source: https://fulla.dev/docs/domains/rbac-guide # RBAC Access Control System (Role-Based Access Control) This document details the design and usage of the system's role-based access control (RBAC). ## 1. Core Concepts The system adopts the standard RBAC model: - **User**: the acting subject of the system. - **Role**: a collection of permissions (e.g., `admin`, `user`). - **Permission**: a specific access capability (e.g., `user:delete`, `sys:monitor`) - *Note: currently simplified to role-based URL interception*. ### Relationship Model - `User <-> Role`: many-to-many - `Role <-> Permission`: many-to-many ## 2. Database Design Relevant table structures (PostgreSQL): ```sql -- Users table CREATE TABLE users (...); -- Roles table CREATE TABLE roles ( id SERIAL PRIMARY KEY, name VARCHAR(50) UNIQUE NOT NULL, -- e.g. 'admin', 'user' description TEXT ); -- User-role association table CREATE TABLE user_roles ( user_id INT REFERENCES users(id), role_id INT REFERENCES roles(id), PRIMARY KEY (user_id, role_id) ); ``` ## 3. Configuration Rules (rbac_rules) Configure the mapping between URL paths and required roles in `config.json`: ```json "rbac_rules": { "/api/admin/.*": ["admin"], // admin only "/api/user/.*": ["user", "admin"] // user or admin } ``` - **Logic**: OR logic (possessing any single role in the list is sufficient to pass). - **Matching**: URL paths are matched by regular expression. ## 4. Authentication Flow 1. **Login/Registration**: - On registration, users are automatically assigned the default `user` role. - On login, the system queries the `user_roles` table to load all of the user's roles. 2. **Token Issuance**: - The `roles` list is included in the token response (JSON body). - `roles` are also issued into the JWT claims along with the access token (written by TokenService at issuance). 3. **Request Interception (AuthorizationFilter)**: - Parse the access token to obtain the `userId`. - Query the cache/database by `userId` to fetch the current roles. - Match the request URL against `rbac_rules`. - Verify that the user holds a required role. - **Pass**: continue processing. - **Deny**: return `403 Forbidden`. ## 5. Management Endpoints - **Dashboard**: `/api/admin/dashboard` (requires the `admin` role) ## 6. How to Grant Admin Privileges Currently this must be granted manually via SQL (in production it is usually done through the SuperAdmin UI): ```sql -- Assume the target user ID is 5 and the Admin role ID is 1 INSERT INTO user_roles (user_id, role_id) VALUES (5, 1); ``` --- # Session Management Source: https://fulla.dev/docs/domains/session-management # Session Management fulla has **two different lifetimes that people often conflate**: the browser SSO session (a server-side session behind a cookie) and API tokens (no session involved at all). This page explains both, how they end, and — the operationally important part — what the Drogon session layer costs under machine traffic and how to size it. ## 1. Two lifetimes, one system | | SSO session | API tokens | |---|---|---| | Who has one | Browsers walking the interactive login/consent flow | Any client calling token/introspect/userinfo — **no cookie is ever sent** | | Backed by | Drogon server-side session (`session_timeout`) | Opaque tokens, hashed at rest ([Token Lifecycle](token-lifecycle.md)) | | Ends via | `end_session` / logout / idle expiry | Expiry, revocation, refresh-family cascade | Machine traffic never touches the session store. But — the critical detail below — with sessions enabled it still *creates* session entries. ## 2. The Drogon session behavior you must know (upstream #278) With `enable_session: true`, Drogon's framework layer creates a Session for **every request that arrives without a session cookie** and retains it in the SessionManager until `session_timeout` expires ([drogon#278](https://github.com/an-tao/drogon/issues/278), verified on this codebase 2026-08-22). API clients (token / introspect / userinfo / discovery) never send cookies, so they pay this cost on **every request**. Measured on a production LTO build (three 60 s c=128 storms): | Quantity | Value | |---|---| | Retained per request | **~750 B** (744/755/759 B across runs) | | Steady-state formula | `API_QPS × session_timeout × 750 B` | | Discovery throughput tax | **~-54%** (164.6k → 76.3k QPS, session OFF/ON interleaved 6×) | The tax affects every endpoint (session creation happens in the framework layer, before routing). Historical benchmark numbers (S1 87–104k) were measured **with** sessions on; the session-less ceiling is ~165k. ### Sizing table Sizing by the formula (interactive logins write-then-read within milliseconds, so correctness never depends on the TTL — verified across the full S4 login/authcode ladder at 120 s): | API QPS (cookie-less) | TTL 3600 (default) | TTL 300 | TTL 120 | TTL 30 | |---|---|---|---|---| | 100 | ~0.3 GB | ~23 MB | ~9 MB | ~2 MB | | 1,000 | **~2.7 GB** | ~225 MB | ~90 MB | ~23 MB | | 10,000 | **~27 GB (OOM zone)** | ~2.2 GB | ~0.9 GB | ~225 MB | **Guidance**: - Mostly-interactive deployments (under 100 QPS of API traffic): keep the default 3600 s — the full SSO experience survives. - Non-trivial API traffic: lower `session_timeout` (and `session_max_age` together) to a row your memory budget tolerates. A 2-minute idle window is acceptable for browser SSO (OIDC deployments commonly use 5–15 min). - The benchmark profile uses 30 s (`config.bench.json`, `QPS × 30 × 750 B` capped). - The throughput tax (not the retention) is independent of TTL and exists whenever sessions are enabled; the structural fix is upstream lazy / per-path session creation — tracked in [drogon#278](https://github.com/an-tao/drogon/issues/278). Deployment-time operational summary: [Production Deployment · performance tuning](../operate/deployment.md). ## 3. Ending a session ### RP-Initiated Logout (F-027) — `GET/POST /oauth2/end_session` Terminates the server-side session and (optionally) redirects. Rules: - `post_logout_redirect_uri` **must** be one of the client's registered redirect URIs; the client is identified by the `id_token_hint`'s `aud`. - The hint's **signature is verified** (RS256 + kid + iss/exp/sub policy). A failed verification → 400 `AUTH_INVALID_ID_TOKEN_HINT` (4006). - No valid hint + registered URI → 400. On success: 302 with `state` echoed, or 200 when no redirect URI was supplied. ### API logout — `POST /oauth2/logout` Revokes the presented tokens **and** calls `session()->clear()` (F-028), so the server-side session dies together with the access token. ### Re-authentication semantics (F-022) `prompt=login` forces re-authentication even with a live session; `prompt=none` forbids UI (errors `login_required` / `consent_required` are redirected back to the verified redirect URI); `max_age=` forces re-auth when the session's `auth_time` is older. `auth_time`, `amr`, and `acr` (1 = password, 2 = MFA) travel on the authorization code and are stamped into the id_token — see [Configuration Guide §6](../operate/configuration-guide.md). ## 4. What sessions are NOT - Token revocation surfaces (revoke by token / client / user) act on **tokens**, not SSO sessions — see [Token Lifecycle §6](token-lifecycle.md). - The admin console's token browser lists at-rest tokens; it does not expose live session inventory. --- # Social Login Guide Source: https://fulla.dev/docs/domains/social-login # Social Login Guide Backend social login is implemented with a "provider adapter" pattern. Since #70 all three providers — GitHub, Google, WeChat — run the SAME closed loop: upstream code exchange → subject-mapping lookup → (first login) local account auto-create → first-party token pair issuance. ## Uniform account model (#70) All three providers map `(provider, subject)` — GitHub numeric id / Google `sub` / WeChat `openid` — to a local user through `oauth2_subject_mappings`: - **Existing mapping**: tokens are issued for the linked local user (soft-deleted or locked linked users are rejected with the generic auth error — no account-status leak). - **No mapping + auto-create enabled** (default): a local account is created (username `gh_` / `google_` / `wx_`; a collision retries once with a random suffix, then fails `VALIDATION_USERNAME_TAKEN`), the mapping row is written, the default `user` role is granted, and tokens are issued. - **No mapping + auto-create disabled**: `403 AUTH_SOCIAL_ACCOUNT_NOT_LINKED` and NO account side effects. 403 (not 401) because "not linked" is an authorization state the client can act on (guide the user to the link flow); probing requires the target account's own upstream provider code, so there is no cross-user enumeration vector. The auto-create gate is GLOBAL — `external_auth.auto_create_on_first_login` (default `true`) governs GitHub/Google/WeChat together; it is a social policy, not a per-provider toggle. Already-linked users are unaffected by the switch. ## First-party token issuance (#70) The login endpoints mint an opaque access/refresh pair via the shared `SocialTokenIssuer`: - Token rows store the **platform subject** (`users.public_sub`) — the same value every Bearer-authenticated handler resolves (`/api/me`, change-password, MFA, WebAuthn). (GitHub previously stored the internal numeric id, which made its tokens 404 on every authenticated endpoint — fixed with the issuer extraction.) - Issued for the configured FIRST-PARTY client: `external_auth.social_token_client_id` (default `fulla-portal`). This key must never point at a third-party client — the issuance has no consent interaction, so doing so would hand that client tokens nobody agreed to. - Scope `openid profile email`. Note: the `openid` scope value carries no OIDC semantics on this endpoint (no id_token is issued); it is kept for consistency with the first-party client's scope set. ### Why an audit event and not a consent row Issuance records a `SOCIAL_LOGIN_TOKEN_ISSUED` audit action (provider, client, scope, internal id) rather than an `oauth2_user_consents` row: a consent row would silently satisfy the consent screen's Tier-3 check and pre-approve the configured client for scopes the user was never prompted for — fabricated consent evidence. An explicit social-consent interaction is a registered follow-up; the audit event is the honest record until then. This is a first-party extension endpoint, not one of RFC 6749's four grants — the same position the GitHub flow has always had, now documented and audit-traced. The standards-track alternative (social login establishes a browser session, then the SPA runs authorization-code + PKCE with a consent-exempt first-party client) is registered as follow-up work. ## GitHub (fully wired, mainline) - Backend route: `POST /api/github/login`; the `frontends/user` frontend has a "Sign in with GitHub" button (the OAuth App's client id is injected via `VITE_GITHUB_CLIENT_ID`). - Callback: `/callback/github`. ## Google (wired, #70) 1. Create an OAuth 2.0 client on the Google Cloud side (Web application; authorized redirect URI = `/callback/google`). 2. Backend configuration (`config.json`): ```json "external_auth": { "google": { "client_id": "", "client_secret": "", "redirect_uri": "/callback/google" } } ``` 3. `POST /api/google/login` (`code` parameter) completes the exchange and returns the token pair (see above). 4. Frontend: the login page renders "Sign in with Google" when `VITE_GOOGLE_CLIENT_ID` is set (unconfigured = hidden); the `/callback/google` route (generalized `SocialCallbackPage`) stores the tokens and lands the user on the home page. ## WeChat (login wired; QR scan needs a mobile-agent surface) 1. Create a website application on the WeChat Open Platform (**localhost callbacks are not supported**; an ICP-registered domain is required). 2. Backend configuration follows the same structure (`external_auth.wechat`: appid / app_secret). 3. Backend route: `POST /api/wechat/login` — same closed loop; WeChat supplies no email, the created account mirrors GitHub's empty-email handling. 4. Frontend: `/callback/wechat` exists; the desktop SPA only surfaces a hint when `VITE_WECHAT_APPID` is configured (the QR-scan authorization flow requires a WeChat-enabled device/browser surface — the desktop button-and-redirect UX cannot complete it; the explicit desktop QR flow is a registered follow-up). 5. Local development tricks: point the callback domain to 127.0.0.1 via the hosts file / an Nginx reverse proxy / an intranet tunnel. ## General Security Notes - The `state` on social callbacks must be verified (CSRF protection); the subject returned by a provider is trusted only from the server-side code exchange result — never trust user information submitted by the frontend. - Unlinking a social account has "last login method" protection and a known limitation around concurrent-unlink races (see the social-link design archived under `docs/history` and the CHANGELOG #54/#69 fix records). > Merged from the retired google-guide.md and wechat-guide.md (docs governance A2), and fixed the self-contradiction > in the Google guide where "there was no frontend button, yet users were told to click the button". #70 closed the > loop for Google/WeChat and rewrote this page around the uniform account/issuance model. --- # Token Lifecycle Source: https://fulla.dev/docs/domains/token-lifecycle # Token Lifecycle How tokens are born, live, and die in fulla: the three token types, what is actually stored, how refresh rotation detects theft, and every knob that controls a lifetime. For the storage schema behind this, see [Data Persistence](../architecture/data-persistence.md); for the HTTP contract, see the [API Reference](api-reference.md) and [ADR-0004](../adr/ADR-0004.md). ## 1. Three token types, three different shapes | Token | Shape | Validated by | Lifetime default | |---|---|---|---| | Access token | **Opaque random string** (`generateSecureToken`) | Server-side state (introspection / userinfo) — never a self-contained JWT | 3600 s (`access_token_ttl`) | | Refresh token | Opaque random string + **family id** | Server-side state; rotation on every use | 30 days (configurable) | | id_token | **RS256 JWT** signed via JWKS (`kid` published at `/.well-known/jwks.json`) | Client-side signature verification (standard OIDC) | Same as access token; issued only with the `openid` scope | Design rationale ([ADR-0004](../adr/ADR-0004.md)): opaque access tokens keep the server in full control — revocation is immediate and stateful, and no key-compromised token outlives its server-side record. The JWT capability is reserved for the OIDC `id_token`, where the protocol requires it. One frequent confusion: the **roles** that accompany a token response are part of the JSON envelope (`"roles": [...]`) and the id_token claims — the opaque access token itself carries nothing that needs decoding. ## 2. Issuance paths All grants converge on `TokenService` (libs/oauth2): | Grant | Mints | Notes | |---|---|---| | `authorization_code` (+ PKCE, mandatory for PUBLIC clients) | access + refresh (+ id_token if `openid`) | The code is single-use with atomic consume (see §5) | | `refresh_token` | new access + **new** refresh | Old refresh is revoked; family id is inherited (§4) | | `client_credentials` | access only (M2M, no user) | CONFIDENTIAL clients only | | `device_code` | access + refresh after user approval | Polling per RFC 8628 | `expires_in` in every response advertises the **configured** access-token lifetime — not a hardcoded 3600 (RFC 6749 §5.1). ## 3. What is stored (and what is not) - Access and refresh tokens are persisted **only as SHA-256 hashes** (`hashToken()` before any repository write) — a database dump exposes no usable credentials ([ADR-0004](../adr/ADR-0004.md)). - The refresh token row additionally stores its `token_family` id, the associated access-token hash, `revoked`/`revoked_at`/`revoked_by`. - Rows past their TTL are deleted by `OAuth2CleanupService` (`cleanup_interval_seconds`, default 3600 s) — Postgres via periodic DELETE, Redis historically via TTL, memory via periodic sweep. ## 4. Refresh rotation, reuse detection, family revocation Every refresh grants a **new** refresh token that inherits the same `token_family` (V008). If a refresh token that was already revoked is presented again, that is treated as theft: the server **cascades revocation to every token in the family** — attacker and legitimate user alike must re-authenticate. The full threat-model walkthrough (with a sequence diagram) lives in [Security Architecture §7](../architecture/security-architecture.md). ## 5. Single-use of authorization codes `consumeAuthCode` is atomic per backend: Postgres uses `UPDATE ... WHERE consumed = false ... RETURNING`, Redis used a Lua script, memory uses a mutex — so a stolen code replayed in a race loses, always. The contract is enforced across all three implementations by `GrantRepositoryContractTest` (`ctest -L Contract`). ## 6. Revocation surfaces | Surface | Granularity | |---|---| | `POST /oauth2/revoke` (RFC 7009) | The presented token | | Admin API `DELETE /api/admin/tokens/{prefix}` | By token prefix | | Admin token management | Revoke by **client** or by **user** — wipes every token minted for that principal | Revocation is stateful and immediate: the very next introspection or userinfo call with a revoked token returns `{"active": false}` / 401. ## 7. Cache interplay With the Redis L2 cache enabled (`cache.enabled`), token and client reads hit `fulla:cache:*` before Postgres. Every write path (issue, refresh, revoke) invalidates through **delayed double-delete** (immediate DEL + a second DEL ~200 ms later, `cache.invalidation_double_delete_delay_ms`), closing the race where a concurrent reader re-fills a stale entry between the write and the first delete. Failures of the second delete are counted in `fulla_cache_invalidation_failures_total` — see [Observability](../operate/observability.md). Details in [Data Persistence · cache consistency](../architecture/data-persistence.md). ## 8. Configuration reference | Key | Default | Meaning | |---|---|---| | `access_token_ttl` | 3600 | Access-token lifetime (seconds); advertised in `expires_in` | | refresh-token TTL | 30 days | Refresh-token lifetime | | `cleanup_interval_seconds` | 3600 | How often `OAuth2CleanupService` purges expired rows | | `cache.enabled` / `cache.ttl_seconds` | false / — | L2 cache for token/client reads ([Configuration Guide](../operate/configuration-guide.md)) | --- # SDK Integration Guide (Consuming Release Artifacts) Source: https://fulla.dev/docs/sdk/sdk-integration-guide # SDK Integration Guide (Consuming Release Artifacts) How to obtain and integrate fulla's release artifacts: the SDK binary package (libraries + headers + `fulla-*Config.cmake`) and the GHCR container images. For runtime behavior guarantees (threading / ABI / exceptions / logging / plugin registration), see the [SDK Runtime Contract](sdk-runtime-contract) — this document covers only how to obtain and wire everything up. The release pipeline is `.github/workflows/release.yml` (triggered by a strict SemVer tag `vX.Y.Z`). > Non-C++ consumers: the officially maintained **Python** (PyPI [`fulla-oauth2`](https://pypi.org/project/fulla-oauth2/)) and **Go** (`github.com/voidvec/fulla/clients/go`) HTTP clients work out of the box; see [clients/](https://github.com/voidvec/fulla/tree/master/clients). --- ## 1. Release Artifact Inventory | Artifact | Location | Notes | |------|------|------| | SDK package `fulla-sdk--linux-x86_64.tar.gz` | GitHub Release attachment | 8 static libraries + `include/fulla/**` headers + `lib/cmake/fulla-*/{Config,ConfigVersion,Targets}.cmake` (with `.sha256`) | | Backend image | `ghcr.io/voidvec/fulla-backend:` | Multi-arch (amd64 + arm64), entry port `:5555`, `/health` liveness probe | | User frontend image | `ghcr.io/voidvec/fulla-frontend:` | nginx static hosting, `:80` | | Admin console image | `ghcr.io/voidvec/fulla-admin:` | nginx static hosting of `/admin`, `:80` | The images also carry a `latest` tag; `-amd64` / `-arm64` are single-arch intermediate tags. The server executable is **not** part of the SDK package — product deployment goes through the image channel. ## 2. SDK Package Prerequisites (Read This First) - **v1.x guarantees only source-level SemVer, not binary ABI** (Contract §2). The published `linux-x86_64` static libraries are compiled with the Release pipeline's toolchain (ubuntu-24.04 / gcc / libstdc++ / C++17 / Conan-locked dependencies); if your toolchain does not match, **fall back to source integration** (`add_subdirectory`, or run `cmake --install` yourself — the same SDK surface). - Third-party dependencies (Drogon / OpenSSL / jsoncpp, etc.) are **not** included in the package. Consumers resolve the same dependency versions using the repository root's `conanfile.py` + `conan.lock`, ensuring the `find_dependency` closure matches what the libraries were compiled against. ## 3. find_package Integration Steps ```bash # 1) 解包 tar xzf fulla-sdk-1.3.2-linux-x86_64.tar.gz # -> fulla-sdk-1.3.2-linux-x86_64/ # 2) 用仓库的 conanfile.py 解析依赖(生成 toolchain + 各依赖的 CMake config) conan install --output-folder=deps --build=missing \ -s build_type=Release -s compiler.cppstd=17 # 3) 配置消费工程:toolchain 供依赖解析,PREFIX_PATH 指向解包目录 cmake -S . -B build \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_TOOLCHAIN_FILE=$PWD/deps/conan_toolchain.cmake \ -DCMAKE_PREFIX_PATH=$PWD/fulla-sdk-1.3.2-linux-x86_64 cmake --build build -j ``` On the CMakeLists side: ```cmake # 全栈宿主:一个包拉全闭包(common/oauth2/identity/storage-*/Drogon/OpenSSL/CURL) find_package(fulla-drogon CONFIG REQUIRED) target_link_libraries(my-host PRIVATE fulla::drogon) # 或只取引擎面(无 Drogon 依赖): find_package(fulla-oauth2 CONFIG REQUIRED) find_package(fulla-storage-memory CONFIG REQUIRED) target_link_libraries(my-engine PRIVATE fulla::oauth2 fulla::storage::memory) ``` Available packages and exported targets: `fulla-common`→`fulla::common` (also provides `fulla::common::testing`), `fulla-oauth2`→`fulla::oauth2`, `fulla-identity`→`fulla::identity`, `fulla-storage-{memory,redis,postgres}`→`fulla::storage::{memory,redis,postgres}`, and `fulla-drogon`→`fulla::drogon`. Version compatibility is SameMajorVersion (`find_package(fulla-drogon 1.0 CONFIG REQUIRED)` pins the major version). Reference consumers (continuously verified by the repository CI): - `examples/full-stack-host/`: a complete HTTP host that uses `find_package(fulla-drogon)` to reuse the product controllers / OAuth2Plugin / views. The Release pipeline uses it to run a consumption smoke test against the **install prefix** (`ctest -L SdkSmoke` performs the same verification against the build tree). - `examples/third-party-host/`: a minimal engine consumer that links only the four Domain-layer packages. ## 4. Plugin Registration and whole-archive (H1/F1/H5 Framing) - The plugin itself is currently linked into the host as an **OBJECT library**: the object files are linked in directly, one by one, so self-registration symbols cannot be stripped — **whole-archive is not needed today**. - In the published SDK package, `fulla::drogon` is a regular static library, but plugin registration goes through `config.json` `plugins[].name = "OAuth2Plugin"` reflection plus an explicit `registerAllControllers()` (see full-stack-host's main.cc); likewise, it does not rely on the linker retaining unreferenced symbols. If a consumer builds a wrapper that **depends on static-initialization self-registration**, they must wrap the corresponding library with `-Wl,--whole-archive` themselves. - For the class-name / config-schema stability guarantees, see Contract §6. ## 5. Using the Images ```bash docker pull ghcr.io/voidvec/fulla-backend:1.3.2 ``` The three images correspond one-to-one to the build targets in `deploy/docker/docker-compose.yml` (`backend-runtime` / `frontend-runtime` / `frontends/admin/Dockerfile`); environment variables and mount conventions are taken directly from the compose file's `fulla-backend` service section (`FULLA_DB_HOST` / `FULLA_REDIS_HOST` / `FULLA_AUTO_MIGRATE`, etc.). ## 6. Release Process (Maintainers) 1. Confirm that the three version sources agree (`cmake/Version.cmake` is the single source of truth; api-diff enforces in CI that it matches the root `CMakeLists.txt` and `conanfile.py`) and that the API baseline has been updated according to SemVer rules (`tools/api-diff/`). 2. (Optional) Refresh CHANGELOG.md locally: `git cliff --unreleased --tag vX.Y.Z --prepend CHANGELOG.md` (configuration lives in the root `cliff.toml`; the release workflow only generates the Release notes and never back-fills commits from the tag ref). 3. Create a strict SemVer tag: `git tag v1.3.2 && git push origin v1.3.2`. Tags with a suffix (e.g. `v1.0.0-rc1`) do **not** trigger a release. 4. `release.yml` then runs automatically: tag/version consistency checks → SDK packaging + install-tree consumption smoke test → native builds of the three images for amd64/arm64 → multi-arch manifest (`` + `latest`) → cosign keyless signing of the three images by digest + syft-generated SPDX SBOMs (three images + source tree) → GitHub Release (notes generated by git-cliff, with the SDK attachment and all SBOMs attached). 5. A manual `workflow_dispatch` trigger = dry run (full build, but nothing is pushed and no Release is published). ### Verifying Release Artifacts (Consumers) ```sh # 镜像签名(keyless:身份 = release.yml 工作流,无需公钥分发) cosign verify ghcr.io/voidvec/fulla-backend: \ --certificate-identity-regexp \ 'https://github.com/voidvec/[^/]+/.github/workflows/release.yml.*' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com # SDK tarball 校验和(Release 附件) sha256sum -c fulla-sdk--linux-x86_64.tar.gz.sha256 ``` ## Quickstart: Embedding fulla into Your Own Drogon Host Only two steps beyond `find_package` (for the package import, see Section 3 above): **1. Activate the plugin in the host's `config.json`** (protocol routes and Filters are registered automatically): ```json { "plugins": [ { "name": "OAuth2Plugin", "dependencies": [], "config": { "storage_type": "postgres", "postgres": { "db_client_name": "default" }, "redis": { "client_name": "default" } } } ] } ``` **2. Protect business APIs with `AuthorizationFilter`** (fully qualified name `fulla::drogon::filters::AuthorizationFilter`): ```cpp METHOD_LIST_BEGIN ADD_METHOD_TO(UserApi::getProfile, "/api/me", drogon::Get, "fulla::drogon::filters::AuthorizationFilter"); METHOD_LIST_END ``` Note: once `fulla::drogon` is linked, Controllers/Filters are registered automatically at Drogon startup — do not invoke the initialization macros manually; the PostgreSQL storage requires running `apps/server/migrations/` first (or setting `FULLA_AUTO_MIGRATE=true`). > This section was merged in from the retired plugin-integration.md (docs governance A2). --- # SDK Runtime Contract Source: https://fulla.dev/docs/sdk/sdk-runtime-contract # SDK Runtime Contract External contract statement: the threading model, ABI, exceptions, logging, and dependency boundaries that the fulla SDK (`fulla::common` / `fulla::oauth2` / `fulla::identity` / `fulla::storage-*` / `fulla::drogon`) commits to for consumers during v1.x. This document is the single source of truth for external commitments; wherever SDK header comments conflict with it, this document prevails and the headers are to be fixed. --- ## 1. Threading Model - Domain services (`libs/oauth2`, `libs/identity`) **do not own an event loop**; all asynchronous operations return via callbacks. - **Callbacks may fire on any Drogon IO thread — the calling thread is not guaranteed**. Consumers must not assume thread affinity; when work must return to a specific thread, the consumer is responsible for dispatching it there. - Read-only singletons (e.g. `JwkManager`) follow **init-once-then-read-only**: a one-shot `init()` completes before the service starts accepting requests; the object is then published as `shared_ptr` and never mutated afterwards. Concurrent reads by consumers are safe once SDK assembly is complete. - Service objects hold `shared_ptr` repository handles to guarantee lifetimes; async continuations always capture `auto self = shared_from_this()` — `[this]` / `[&]` are forbidden. ## 2. ABI Stability - v1.x **supports `find_package` source integration only and makes no binary-ABI commitment**. - Semantic versioning covers the **source-level API** only: public headers under `include/fulla/**` follow SemVer, and breaking changes require a major-version bump (enforced in CI by the api-diff tool). - Mixing precompiled binaries across compilers / STLs is out of scope; a dedicated ABI policy will be defined separately once the project moves to Conan binary-package distribution. - Deprecation process: `[[deprecated]]` annotation + at least one minor-cycle transition period before removal. ## 3. Exception-Safety Contract - Domain public APIs return **expected errors** via `Result`; exceptions are not used to express business failure. - Exceptions are thrown only for unrecoverable programming errors (contract violations, assertion-level problems). - Storage-level exceptions (e.g. `DrogonDbException`) **must be caught in the Adapter layer (`libs/storage-*`, `libs/drogon`) and converted to `Error` — they must not leak into Domain callbacks**. Consumers need no try/catch for storage exceptions inside Domain callbacks. ## 4. Logging Abstraction - Domain code emits logs through the `common::ports::ILogger` port and **does not use Drogon `LOG_*` macros directly** (arch-guard enforces that the Domain layer does not include drogon headers). - The SDK provides a default Drogon logging adapter implementation (the `libs/drogon` Adapter); consumers hosted outside Drogon can inject their own `ILogger` implementation as a replacement. ## 5. Dependency Declarations - Feature-surface dependencies are **explicitly gated by the root `conanfile.py`'s `with_webauthn` / `with_identity` / `with_social` options**, not smuggled in as transitive surprises. NOTE: the CBOR decoding dependency (`libcbor`) required by real WebAuthn (FIDO2) used to be declared here, but the current WebAuthn controller is a non-cryptographic stub (it consumes no CBOR), so `libcbor` has been removed as a dead dependency; it must be re-added once real WebAuthn cryptography lands (see the corresponding comment in `conanfile.py`). - Disabling an option (e.g. `-o with_webauthn=False`) maps through to the CMake-side `WITH_*` variables and prunes the corresponding compiled surface, so consumers can shrink their dependency footprint accordingly. ## 6. Plugin Registration and Configuration Contract (Host Integration) - The `OAuth2Plugin` class name and the config `plugins[].name` reflective-loading contract **remain stable** (Option A): the class-name string in `"plugins":[{"name":"OAuth2Plugin","config":{...}}]` across the configs (`config.{json,dev,ci,prod,bench}.json`) and the schema of the `config{}` block are part of the product configuration contract and will not be renamed in v1.x. - The plugin itself is linked into the host as a CMake **OBJECT library**: object files are linked in directly, one by one, so there is no static-library on-demand extraction that could drop self-registration symbols — **whole-archive is not needed today**. - If the plugin is ever **distributed in static-library form**, the linker may strip its self-registration symbols and Drogon reflection will fail with "plugin not found" — at that point whole-archive (or an equivalent forced-linking scheme) becomes mandatory; `OAuth2Plugin` has no `AutoCreation` parameter available, so a whole-archive-free scheme is not applicable. - See `examples/third-party-host/` for a third-party host integration example. --- # Account Lockout Mechanism Source: https://fulla.dev/docs/operate/account-lockout # Account Lockout Mechanism ## Problem Description The OAuth2 system implements an account lockout mechanism to defend against brute-force attacks. After repeated failed logins, an account is temporarily locked. ## Lockout Rules Per the implementation in `AuthService.cc`, the lockout rules are: | Failed attempts | Lockout duration | |---------|---------| | 5-9 | 1 minute | | 10-14 | 5 minutes | | 15-19 | 30 minutes| | 20+ | 1 hour | ## Common Scenarios ### Scenario 1: lockout caused by repeated test-script runs **Symptoms**: - The first run of the test script succeeds - The second run fails all tests - Backend logs show: `Account locked for user: admin until 1779441748` **Cause**: A test case in the script failed to log in (e.g. wrong credentials), accumulating failed attempts up to the threshold. **Solution**: The test script now automatically resets the account lockout state at the end. If the problem persists, reset manually. ## Manually Resetting Account Lockout ### Method 1: use the reset script (recommended) #### Local PostgreSQL database ```powershell # 默认使用config.json中的配置(fulla_user/fulla_db/123456) .\scripts\backend\reset-account-lockout.ps1 # 重置特定用户 .\scripts\backend\reset-account-lockout.ps1 -Username admin # 自定义数据库连接 .\scripts\backend\reset-account-lockout.ps1 -DbHost localhost -DbUser fulla_user -DbPassword 123456 ``` #### Docker database ```powershell # 脚本会自动检测Docker容器 .\scripts\backend\reset-account-lockout.ps1 # 重置特定用户 .\scripts\backend\reset-account-lockout.ps1 -Username admin ``` ### Method 2: reset the admin password If the admin password was changed accidentally, or login fails after the upgrade to PBKDF2, use this script to reset it to the default password: ```powershell # 重置admin密码为默认值 'admin' .\scripts\backend\reset-admin-password.ps1 ``` **Note**: this script resets the admin password to the default in SHA-256 form (for development environments). On first login, the system automatically upgrades it to PBKDF2. ### Method 3: direct SQL #### Local PostgreSQL ```powershell # Windows PowerShell - 重置锁定状态 $env:PGPASSWORD = "123456" psql -U fulla_user -d fulla_db -h localhost -c "UPDATE users SET failed_login_count = 0, locked_until = 0 WHERE username='admin';" $env:PGPASSWORD = $null # 如果密码也需要重置(重置为默认密码 'admin') $env:PGPASSWORD = "123456" psql -U fulla_user -d fulla_db -h localhost -c "UPDATE users SET password_hash = '$pbkdf2-sha256$310000$61646d696e5f736565645f73616c74$6c0307305e1390e1214b15f1f4d0250b2de86aa0e8aa0e008e5cca03084d3d62', salt = '', failed_login_count = 0, locked_until = 0 WHERE username = 'admin';" $env:PGPASSWORD = $null ``` ```bash # Linux/Mac - 重置锁定状态 PGPASSWORD=123456 psql -U fulla_user -d fulla_db -h localhost -c "UPDATE users SET failed_login_count = 0, locked_until = 0 WHERE username='admin';" # 如果密码也需要重置 PGPASSWORD=123456 psql -U fulla_user -d fulla_db -h localhost -c "UPDATE users SET password_hash = '$pbkdf2-sha256$310000$61646d696e5f736565645f73616c74$6c0307305e1390e1214b15f1f4d0250b2de86aa0e8aa0e008e5cca03084d3d62', salt = '', failed_login_count = 0, locked_until = 0 WHERE username = 'admin';" ``` #### Docker database ```bash # 重置锁定状态 docker exec psql -U fulla_user -d fulla_db -c "UPDATE users SET failed_login_count = 0, locked_until = 0 WHERE username='admin';" # 如果密码也需要重置 docker exec psql -U fulla_user -d fulla_db -c "UPDATE users SET password_hash = '$pbkdf2-sha256$310000$61646d696e5f736565645f73616c74$6c0307305e1390e1214b15f1f4d0250b2de86aa0e8aa0e008e5cca03084d3d62', salt = '', failed_login_count = 0, locked_until = 0 WHERE username = 'admin';" ``` ### Method 4: inspect lockout state ```sql -- 查看所有用户的锁定状态 SELECT username, failed_login_count, locked_until, CASE WHEN locked_until > EXTRACT(EPOCH FROM NOW()) THEN 'LOCKED' ELSE 'UNLOCKED' END as status, CASE WHEN locked_until > EXTRACT(EPOCH FROM NOW()) THEN TO_TIMESTAMP(locked_until) - NOW() ELSE INTERVAL '0' END as remaining_time FROM users ORDER BY username; ``` ## Test Script Auto-Cleanup `test-admin-endpoints.ps1` already resets the admin account's lockout state when tests finish: ```powershell # 测试脚本会在结束时执行: # 1. 尝试连接Docker容器 # 2. 如果没有Docker,尝试连接本地PostgreSQL # 3. 重置admin账号的 failed_login_count 和 locked_until ``` **Note**: when using a local PostgreSQL, configure the database password in the script: ```powershell # 编辑 test-admin-endpoints.ps1,找到这一行: $env:PGPASSWORD = "your_password" # 修改为你的数据库密码 ``` ## Preventive Measures ### 1. Use a dedicated account for testing Do not use the production admin account in tests. Create a dedicated test account: ```sql INSERT INTO users (username, password_hash, salt, email, email_verified) VALUES ('test_admin', '', '', 'test@example.com', true); INSERT INTO user_roles (user_id, role_id) SELECT u.id, r.id FROM users u, roles r WHERE u.username = 'test_admin' AND r.name = 'admin'; ``` ### 2. Automatic cleanup after tests Add cleanup code at the end of every test script: ```powershell # Cleanup try { # 重置测试账号 psql -U fulla_user -d fulla_db -h localhost -c "UPDATE users SET failed_login_count = 0, locked_until = 0 WHERE username='test_admin';" } catch { Write-Host "Warning: Failed to reset test account" -ForegroundColor Yellow } ``` ### 3. Use correct credentials Make sure the usernames and passwords used in test scripts match the database: ```powershell # 检查数据库中的用户 psql -U fulla_user -d fulla_db -h localhost -c "SELECT username FROM users;" # 如果需要重置密码(使用PBKDF2) # 需要通过应用程序的注册接口或直接调用PasswordHasher ``` ## Production Recommendations ### 1. Monitor lockout events Monitor account lockout events in production: ```sql -- 查找最近被锁定的账号 SELECT username, failed_login_count, TO_TIMESTAMP(locked_until) as locked_until_time, TO_TIMESTAMP(last_failed_login) as last_failed_time FROM users WHERE locked_until > EXTRACT(EPOCH FROM NOW()) ORDER BY locked_until DESC; ``` ### 2. Set up alerts Raise an alert when critical accounts (such as admin) get locked: ```sql -- 可以通过定时任务检查 SELECT COUNT(*) FROM users WHERE username IN ('admin', 'superuser') AND locked_until > EXTRACT(EPOCH FROM NOW()); ``` ### 3. Audit logs The backend logs every lockout event: ``` WARN Account locked for user: admin until 1779441748 INFO [METRIC] oauth2_login_failures_total reason=bad_credentials ``` Consider shipping these logs to a centralized logging system (e.g. ELK, Grafana Loki) for analysis. ## Security Considerations 1. **Do not disable the lockout mechanism**: it is an important defense against brute-force attacks 2. **Do not hardcode database passwords in code**: use environment variables or a secret management system 3. **Restrict reset permissions**: only administrators should be able to reset account lockout state 4. **Record reset operations**: in production, every reset operation should be audited ## Related Files - `libs/drogon/src/AuthService.cc` - account lockout logic implementation - `scripts/backend/test-admin-endpoints.ps1` - test script (with auto-cleanup) - `scripts/backend/reset-account-lockout.ps1` - manual reset script - Database table: `users` (columns: `failed_login_count`, `locked_until`, `last_failed_login`) --- # Configuration Guide Source: https://fulla.dev/docs/operate/configuration-guide # Configuration Guide ## 1. Environment Variable Injection The application supports overriding key configuration items with environment variables. This matters especially in Docker/Kubernetes environments — sensitive values should not be hardcoded in `config.json`. ### Supported Environment Variables | Variable | Description | Config path overridden | Example | |---|---|---|---| | `FULLA_DB_HOST` | Database host | `db_clients[0].host` | `postgres` | | `FULLA_DB_NAME` | Database name | `db_clients[0].dbname` | `fulla_db` | | `FULLA_DB_PASSWORD` | Database password | `db_clients[0].passwd` | `secret` | | `FULLA_REDIS_HOST` | Redis host | `redis_clients[0].host` | `redis` | | `FULLA_REDIS_PASSWORD` | Redis password | `redis_clients[0].passwd` | `secret` | | `FULLA_PORTAL_CLIENT_SECRET` | Portal client secret (legacy alias: `FULLA_VUE_CLIENT_SECRET`) | `plugins[OAuth2Plugin].config.clients.fulla-portal.secret` | `...` | > For the full production environment variable list (30+ entries), see the variable table in > [Production Deployment](deployment.md); this table lists only the six core items of the > injection mechanism. ### How It Works 1. **Load hook**: at startup, `loadConfiguration()` in `main.cc` first calls `common::config::ConfigManager::load()`, then `ConfigManager::validate()`. 2. **Parse**: the base `config.json` is read into a `Json::Value` object. 3. **Inject**: the environment variables above are checked; when present, the corresponding nodes in the `Json::Value` are updated in place. 4. **Load**: Drogon loads the modified configuration object directly via `drogon::app().loadConfigJson(config)`; no temporary files are written to disk. ### Verification A dedicated test, `EnvInjectionVerify` (`EnvConfigTest.cc`), guarantees this logic is correct. ## 2. Docker Deployment The repository ships a `docker-compose.yml` that orchestrates the full stack (see [Docker Deployment](docker-deployment.md) for details). ### Service Stack - **fulla-frontend**: Vue SPA + Nginx (built from the `frontend-runtime` stage of `deploy/docker/Dockerfile`). - **fulla-admin**: admin console frontend (built from `frontends/admin/Dockerfile`). - **fulla-backend**: Drogon backend (built from the `backend-runtime` stage of `deploy/docker/Dockerfile`). - **fulla-postgres**: PostgreSQL 17 (schema under `apps/server/migrations/` applied at backend startup via `FULLA_AUTO_MIGRATE=true`). - **fulla-redis**: password-protected Redis 7. - **fulla-prometheus**: metrics collection. ### Quick Start ```bash # 构建并启动(在仓库根目录执行) docker compose -f deploy/docker/docker-compose.yml up -d --build # 查看日志 docker compose -f deploy/docker/docker-compose.yml logs -f fulla-backend # 停止 docker compose -f deploy/docker/docker-compose.yml down ``` ### Configuration Handling under Docker `docker-compose.yml` mounts `apps/server/config/config.json` into the container read-only; the `environment` section injects environment variables (see §1), and at runtime `ConfigManager::load()` plus environment injection override the file defaults. ## 3. Storage Backend Selection The OAuth2 plugin's `config.storage_type` determines the persistence backend: | `storage_type` | Status | Notes | |---|---|---| | `postgres` | **Supported (the only production backend)** | Full token persistence, refresh token rotation and reuse detection. | | `redis` | **Deprecated** | Never persisted refresh tokens historically (`saveRefreshToken`/`getRefreshToken` are no-ops), so rotation and reuse detection silently fail. The mode still starts (for compatibility; it logs an ERROR at startup), but the `refresh_token` grant is rejected with `unsupported_grant_type`. Do not use in new deployments. | | `memory` | Test only | For unit/integration tests; not for production. | Target architecture: **Postgres as the storage layer, fronted by an online Redis L2 cache** (keyspace `fulla:cache:*`, configured via the `cache` block in `config.json` — `enabled` / `ttl_seconds` / `invalidation_double_delete_delay_ms`; invalidation uses the delayed double-delete, see `DelayedDoubleDelete`). There is no standalone Redis storage mode. ## 4. Issuer Configuration `config.metadata.issuer` (custom config) is the single source of truth for the server's issuer URL. `OAuth2Plugin` reads it once at startup and uses it consistently for: - the `iss` claim stamped on issued access tokens (authorization_code / refresh_token / client_credentials / device_code grants); - `iss` in introspection responses (backfilled from the configured value when the stored row carries none); - the discovery documents (`/.well-known/openid-configuration`, `/.well-known/oauth-authorization-server`). Constraints: - Trailing slashes are normalized away automatically; do not rely on them. - Defaults to `http://localhost:5555` when unset, with a `LOG_WARN`. - Production deployments **must** configure an `https://` issuer; a plaintext http issuer on a non-loopback host triggers a startup warning. - Introspection `iss` and the discovery documents' `issuer` are guaranteed byte-for-byte identical (as OIDC Discovery §3 requires). ## 4a. Admin Console Origin & Device Verification URI (#146) `config.admin_console.url` (custom config) is the ORIGIN (scheme + host [+ port], no path) of the admin console SPA — `https://admin.example.com` in production, default `http://localhost:5174` (the dev vite server). It feeds exactly one consumer today: the RFC 8628 device-authorization response's `verification_uri`/`verification_uri_complete`, which default to `{admin_console.url}/admin/devices` — the real device-approval page. Before #146 the default pointed at `/oauth2/device`, a path with no page behind it. - Explicit override: `config.device_authorization.verification_uri` (full URL) wins over the derived default; `verification_uri_complete` is always derived (`verification_uri` + `?user_code=`). - Trailing slashes on `admin_console.url` are normalized away. ## 4b. Forced First-Login Password Change (#145) Accounts created with `users.must_change_password = true` — the bootstrap admin (both the random and the `FULLA_BOOTSTRAP_ADMIN_PASSWORD` variants) and users created/updated via the admin API with `must_change_password: true` — must change the password before any authorization code is issued: - `/oauth2/login` answers `200 {"password_change_required": true, ...}` instead of issuing a code; - `/oauth2/authorize` redirects to the frontend login page (which renders the change-password form); `prompt=none` answers `error=login_required`; - `POST /oauth2/consent` answers 403 `AUTH_PASSWORD_CHANGE_REQUIRED`. The change path is `POST /oauth2/password/change` (session-authenticated, requires `old_password`, enforces `auth.min_password_length`, revokes all tokens, clears the flag). `PUT /api/me/password` also clears the flag. An admin-set flag on an existing account takes effect at that user's next login. ## 5. Client Token-Endpoint Authentication Methods (F-017) Each client declares, via the `oauth2_clients.token_endpoint_auth_method` column, how it authenticates at `/oauth2/token`, `/oauth2/introspect`, and `/oauth2/revoke`: | Value | Semantics | |---|---| | `client_secret_basic` | The secret **must** be sent in the `Authorization: Basic` header; a `client_secret` in the body is rejected. | | `client_secret_post` | The secret **must** be sent in the POST body; the Basic header is rejected. | | `none` | PUBLIC client; any `client_secret` present is rejected. | | NULL / empty | Legacy lenient fallback: accepts the Basic header and also a body secret (Basic→body fallback). | When the field is omitted at creation through the registration/admin endpoints, the following defaults are stored: - `PUBLIC` clients → `none` (they have no secret to begin with). - `CONFIDENTIAL` clients → `client_secret_basic`. Seed clients declare it explicitly: `fulla-portal` and `fulla-admin-console` → `none`; `backend-svc` → `client_secret_basic`. Existing clients with NULL values keep their pre-upgrade behavior; the upgrade does not break existing deployments. ## 6. OIDC prompt / max_age / auth_time (F-022) The authorization endpoint supports the `prompt` and `max_age` parameters from OIDC Core §3.1.2.1: - **`prompt=none`**: no UI of any kind. No session → 302 `error=login_required`; consent required → `error=consent_required`. Errors redirect back to the validated `redirect_uri` carrying the echoed `state`. Combining `none` with other values (such as `none login`) is self-contradictory and returns 400 outright. - **`prompt=login`**: forces re-authentication even when a session already exists. - **`prompt=consent`**: forces the consent page even when existing consent already covers the requested scopes. - **`max_age=`**: forces re-authentication if the session's `auth_time` (set at login / MFA verification) is older than `max_age`. `auth_time` and `amr` are persisted with the authorization code and included in the id_token at redemption: `auth_time` (when greater than 0), `amr` (a JSON array when set), `acr` (`1` = password only, `2` = MFA). The discovery document advertises `prompt_values_supported`, `acr_values_supported`, and related claims. ## 7. RP-Initiated Logout (F-027) and Session Invalidation (F-028) `/oauth2/end_session` (GET + POST) terminates the server-side session. To redirect after logout, the client must supply a `post_logout_redirect_uri`, and it **must** be one of the client's registered redirect URIs; the client is identified by the `aud` claim of the `id_token_hint`. The hint's signature **is** verified (RS256 + kid + iss/exp/sub policy, issue #78); failed verification is rejected with 400 `AUTH_INVALID_ID_TOKEN_HINT`. Without a valid hint plus a registered URI, the request is rejected with 400; on success a 302 redirect carries the echoed `state`, and a 200 is returned when no redirect URI is provided. `POST /oauth2/logout` (the existing API logout) additionally calls `session()->clear()` (F-028), so the server-side session is terminated together with access-token revocation. ## 8. Authentication Failure Rate Limiting (F-018) The token / introspect / revoke / device-code polling endpoints share one in-process sliding-window rate limiter, bucketed by `(client_ip, client_id)`. Once **failed** attempts within the rolling window (default 60s) reach `max_failures` (default 30), subsequent requests return **HTTP 429** with a `Retry-After` header and an OAuth2-style `{error, error_description}` body. Only **failures** count; a single success resets the counter, so normal load (and integration suites making many consecutive successful requests) is never rate-limited. Configured via `custom_config.auth.rate_limit` (all `config*.json` carry the defaults explicitly): ```json "custom_config": { "auth": { "require_pkce_for_public": true, "allow_http_redirect_uri": true, "rate_limit": { "max_failures": 30, "window_seconds": 60 } } } ``` Both keys may be omitted; when the `rate_limit` object is missing, built-in defaults are used (30 / 60). The limiter is a function-local singleton (`RateLimiter::instance()` from `libs/common/include/fulla/common/utils/RateLimiter.h`); the four protected endpoints share one counter table within the same process. This is minimal brute-force / token-probing protection; multi-instance deployments require shared storage (Redis), which is future work. ## 9. JWKS Key Rotation (#110 — keystore directory) `plugins.OAuth2Plugin.config.oidc.signing_keystore_dir` points at a **keystore directory** and is the key-rotation mechanism: ``` / 2026-09.pem # one RSA private key per file; FILENAME (minus .pem) = kid 2026-12.pem active_kid # one-line text file naming the signing kid (e.g. "2026-12") ``` Semantics: - **Signing** always uses the key named by `active_kid`; the JWT header carries that kid. - **Verification** routes on the token's header kid across ALL loaded keys, and the JWKS endpoint (`/.well-known/jwks.json`) publishes every loaded public key — so outstanding tokens keep resolving while their key remains in the directory. - `GET /api/admin/oidc/keys` reports the live state: every kid, which one is `active`, which are merely `published`. - The keystore takes **precedence** over `FULLA_SIGNING_KEY` / `FULLA_JWT_KEY_PATH` / `signing_key_path`, and a configured-but-broken directory is a **hard startup failure** (never a silent fallback to a different key source). **Rotation procedure** (JwkManager is init-once/read-only by design, so each step is a restart — three restarts per cycle): 1. **Publish**: drop the new `.pem` into the directory, restart. Both keys are now in the JWKS; the OLD key keeps signing. Wait at least the JWKS `Cache-Control` max-age (1 h) before step 2 — a strictly-caching RP otherwise cannot resolve the new kid yet. 2. **Switch**: edit `active_kid` to the new kid, restart. New tokens carry the new kid; old tokens still verify (old key still published). 3. **Retire**: after the maximum token lifetime has elapsed (access + refresh chain — size this from your token TTLs, not the clock of step 2), delete the old `.pem` and restart. Tokens from the old key now fail verification with an unknown-kid error. Skipping step 3 leaves the old key published forever (harmless, but keeps the compromise surface open); performing it early invalidates outstanding old-key tokens. Key generation is standard tooling, e.g. `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out 2026-12.pem`. Protect the directory like any private-key material (file permissions / secret mounts). ## 10. Legacy Password-Hash Migration Window (#103) fulla verifies passwords exclusively through PBKDF2-SHA256 (`$pbkdf2-sha256$310000$$`). The pre-1.0 unsalted-SHA256 format is **retired and rejected by default** on every path: - `auth.allow_legacy_hash` is `false` in all shipped configs, and a *missing* key also means `false` (assembly-level default). While `false`, a login attempt against a legacy-format hash is denied before any password verification — with the **correct** password too. - The denial is a **policy rejection, not a wrong password**: it does not advance the account-lockout counter (a username alone must not let an attacker lock a legacy user out, and a later window reopen must not be blocked by `locked_until`). - Each denial logs `AUTH_LEGACY_HASH_REJECTED` (WARN) with the internal user id — the operator-facing signal for who still needs migration. The client always receives the generic `AUTH_INVALID_CREDENTIALS`; nothing distinguishes "legacy denied" from "wrong password" from the outside. **Who is affected?** Only databases seeded before v1.0.1 that still hold legacy-format rows. Inventory SQL: ```sql SELECT count(*) FROM users WHERE password_hash NOT LIKE '$pbkdf2-sha256$%'; ``` **Migration paths that need no window:** - **Email password reset** — the reset flow always writes PBKDF2, so a legacy user who resets migrates transparently on completion. - **Administrator-triggered reset** — same effect. **Change-password does NOT migrate a legacy user**: it verifies the old password through the same retired branch and therefore fails; use a reset instead. **Reopening the window (temporary, login-only):** set `auth.allow_legacy_hash: true` and restart. Legacy users can then log in and are transparently rehashed to PBKDF2 on that first successful login (identity login path only). Because policy rejections never advanced the lockout counter, no unlock is required before reopening. Close the window again once the inventory query returns `0`; the window (and the identity-side rehash code) is scheduled for removal in v1.1. ## 11. WebAuthn / Passkeys (#142) Passkey registration and authentication perform REAL cryptographic verification (W3C WebAuthn Level 2 §7.1/§6.1): ES256 only, `fmt="none"` attestations only, and the assertion signature is checked against the COSE public key stored at registration time. Configuration: ```json "webauthn": { "rp_id": "localhost", "rp_name": "OAuth2 Server", "rp_origins": ["http://localhost:5173"] } ``` - `rp_id` — Relying Party id (the registrable domain the credential is scoped to; `localhost` for local development). - `rp_origins` — STRICT allowlist of accepted `clientDataJSON` origins. **Required before the finish endpoints accept anything**: while the list is empty (or the key absent), registration/authentication finish fail closed. Production must list the exact portal origin(s), e.g. `["https://auth.example.com"]`. - Challenge lifetime is 300 seconds (code-enforced). The REGISTRATION challenge is bound to the Bearer subject (the register endpoints sit behind the token filter; the SPA sends no session cookie); the AUTHENTICATION challenge is bound to the caller's session — the login flow requires cookies (`credentials: 'include'`). - `userVerification` is `required` in both begin responses and enforced (`UV=1`) on authentication — authenticators without user-verification capability cannot register credentials here (they would never authenticate). Sign-count regression is treated as authenticator cloning: the assertion is rejected and a `webauthn_clone_detected` audit action is recorded. - **V028 cleared all pre-existing credential rows**: every stored row was client-asserted material that never passed attestation verification (and authenticateFinish used to accept a bare credential id as proof of possession). Users re-register their passkeys after upgrading. - Single-instance deployments: the subject-bound registration challenge store is in-process (same limitation class as the consent_csrf nonce). Multi-instance deployments need shared challenge storage (follow-up). --- # Windows Docker Desktop Deployment Validation Guide Source: https://fulla.dev/docs/operate/deployment-windows-docker-desktop # Windows Docker Desktop Deployment Validation Guide This guide explains how to validate the deployment of the full fulla stack on Windows Docker Desktop. **Apart from domain and SSL, every other feature is fully identical to the Linux production environment**. --- ## Why validate on Windows Docker Desktop? ✓ **Fully simulates the production environment**: the same Docker Compose configuration, the same container images, the same network topology ✓ **Fast feedback loop**: modify code locally → validate immediately → push to the Linux server only after everything checks out ✓ **Saves time**: avoids the long "push → server pull → restart services → discover the problem" loop every single time ✓ **Full coverage of core functionality**: database migrations, API endpoints, frontend routing, and OAuth2 flows are all testable **Differences from the Linux production environment**: | Feature | Windows Docker Desktop | Linux production | |------|------------------------|----------------| | PostgreSQL | ✓ Identical | ✓ | | Redis | ✓ Identical | ✓ | | Backend API | ✓ Identical | ✓ | | Frontend | ✓ Identical | ✓ | | Admin console | ✓ Identical | ✓ | | Nginx reverse proxy | ⚠ Simplified configuration (no TLS) | ✓ | | Domain access | ✗ localhost only | ✓ | | SSL/TLS | ✗ Not enabled | ✓ | --- ## Prerequisites ### Software requirements 1. **Windows 10/11 Pro or Enterprise** (Home edition requires manual WSL2 configuration) 2. **Docker Desktop for Windows** (latest stable version) - Download: https://www.docker.com/products/docker-desktop/ - Enable the WSL2 backend (recommended) or Hyper-V during installation 3. **Git** (for cloning the project) - Download: https://git-scm.com/download/win 4. **OpenSSL** (for generating JWT keys, optional) - Windows: download from https://slproweb.com/products/Win32OpenSSL.html - Or use the OpenSSL bundled with Git Bash ### Verify the Docker Desktop installation Open PowerShell or Windows Terminal: ```powershell # Check the Docker version (20.10+ required) docker --version # Check the Docker Compose version (v2+ required) docker compose version # Check that Docker is running properly docker ps ``` Example expected output: ``` Docker version 24.0.7, build afdd53b Docker Compose version v2.23.0 CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES ``` --- ## Quick start (5 steps) ### 1. Clone the project ```powershell # Clone the repository (replace with the actual URL) git clone cd fulla # Check the branch git branch ``` ### 2. Generate JWT keys **Option A: use Git Bash (recommended)** ```bash # Run from the project root cd /path/to/repo-root # Generate the JWT signing key chmod +x scripts/generate-jwt-keys.sh ./scripts/generate-jwt-keys.sh # Verify key generation ls -la deploy/keys/ # You should see signing.pem and signing.pub ``` **Option B: use PowerShell + OpenSSL** ```powershell # After installing OpenSSL for Windows openssl genrsa -out deploy\keys\signing.pem 2048 openssl rsa -in deploy\keys\signing.pem -pubout -out deploy\keys\signing.pub # Verify dir deploy\keys ``` **Option C: skip key generation (testing only)** If you are only validating the deployment workflow, you can skip this step for now and the backend will use its built-in test keys (⚠ real keys must be generated for production). ### 3. Configure environment variables ```powershell # Copy the environment variable template Copy deploy\env\docker.env.example .env.docker # Edit the file (with VS Code or Notepad) notepad .env.docker ``` **Edit `.env.docker` and set local test passwords**: ```env # PostgreSQL POSTGRES_USER=fulla_user POSTGRES_PASSWORD=WinDockerTest2024! POSTGRES_DB=fulla_db # Redis REDIS_PASSWORD=WinDockerTest2024! # OAuth2 Backend FULLA_DB_HOST=fulla-postgres FULLA_DB_NAME=fulla_db FULLA_DB_PASSWORD=WinDockerTest2024! FULLA_REDIS_HOST=fulla-redis FULLA_REDIS_PASSWORD=WinDockerTest2024! FULLA_FRONTEND_URL=http://localhost:8080 # Domain (ignored for local testing) DOMAIN=localhost ``` > **Note**: environment files on Windows use CRLF line endings; Docker Compose handles them automatically. #### Email service configuration (optional) The backend email service has two modes (decided by `getEmailService()` in [EmailService.cc](https://github.com/voidvec/fulla/blob/master/libs/drogon/src/utils/EmailService.cc)): | Mode | Trigger | Behavior | |------|---------|------| | **Console mode** (default) | `FULLA_SMTP_*` not set | Verification emails are only written to the backend log; nothing is actually sent | | **SMTP mode** | `FULLA_SMTP_HOST` + `FULLA_SMTP_USER` + `FULLA_SMTP_PASSWORD` set | Emails are actually sent via SMTP | **Default Console mode (recommended for local validation)**: No configuration required. After clicking "Send verification email", the email content (including the verification link) is written to the backend log; copy the link from there to verify: ```bash docker logs fulla-backend --tail 50 2>&1 | grep -A 5 -iE "verify|email" ``` **Enable real SMTP delivery (163 Mail example)**: Append the following to the end of `.env.docker`: ```env # Email / SMTP FULLA_SMTP_HOST=smtp.163.com FULLA_SMTP_PORT=465 FULLA_SMTP_USER=your-email@163.com FULLA_SMTP_PASSWORD=your-authorization-code # 163 authorization code, not the login password FULLA_SMTP_FROM_NAME=OAuth2 Platform FULLA_SMTP_SSL=true # Port 465 requires SSL ``` > **Obtaining a 163 authorization code**: log in to the 163 Mail web interface → Settings → POP3/SMTP/IMAP → enable the SMTP service → generate an authorization code. Restart the backend after the change for it to take effect: ```bash docker compose -f deploy/docker/docker-compose.yml --env-file .env.docker up -d fulla-backend # Verify the switch to SMTP mode (you should see "Email service: SMTP (smtp.163.com:465)") docker logs fulla-backend 2>&1 | grep -i "Email service" ``` > **Note**: verification links inside emails use `FULLA_FRONTEND_URL` (`http://localhost:8080` locally), so they stop working when opened from another machine — this is an expected limitation of a local deployment. ### 4. Adjust the Docker Compose configuration Since the local environment does not need HTTPS, we create a simplified Compose file: **Option A: use the existing development configuration (recommended)** ```powershell # Use docker-compose.yml directly (local ports already configured) docker compose -f deploy/docker/docker-compose.yml --env-file .env.docker up -d --build ``` **Option B: create a custom configuration** If you need more control, create `deploy/docker/docker-compose.windows.yml`: ```yaml # Based on docker-compose.yml, with external auth and TLS-related configuration removed services: fulla-backend: environment: - FULLA_DB_HOST=fulla-postgres - FULLA_DB_NAME=fulla_db - FULLA_DB_PASSWORD=${POSTGRES_PASSWORD} - FULLA_REDIS_HOST=fulla-redis - FULLA_REDIS_PASSWORD=${REDIS_PASSWORD} - FULLA_AUTO_MIGRATE=true - FULLA_FRONTEND_URL=http://localhost:8080 volumes: - ../../deploy/keys:/app/keys:ro # JWT keys - ../../apps/server/migrations:/app/sql/migrations:ro - ../../apps/server/seed:/app/sql/seed:ro # Other services unchanged... ``` ### 5. Start the services ```powershell # Start all services docker compose -f deploy/docker/docker-compose.yml --env-file .env.docker up -d --build # Watch the startup logs docker compose -f deploy/docker/docker-compose.yml logs -f ``` Expected output (services started successfully): ``` [+] Running 8/8 [+] Network oauth2-net Created 0.1s [+] Volume "oauth2_plugin_postgres_prod" Created [+] Container fulla-postgres Started 2.3s [+] Container fulla-redis Started 1.8s [+] Container fulla-backend Started 5.2s [+] Container fulla-frontend Started 3.1s [+] Container fulla-admin Started 2.9s [+] Container fulla-prometheus Started 1.5s ``` --- ## Validating the deployment ### 1. Check container status ```powershell docker compose -f deploy/docker/docker-compose.yml ps ``` All containers are expected to be `Up`: ``` NAME STATUS PORTS fulla-admin Up 0.0.0.0:8081->80/tcp fulla-backend Up 0.0.0.0:5555->5555/tcp fulla-frontend Up 0.0.0.0:8080->80/tcp fulla-postgres Up 0.0.0.0:5433->5432/tcp fulla-prometheus Up 0.0.0.0:9090->9090/tcp fulla-redis Up 0.0.0.0:6380->6379/tcp ``` ### 2. Verify backend health ```powershell curl http://localhost:5555/health ``` Expected response: ```json {"status":"healthy","timestamp":"2024-06-23T10:30:00Z"} ``` ### 3. Verify database migration ```powershell # Enter the postgres container docker exec -it fulla-postgres psql -U fulla_user -d fulla_db -c "\dt" # Expect to see the OAuth2-related tables # clients, users, tokens, authorization_codes, etc. ``` ### 4. Verify frontend access Open in a browser: - **User frontend**: http://localhost:8080 - **Admin console**: http://localhost:8081/admin/ - **Prometheus**: http://localhost:9090 ### 5. Create the administrator account ```Git Bash # Run the seed scripts docker exec -i fulla-postgres psql -U fulla_user -d fulla_db < apps/server/seed/dev_admin_user.sql docker exec -i fulla-postgres psql -U fulla_user -d fulla_db < apps/server/seed/dev_admin_console_client.sql docker exec -i fulla-postgres psql -U fulla_user -d fulla_db < apps/server/seed/dev_vue_client.sql docker exec -i fulla-postgres psql -U fulla_user -d fulla_db < apps/server/seed/dev_backend_client.sql ``` ```powershell # Run the admin user seed Get-Content apps\server\seed\dev_admin_user.sql | docker exec -i fulla-postgres psql -U fulla_user -d fulla_db # Run the admin console client seed Get-Content apps\server\seed\dev_admin_console_client.sql | docker exec -i fulla-postgres psql -U fulla_user -d fulla_db # Run the Vue client seed Get-Content apps\server\seed\dev_vue_client.sql | docker exec -i fulla-postgres psql -U fulla_user -d fulla_db # Run the backend-svc client seed Get-Content apps\server\seed\dev_backend_client.sql | docker exec -i fulla-postgres psql -U fulla_user -d fulla_db ``` Verify the administrator account: **Method 1: use Git Bash (recommended)** ```bash docker exec -it fulla-postgres psql -U fulla_user -d fulla_db -c "SELECT username, email FROM users WHERE username = 'admin';" ``` **Method 2: use PowerShell** ```powershell docker exec fulla-postgres psql -U fulla_user -d fulla_db -c "SELECT username, email FROM users WHERE username = 'admin';" ``` **Expected output**: ``` username | email ----------+------------------- admin | admin@example.com ``` **Verify the administrator role**: ```bash docker exec -it fulla-postgres psql -U fulla_user -d fulla_db -c "SELECT u.username, u.email, r.name FROM users u LEFT JOIN user_roles ur ON u.id = ur.user_id LEFT JOIN roles r ON ur.role_id = r.id WHERE u.username = 'admin';" ``` **Expected output**: ``` username | email | name ----------+-------------------+------- admin | admin@example.com | admin ``` --- ## Running endpoint tests The project ships a complete endpoint test suite for validating core OAuth2 functionality and the admin console API. ### Recommended: Git Bash **Advantages**: native shell-script support, correct path handling, and parity with the Linux environment #### 1. Run the core OAuth2 endpoint tests ```bash # Enter the project directory cd /path/to/repo-root # Make sure the test scripts are executable chmod +x scripts/backend/test-oauth2-endpoints.sh # Run the tests (55 tests) ./scripts/backend/test-oauth2-endpoints.sh http://localhost:5555 ``` **Test coverage**: - Health check, JWKS endpoint - OAuth2 login, token exchange, refresh token - Token introspection, revocation - User registration, login, profile - Password reset and change - MFA setup, verification, disable - Dynamic client registration (RFC 7591) - WebAuthn authentication - Device authorization flow - External authentication (GitHub, Google, WeChat) #### 2. Run the admin console API tests ```bash chmod +x scripts/backend/test-admin-endpoints.sh ./scripts/backend/test-admin-endpoints.sh http://localhost:5555 ``` **Test coverage**: - Admin login, dashboard statistics - User management (CRUD operations) - Client application management - Scope management - Token management - Authorized user management - Role and permission management ### Alternative: WSL2 Ubuntu ```bash # 1. Start WSL2 wsl # 2. Enter the project directory (mind the path translation) cd /path/to/repo-root # 3. Run the tests ./scripts/backend/test-oauth2-endpoints.sh http://localhost:5555 ./scripts/backend/test-admin-endpoints.sh http://localhost:5555 ``` ### PowerShell hybrid approach ```powershell # Invoke the tests via Git Bash from PowerShell bash ./scripts/backend/test-oauth2-endpoints.sh http://localhost:5555 bash ./scripts/backend/test-admin-endpoints.sh http://localhost:5555 ``` ### Interpreting test results #### Expected output ```bash ======================================== OAuth2 Endpoints Tests (59 tests) ======================================== Base URL: http://localhost:5555 [Test 1/59] Test 1: Health Check Status: ok [+] PASS (0.1s) [Test 10/59] Test 10: Client Credentials AT: eyJhbGciOiJSUzI1Ni..., Scope: read [+] PASS (0.2s) ... ======================================== Test Results: 59/59 passed, 0 failed ======================================== ``` #### Troubleshooting failures **All endpoint tests are expected to pass (current tally: 59 on the OAuth2 side, 52 on the Admin side)**. If any fail, check the following known environment dependencies: 1. **Test 10 fails**: `no access_token` - **Cause**: the `backend-svc` test client is missing - **Fix**: run `dev_backend_client.sql` to create the test client 2. **Test 20/20b fails**: `missing field: .client_id` or `Expected HTTP 400, got 403` - **Cause**: RBAC access control is working correctly; dynamic client registration requires special configuration - **Impact**: none (this is expected behavior) 3. **Cascading failures**: `skipped: no token` - **Cause**: an earlier test revoked the token, leaving later tests without one - **Impact**: none (the test scripts are designed this way) #### Success criteria **Deployment validation counts as successful only when all 59/52 tests pass**. If individual failures appear, first check whether they match the known script environment dependencies above (database reset, seed data, port conflicts); do not treat "partially passing" as the success criterion for a deployment. ### Pre-test preparation #### 1. Make sure the database has seed data ```bash # Run the required seed scripts docker exec -i fulla-postgres psql -U fulla_user -d fulla_db < apps/server/seed/dev_admin_user.sql docker exec -i fulla-postgres psql -U fulla_user -d fulla_db < apps/server/seed/dev_admin_console_client.sql docker exec -i fulla-postgres psql -U fulla_user -d fulla_db < apps/server/seed/dev_vue_client.sql # Optional: create the test client (improves the pass rate) docker exec -i fulla-postgres psql -U fulla_user -d fulla_db < apps/server/seed/dev_backend_client.sql ``` #### 2. Verify the base services ```bash # Check container status docker ps # Check backend health curl http://localhost:5555/health # Check the database connection docker exec fulla-postgres pg_isready -U fulla_user ``` ### Quick validation command ```bash # Run all tests in one go cd /path/to/repo-root && \ chmod +x scripts/backend/*.sh && \ echo "[+] Running OAuth2 core tests..." && \ ./scripts/backend/test-oauth2-endpoints.sh http://localhost:5555 && \ echo "" && \ echo "[+] Running admin console API tests..." && \ ./scripts/backend/test-admin-endpoints.sh http://localhost:5555 ``` --- ## Functional test checklist ### Core OAuth2 flows | Test item | Method | Expected result | |--------|---------|---------| | User registration | Frontend registration page | Registration succeeds and the user can log in | | User login | POST /oauth2/login (first step of the authorization-code + PKCE flow) | Returns an authorization code | | Refresh token | POST /oauth2/token (refresh_token grant) | Returns a new access_token | | Token validation | POST /oauth2/introspect | Returns valid token information | | Token revocation | POST /oauth2/revoke | Returns 200 OK | | Authorization-code flow | /oauth2/authorize → /callback | Complete OAuth2 flow | | Client management | Create/delete clients in the Admin Console | Operations succeed | ### API endpoint tests #### Method 1: use the existing test scripts (recommended) The project includes complete endpoint test scripts; running them via Git Bash is recommended: **Run the core OAuth2 endpoint tests (59 tests)**: ```bash # 1. Enter the project directory (Git Bash) cd /path/to/repo-root # 2. Make sure the test scripts are executable chmod +x scripts/backend/test-oauth2-endpoints.sh # 3. Run the tests ./scripts/backend/test-oauth2-endpoints.sh http://localhost:5555 ``` **Run the admin console API tests (52 tests)**: ```bash chmod +x scripts/backend/test-admin-endpoints.sh ./scripts/backend/test-admin-endpoints.sh http://localhost:5555 ``` **Example expected output**: ```bash ======================================== OAuth2 Endpoints Tests (59 tests) ======================================== Base URL: http://localhost:5555 [Test 1/59] Test 1: Health Check Status: ok [+] PASS (0.1s) ... ======================================== Test Results: 59/59 passed, 0 failed ======================================== ``` **Success criteria**: all pass (59/52). For individual failures, first cross-check the environment dependencies per "Troubleshooting failures" above; do not treat "partially passing" as the success criterion for a deployment. #### Method 2: test with the PowerShell script `fulla-admin-console` is a PUBLIC client (PKCE enforced, no secret, no password grant), so constructing the token flow by hand is rather tedious; using the repository's bundled PowerShell test script is recommended (it implements PKCE login internally): ```powershell # Run the admin console endpoint tests (PKCE login + 52 assertions) .\scripts\backend\test-admin-endpoints.ps1 -BaseUrl "http://localhost:5555" # If you already have an access_token from another source, call protected APIs directly to verify: $headers = @{ Authorization = "Bearer " } Invoke-RestMethod -Uri "http://localhost:5555/api/admin/users" -Headers $headers # Expected: user list JSON ``` #### Method 3: use WSL2 Ubuntu (recommended) ```bash # 1. Start WSL2 wsl # 2. Enter the project directory cd /path/to/repo-root # 3. Run the tests ./scripts/backend/test-oauth2-endpoints.sh http://localhost:5555 ./scripts/backend/test-admin-endpoints.sh http://localhost:5555 ``` ### Frontend routing tests | Path | Expected page | |------|---------| | http://localhost:8080/ | User login page | | http://localhost:8080/register | User registration page | | http://localhost:8080/profile | Profile page (login required) | | http://localhost:8080/callback | OAuth2 callback page | | http://localhost:8081/admin/ | Admin console (login required) | | http://localhost:8081/admin/apps | Application management page | --- ## Troubleshooting common issues ### Docker Desktop fails to start **Symptom**: `docker ps` reports "Cannot connect to the Docker daemon" **Solution**: 1. Check whether Docker Desktop is running (system tray icon) 2. Restart Docker Desktop 3. Check whether Hyper-V or WSL2 is enabled: ```powershell # WSL2 wsl --list --verbose # Hyper-V dism /Online /Get-FeatureInformation /FeatureName:Microsoft-Hyper-V ``` ### Port conflicts **Symptom**: containers fail to start and the log shows "port is already allocated" **Find the process holding the port**: ```powershell # Check port 8080 netstat -ano | findstr :8080 # Check port 5433 netstat -ano | findstr :5433 ``` **Solution**: 1. Stop the conflicting service 2. Or change the port mapping in `docker-compose.yml` (e.g. to `8082:80`) ### Database connection failure **Symptom**: the backend log shows "Connection refused" or "Host unreachable" **Diagnosis**: ```powershell # 1. Check the postgres container status docker ps | findstr fulla-postgres # 2. Check the postgres logs docker logs fulla-postgres # 3. Test the database connection docker exec fulla-postgres pg_isready -U fulla_user # 4. Test network connectivity from the backend container docker exec fulla-backend curl -s http://fulla-postgres:5432 2>&1 docker exec fulla-backend curl -s http://fulla-redis:6379 2>&1 ``` ### Build failures **Symptom**: `docker compose build` fails with "failed to solve" **Solution**: ```powershell # Clean the build cache docker builder prune -a # Rebuild docker compose -f deploy/docker/docker-compose.yml --env-file .env.docker build --no-cache # If it still fails, check disk space docker system df ``` ### Windows path issues **Symptom**: volume mounts fail with the error "invalid mount config" **Cause**: Windows path-translation problems (`C:\` → `/c/`) **Solution**: 1. Run Docker commands from Git Bash (paths are translated automatically) 2. Or run them from WSL2: ```powershell wsl docker compose -f deploy/docker/docker-compose.yml up -d ``` --- ## Mapping to the Linux production deployment ### Configuration file mapping | Windows local | Linux production | Notes | |-------------|-----------|------| | `.env.docker` | `/root/fulla/.env.docker` | Environment variables are identical | | `deploy/keys/signing.pem` | `/root/fulla/deploy/keys/signing.pem` | JWT keys | | `docker-compose.yml` | `docker-compose.prod.yml` | Differences in ports and TLS configuration | ### Deployment command mapping | Operation | Windows Docker Desktop | Linux production | |------|----------------------|-----------| | Start | `docker compose --env-file .env.docker up -d` | `docker compose --env-file .env.docker -f docker-compose.prod.yml up -d` | | View logs | `docker compose logs -f` | `docker compose -f docker-compose.prod.yml logs -f` | | Rebuild | `docker compose up -d --build` | `docker compose -f docker-compose.prod.yml up -d --build` | | Stop | `docker compose down` | `docker compose -f docker-compose.prod.yml down` | ### Access URL mapping | Service | Windows local | Linux production | |------|-------------|-----------| | User frontend | http://localhost:8080 | https://your-domain.com/ | | Admin console | http://localhost:8081 | https://your-domain.com/admin/ | | Backend API | http://localhost:5555 | https://your-domain.com/api/ | | Prometheus | http://localhost:9090 | http://your-server:9090 | --- ## From local validation to production deployment ### Deploy to Linux after validation passes 1. **Make sure local validation succeeded**: ```powershell # Run the full test suite .\scripts\backend\test-oauth2-endpoints.ps1 .\scripts\backend\test-admin-endpoints.ps1 ``` 2. **Commit the code**: ```powershell git add . git commit -m "feat: XXX (tested on Windows Docker Desktop)" git push ``` 3. **Deploy on the Linux server**: ```bash # Pull the code git pull # Copy the environment variables (one-time only) cp deploy/env/docker.env.example .env.docker # Edit .env.docker to set production passwords # Start with the production configuration docker compose -f deploy/docker/docker-compose.prod.yml --env-file .env.docker up -d --build ``` 4. **Configure TLS** (the only extra step on Linux): ```bash # Use Let's Encrypt sudo certbot certonly --standalone -d your-domain.com cp /etc/letsencrypt/live/your-domain.com/fullchain.pem deploy/nginx/ssl/ cp /etc/letsencrypt/live/your-domain.com/privkey.pem deploy/nginx/ssl/ docker compose -f deploy/docker/docker-compose.prod.yml restart nginx ``` --- ## Performance comparison | Metric | Windows Docker Desktop | Linux production server | |------|----------------------|----------------| | Startup time | ~45 s (6 containers) | ~30 s (same configuration) | | Memory footprint | ~2.5 GB | ~1.8 GB | | API response time | ~50ms | ~40ms | | Database query | ~10ms | ~8ms | > The differences mainly come from Windows system overhead and the WSL2 virtualization layer, but functionality is fully identical. --- ## Next steps 1. **Finish local validation**: make sure all core functionality works 2. **Record test results**: mark "Verified on Windows Docker Desktop" in the project documentation 3. **Push to Linux**: succeed with a one-shot deployment 4. **Configure monitoring**: set up Prometheus + Grafana --- ## Appendix: complete port mapping ``` ┌─────────────────────────────────────────────────────────────┐ │ Windows host │ ├─────────────────────────────────────────────────────────────┤ │ Port 8080 ──→ fulla-frontend:80 (Vue user frontend) │ │ Port 8081 ──→ fulla-admin:80 (Vue admin console) │ │ Port 5555 ──→ fulla-backend:5555 (C++ API) │ │ Port 5433 ──→ fulla-postgres:5432 (PostgreSQL) │ │ Port 6380 ──→ fulla-redis:6379 (Redis) │ │ Port 9090 ──→ fulla-prometheus:9090 (monitoring) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Docker internal network (oauth2-net) │ │ │ │ All containers reach each other via internal DNS: │ │ - fulla-backend → fulla-postgres:5432 │ │ - fulla-backend → fulla-redis:6379 │ │ - fulla-frontend → fulla-backend:5555 │ │ - fulla-admin → fulla-backend:5555 │ └─────────────────────────────────────────────────────────────┘ ``` --- ## Summary ✓ **Feasible**: Windows Docker Desktop can validate the entire deployment workflow (except domain and SSL) ✓ **Recommended**: validate locally → push code → deploy on Linux; dramatically reduces debugging time ✓ **Consistency**: database schema, API surface, and frontend logic are 100% identical to production **Suitable scenarios**: - ✓ Verifying code changes - ✓ Testing database migrations - ✓ Debugging API endpoints - ✓ Verifying frontend routing - ✓ Testing OAuth2 flows **Unsuitable scenarios**: - ✗ TLS/SSL testing (self-signed certificates can partially substitute) - ✗ Performance/load testing (use a Linux server) - ✗ High-availability configurations (multiple servers required) --- # Production Deployment Guide Source: https://fulla.dev/docs/operate/deployment # Production Deployment Guide This guide explains how to deploy the full OAuth2 stack (user frontend + admin console + backend API) to a production environment. --- ## Architecture Overview ``` Internet │ ┌──────┴──────┐ │ Nginx │ :80 → :443 (TLS) │ reverse │ │ proxy │ └──────┬──────┘ ┌────────────┼────────────┐ │ │ │ ┌─────┴─────┐ ┌────┴────┐ ┌────┴────┐ │ Frontend │ │ Admin │ │ Backend │ │ (Vue SPA) │ │ (Vue) │ │ (C++) │ │ :80 │ │ :80 │ │ :5555 │ └───────────┘ └─────────┘ └────┬────┘ │ ┌─────────┼─────────┐ │ │ ┌─────┴─────┐ ┌───────┴───────┐ │ PostgreSQL│ │ Redis │ │ :5432 │ │ :6379 │ └───────────┘ └───────────────┘ ``` **Routing rules (Nginx)**: - `/api/*`, `/oauth2/*`, `/.well-known/*`, `/health` → Backend - `/admin/*` → Admin Console - `/*` (everything else) → User Frontend --- ## Prerequisites ### Hardware requirements - **CPU**: 2 cores or more - **Memory**: 4GB or more (8GB recommended) - **Disk**: 20GB or more of free space - **Network**: public IP, with a domain resolved to the server ### Supported operating systems - Ubuntu 20.04 / 22.04 / 24.04 LTS - Debian 11 / 12 - CentOS Stream 8 / 9 - Rocky Linux 8 / 9 ### Installing software dependencies #### 1. Install Docker **Ubuntu/Debian**: ```bash # Update the package index sudo apt update # Install required dependencies sudo apt install -y ca-certificates curl gnupg lsb-release # Add Docker's official GPG key sudo mkdir -p /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg # Set up the Docker repository echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # Install Docker Engine sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # Start the Docker service sudo systemctl start docker sudo systemctl enable docker # Verify the installation docker --version docker compose version ``` **CentOS/Rocky Linux**: ```bash # Install required dependencies sudo yum install -y yum-utils device-mapper-persistent-data lvm2 # Add the Docker repository sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo # Install Docker sudo yum install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # Start the Docker service sudo systemctl start docker sudo systemctl enable docker # Verify the installation docker --version docker compose version ``` #### 2. Configure the Docker group (optional but recommended) ```bash # Create the docker group (if it does not exist) sudo groupadd docker # Add the current user to the docker group sudo usermod -aG docker $USER # Log out and back in, or run the following command for the group membership to take effect newgrp docker # Verify: run docker without sudo docker ps ``` #### 2.5. Configure Docker registry mirrors (required in mainland China) Docker Hub is unstable to reach from mainland China and image pulls will time out (typical error: `dial tcp registry-1.docker.io:443: i/o timeout`); you must configure registry mirrors. The following mirror addresses were verified working on Alibaba Cloud servers as of 2026-06: **Create or modify the Docker configuration file**: ```bash sudo mkdir -p /etc/docker sudo tee /etc/docker/daemon.json > /dev/null << 'EOF' { "registry-mirrors": [ "https://docker.1panel.live", "https://docker.awsl9527.cn", "https://docker.xuanyuan.me" ], "log-driver": "json-file", "log-opts": { "max-size": "100m", "max-file": "3" } } EOF ``` > Note: with multiple mirrors configured, Docker tries them in order; a pull succeeds as soon as any one of them works. **Restart the Docker service to apply the configuration**: ```bash sudo systemctl daemon-reload sudo systemctl restart docker sudo systemctl status docker ``` **Verify the registry mirror configuration**: ```bash # Check that the configuration was loaded (should show the registry-mirrors list above) docker info | grep -A 5 "Registry Mirrors" # Test image pulls (all images required by this project) docker pull postgres:17-alpine docker pull redis:7-alpine docker pull nginx:stable-alpine docker pull prom/prometheus:latest docker pull ubuntu:24.04 ``` If a mirror reports an error (such as `502` or `i/o timeout`), Docker automatically tries the next one; if all of them fail, see the troubleshooting notes below. **Troubleshooting**: 1. **All mirrors failed**: visit [dongyubin/DockerHub](https://github.com/dongyubin/DockerHub) for the latest working list, replace the addresses in `daemon.json`, and restart Docker. 2. **Use a dedicated Alibaba Cloud mirror** (requires an Alibaba Cloud account; most stable): - Log in to [Alibaba Cloud Container Registry](https://cr.console.aliyun.com/) → Image Tools → Image Accelerator - Obtain your dedicated mirror address (of the form `https://.mirror.aliyuncs.com`) - Put that address at the head of the `registry-mirrors` array in `daemon.json` 3. **Pull through a proxy** (if you have a usable proxy server): ```bash # Configure a proxy for the Docker daemon sudo mkdir -p /etc/systemd/system/docker.service.d sudo tee /etc/systemd/system/docker.service.d/http-proxy.conf > /dev/null << EOF [Service] Environment="HTTP_PROXY=http://your-proxy:port" Environment="HTTPS_PROXY=http://your-proxy:port" Environment="NO_PROXY=localhost,127.0.0.1" EOF sudo systemctl daemon-reload sudo systemctl restart docker ``` #### 3. Install Git **Ubuntu/Debian**: ```bash sudo apt install -y git ``` **CentOS/Rocky Linux**: ```bash sudo yum install -y git ``` #### 4. Install OpenSSL (for key generation) **Ubuntu/Debian**: ```bash sudo apt install -y openssl ``` **CentOS/Rocky Linux**: ```bash sudo yum install -y openssl ``` #### 5. Install Certbot (for obtaining Let's Encrypt certificates) **Ubuntu/Debian**: ```bash sudo apt install -y certbot ``` **CentOS/Rocky Linux**: ```bash sudo yum install -y certbot ``` ### Verify the dependency installation ```bash # Check the Docker version (24+ required) docker --version # Check the Docker Compose version (v2 required) docker compose version # Check Git git --version # Check OpenSSL openssl version # Check Certbot certbot --version ``` ### Domain and DNS configuration 1. **Domain resolution**: make sure the A record of your domain (e.g. `your-domain.example.com`) points to the server's public IP 2. **Verify DNS propagation**: ```bash # Check that the domain resolves correctly dig +short your-domain.example.com nslookup your-domain.example.com ``` 3. **Firewall configuration**: make sure the following ports are reachable: - `80/tcp` (HTTP) - `443/tcp` (HTTPS) ### Firewall configuration **Ubuntu (UFW)**: ```bash sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable ``` **CentOS/Rocky Linux (firewalld)**: ```bash sudo firewall-cmd --permanent --add-service=http sudo firewall-cmd --permanent --add-service=https sudo firewall-cmd --reload ``` --- ## Quick deployment (5 steps) ### 1. Clone the project ```bash git clone cd fulla ``` ### 2. Generate keys ```bash # Generate the JWT signing key chmod +x scripts/generate-jwt-keys.sh ./scripts/generate-jwt-keys.sh # Generate a temporary self-signed TLS certificate (needed for nginx to start) chmod +x scripts/generate-certs.sh ./scripts/generate-certs.sh ``` > The self-signed certificate is a bootstrap placeholder — nginx requires cert files to exist before it can start. You will replace it with a Let's Encrypt certificate in step 6 below. **Using Let's Encrypt in production** (after step 4 starts the services): ```bash # 1. Stop the nginx container to free port 80 docker compose -f deploy/docker/docker-compose.prod.yml stop nginx # 2. Obtain the certificate (port 80 must be free for standalone challenge) sudo certbot certonly --standalone -d your-domain.com # 3. Copy the certificates cp /etc/letsencrypt/live/your-domain.com/fullchain.pem deploy/nginx/ssl/ cp /etc/letsencrypt/live/your-domain.com/privkey.pem deploy/nginx/ssl/ # 4. Restart nginx with the real certificate docker compose -f deploy/docker/docker-compose.prod.yml start nginx ``` ### 3. Configure environment variables ```bash # Check that the template file exists [ -f deploy/env/docker.env.example ] && echo "Template file exists" || echo "Error: template file missing" cp deploy/env/docker.env.example .env.docker ``` Edit `.env.docker` to set strong passwords and the HTTPS-domain-related configuration: ```env # Image version tag for ghcr.io/voidvec/fulla-* images (default: latest) FULLA_VERSION=latest # Run mode (production enforces HTTPS issuer / strong passwords; must be paired with FULLA_ISSUER=https://) FULLA_ENV=production FULLA_ISSUER=https://your-domain.com # JWT signing key (required in production; without it tokens are invalidated on every restart) FULLA_JWT_KEY_PATH=/app/keys/signing.pem # ⚠ POSTGRES_PASSWORD and FULLA_DB_PASSWORD must be identical POSTGRES_USER=fulla_user POSTGRES_PASSWORD= POSTGRES_DB=fulla_db FULLA_DB_HOST=fulla-postgres FULLA_DB_PORT=5432 FULLA_DB_NAME=fulla_db FULLA_DB_USER=fulla_user FULLA_DB_PASSWORD= # ⚠ REDIS_PASSWORD and FULLA_REDIS_PASSWORD must be identical REDIS_PASSWORD= FULLA_REDIS_HOST=fulla-redis FULLA_REDIS_PORT=6379 FULLA_REDIS_PASSWORD= # CORS / OAuth callbacks (HTTPS domain required, otherwise browser requests are blocked) FULLA_FRONTEND_URL=https://your-domain.com FULLA_CORS_ALLOW_ORIGINS=https://your-domain.com # ⚠ FULLA_VUE_REDIRECT_URI and VITE_REDIRECT_URI must be identical FULLA_VUE_REDIRECT_URI=https://your-domain.com/callback FULLA_VUE_CLIENT_SECRET= FULLA_GOOGLE_REDIRECT_URI=https://your-domain.com/callback # Error verbosity (false recommended in production; do not expose field-level validation errors) DETAILED_VALIDATION_ERRORS=false # External Auth (optional) FULLA_GITHUB_CLIENT_ID= FULLA_GITHUB_CLIENT_SECRET= FULLA_GOOGLE_CLIENT_ID= FULLA_GOOGLE_CLIENT_SECRET= FULLA_WECHAT_APPID= FULLA_WECHAT_SECRET= # Email service (SMTP) — must be configured in production FULLA_SMTP_HOST=smtp.example.com FULLA_SMTP_PORT=465 FULLA_SMTP_USER=noreply@example.com FULLA_SMTP_PASSWORD= FULLA_SMTP_FROM_NAME=Fulla FULLA_SMTP_SSL=true # Frontend build variables (injected at Vite build time) # VITE_API_BASE_URL must be left empty in production → the SPA uses relative paths (same-origin reverse proxying via nginx) VITE_API_BASE_URL= VITE_CLIENT_ID=fulla-portal VITE_REDIRECT_URI=https://your-domain.com/callback # Set to enable the "Continue with GitHub" button on the login page # (baked into the SPA bundle at image build time): VITE_GITHUB_CLIENT_ID= ``` #### Enabling GitHub login (optional) 1. Create a GitHub OAuth App: GitHub → Settings → Developer settings → **OAuth Apps** → *New OAuth App*. Homepage URL: `https://your-domain.com`; Authorization callback URL: `https://your-domain.com/callback/github`. 2. Fill `.env.docker` with the client ID and client secret: `FULLA_GITHUB_CLIENT_ID`, `FULLA_GITHUB_CLIENT_SECRET`, and — for the login-page button — `VITE_GITHUB_CLIENT_ID` (same ID; it is compiled into the SPA bundle). 3. Rebuild and restart: `docker compose build frontend && docker compose up -d` (the backend alone can pick the credentials up from a restart, but the login-page button needs the frontend rebuild). 4. Account linking is independent of the button: an authenticated user can connect GitHub from Portal → Security at any time. #### Enabling passkeys (WebAuthn, optional) Passkeys stay disabled (fail-closed, #142) while `FULLA_WEBAUTHN_RP_ORIGINS` is empty. To enable: 1. In `.env.docker`, set `FULLA_WEBAUTHN_RP_ID=your-domain.com` (the registrable domain the credential is scoped to — a parent domain also works), `FULLA_WEBAUTHN_RP_ORIGINS=https://your-domain.com` (the exact portal origin; comma-separate several), and optionally `FULLA_WEBAUTHN_RP_NAME` (passkey dialog display name, default `Fulla`). 2. Restart the backend: `docker compose up -d backend`. 3. Users add a passkey from Portal → Security as a second factor. > **Critical coupling**: `FULLA_ENV=production` and `FULLA_ISSUER=https://...` must be set together. Setting production without an HTTPS issuer makes backend startup validation fail (the prod-mode check in `ConfigManager` rejects non-https issuers). Likewise, the DB/Redis passwords must not be the defaults `123456` / `password`, or the prod validation will also refuse to start. Generate strong passwords: ```bash openssl rand -base64 32 ``` #### Email service (SMTP) configuration notes The backend email service has two modes (chosen automatically by `getEmailService()` based on environment variables): | Mode | Trigger | Behavior | |------|---------|------| | **Console mode** | `FULLA_SMTP_HOST` / `USER` / `PASSWORD` not set | Email content is only written to the backend log; **nothing is actually sent** | | **SMTP mode** | All three variables above are set and non-empty | Emails are actually sent via SMTP | > **SMTP must be configured in production**, otherwise emails for features such as email verification and password reset are never actually delivered to users (they only land in the server logs). **Common email provider configuration reference**: | Provider | SMTP host | Port | SSL | Credential notes | |--------|----------|------|-----|---------| | 163 Mail | `smtp.163.com` | 465 | true | Authorization code (not the login password) | | QQ Mail | `smtp.qq.com` | 465 | true | Authorization code | | Gmail | `smtp.gmail.com` | 465 | true | App password (2FA must be enabled) | | Tencent Exmail | `smtp.exmail.qq.com` | 465 | true | Mailbox password | | Alibaba Cloud enterprise mail | `smtp.qiye.aliyun.com` | 465 | true | Mailbox password | | SendGrid | `smtp.sendgrid.net` | 587 | false | Username `apikey`, password is the API key | **Obtaining an authorization code (163 example)**: 1. Log in to the 163 Mail web interface 2. Settings → POP3/SMTP/IMAP 3. Enable the SMTP service 4. Follow the prompts to generate an authorization code (a 16-character string) Restart the backend after configuring for the change to take effect: ```bash docker compose -f deploy/docker/docker-compose.prod.yml --env-file .env.docker up -d fulla-backend # Verify the switch to SMTP mode (should print "Email service: SMTP (...)") docker compose -f deploy/docker/docker-compose.prod.yml logs fulla-backend | grep "Email service" ``` ### 4. Start the services ```bash # --build compiles images from source (required for first deployment from a cloned repo) docker compose -f deploy/docker/docker-compose.prod.yml --env-file .env.docker up -d --build ``` > Subsequent restarts (after config changes only, no code changes) can omit `--build`. After code updates via `git pull`, always include `--build` to pick up the changes. ### 5. Verify the deployment ```bash # Check the status of all containers docker compose -f deploy/docker/docker-compose.prod.yml ps # Check backend health curl -k https://localhost/health # Check the frontend curl -k https://localhost/ # Check the admin console curl -k https://localhost/admin/ ``` --- ## Service details ### User frontend (OAuth2Frontend) | Item | Value | |------|-----| | Container name | fulla-frontend | | Build | Dockerfile (target: frontend-runtime) | | Base image | nginx:stable-alpine | | Internal port | 80 | | Access path | `https://your-domain.com/` | | Features | Login, registration, profile, security settings, OAuth2 authorization | ### Admin console (OAuth2Admin) | Item | Value | |------|-----| | Container name | fulla-admin | | Build | frontends/admin/Dockerfile | | Base image | nginx:alpine | | Internal port | 80 | | Access path | `https://your-domain.com/admin/` | | Features | Application management, user management, role/scope/token management | ### Backend API (fulla-server) | Item | Value | |------|-----| | Container name | fulla-backend | | Build | Dockerfile (target: backend-runtime) | | Base image | ubuntu:24.04 (minimal) | | Internal port | 5555 | | Access path | `https://your-domain.com/api/*`, `/oauth2/*` | | Database migration | One-shot `migrate` service (compose profile `migrate`; `FULLA_AUTO_MIGRATE=false`) | ### Infrastructure | Service | Image | Purpose | |------|------|------| | fulla-postgres | postgres:17-alpine | Primary database | | fulla-redis | redis:7-alpine | Token cache | | oauth2-nginx | nginx:stable-alpine | TLS termination + reverse proxy | | fulla-prometheus | prom/prometheus | Monitoring metrics collection | --- ## Configuration reference ### Backend configuration (config.prod.json) The backend overrides configuration-file values with environment variables (precedence: `.env` file > system environment variables > `config.prod.json` defaults): | Environment variable | Purpose | Default | |----------|------|--------| | `FULLA_ENV` | Run mode (`production` enables strict HTTPS issuer + strong-password validation) | development | | `FULLA_ISSUER` | JWT issuer (must be `https://` in production) | http://localhost:5555 | | `FULLA_JWT_KEY_PATH` | Path to the JWT signing key file | /app/keys/signing.pem | | `FULLA_SIGNING_KEY` | JWT key PEM content (either this or `JWT_KEY_PATH`) | (optional) | | `FULLA_DB_HOST` | PostgreSQL host | postgres | | `FULLA_DB_PORT` | PostgreSQL port | 5432 | | `FULLA_DB_NAME` | Database name | fulla_db_prod | | `FULLA_DB_USER` | Database user | fulla_user | | `FULLA_DB_PASSWORD` | Database password | (must be set) | | `FULLA_REDIS_HOST` | Redis host | redis | | `FULLA_REDIS_PORT` | Redis port | 6379 | | `FULLA_REDIS_PASSWORD` | Redis password | (must be set) | | `FULLA_LISTEN_PORT` | Backend listen port | 5555 | | `FULLA_FRONTEND_URL` | Frontend URL (used for redirects etc.) | http://localhost:5173 | | `FULLA_CORS_ALLOW_ORIGINS` | CORS allowed origins (comma-separated; overrides the JSON array) | localhost list from config | | `FULLA_PORTAL_REDIRECT_URI` | fulla-portal OAuth callback URI (legacy alias: `FULLA_VUE_REDIRECT_URI`) | localhost value from config | | `FULLA_GOOGLE_REDIRECT_URI` | Google OAuth callback URI | localhost value from config | | `FULLA_PORTAL_CLIENT_SECRET` | fulla-portal secret (legacy alias: `FULLA_VUE_CLIENT_SECRET`) | 123456 | | `FULLA_ADMIN_CONSOLE_REDIRECT_URI` | fulla-admin-console OAuth callback URI | localhost value from config | | `FULLA_MFA_TOTP_ISSUER` | Issuer name shown by authenticator apps for TOTP entries | `Fulla` | | `FULLA_WEBAUTHN_RP_ID` | WebAuthn relying-party ID (registrable domain) | (empty = passkeys disabled) | | `FULLA_WEBAUTHN_RP_NAME` | Passkey dialog display name | `Fulla` | | `FULLA_WEBAUTHN_RP_ORIGINS` | WebAuthn origin allowlist (comma-separated) | (empty = passkeys disabled) | | `FULLA_AUTO_MIGRATE` | Run database migrations automatically | false (use the one-shot `migrate` service) | | `DETAILED_VALIDATION_ERRORS` | Whether to return field-level validation errors (false recommended in production) | false | | `FULLA_GITHUB_CLIENT_ID` / `FULLA_GITHUB_CLIENT_SECRET` | GitHub OAuth (optional) | (empty) | | `FULLA_GOOGLE_CLIENT_ID` / `FULLA_GOOGLE_CLIENT_SECRET` | Google OAuth (optional) | (empty) | | `FULLA_WECHAT_APPID` / `FULLA_WECHAT_SECRET` | WeChat OAuth (optional) | (empty) | | `FULLA_SMTP_HOST` | SMTP server host (unset means email stays in Console mode) | (optional) | | `FULLA_SMTP_PORT` | SMTP port | 465 | | `FULLA_SMTP_USER` | SMTP username (full email address) | (optional) | | `FULLA_SMTP_PASSWORD` | SMTP authorization code (not the mailbox login password) | (optional) | | `FULLA_SMTP_FROM_NAME` | Sender display name | Fulla | | `FULLA_SMTP_SSL` | Whether to enable SSL | true | > **Email mode note**: real SMTP sending is enabled only when all three of `FULLA_SMTP_HOST` + `FULLA_SMTP_USER` + `FULLA_SMTP_PASSWORD` are non-empty; otherwise email is only written to the backend log. See "Email service (SMTP) configuration notes" above. > > **CORS array override**: `FULLA_CORS_ALLOW_ORIGINS` is a comma-separated string (e.g. `https://a.com,https://b.com`) that the backend splits into a JSON array at startup to override `custom_config.cors.allow_origins` from `config.prod.json`. The CORS validation code requires this field to be an array, so you **must** use the comma-separated form — never write it as a JSON array literal. ### Nginx configuration `deploy/nginx/nginx.conf` includes: - Automatic HTTP → HTTPS redirection - TLS 1.2/1.3 configuration - Rate limiting rules (login: 5 requests/min/IP; API: 30 requests/s/IP) - `/metrics` endpoint restricted to internal-network access - HSTS headers ### Frontend configuration The frontend (the user-facing OAuth2Frontend) is configured through Vite environment variables that are **injected at image build time** into the SPA bundle (they are not read at runtime). `fulla-frontend.build.args` in `docker-compose.prod.yml` passes these variables through from `.env.docker`, and the `frontend-builder` stage of the `Dockerfile` exposes them to Vite via `ARG`/`ENV`. | Variable | Purpose | Production value | |------|------|--------| | `VITE_API_BASE_URL` | API base URL | **(empty)** — the SPA uses same-origin relative paths; setting a value breaks the nginx reverse-proxy routing | | `VITE_CLIENT_ID` | OAuth2 Client ID | fulla-portal | | `VITE_REDIRECT_URI` | OAuth2 callback URI | https://your-domain.com/callback | | `VITE_GITHUB_CLIENT_ID` | GitHub "Sign in with GitHub" button (optional) | (button hidden when empty) | > **The admin console (OAuth2Admin) needs no configuration**: its source code reads no `import.meta.env` at all; every API call uses the relative path `/api/admin/*`, which nginx reverse-proxies to the backend. When changing domains you only need to keep the nginx `/admin/` route correct — no admin image rebuild required. > > **Changing the domain requires rebuilding the frontend image**: because VITE variables are baked in at build time, after switching domains you must run `docker compose ... up -d --build fulla-frontend` (the admin console is unaffected). --- ## Database initialization On first deployment the one-shot `migrate` service creates all required tables (the compose stack keeps `FULLA_AUTO_MIGRATE=false`; run `docker compose -f docker-compose.prod.yml --env-file --profile migrate run --rm --build migrate` after `up` of the postgres/redis services — `--build` is REQUIRED on a clean checkout, otherwise compose pulls the released image, which may predate your local migrations). The **administrator account is bootstrapped automatically** on first start (see next step); OAuth2 clients are not — create them manually. > The `dev_*.sql` files in `apps/server/seed/` use hard-coded passwords and localhost redirect URIs. **Do not use them in production.** Follow the steps below instead. ### 1. Create the administrator account Generate a secure password hash and create the admin user: ```bash # Generate a random password for the admin user ADMIN_PASSWORD=$(openssl rand -base64 24) echo "Admin password: $ADMIN_PASSWORD" echo "Save this password — you will need it to log in to the admin console." # The account is flagged must_change_password (#145): the first login forces a # password change via the admin console's change-password form before any # authorization is granted, so the printed/initial password cannot linger. # Preferred: let the server bootstrap the admin on first start (random # PBKDF2 password printed ONCE to the container log): # docker compose logs backend | grep Bootstrap # Or set it explicitly before first start: FULLA_BOOTSTRAP_ADMIN_PASSWORD=... # Optionally also set a real mailbox for the admin: # FULLA_BOOTSTRAP_ADMIN_EMAIL=admin@your-domain.com # When a real mailbox AND working SMTP delivery are both configured, the # bootstrap admin is created UNVERIFIED — completing the forced first-login # password change sends the verification email, and sign-in stays blocked # until the link is clicked. Without them, the account ships with the # placeholder address and email_verified=true (no mailbox to verify). # Manual fallback (PBKDF2-SHA256, 310k iterations, same format as the # server). Note: the users.salt column stays empty — it is only used by the # retired legacy SHA-256 verification path; PBKDF2 embeds the salt in the # hash string: ADMIN_HASH=$(python3 -c "import hashlib,os;pw=os.environ['ADMIN_PASSWORD'];salt=os.urandom(16);print('\$pbkdf2-sha256\$310000\$'+salt.hex()+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),salt,310000,32).hex())") # Create the admin user (must_change_password=true mirrors the bootstrapper: # the first login forces a password change, #145) docker exec -i fulla-postgres psql -U fulla_user -d fulla_db < **Upgrading from a pre-1.3.0 deployment** (clients named `vue-client` / `admin-console`, or rows created by the old manual SQL): rename the existing rows once so consents and tokens keep pointing at a live client, then let the seeder add anything missing: > > ```sql > UPDATE oauth2_clients SET client_id = 'fulla-portal' WHERE client_id = 'vue-client'; > UPDATE oauth2_client_scopes SET client_id = 'fulla-portal' WHERE client_id = 'vue-client'; > UPDATE oauth2_clients SET client_id = 'fulla-admin-console' WHERE client_id = 'admin-console'; > UPDATE oauth2_client_scopes SET client_id = 'fulla-admin-console' WHERE client_id = 'admin-console'; > ``` --- ## Performance tuning (recommended configuration) > This section is the officially recommended production performance baseline (the analysis draws on the maintainers' benchmark archives; key conclusions and measured data are incorporated directly in this section). Benchmarks treat the configuration in this section as authoritative — whatever is written here is the "official configuration". ### 1. Enable the Redis L2 cache (recommended for the throughput tier; requires enlarging the Redis connection pool accordingly) On the read path (token and client lookups for introspect / userinfo), requests hit Redis instead of falling through to PostgreSQL. `config.prod.json` ships with the cache disabled (`cache.enabled: false`) — before enabling it you **must** also enlarge `redis_clients[0].number_of_connections` (see the measured data below): ```json "cache": { "enabled": true, "redis_client_name": "default", "ttl_seconds": { "client": 300, "access_token_max": 60 } } ``` Semantics: the token cache TTL never exceeds 60s and revocations take effect immediately (negative cache included); the client cache TTL is 300s. It requires Redis to be available inside the deployment (the production compose already includes fulla-redis). Note for multi-instance deployments: the Redis DEL issued by write-path invalidation takes effect immediately across all instances, but the in-process piggyback memo for userinfo (a 2s one-shot) is only synchronously cleared on the instance that handled the write request; other instances lag by at most 2s (the TTL self-heals, which is acceptable). **Measured (2026-08-18 benchmark environment, 10s quick test)**: with the cache on and a Redis pool of 20, S6 actually regressed by -18% (pool queuing); after enlarging the Redis pool to 64, S2 +39%, S3 +59%, S6 +6%. Conclusion: **cache gains presuppose a Redis pool ≥ the expected concurrency**; the factory default of disabled plus the guidance in this section is the safe posture. Also note: the introspect positive cache is constrained by the N2 discriminator (it is only backfilled after a token has gone through the issuance/validation path), so the S3 gains come mainly from the client cache and the PG tuning. ### 2. PostgreSQL instance tuning The factory defaults (`shared_buffers=128MB`, `checkpoint_timeout=5min`, `max_wal_size=1GB`) target small-memory machines and produce periodic checkpoint flush spikes under high write rates. Recommended tuning for a 16GB / 8 vCPU host (scale `shared_buffers` proportionally with memory, ≈ 25% of RAM): ```yaml fulla-postgres: command: - postgres - -c - shared_buffers=4GB - -c - effective_cache_size=12GB - -c - work_mem=16MB - -c - checkpoint_timeout=15min - -c - max_wal_size=4GB - -c - min_wal_size=1GB - -c - wal_compression=on - -c - checkpoint_completion_target=0.9 - -c - autovacuum_vacuum_insert_scale_factor=0.02 - -c - autovacuum_vacuum_scale_factor=0.02 ``` This is the exact form used in the measured benchmark environment; a complete runnable example lives in `benchmarks/fulla/docker-compose.bench.yml` (a bench overlay layered on top of `deploy/docker/docker-compose.yml`). Pure conf-level tuning has no compatibility impact on existing data volumes and can be enabled or rolled back at any time. **Note**: the bench overlay has since lowered `shared_buffers` to 1GB (verified in the 2026-08-22 three-arm A/B test to be equivalent to 4GB); the 4GB value above remains the officially recommended PG starting point for 16GB hosts. **Version and upgrade note**: since 2026-08-18 the deploy compose uses `postgres:17-alpine` (aligned with the client-side libpq 17.x; benchmarks were measured on 17). **Existing data volumes from version 15 cannot start directly on 17** (major-version data-directory incompatibility) — run `pg_dump`/`pg_restore` or use `pg_upgrade` before upgrading; fresh deployments can skip this step. ### 3. Session retention (session_timeout) — size it to your API traffic **Key point**: with `enable_session: true`, the Drogon framework layer creates a Session for every request that arrives without a session cookie and holds it until `session_timeout` expires (upstream [drogon#278](https://github.com/an-tao/drogon/issues/278) behavior, verified by measurement in this repo on 2026-08-22). API traffic (which never carries a cookie) pays per request: **about 750 B retained per request** (`API_QPS × session_timeout × 750 B`), plus a throughput tax of roughly -54% across all endpoints. The full **retention formula, sizing quick-reference table, and tier-specific guidance** (interactive workloads under 100 QPS keep the default 3600s; API workloads lower it per the table; the benchmark tier uses 30s) lives in [Session management · sizing quick reference](../domains/session-management.md) — that document is the single source of truth on this topic, and this section keeps only the operational essentials. ### 4. Docker network topology (optional on the native engine; unavailable under Docker Desktop) If you run the **native Docker Engine** (installed directly on a Linux server), you can put backend + PG + redis in `network_mode: host`: backend↔PG/Redis traffic goes over loopback, eliminating the per-packet veth traversal. **Do not use this under Docker Desktop (WSL2 integration)** (measured 2026-08-18): `host` is the engine VM's netns, not the distro's netns, so ports listened on in host mode are completely unreachable from the distro (127.0.0.1, the shared eth0 IP, and host.docker.internal all time out; only published ports are forwarded). The benchmark environment therefore kept the bridge + published-ports topology, identical across all four products (fairness is unaffected). Cross-machine deployments (nginx fronting, standalone DB) are unaffected by this item. --- ## Operational tasks ### View logs ```bash # All services docker compose -f deploy/docker/docker-compose.prod.yml logs -f # A single service docker compose -f deploy/docker/docker-compose.prod.yml logs -f fulla-backend docker compose -f deploy/docker/docker-compose.prod.yml logs -f nginx ``` ### Restart services ```bash # Restart a single service docker compose -f deploy/docker/docker-compose.prod.yml restart fulla-backend # Rebuild and restart (after code updates) docker compose -f deploy/docker/docker-compose.prod.yml up -d --build fulla-backend docker compose -f deploy/docker/docker-compose.prod.yml up -d --build fulla-frontend docker compose -f deploy/docker/docker-compose.prod.yml up -d --build fulla-admin ``` ### Update the deployment ```bash git pull docker compose -f deploy/docker/docker-compose.prod.yml up -d --build ``` ### Database backup ```bash # Backup docker exec fulla-postgres pg_dump -U fulla_user fulla_db > backup_$(date +%Y%m%d).sql # Restore docker exec -i fulla-postgres psql -U fulla_user -d fulla_db < backup_20260526.sql ``` ### TLS certificate renewal Let's Encrypt certificates expire after 90 days. Set up automatic renewal: ```bash # Test the renewal process (dry run) sudo certbot renew --dry-run # Add a systemd timer for automatic renewal (most distros already do this on certbot install) sudo systemctl enable certbot.timer sudo systemctl start certbot.timer # Or use a cron job as an alternative (crontab -l 2>/dev/null; echo "0 3 1 * * sudo certbot renew --quiet && cp /etc/letsencrypt/live/your-domain.com/fullchain.pem /path/to/repo/deploy/nginx/ssl/ && cp /etc/letsencrypt/live/your-domain.com/privkey.pem /path/to/repo/deploy/nginx/ssl/ && docker compose -f /path/to/repo/deploy/docker/docker-compose.prod.yml restart nginx") | sudo crontab - ``` > The cron job copies the renewed certs to the nginx SSL directory and restarts the nginx container. Adjust paths to match your deployment location. ### Monitoring - Prometheus: `http://your-server:9090` - Backend metrics: `https://your-domain.com/metrics` (internal network only) - Health check: `https://your-domain.com/health` ### audit_logs 分区维护(#83) `audit_logs` 自 V025 起按月分区(`[当前月-12, 当前月+25]` 的窗口 + 一个 DEFAULT 兜底分区)。 **分区窗口不再需要手工 cron**:postgres 存储部署下,后端的清理服务 (`cleanup_interval_seconds`,默认 3600s)每个周期会在分布式锁内调用 `ensure_audit_partitions()`,自动创建缺失的月分区并把落入 DEFAULT 分区的行迁出。 排查命令: ```sql -- 窗口内各分区的行量与 DEFAULT 分区是否在堆积(堆积 = 维护没在跑) SELECT tableoid::regclass AS partition, count(*) FROM audit_logs GROUP BY 1 ORDER BY 1; -- 手工触发一次维护(与自动路径相同的幂等函数;ahead/behind 可调) SELECT ensure_audit_partitions(); -- 默认 ahead 24 / behind 12 SELECT ensure_audit_partitions(36, 12); -- 扩大预建窗口 ``` 注意: - 该函数幂等但非并发安全——自动路径已在清理锁内串行化;手工调用请避开整点清理时刻。 - memory/redis 存储模式没有 audit 分区(该功能仅 postgres)。 - 维护失败只会 `LOG_ERROR` 并在下个清理周期重试,不影响清理主流程。 ### 社交账号绑定(#71)Redis 依赖 社交账号绑定功能的 link state(绑定流程的临时状态)存储在 Redis 中(`SET NX EX 600` / `GETDEL`,TTL 10 分钟)。 - **有 Redis 时**:绑定发起端点正常工作,link state 存入 Redis 并在回调时校验。 - **无 Redis 时**:绑定发起端点 fail-closed,返回 `NotConfigured` 类错误(HTTP 503)。 - **登录不受影响**:社交登录(已绑定账号的直接登录)不依赖 Redis link state,无需 Redis 也可正常工作。 生产部署确保 `fulla-redis` 容器运行即可(默认 compose 已包含)。 --- ## Troubleshooting ### Container fails to start ```bash # View container status docker compose -f deploy/docker/docker-compose.prod.yml ps # View the failed container's logs docker compose -f deploy/docker/docker-compose.prod.yml logs fulla-backend ``` ### Database connection failure ```bash # Check whether postgres is ready docker exec fulla-postgres pg_isready -U fulla_user # Check network connectivity docker exec fulla-backend curl -s http://fulla-postgres:5432 || echo "Cannot reach postgres" ``` ### Certificate problems ```bash # Check that the certificates exist ls -la deploy/nginx/ssl/ # Check the certificate validity period openssl x509 -in deploy/nginx/ssl/fullchain.pem -noout -dates ``` ### Frontend 404 If frontend pages return 404 after a refresh, check the SPA fallback configuration in nginx.conf: - OAuth2Frontend: `try_files $uri $uri/ /index.html` - OAuth2Admin: `try_files $uri $uri/ /admin/index.html` --- ## Security checklist - [ ] All passwords use strong random values (`openssl rand -base64 32`) - [ ] TLS certificates are valid and auto-renew (certbot timer or cron job configured) - [ ] `.env.docker` file permissions set to 600 - [ ] `deploy/keys/signing.pem` permissions set to 600 - [ ] Default admin password changed after the first deployment - [ ] Prometheus port 9090 not exposed to the public (or protected by authentication) - [ ] Database backed up regularly - [ ] Disk space monitored (logs, database) --- # Docker Deployment and Container Orchestration Guide Source: https://fulla.dev/docs/operate/docker-deployment # Docker Deployment and Container Orchestration Guide This document explains how to deploy the complete OAuth2 service stack with Docker Compose, locally or in production. --- ## 1. Service Stack Architecture `docker-compose.yml` orchestrates the following 6 services: ``` Internet │ │ :8080 / :8081 ▼ ┌────────────────────────┐ ┌────────────────────────┐ │ fulla-frontend │ │ fulla-admin │ │ Vue 用户前端 (Nginx) │ │ 管理后台前端 (Nginx) │ │ Port: 8080 │ │ Port: 8081 │ └───────────┬────────────┘ └───────────┬────────────┘ │ 内网 │ └────────────┬───────────────┘ ▼ ┌────────────────────────┐ │ fulla-backend │ │ Drogon 后端 :5555 │ │ → postgres → redis │ └───────────┬────────────┘ ┌────┴─────┐ ▼ ▼ postgres redis (5433:5432) (6380:6379) prometheus (9090:9090) ``` | Service | Image/build | Exposed port | Notes | |---|---|---|---| | `fulla-frontend` | `deploy/docker/Dockerfile` (`frontend-runtime`) | `8080:80` | Vue SPA (user-facing) + Nginx | | `fulla-admin` | `frontends/admin/Dockerfile` | `8081:80` | Admin console frontend | | `fulla-backend` | `deploy/docker/Dockerfile` (`backend-runtime`) | `5555:5555` | Drogon C++ backend | | `fulla-postgres` | `postgres:17-alpine` | `5433:5432` | PostgreSQL (host port 5433, avoiding local conflicts)| | `fulla-redis` | `redis:7-alpine` | `6380:6379` | Redis (host port 6380, avoiding local conflicts)| | `fulla-prometheus` | `prom/prometheus:latest` | `9090:9090` | Metrics collection | --- ## 2. Quick Start See the [Docker image and container specification guide](#image--container--network-naming-conventions). ```bash # 第一次或代码变更后:重新构建并启动(在项目根目录执行) docker-compose -f deploy/docker/docker-compose.yml up -d --build # 后续启动(无代码变更) docker-compose -f deploy/docker/docker-compose.yml up -d # 查看服务状态 docker-compose -f deploy/docker/docker-compose.yml ps # 实时查看后端日志 docker-compose -f deploy/docker/docker-compose.yml logs -f fulla-backend # 停止所有服务 docker-compose -f deploy/docker/docker-compose.yml down # 停止并删除数据卷(数据库会被清空) docker-compose -f deploy/docker/docker-compose.yml down -v ``` --- ## 3. Environment Variables and Secret Injection `fulla-backend` receives sensitive configuration through environment variables in the `environment` section of `docker-compose.yml`, **fully overriding the defaults in `config.json`**. The development defaults (for local evaluation only) are: ```yaml environment: - FULLA_DB_HOST=fulla-postgres # 指向 Docker 内网的 postgres 服务名 - FULLA_DB_NAME=fulla_db - FULLA_DB_PASSWORD=123456 - FULLA_REDIS_HOST=fulla-redis - FULLA_REDIS_PASSWORD=redis_secret_pass - FULLA_VUE_CLIENT_SECRET=123456 - FULLA_AUTO_MIGRATE=true # 启动时自动执行 apps/server/migrations - FULLA_FRONTEND_URL=http://localhost:8080 # SMTP 配置经 ${FULLA_SMTP_*:-} 占位从 .env.docker 注入;留空则回退到控制台模式 - FULLA_SMTP_HOST=${FULLA_SMTP_HOST:-} ... ``` > **WARNING** **Production security notes**: > - **Never** write real passwords directly into `docker-compose.yml` and commit them to Git. > - **Docker Secrets** or an external secret manager (Vault, AWS Secrets Manager) is recommended. > - Minimum requirement: use an `.env` file and add it to `.gitignore`. ### Using an `.env` file (recommended) The repository ships the example files `deploy/env/docker.env.example` (and `deploy/env/server.env.example`). Copy one to `.env.docker` (already excluded via `.gitignore`) and fill in production values: ```env FULLA_DB_PASSWORD=your_strong_password FULLA_REDIS_PASSWORD=your_redis_password FULLA_VUE_CLIENT_SECRET=your_client_secret # SMTP(留空则后端回退到控制台模式) FULLA_SMTP_HOST= FULLA_SMTP_PORT=465 ... ``` Then reference the values from `docker-compose.yml` via `${VAR_NAME:-default}`. --- ## 4. Data Persistence Data persistence is achieved through named volumes, so container restarts do not lose data: ```yaml volumes: pgdata: # PostgreSQL 数据文件 redisdata: # Redis RDB / AOF 文件 ``` Database initialization is performed automatically by the backend at startup (`FULLA_AUTO_MIGRATE=true` executes `apps/server/migrations/V*.sql` in filename order, followed by `apps/server/seed/*.sql`). `docker-compose.yml` also mounts the migration and seed scripts into subdirectories of the postgres container: ```yaml volumes: - ../../apps/server/migrations:/docker-entrypoint-initdb.d/migrations:ro - ../../apps/server/seed:/docker-entrypoint-initdb.d/seed:ro ``` > **WARNING** **Note**: the postgres entrypoint does **not** recurse into subdirectories of > `/docker-entrypoint-initdb.d`, so these two mounts are **no-ops** for first-time > initialization; actual schema initialization is performed by the backend's `FULLA_AUTO_MIGRATE`. --- ## 5. Prometheus Monitoring Configuration `prometheus.yml` configures Prometheus to scrape the `/metrics` endpoint of `fulla-backend`: ```yaml scrape_configs: - job_name: "fulla-backend" static_configs: - targets: ["fulla-backend:5555"] ``` Prometheus sits on the same Docker network as fulla-backend, `oauth2-net`, and reaches it directly by service name (no host port needs to be exposed). Visit `http://localhost:9090` for the Prometheus UI. --- ## 6. Production Deployment Recommendations ### 6.1 Add SSL termination in front of the frontend service The Nginx inside `fulla-frontend` serves static files; put an SSL-terminating Nginx/Traefik layer in front of it: ```nginx server { listen 443 ssl; server_name your-domain.com; ssl_certificate /etc/ssl/certs/cert.pem; ssl_certificate_key /etc/ssl/private/key.pem; location / { proxy_pass http://fulla-frontend:80; proxy_set_header X-Forwarded-Proto https; } location /api/ { proxy_pass http://fulla-backend:5555; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; } } ``` > **Important**: forward the `X-Forwarded-For` header so the backend's Hodor plugin can obtain the real client IP. ### 6.2 Block the `/metrics` endpoint The Prometheus `/metrics` endpoint must not be exposed to the public internet. Add to Nginx: ```nginx location /metrics { deny all; } ``` Alternatively, do not expose `fulla-backend:5555` through Docker at all, allowing access only to Prometheus on the internal network. ### 6.3 Database connection pool tuning For production, consider raising `number_of_connections` in `config.prod.json` from `4` to `10-50`, determined by testing against actual concurrency. --- ## 7. Health Checks and Troubleshooting ```bash # 检查所有容器状态 docker-compose ps # 检查后端服务是否可达 curl http://localhost:5555/metrics # 查看数据库是否已初始化 docker exec -it fulla-postgres psql -U fulla_user -d fulla_db -c "\dt" # 查看 Redis 连接 docker exec -it fulla-redis redis-cli -a redis_secret_pass ping # 清理并重建(数据会丢失) docker-compose down -v docker-compose up -d --build ``` ## Image / Container / Network Naming Conventions | Image purpose | Name | Build target | Notes | |---------|------|--------------------|------| | Production backend | `fulla-backend` | `backend-runtime` | Runtime only, small footprint; multi-arch GHCR release | | Debug backend | `fulla-backend-debug` | `backend-dev` | Includes the full compilation toolchain | | Production frontend | `fulla-frontend` | `frontend-runtime` | Nginx + static assets | Container naming: `fulla-{service}[-debug]` (backend/frontend/postgres/redis); networks: `oauth2-net` for Release, `fulla-debug-net` for Debug (see the compose files for legacy retained names). Three compose matrices: `docker-compose.yml` (development, 6 services), `docker-compose.debug.yml` (debug, 3 services), and `docker-compose.prod.yml` (production, 8 services including Nginx and a migrate job). **All compose commands are run from the repository root with `-f deploy/docker/...`**. ## Debug Environment (source mounts / GDB) ```bash docker build -f deploy/docker/Dockerfile --target backend-dev -t fulla-backend-debug:v1.3.2 . docker compose -f deploy/docker/docker-compose.debug.yml up -d docker compose -f deploy/docker/docker-compose.debug.yml run --rm debug-env bash ``` ## Automated Verification - `deploy/docker/docker-quick-verify-debug.sh` (full in-container pipeline: dependency check → wait for PG/Redis readiness → create the database → parallel build → unit tests); - `scripts/backend/full_test_docker.bat` (one-click on the host: start containers → initialize → regenerate the ORM → build → test → start the server → OAuth2/Admin endpoint tests → cleanup). --- # OAuth2 Observability Design Source: https://fulla.dev/docs/operate/observability # OAuth2 Observability Design The system integrates complete Prometheus monitoring metrics and structured, context-aware logging, supporting real-time monitoring and troubleshooting in production. ## 1. Prometheus Metrics The system exposes standard Prometheus metrics through an exporter. ### 1.1 Metric List | Metric | Type | Labels | Description | |----------|------|--------------|------| | `oauth2_requests_total` | Counter | `endpoint`, `status` | Total OAuth2 requests | | `oauth2_login_failures_total` | Counter | `reason` | Login failures | | `oauth2_introspect_requests_total` | Counter | `client_id` | Total token introspection requests | | `oauth2_introspect_errors_total` | Counter | `client_id`, `error` | Introspection errors | | `oauth2_revocation_requests_total` | Counter | `client_id` | Total token revocation requests | | `oauth2_revocation_errors_total` | Counter | `client_id`, `error` | Revocation errors | | `oauth2_latency_seconds` | Histogram | `operation`, `storage` | Latency distribution of key steps (including storage backend) | | `oauth2_active_tokens` | Gauge | — | Current estimate of active (unexpired) tokens | > Metrics are emitted uniformly by `libs/drogon/src/observability/Metrics.cc` and `libs/drogon/src/adapters/DrogonMetrics.cc` (as `[METRIC]` structured logs via `LOG_INFO`, consumed by PromExporter/log collectors); definitions live in `libs/drogon/include/fulla/drogon/observability/Metrics.h`. ### 1.2 Sample Dashboard Panels (Grafana) Recommended panels: - **QPS & Error Rate**: `rate(oauth2_requests_total[1m])` vs `rate(oauth2_login_failures_total[1m])` - **P99 Latency**: `histogram_quantile(0.99, rate(oauth2_latency_seconds_bucket[1m]))` - **Business**: Active tokens trend. ## 2. Structured Logging The system uses context-aware structured logging that Splunk/ELK can easily collect and analyze. ### 2.1 Audit Logs Critical security operations (such as token issuance) emit logs tagged with `[AUDIT]`. **Format**: `[AUDIT] Action={Action} User={UserId} Client={ClientId} Success={True/False} IP={RemoteAddr}` **Example**: ``` 2026-01-18 10:00:00 INFO [AUDIT] Action=IssueToken User=admin Client=fulla-portal Success=True 2026-01-18 10:05:00 WARN [AUDIT] Action=ExchangeCode User=admin Client=fulla-portal Success=False Reason="Replay Detected" ``` Security-relevant policy denials and social-login issuance use stable action keywords: - `AUTH_LEGACY_HASH_REJECTED` — login denied because the stored password hash is legacy-format and `auth.allow_legacy_hash=false` (#103). WARN with the internal user id; migrate the account via password reset or a temporary window reopen (docs/operate/configuration-guide.md §10). - `SOCIAL_LOGIN_TOKEN_ISSUED` — a social login endpoint (GitHub/Google/WeChat)- `webauthn_clone_detected` — an assertion presented a non-increasing signCount (#142): rejected as a possible cloned authenticator; the credential should be de-registered and re-registered. issued a first-party token pair (#70). Details carry provider/client/scope/internal id. ### 2.2 Contextual Logs Along the request-processing chain, every log line automatically carries a `RequestId` for correlation in distributed tracing. ```cpp LOG_INFO << "Processing request"; // 输出: [ReqId: abc-123] Processing request ``` ## 3. Configuration and Integration ### 3.1 Enabling Metrics By default the metrics exporter listens on the `/metrics` endpoint (the exporter must be enabled in the Drogon configuration file). ### 3.2 Log Levels The system uses six log levels throughout, mapping one-to-one to Drogon/Trantor's built-in levels (trace < debug < info < warn < error < fatal): | Level | Meaning | Typical scenarios | |------|------|----------| | `trace` | Finest-grained tracing | Function inputs/outputs, loop iterations, line-by-line execution traces — for deep debugging and pinpointing | | `debug` | Debug information | Variable values, branch decisions, internal state changes — for troubleshooting during development | | `info` | Routine information | Service start/stop, key flow milestones, user logins, task completion and other normal business events | | `warn` | Warnings | Recoverable anomalies, degraded handling, configuration falling back to defaults, resources nearing thresholds — the system still runs normally | | `error` | Errors | Feature failures, request errors, database connection failures — affect a single operation but the service remains available overall | | `fatal` | Fatal errors | Severe faults that crash the service or prevent it from running; requires immediate alerting and human intervention | **Conventions**: - Production enables `info` and above by default; `trace`/`debug` are enabled dynamically on demand. - Higher levels log less; `fatal` should be extremely rare. - `apps/server/config/config.prod.json` defaults to `INFO`, `config.dev.json` defaults to `DEBUG`, and `config.json`/`config.ci.json` default to `DEBUG`. **Dynamic adjustment**: edit the `app.log.log_level` field in the configuration file; allowed values are `TRACE`/`DEBUG`/`INFO`/`WARN`/`ERROR`/`FATAL` (case-insensitive): ```json "app": { "log": { "log_level": "INFO" } } ``` **Code conventions**: - The domain layer (`libs/common`, `libs/oauth2`, `libs/identity`) **must not** use Drogon's `LOG_*` macros directly; it must go through the `fulla::common::ports::ILogger` port so that unit tests can capture and assert with `FakeLogger`. - The adapter / infrastructure layer (`libs/drogon`, `libs/storage-*`, `apps/server`) may use `LOG_*` macros directly. - Six-level mapping: `LogLevel::Trace`→`LOG_TRACE`, `Debug`→`LOG_DEBUG`, `Info`→`LOG_INFO`, `Warn`→`LOG_WARN`, `Error`→`LOG_ERROR`, `Fatal`→`LOG_FATAL`. > For the test-output minimization strategy (ctest prints only failed cases and a summary by default), see [testing-guide.md](../contribute/testing-guide.md). --- # PostgreSQL Major-Version Upgrade Runbook (15 → 17) Source: https://fulla.dev/docs/operate/postgresql-major-upgrade # PostgreSQL Major-Version Upgrade Runbook (15 → 17) > Scope: existing deployments using this repository's `deploy/docker/docker-compose.prod.yml` > (or the Helm chart) with **persistent data volumes**. A PG major version **refuses to > mount** an older version's data directory — a plain `docker compose up` (with the image > already bumped to `postgres:17-alpine`) will leave the database container crash-looping; > data is not corrupted, but the service is unavailable. You must follow this runbook > before upgrading. > > Fresh deployments are unaffected (the 17 data directory is created directly by > initialization). > > fulla's own benchmark difference between PG 15 and 17 is within the noise band (2026-08-18 > A/B measurements). The motivation for the upgrade is alignment with the server-side libpq > 17.x client and benchmark-environment consistency — **there is no throughput requirement > behind it**. Deployments in no hurry can keep running 15 (pin the compose image tag back > to `postgres:15-alpine`; it is compatible with the application). ## Choosing a Route | Route | Downtime window | Fits | |---|---|---| | A. dump/restore (recommended) | Proportional to data size (GB-scale usually minutes) | Small-to-medium data volumes; simple to operate, cross-verifiable | | B. pg_upgrade (in place) | Usually <1 minute | Large data volumes (tens of GB+) that cannot tolerate a long downtime | Both routes require: **complete a full backup and keep the old data volume before upgrading**; clean up only after verification passes. ## Route A: dump/restore (recommended) Using the prod compose as the example (credentials come from `.env`: `POSTGRES_USER`/ `POSTGRES_PASSWORD`/`POSTGRES_DB`; substitute the actual compose project name, same below). ### 1. Stop the application, keep the old database ```bash cd deploy/docker docker compose -f docker-compose.prod.yml stop fulla-backend # 旧 PG(15)保持运行,用它导出 ``` ### 2. Full export ```bash docker compose -f docker-compose.prod.yml exec -T fulla-postgres \ pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-privileges \ > backup_pg15_$(date +%Y%m%d).sql # 校验导出文件非空且含表结构 grep -c "CREATE TABLE" backup_pg15_*.sql ``` ### 3. Switch the data volume ```bash docker compose -f docker-compose.prod.yml down # 重命名旧卷留底(project 名按实际替换;默认 project 名 = 目录名) docker volume rm _pgdata 2>/dev/null || true # 更稳妥:先改名留底而不是删除 # docker run --rm -v _pgdata:/src -v /var/backups:/dst alpine \ # cp -a /src /dst/pgdata_pg15_backup ``` > Simplified variant: you may also just `docker volume rm` the old volume — provided the > dump file from step 2 is safely stored on the host and verified. The retained copy is > insurance for "rollback if verification fails". ### 4. Start 17 and import ```bash docker compose -f docker-compose.prod.yml up -d fulla-postgres # 等健康检查通过后导入 docker compose -f docker-compose.prod.yml exec -T fulla-postgres \ psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ < backup_pg15_$(date +%Y%m%d).sql 2>&1 | grep -i "error\|fatal" || echo "IMPORT CLEAN" ``` > With the server's startup auto-migration configured (`FULLA_AUTO_MIGRATE=true`), the > first startup after importing the old schema automatically applies the newer migrations > (V025 partitioning etc., idempotent). ### 5. Verify, then restore traffic ```bash docker compose -f docker-compose.prod.yml up -d curl -fsS http:///health/ready ``` Run the core paths from [verification-checklist.md](../operate/verification-checklist) (login → authorization code → token → introspect → userinfo). After passing, keep the dump file and the old-volume copy for at least one regression cycle before cleaning up. ## Route B: pg_upgrade (in place, large data volumes) pg_upgrade needs both the old and new binaries and data directories present at once. The easiest approach under containers is the official `postgres:17` image's built-in `/usr/local/bin/pg_upgrade` (the alpine image does not include it; use a non-alpine tag or mount a tooling image yourself): ```bash # 1) 停应用与旧库 docker compose -f docker-compose.prod.yml stop fulla-backend fulla-postgres # 2) 复制旧数据卷到新卷(pg_upgrade --link 可省空间但有损坏回滚风险,不默认) docker run --rm -v _pgdata:/old -v _pgdata17:/new alpine \ cp -a /old /new # 3) 用 17 镜像对新副本执行 pg_upgrade(旧目录只读挂载) docker run --rm \ -v _pgdata:/var/lib/postgresql/15 \ -v _pgdata17:/var/lib/postgresql/17 \ -e POSTGRES_USER=$POSTGRES_USER postgres:17 \ bash -c 'install -d /var/lib/postgresql/17.tmp && gosu postgres pg_upgrade \ -b /usr/lib/postgresql/15/bin -B /usr/local/bin \ -d /var/lib/postgresql/15 -D /var/lib/postgresql/17 \ -U "$POSTGRES_USER"' # 注:postgres:17 非 alpine 镜像内含 15 的 binary(多版本包),命令按镜像实际 # 路径调整;上述为骨架,执行前先在测试环境演练一遍。 # 4) 把 compose 的 pgdata 卷指向升级后的卷(或交换卷名),up -d,验证同路线 A。 ``` After pg_upgrade succeeds, run `vacuumdb --all --analyze` as instructed (or wait for the automatic analyze). ## Helm Deployments The chart's built-in PostgreSQL image tag lives in `deploy/helm/fulla/values.yaml` (`postgresql.image`). The StatefulSet's PVC is likewise a major-version-bound data directory: 1. `kubectl scale deploy/fulla --replicas=0` (stop the application); 2. Export/upgrade the data in the PVC via either route above; 3. Update the values, run `helm upgrade`, and restore the replicas once ready. Deployments with external databases (self-managed PG / RDS-style) do not involve this repository's configuration; follow your cloud provider's or DBA's major-version upgrade process. ## Rollback - Route A: `down` → restore the retained old volume (or restore the `postgres:15-alpine` tag + the original volume) → `up -d`. The application image is compatible with PG 15 (a libpq 17 client connecting to a 15 server is fine). - Route B: the old volume was untouched (mounted read-only); simply switch back to it. - Rolling back after new data has been written to the new database loses that data — make the rollback decision before restoring verification traffic. --- # Deployment Verification Checklist Source: https://fulla.dev/docs/operate/verification-checklist # Deployment Verification Checklist This document provides complete deployment verification procedures to ensure the fulla full-stack system runs correctly on Windows Docker Desktop or in a Linux production environment. --- ## Quick Verification (5 Minutes) ### 1. Check All Container Statuses ```powershell # Windows docker compose -f deploy/docker/docker-compose.yml ps # Linux docker compose -f deploy/docker/docker-compose.prod.yml --env-file .env.docker ps ``` **Expected result**: all containers show a status of `Up` or `Up (healthy)` | Container | Status | Port Mapping | |--------|------|---------| | fulla-frontend | Up | 8080:80 | | fulla-admin | Up | 8081:80 | | fulla-backend | Up (healthy) | 5555:5555 | | fulla-postgres | Up (healthy) | 5433:5432 | | fulla-redis | Up | 6380:6379 | | fulla-prometheus | Up | 9090:9090 | ### 2. Health Check ```powershell # Backend health endpoint curl http://localhost:5555/health # Expected output {"status":"healthy","timestamp":"2026-08-26T10:30:00Z"} ``` ### 3. Database Connection Test ```powershell # Enter the postgres container docker exec -it fulla-postgres psql -U fulla_user -d fulla_db -c "\dt" # Expected output: list of OAuth2-related tables # oauth2_clients, oauth2_codes, oauth2_access_tokens, oauth2_refresh_tokens, # oauth2_scopes, users, roles, user_roles, organizations, audit_logs, etc. (21 tables in total after V026) ``` ### 4. Frontend Access Test Open the following in a browser: - **User frontend**: http://localhost:8080 or https://your-domain.com - **Admin console**: http://localhost:8081 or https://your-domain.com/admin **Expected result**: pages load normally with no 404 or 502 errors --- ## Full Verification (30 Minutes) ## Phase 1: Infrastructure Verification ### 1.1 PostgreSQL Verification ```powershell # Connection test docker exec fulla-postgres pg_isready -U fulla_user # Expected output: /var/run/postgresql:5432 - accepting connections # Table structure check docker exec fulla-postgres psql -U fulla_user -d fulla_db -c " SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name; " # Expected table list (V002-V026 actual schema, all with the oauth2_ prefix): # - oauth2_access_tokens, oauth2_refresh_tokens, oauth2_codes # - oauth2_clients, oauth2_scopes, oauth2_client_scopes # - oauth2_user_consents, oauth2_subject_mappings, oauth2_device_codes # - users, roles, permissions, user_roles, role_permissions # - organizations, audit_logs, webauthn_credentials, etc. # Database version check docker exec fulla-postgres psql -U fulla_user -d fulla_db -c "SELECT version();" # Expected: PostgreSQL 17.x (deploy compose defaults to postgres:17-alpine; # existing deployments explicitly pinned to 15 should show 15.x here, see docs/operate/postgresql-major-upgrade.md) ``` ### 1.2 Redis Verification ```powershell # Enter the redis container docker exec -it fulla-redis redis-cli -a redis_secret_pass ping # Expected output: PONG # Test read/write docker exec fulla-redis redis-cli -a redis_secret_pass SET test_key "hello" docker exec fulla-redis redis-cli -a redis_secret_pass GET test_key # Expected output: "hello" # Check memory usage docker exec fulla-redis redis-cli -a redis_secret_pass INFO memory # Expected: used_memory_human shows a reasonable amount of memory usage ``` ### 1.3 Network Connectivity Verification ```powershell # Test database connectivity from the backend container docker exec fulla-backend ping -c 3 fulla-postgres # Expected: 3 packets transmitted, 3 received, 0% packet loss # Test Redis connectivity from the backend container docker exec fulla-backend ping -c 3 fulla-redis # Expected: 3 packets transmitted, 3 received, 0% packet loss # Check DNS resolution docker exec fulla-backend nslookup fulla-postgres # Expected: returns the container IP address of fulla-postgres (e.g., 172.x.x.x) ``` --- ## Phase 2: Database Initialization Verification ### 2.1 Check Seed Data ```powershell # Check the admin user (roles are linked through user_roles; the users table itself has no role column) docker exec fulla-postgres psql -U fulla_user -d fulla_db -c " SELECT u.username, u.email, r.name AS role, u.created_at FROM users u LEFT JOIN user_roles ur ON ur.user_id = u.id LEFT JOIN roles r ON r.id = ur.role_id WHERE u.username = 'admin'; " # Expected output: # username | email | role | created_at # ----------+-------------------+-------+---------------------------- # admin | admin@example.com | admin | 2026-xx-xx xx:xx:xx # Check the default clients (the table name carries the oauth2_ prefix; the name column is name) docker exec fulla-postgres psql -U fulla_user -d fulla_db -c " SELECT client_id, name, client_type, token_endpoint_auth_method FROM oauth2_clients WHERE client_id IN ('fulla-admin-console', 'fulla-portal'); " # Expected output: both fulla-admin-console and fulla-portal are PUBLIC (token_endpoint_auth_method = none) # Check the default scopes (the scope name column is name) docker exec fulla-postgres psql -U fulla_user -d fulla_db -c " SELECT name, description FROM oauth2_scopes LIMIT 5; " # Expected output: standard scopes such as openid, profile, email, admin ``` ### 2.2 Verify Database Migrations ```powershell # Check the migrations table (if present) docker exec fulla-postgres psql -U fulla_user -d fulla_db -c "\d schema_migrations" # Or check table structure integrity docker exec fulla-postgres psql -U fulla_user -d fulla_db -c " SELECT COUNT(*) AS table_count FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'; " # Expected: table_count >= 15 (measured 21 public tables after V026) ``` --- ## Phase 3: Backend API Verification ### 3.1 Obtain an Admin Token `fulla-admin-console` is a **PUBLIC client** (`token_endpoint_auth_method=none`, no client_secret, password grant not supported). Tokens must go through the two-step **authorization code + PKCE** flow (F-011: PKCE is mandatory for PUBLIC clients). The following is equivalent to the setup step in `scripts/backend/test-admin-endpoints.sh`: ```bash # 1) Log in to obtain an authorization code (form-encoded; code_challenge = BASE64URL(SHA256(code_verifier))) CODE_VERIFIER=$(head -c 32 /dev/urandom | basenc --base64url | tr -d '=' | tr -d '+/' | head -c 43) CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | basenc --base64url | tr -d '=') LOGIN_RESP=$(curl -s -X POST http://localhost:5555/oauth2/login \ -d "username=admin&password=admin" \ -d "client_id=fulla-admin-console&redirect_uri=http://localhost:5174/admin/callback" \ -d "scope=openid+profile+admin&state=verify-state" \ -d "code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&json=true") CODE=$(echo "$LOGIN_RESP" | jq -r '.code') # 2) Exchange the authorization code for tokens (form-encoded; a PUBLIC client sends only client_id and must not include any secret) curl -s -X POST http://localhost:5555/oauth2/token \ -d "grant_type=authorization_code&code=$CODE" \ -d "redirect_uri=http://localhost:5174/admin/callback" \ -d "client_id=fulla-admin-console&code_verifier=$CODE_VERIFIER" # Expected response (save the access_token): { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "tGzv3JH7xN1yQ9X2...", "scope": "openid profile admin" } # Set an environment variable (used by the tests below) export TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." ``` > On Windows, use the repository-provided `scripts/backend/test-admin-endpoints.ps1` to run the same login + token flow. ### 3.2 Verify Token Introspection ```bash # Introspect the token (RFC 7662, form-encoded) curl -s -X POST http://localhost:5555/oauth2/introspect \ -d "token=$TOKEN" \ -d "token_type_hint=access_token" \ -d "client_id=fulla-admin-console" # Expected response: { "active": true, "client_id": "fulla-admin-console", "username": "admin", "scope": "openid profile admin", "exp": 1719123456, "iat": 1719119856, "sub": "admin", "iss": "http://localhost:5555" } # Test an invalid token curl -s -X POST http://localhost:5555/oauth2/introspect \ -d "token=invalid_token" \ -d "token_type_hint=access_token" \ -d "client_id=fulla-admin-console" # Expected response: {"active": false} ``` ### 3.3 Refresh a Token ```bash # Use the refresh_token to obtain a new access_token (form-encoded; # a PUBLIC client sends only client_id — including a client_secret would actually be rejected by F-017) curl -s -X POST http://localhost:5555/oauth2/token \ -d "grant_type=refresh_token" \ -d "refresh_token=tGzv3JH7xN1yQ9X2..." \ -d "client_id=fulla-admin-console" # Expected response: returns a new access_token and refresh_token { "access_token": "new access token...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "new refresh token...", "scope": "openid profile admin" } ``` ### 3.4 Revoke a Token ```bash # Revoke the token (RFC 7009, form-encoded; the client authentication method must match # the registered token_endpoint_auth_method — a PUBLIC client uses only client_id) curl -s -X POST http://localhost:5555/oauth2/revoke \ -d "token=$TOKEN" \ -d "token_type_hint=access_token" \ -d "client_id=fulla-admin-console" # Expected response: HTTP 200 OK (empty response body) # Verify the token has been revoked curl -s -X POST http://localhost:5555/oauth2/introspect \ -d "token=$TOKEN" \ -d "token_type_hint=access_token" \ -d "client_id=fulla-admin-console" # Expected response: {"active": false} ``` --- ## Phase 4: Admin Console API Verification ### 4.1 User Management API ```powershell # Get the user list curl -X GET http://localhost:5555/api/admin/users \ -H "Authorization: Bearer $TOKEN" # Expected response: user list JSON { "users": [ { "user_id": 1, "username": "admin", "email": "admin@example.com", "role": "admin", "created_at": "2026-08-26T10:00:00Z", "updated_at": "2026-08-26T10:00:00Z" } ], "total": 1, "page": 1, "per_page": 20 } # Create a new user curl -X POST http://localhost:5555/api/admin/users \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "username": "testuser", "email": "test@example.com", "password": "TestPassword123!", "role": "user" }' # Expected response: HTTP 201 Created { "user_id": 2, "username": "testuser", "email": "test@example.com", "role": "user", "created_at": "2026-08-26T10:30:00Z" } # Get a single user's details curl -X GET http://localhost:5555/api/admin/users/2 \ -H "Authorization: Bearer $TOKEN" # Expected response: shows the details of testuser ``` ### 4.2 Client Management API ```powershell # Get the client list curl -X GET http://localhost:5555/api/admin/clients \ -H "Authorization: Bearer $TOKEN" # Expected response: client list (client secrets are never echoed back; only hashes are stored in the database) { "clients": [ { "client_id": "fulla-admin-console", "name": "Admin Console", "client_type": "PUBLIC", "token_endpoint_auth_method": "none", "redirect_uris": ["http://localhost:5174/admin/callback"], "allowed_grant_types": ["authorization_code", "refresh_token"], "scopes": ["openid", "profile", "admin"] } ], "total": 1 } # Create a new client curl -X POST http://localhost:5555/api/admin/clients \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "client_id": "test-client", "name": "Test Client", "client_type": "CONFIDENTIAL", "client_secret": "test-secret", "redirect_uris": ["http://localhost:8080/callback"], "allowed_grant_types": ["authorization_code", "refresh_token"], "scopes": ["openid", "profile", "email"] }' # Expected response: HTTP 201 Created (the response contains the newly created client's metadata; the secret is not echoed back) ``` ### 4.3 Scope Management API ```powershell # Get all scopes curl -X GET http://localhost:5555/api/admin/scopes \ -H "Authorization: Bearer $TOKEN" # Expected response: scope list (the scope name field is name, consistent with the oauth2_scopes table) { "scopes": [ {"name": "openid", "description": "OpenID Connect"}, {"name": "profile", "description": "User profile"}, {"name": "email", "description": "User email"}, {"name": "admin", "description": "Administrative access"} ] } # Create a new scope curl -X POST http://localhost:5555/api/admin/scopes \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "read", "description": "Read access to user resources" }' # Expected response: HTTP 201 Created ``` --- ## Phase 5: Frontend Feature Verification ### 5.1 User Frontend Verification | Test Item | Steps | Expected Result | |--------|---------|---------| | Visit the home page | Open http://localhost:8080 | The login page is displayed | | User registration | Fill in the registration form (username, email, password) | Registration succeeds and redirects to the login page | | User login | Log in with the account just registered | Login succeeds and redirects to the profile page | | View profile | Click the "Profile" menu | User information is displayed (username, email) | | Change password | Enter the old and new passwords | The password change succeeds and the user must log in again | | Log out | Click the "Log out" button | Logout succeeds and redirects to the login page | ### 5.2 Admin Console Verification | Test Item | Steps | Expected Result | |--------|---------|---------| | Open the admin console | Open http://localhost:8081/admin | The admin console login page is displayed | | Admin login | Log in with admin/admin | Login succeeds and the dashboard is displayed | | App management | Click the "Apps" menu | The client list is displayed (contains at least fulla-admin-console) | | Create an app | Click "New App" and fill in the form | The app is created successfully and appears in the list | | User management | Click the "Users" menu | The user list is displayed (contains at least admin and the newly registered user) | | Token management | Click the "Token" menu | The list of active tokens is displayed | ### 5.3 OAuth2 Authorization Code Flow Verification ```bash # Step 1: Build the authorization URL (visit it in a browser) # Note: the redirect_uri must exactly match the client's registered value # (fulla-portal's seed registration uses http://127.0.0.1:8080/callback — using localhost will be rejected) # http://localhost:5555/oauth2/authorize? # response_type=code& # client_id=fulla-portal& # redirect_uri=http://127.0.0.1:8080/callback& # scope=openid profile email& # state=random_state_value # Expected: redirect to the login page # Step 2: User login # Log in with a test account (e.g., testuser) # Expected: the authorization consent page is displayed # Step 3: User consent # Click the "Authorize" button # Expected: redirect to the redirect_uri carrying the authorization code # http://127.0.0.1:8080/callback?code=xxx&state=random_state_value # Step 4: Exchange the token (fulla-portal is a PUBLIC client → must include the PKCE code_verifier # and must not include a client_secret) curl -s -X POST http://localhost:5555/oauth2/token \ -d "grant_type=authorization_code" \ -d "code=" \ -d "redirect_uri=http://127.0.0.1:8080/callback" \ -d "client_id=fulla-portal" \ -d "code_verifier=" # Expected response: returns an access_token and refresh_token { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "xxx", "scope": "openid profile email" } ``` --- ## Phase 5 Supplement: Email Service Verification The email service has two modes, selected by the backend's `getEmailService()` based on the `FULLA_SMTP_*` environment variables. ### 5.4 Confirm the Email Service Mode ```powershell # Check the email service mode in the backend startup logs docker logs fulla-backend 2>&1 | grep -i "Email service" ``` **Expected output (one of the following)**: - Console mode (SMTP not configured): `Email service: Console (set FULLA_SMTP_* env vars to enable SMTP)` - SMTP mode (configured): `Email service: SMTP (smtp.163.com:465)` ### 5.5 Email Address Verification Message **Steps**: log in to the user frontend → Profile page → click "Send Email Verification" | Mode | Verification Method | |------|---------| | Console mode | The message content is written to the backend logs; copy the verification link from there | | SMTP mode | A real message should arrive in the inbox | **To view the verification link in Console mode**: ```powershell docker logs fulla-backend --tail 50 2>&1 | grep -A 5 -iE "verify|email" # Expected: a link containing "verify-email?token=xxx" ``` **To verify message delivery in SMTP mode**: ```powershell # After triggering the send, check the backend for SMTP errors docker logs fulla-backend --tail 50 2>&1 | grep -iE "smtp|email|curl" # Expected: no ERROR-level logs; a "Verify Your Email" message arrives in the inbox ``` ### 5.6 Password Reset Message **Steps**: frontend "Forgot Password" page → enter the email address → submit - **Expected response** (anti-enumeration): regardless of whether the email address exists, the same message is returned: `If the email exists, a reset link has been sent` - In Console mode, the reset link is likewise written to the backend logs ### 5.7 Enable Real SMTP (Optional; see the deployment guide for details) For real message delivery, set `FULLA_SMTP_HOST` / `FULLA_SMTP_USER` / `FULLA_SMTP_PASSWORD` in `.env.docker` and restart the backend: ```powershell docker compose -f deploy/docker/docker-compose.yml --env-file .env.docker up -d fulla-backend docker logs fulla-backend 2>&1 | grep -i "Email service" # Expected: Email service: SMTP (...) ``` > **Note**: email verification links use `FULLA_FRONTEND_URL`. In a local deployment this is `http://localhost:8080`; clicking the link from another machine will not work. --- ## Phase 6: Security Verification ### 6.1 Error Response Verification ```bash # Test an invalid client ID (token endpoint, form-encoded) curl -s -X POST http://localhost:5555/oauth2/token \ -d "grant_type=authorization_code&code=x" \ -d "client_id=invalid-client&code_verifier=x" # Expected response: HTTP 401 Unauthorized { "error": "invalid_client", "error_description": "Client authentication failed" } # Test a wrong password (login endpoint — note: failed attempts trigger F-018 rate limiting, so do not exceed the threshold with repeated attempts) curl -s -X POST http://localhost:5555/oauth2/login \ -d "username=admin&password=wrong-password" \ -d "client_id=fulla-admin-console&redirect_uri=http://localhost:5174/admin/callback" \ -d "scope=openid&state=t&code_challenge=x&code_challenge_method=S256" # Expected response: HTTP 401 Unauthorized (error codes go through the ErrorCatalog, ensuring a unified anti-enumeration posture) { "error": "invalid_grant", "error_description": "Invalid username or password" } # Test a missing required parameter curl -s -X POST http://localhost:5555/oauth2/token \ -d "grant_type=authorization_code" # Expected response: HTTP 400 Bad Request { "error": "invalid_request", "error_description": "Missing required parameter: client_id" } ``` ### 6.2 Token Expiration Verification ```powershell # Wait for the token to expire (3600 seconds), or change the backend configuration to a shorter expiration time for testing # Or use an already-revoked token curl -X GET http://localhost:5555/api/admin/users \ -H "Authorization: Bearer revoked_token" # Expected response: HTTP 401 Unauthorized { "error": "invalid_token", "error_description": "The access token expired or has been revoked" } ``` ### 6.3 Scope Authorization Verification ```powershell # Request a resource beyond the granted scope (if scope-based access control is implemented) curl -X GET http://localhost:5555/api/admin/users \ -H "Authorization: Bearer $token_with_limited_scope" # Expected response: HTTP 403 Forbidden { "error": "insufficient_scope", "error_description": "The request requires higher privileges than provided by the access token" } ``` --- ## Phase 7: Performance and Monitoring Verification ### 7.1 Prometheus Metrics Verification ```powershell # Access the Prometheus UI # Open in a browser: http://localhost:9090 # Example metric queries (the authoritative list is in docs/operate/observability.md): # - oauth2_requests_total: total number of requests (by endpoint/status dimensions) # - oauth2_latency_seconds: latency histogram for key steps # - oauth2_active_tokens: current number of active tokens # - oauth2_login_failures_total: number of login failures # Expected: metrics are collected normally and data is present ``` ### 7.2 Log Verification ```powershell # View backend logs docker logs fulla-backend --tail 50 # Expected: no ERROR-level logs, normal INFO/DEBUG logs # View nginx logs (Linux production environment) docker logs oauth2-nginx --tail 50 # Expected: normal access logs, no 5xx errors # Follow logs in real time docker compose -f deploy/docker/docker-compose.yml logs -f fulla-backend ``` ### 7.3 Database Performance Verification ```powershell # Check the number of database connections docker exec fulla-postgres psql -U fulla_user -d fulla_db -c " SELECT count(*) AS connections FROM pg_stat_activity WHERE datname = 'fulla_db'; " # Expected: connections is a reasonable value (usually < 20) # Check slow queries (if the pg_stat_statements extension is available) docker exec fulla-postgres psql -U fulla_user -d fulla_db -c " SELECT query, calls, total_time, mean_time FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 5; " # Expected: no significant slow queries (mean_time < 100ms) ``` --- ## Troubleshooting Checkpoints ### Problem 1: Containers fail to start **Check steps**: ```powershell # View container status docker compose -f deploy/docker/docker-compose.yml ps # View the failed container's logs docker logs fulla-backend # Check resource usage docker stats # Validate the configuration file docker compose -f deploy/docker/docker-compose.yml config ``` ### Problem 2: Database connection failures **Check steps**: ```powershell # Verify the postgres container health status docker exec fulla-postgres pg_isready -U fulla_user # Check network connectivity docker exec fulla-backend ping fulla-postgres # Verify environment variables docker exec fulla-backend env | grep FULLA_DB # View database logs docker logs fulla-postgres ``` ### Problem 3: Frontend cannot reach the backend API **Check steps**: ```powershell # Test the backend connection from the frontend container docker exec fulla-frontend curl -s http://fulla-backend:5555/health # Check the nginx configuration (production environment) docker exec oauth2-nginx nginx -t # View the backend CORS configuration docker logs fulla-backend | grep -i cors ``` ### Problem 4: Token verification failures **Check steps**: ```powershell # Verify that the JWT keys exist docker exec fulla-backend ls -la /app/keys/ # Check the token signature # Copy the access_token to https://jwt.io to decode and verify it # View authentication errors in the backend logs docker logs fulla-backend | grep -i "auth\|token" ``` --- ## Automated Verification Script ### Full Verification Script (PowerShell) The script ships in the repository: [`scripts/backend/verify-deployment.ps1`](https://github.com/voidvec/fulla/blob/master/scripts/backend/verify-deployment.ps1). Run it from the repo root — it executes the eight checks below (container status, backend health, DB connection, table completeness, seed admin, Redis, OIDC discovery, frontend access) and exits non-zero on any failure. **Usage**: ```powershell # Basic verification .\scripts\backend\verify-deployment.ps1 # Custom endpoints .\scripts\backend\verify-deployment.ps1 -BackendUrl "https://your-domain.com" -FrontendUrl "https://your-domain.com" ``` --- ## Verification Report Template After completing verification, fill in the following report template: ```markdown ## fulla Deployment Verification Report **Verification date**: YYYY-MM-DD **Verification environment**: Windows Docker Desktop / Linux production server **Verified by**: [Name] ### Verification Results Summary | Phase | Status | Notes | |------|------|------| | Infrastructure verification | Passed | All containers running normally | | Database initialization verification | Passed | 21 tables (V026), admin account created | | Backend API verification | Passed | Token endpoint, introspection, and revocation working normally | | Admin console API verification | Passed | User, client, and scope management working normally | | Frontend feature verification | Passed | User login, registration, and profile features working normally | | Security verification | Passed | Error handling and token verification working normally | | Performance and monitoring verification | Partially passed | Prometheus normal; slow queries need optimization | ### Issues Found 1. **Issue description**: [specific issue] - **Impact scope**: [affected functionality] - **Resolution**: [how it was resolved] - **Status**: [Open | Resolved] ### Optimization Suggestions 1. [Suggestion 1] 2. [Suggestion 2] ### Next Actions - [ ] Deploy to production - [ ] Configure Let's Encrypt certificates - [ ] Set up monitoring alerts - [ ] Run performance load tests ### Sign-off Verified by: __________ Date: __________ Reviewed by: __________ Date: __________ ``` --- ## Summary This verification checklist covers all core functionality of the fulla system: - **Infrastructure**: Docker containers, networking, storage volumes - **Data layer**: PostgreSQL database, Redis cache - **Business layer**: OAuth2 core flows, admin console APIs - **Presentation layer**: Vue.js user frontend, admin console - **Security**: authentication, authorization, token management - **Observability**: logs, metrics, health checks **Pass criteria**: - All containers have a status of `Up` - Backend health check passes - Database table structure is complete (21 public tables in total after V026) - The admin account is usable - OAuth2 core flows (authorization, tokens, introspection, revocation) work correctly - Frontend pages are accessible and basic operations complete successfully Once this checklist has been fully verified, the system is ready for production use. --- # Playwright E2E Automated Testing Integration Guide Source: https://fulla.dev/docs/contribute/admin-e2e-testing-guide # Playwright E2E Automated Testing Integration Guide > Summarized from the OAuth2 Admin project's practices, as a reference for adopting Playwright E2E testing in other frontend projects. --- ## Table of Contents 1. [Core Principles](#1-core-principles) 2. [Project Setup](#2-project-setup) 3. [Mock API Layer Design](#3-mock-api-layer-design) 4. [Test File Organization](#4-test-file-organization) 5. [Test Writing Patterns](#5-test-writing-patterns) 6. [Advanced Techniques](#6-advanced-techniques) 7. [CI/CD Integration](#7-cicd-integration) 8. [FAQ](#8-faq) --- ## 1. Core Principles ### 1.1 Why request interception instead of a real backend | Approach | Pros | Cons | |------|------|------| | **Request interception mocks** | No backend dependency, fast execution (<5s), stable and non-flaky, full control over responses | Does not verify frontend-backend integration | | **Real backend** | Verifies end-to-end integration | Requires database/cache/services, slow (minutes), many environment dependencies, hard data isolation | | **MSW (Mock Service Worker)** | Intercepts at the browser layer, closer to real behavior | Complex configuration, requires Service Worker support | This project uses **Playwright's native `page.route()` request interception**, for the following reasons: - Zero extra dependencies (built into Playwright) - Clean and intuitive API - Interception happens before the network layer, delivering the best performance - Supports precise URL pattern and HTTP method matching - Supports overriding the global mocks within a single test ### 1.2 How request interception works ``` ┌──────────────┐ HTTP request ┌──────────────────┐ │ Frontend │ ───────────────────→ │ page.route() │ │ app code │ │ URL pattern │ │ (browser) │ ←─────────────────── │ route.fulfill() │ └──────────────┘ Mock response └──────────────────┘ ↑ bypasses the network layer entirely (no real HTTP connection) ``` Playwright's `page.route()` intercepts requests at the browser's network layer, so **requests never leave the browser process**. This means: - No backend service needs to be running - No network connection required - Responses are immediate, with zero latency - Tests are fully deterministic, with no network flakiness ### 1.3 Three-layer architecture ``` tests/e2e/ ├── helpers/ │ └── mock-api.ts ← Layer 1: mock data + interceptors ├── auth.spec.ts ← Layer 2: test cases ├── applications.spec.ts └── ... playwright.config.ts ← Layer 3: Playwright configuration ``` | Layer | Responsibility | Change frequency | |------|------|---------| | **Mock layer** | Defines mock data constants + the `setupAuthenticatedMocks()` global interception function | When the backend API changes | | **Test layer** | Contains the actual test cases and calls functions provided by the mock layer | When new features/pages are added | | **Config layer** | Playwright runtime options, browsers, webServer | At project setup | --- ## 2. Project Setup ### 2.1 Install dependencies ```bash npm install -D @playwright/test npx playwright install chromium ``` Chromium alone is enough; there is no need to install WebKit/Firefox. The goal of E2E tests is to verify functional logic, not cross-browser compatibility. ### 2.2 Playwright configuration Create `playwright.config.ts`: ```typescript import { defineConfig, devices } from '@playwright/test' export default defineConfig({ testDir: './tests/e2e', // test file directory fullyParallel: true, // run fully in parallel (enable when tests have no interdependencies) forbidOnly: !!process.env.CI, // forbid test.only on CI (prevents accidental commits) retries: process.env.CI ? 2 : 0, // retry twice on CI (mitigates flakiness) workers: process.env.CI ? 1 : undefined, // single worker on CI (avoids resource contention) reporter: 'html', // HTML test report use: { baseURL: 'http://localhost:5174', // application base URL (used with page.goto()) trace: 'on-first-retry', // record a trace on failed retries (for debugging) }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, ], webServer: { command: 'npm run dev', // start the dev server automatically url: 'http://localhost:5174/', // wait until this URL is reachable reuseExistingServer: !process.env.CI, // reuse an already-running server locally timeout: 30000, // 30s startup timeout }, }) ``` **Key configuration notes:** | Option | Purpose | Recommended value | |--------|------|--------| | `baseURL` | Prefix automatically prepended when calling `page.goto('/path')` | Dev server address | | `webServer` | Starts/reuses the dev server automatically | Distinct settings for dev mode vs CI mode | | `trace` | Generates a trace file on failure, inspectable with `npx playwright show-trace` | `on-first-retry` | | `fullyParallel` | Runs multiple test files in parallel | `true` (safe with mocking) | | `retries` | Number of retries on failure | CI: 2, local: 0 | ### 2.3 package.json scripts ```json { "scripts": { "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:ui": "playwright test --ui" } } ``` ### 2.4 Directory structure ``` your-project/ ├── playwright.config.ts ├── package.json ├── src/ ← application source └── tests/ └── e2e/ ├── helpers/ │ └── mock-api.ts ← mock data + interception functions ├── auth.spec.ts ← authentication-related tests ├── page-a.spec.ts ← page A tests └── page-b.spec.ts ← page B tests ``` --- ## 3. Mock API Layer Design The mock API layer is the **core** of the entire testing system. Design this layer well and writing test cases becomes straightforward. ### 3.1 File structure `tests/e2e/helpers/mock-api.ts` consists of three parts: ``` Part 1: Mock data constants → fake data for all APIs Part 2: setupAuthenticatedMocks() → registers global route interception Part 3: Helper functions → common operations such as loginAsAdmin() ``` ### 3.2 Mock data constants **Principle: keep the data as realistic as possible, with fields matching the backend API responses.** ```typescript // ✅ Good design: field names and types match the real API export const MOCK_USERS = [ { id: '550e8400-e29b-41d4-a716-446655440000', username: 'admin', email: 'admin@example.com', email_verified: true, // boolean, not a string mfa_enabled: true, }, { id: '660e8400-e29b-41d4-a716-446655440001', username: 'testuser', email: 'test@example.com', email_verified: false, mfa_enabled: false, }, ] // ❌ Bad design: arbitrary field names, unrealistic data export const users = [ { uid: 1, name: 'a', mail: 'a@b' }, // field names don't match the real API ] ``` **Why multiple data sets?** Prepare at least two states in the mock data to cover different UI presentations: - `email_verified: true` + `false` → test the "verified" and "pending verification" badges - `mfa_enabled: true` + `false` → test the "enabled" and "disabled" badges ### 3.3 Route interception: setupAuthenticatedMocks() This is the most critical function; it intercepts every API request the frontend makes. ```typescript import { Page } from '@playwright/test' export async function setupAuthenticatedMocks(page: Page) { // Interception rule: ** is a wildcard that matches any origin await page.route('**/api/users', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ users: MOCK_USERS }), }) }) } ``` **URL matching patterns:** | Pattern | What it matches | Example | |------|---------|------| | `**/api/users` | Any origin + exact path match | `http://localhost:5174/api/users` ✅ | | `**/api/admin/logs**` | Path prefix match (including query parameters) | `/api/admin/logs?page=2` ✅ | | `**/api/admin/clients/*` | Path + single-segment wildcard | `/api/admin/clients/fulla-portal` ✅ | | `**/api/admin/clients/*/reset-secret` | Multi-segment path combination | `/api/admin/clients/fulla-portal/reset-secret` ✅ | **Same URL, different HTTP methods:** ```typescript await page.route('**/api/admin/clients', async (route) => { if (route.request().method() === 'GET') { await route.fulfill({ status: 200, body: JSON.stringify({ clients: MOCK_CLIENTS }) }) } else if (route.request().method() === 'POST') { await route.fulfill({ status: 201, body: JSON.stringify({ client_id: 'new-123' }) }) } else { // Unexpected method: pass to the next handler or the real network await route.continue() } }) ``` **Sub-resource route priority:** Playwright matches routes in registration order, so **more specific routes should be registered first**: ```typescript // ✅ Correct: register more specific routes first await page.route('**/api/admin/clients/*/reset-secret', ...) // matched first await page.route('**/api/admin/clients/*', ...) // matched later (fallback) // In practice, Playwright's wildcard matching has an implicit priority, // but explicitly checking inside the handler is safer: await page.route('**/api/admin/clients/*', async (route) => { const url = route.request().url() if (url.includes('/scopes') || url.includes('/reset-secret')) { await route.continue() // skip; let a more specific handler deal with it return } // ... handle DELETE / GET / PUT }) ``` ### 3.4 Helper function: loginAsAdmin() ```typescript export async function loginAsAdmin(page: Page) { await page.goto('/login') await page.fill('input[type="text"]', 'admin') await page.fill('input[type="password"]', 'admin') await page.click('button[type="submit"]') await page.waitForURL('**/dashboard') // wait for the successful-login redirect } ``` **Design notes:** - Logs in through UI interactions (simulating a real user) - Relies on `setupAuthenticatedMocks()` having intercepted the authentication APIs - `waitForURL` guarantees the login has completed, so subsequent tests run in an authenticated state ### 3.5 Mock template for adapting to other projects ```typescript // === helpers/mock-api.ts template === import { Page } from '@playwright/test' // ---- Part 1: mock data ---- export const CURRENT_USER = { id: '1', name: 'Test User', email: 'test@example.com', role: 'admin', } export const MOCK_ITEMS = [ { id: '1', title: 'Item A', status: 'active' }, { id: '2', title: 'Item B', status: 'inactive' }, ] // ---- Part 2: global route interception ---- export async function setupAuthenticatedMocks(page: Page) { // Authentication await page.route('**/auth/login', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ token: 'mock-jwt-token', user: CURRENT_USER }), }) }) await page.route('**/auth/me', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(CURRENT_USER), }) }) // Business data await page.route('**/api/items', async (route) => { if (route.request().method() === 'GET') { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: MOCK_ITEMS }), }) } else if (route.request().method() === 'POST') { const body = JSON.parse(route.request().postData() || '{}') await route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'new-' + Date.now(), ...body }), }) } else { await route.continue() } }) // Single-item operations (GET / PUT / DELETE) await page.route('**/api/items/*', async (route) => { if (route.request().method() === 'DELETE') { await route.fulfill({ status: 204 }) } else { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ITEMS[0]), }) } }) } // ---- Part 3: helper functions ---- export async function loginAs(page: Page, username = 'admin', password = 'admin') { await page.goto('/login') await page.fill('[name="username"]', username) await page.fill('[name="password"]', password) await page.click('button[type="submit"]') await page.waitForURL('**/dashboard') } ``` --- ## 4. Test File Organization ### 4.1 Naming conventions ``` tests/e2e/ ├── helpers/ │ └── mock-api.ts ← fixed name, shared by all tests ├── auth.spec.ts ← authentication/login related ├── {page-name}.spec.ts ← one file per page └── navigation.spec.ts ← global navigation/layout ``` **One page = one spec file**, organized by functional domain rather than by operation type. ### 4.2 test.describe grouping ```typescript test.describe('Page/Feature name', () => { // beforeEach: shared setup test.beforeEach(async ({ page }) => { await setupAuthenticatedMocks(page) await loginAsAdmin(page) // navigate to the target page await page.click('nav a:has-text("Target Page")') await page.waitForURL('**/target-page') }) // Test cases ordered: rendering → data → interaction → edge cases test('displays page title', ...) test('shows data from API', ...) test('button click triggers action', ...) test('shows error on failure', ...) }) ``` ### 4.3 Test case naming Use **declarative sentences** that describe expected behavior, not operation steps: ```typescript // ✅ Good: describes the expected outcome test('displays users list with correct columns', ...) test('shows error on login failure', ...) test('delete button removes item from list', ...) // ❌ Bad: describes operation steps test('clicks button and checks result', ...) test('test 1', ...) ``` --- ## 5. Test Writing Patterns ### 5.1 Standard test flow (the beforeEach pattern) **The most common pattern** — 90% of tests follow this flow: ```typescript test.describe('User Management', () => { test.beforeEach(async ({ page }) => { // Step 1: register global mocks await setupAuthenticatedMocks(page) // Step 2: simulate login await loginAsAdmin(page) // Step 3: navigate to the target page await page.click('nav a:has-text("Users")') await page.waitForURL('**/users') }) test('displays user table', async ({ page }) => { await expect(page.locator('h2')).toContainText('Users') await expect(page.locator('th:has-text("Username")')).toBeVisible() }) }) ``` **Flow diagram:** ``` beforeEach: setupAuthenticatedMocks(page) ↓ loginAsAdmin(page) ↓ navigate to the target page + waitForURL ↓ ═══════════════════════════ ↓ test case 1 runs ↓ ↓ test case 2 runs ↓ ↓ ... ↓ ═══════════════════════════ ``` ### 5.2 Verifying page rendering Verify that the page displays data correctly: ```typescript test('displays users list with correct columns', async ({ page }) => { // Verify the title await expect(page.locator('h2')).toContainText('Users') // Verify the table headers await expect(page.locator('th:has-text("Username")')).toBeVisible() await expect(page.locator('th:has-text("Email")')).toBeVisible() // Verify data rows (from the mock data) const tableBody = page.locator('tbody') await expect(tableBody.getByRole('cell', { name: 'admin', exact: true })).toBeVisible() }) ``` ### 5.3 Form interaction tests Verify form filling, submission, and responses: ```typescript test('creates a new application and shows secret', async ({ page }) => { // 1. Open the dialog await page.click('button:has-text("Create Application")') // 2. Verify the dialog appears await expect(page.locator('h3:has-text("Create Application")')).toBeVisible() // 3. Fill in the form await page.fill('input[placeholder="My App"]', 'Test Application') await page.selectOption('select', 'CONFIDENTIAL') // 4. Submit await page.locator('.fixed button[type="submit"]').click() // 5. Verify the result await expect(page.locator('h3:has-text("Client Secret")')).toBeVisible() await expect(page.locator('.font-mono.select-all')).toContainText('generated-secret-abc123xyz') }) ``` ### 5.4 Modal/dialog tests ```typescript test('opens and closes role assignment modal', async ({ page }) => { // Open await page.click('button:has-text("Assign Roles")') await expect(page.locator('h3:has-text("Assign Roles")')).toBeVisible() // Close await page.click('button:has-text("Cancel")') await expect(page.locator('h3:has-text("Assign Roles")')).not.toBeVisible() }) // Handling native confirm dialogs test('delete with confirmation', async ({ page }) => { page.on('dialog', (dialog) => dialog.accept()) // auto-accept the confirm await page.click('button:has-text("Delete")') await expect(page.locator('h2')).toContainText('Applications') }) ``` ### 5.5 Pagination tests ```typescript test('pagination sends correct page parameter', async ({ page }) => { // Build enough data to enable the "Next" button const manyItems = Array.from({ length: 50 }, (_, i) => ({ id: i + 1, title: `Item ${i}`, status: 'active', })) let requestedPage = 1 await page.route('**/api/items**', async (route) => { const url = new URL(route.request().url()) requestedPage = parseInt(url.searchParams.get('page') || '1') await route.fulfill({ status: 200, body: JSON.stringify({ items: requestedPage === 1 ? manyItems : MOCK_ITEMS }), }) }) // Navigate away and back to trigger a reload await page.click('nav a:has-text("Dashboard")') await page.click('nav a:has-text("Items")') await page.waitForURL('**/items') // Click Next const nextBtn = page.locator('button:has-text("Next")') await expect(nextBtn).not.toBeDisabled() await nextBtn.click() await expect(page.locator('text=Page 2')).toBeVisible() expect(requestedPage).toBe(2) }) ``` ### 5.6 Navigation tests ```typescript test('sidebar navigation works for all pages', async ({ page }) => { // Verify nav items are visible await expect(page.locator('nav a:has-text("Dashboard")')).toBeVisible() await expect(page.locator('nav a:has-text("Users")')).toBeVisible() // Click and verify URL + page title await page.click('nav a:has-text("Users")') await expect(page).toHaveURL(/\/users/) await expect(page.locator('h2')).toContainText('Users') // Navigate back and verify await page.click('nav a:has-text("Dashboard")') await expect(page).toHaveURL(/\/dashboard/) }) ``` --- ## 6. Advanced Techniques ### 6.1 Overriding global mocks (testing error scenarios) This is the most powerful feature of this approach: **override the global mocks within a single test, without modifying setupAuthenticatedMocks()**. **How it works:** handlers registered later via `page.route()` are matched first. A later registration overrides an earlier one for the same URL pattern. ```typescript test.describe('Dashboard', () => { test.beforeEach(async ({ page }) => { await setupAuthenticatedMocks(page) // global mocks: health returns ok await loginAsAdmin(page) }) test('displays healthy status', async ({ page }) => { await expect(page.locator('text=Healthy')).toBeVisible() // uses the global mock }) test('shows unhealthy status when backend is down', async ({ page }) => { // Key point: registered after the global mocks, overriding the global health response await page.route('**/health/ready', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'error', message: 'Database unreachable' }), }) }) await loginAsAdmin(page) // log in again to trigger the health check await expect(page.locator('text=Unhealthy')).toBeVisible() }) }) ``` **Common override scenarios:** ```typescript // Scenario 1: API returns an error await page.route('**/oauth2/login', async (route) => { await route.fulfill({ status: 401, body: JSON.stringify({ error: 'invalid_credentials' }) }) }) // Scenario 2: API returns empty data await page.route('**/api/admin/clients', async (route) => { if (route.request().method() === 'GET') { await route.fulfill({ status: 200, body: JSON.stringify({ clients: [] }) }) } else { await route.continue() } }) // Scenario 3: non-admin user await page.route('**/oauth2/userinfo', async (route) => { await route.fulfill({ status: 200, body: JSON.stringify({ sub: '123', username: 'user', roles: ['user'] }), }) }) // Scenario 4: MFA required await page.route('**/oauth2/login', async (route) => { await route.fulfill({ status: 200, body: JSON.stringify({ mfa_required: true, mfa_token: 'mfa-token-123' }), }) }) ``` ### 6.2 Capturing and verifying request bodies Verify that the parameters the frontend sends are correct: ```typescript test('assigns roles with correct request body', async ({ page }) => { let requestBody: any = null // Override the global mock and capture the request body at the same time await page.route('**/api/admin/users/*/roles', async (route) => { requestBody = JSON.parse(route.request().postData() || '{}') await route.fulfill({ status: 200, body: JSON.stringify({ message: 'Roles updated' }), }) }) // Perform the action await page.locator('button:has-text("Assign Roles")').first().click() await page.fill('input[placeholder="admin, user"]', 'admin, editor') await page.click('button:has-text("Save Roles")') // Verify the request body expect(requestBody).toEqual({ roles: ['admin', 'editor'] }) }) ``` ### 6.3 Empty-state tests Verify the UI presentation when the list is empty: ```typescript test('shows empty state when no items exist', async ({ page }) => { // Override the mock to return an empty list await page.route('**/api/items', async (route) => { if (route.request().method() === 'GET') { await route.fulfill({ status: 200, body: JSON.stringify({ items: [] }) }) } else { await route.continue() } }) // Navigate away and back to trigger a data reload await page.click('nav a:has-text("Dashboard")') await page.click('nav a:has-text("Items")') await page.waitForURL('**/items') // Verify the empty-state prompt await expect(page.locator('text=No items yet')).toBeVisible() await expect(page.locator('button:has-text("Create your first item")')).toBeVisible() }) ``` **Why "navigate away and back"?** Because `beforeEach` has already navigated to the target page and the data is loaded, overriding the mock requires forcing the component to remount and re-fetch the data. Two approaches: 1. Navigate to another page and back (recommended; simulates real user behavior) 2. `await page.reload()` (simple, but some components may not re-request) ### 6.4 Verifying request query parameters ```typescript test('filter sends correct parameters', async ({ page }) => { let capturedUrl = '' await page.route('**/api/items**', async (route) => { capturedUrl = route.request().url() await route.fulfill({ status: 200, body: JSON.stringify({ items: [] }) }) }) // Perform the filter operation await page.selectOption('select[name="status"]', 'active') await page.click('button:has-text("Filter")') // Verify the URL parameters const url = new URL(capturedUrl) expect(url.searchParams.get('status')).toBe('active') }) ``` --- ## 7. CI/CD Integration ### 7.1 GitHub Actions example ```yaml name: E2E Tests on: [push, pull_request] jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - name: Install dependencies run: npm ci - name: Install Playwright browsers run: npx playwright install chromium --with-deps - name: Run E2E tests run: npm run test:e2e - name: Upload test report if: always() uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report/ ``` ### 7.2 CI configuration notes | Option | CI value | Local value | Reason | |------|-------|-------|------| | `workers` | 1 | auto (multiple) | CI resources are limited; avoids contention | | `retries` | 2 | 0 | CI networks are unstable; retries mitigate flakiness | | `reuseExistingServer` | `false` | `true` | CI must start a fresh server | | `forbidOnly` | `true` | `false` | Prevents `test.only` from being committed | --- ## 8. FAQ ### Q1: What if tests fail intermittently (flaky)? 1. Check whether you use `page.waitForTimeout()` — switch to `waitForSelector` / `waitForURL` / `expect().toBeVisible()` 2. Check the mocks for race conditions — make sure every API is intercepted and no request is left unmocked 3. Enable `trace: 'on-first-retry'` and analyze failures with `npx playwright show-trace` ### Q2: What if a request is not intercepted? - Check that the URL pattern matches (the scope of the `**` wildcard) - Open the browser DevTools and inspect the full URL of the actual request - Add `console.log(route.request().url())` inside the handler to debug ### Q3: How do I test file uploads? ```typescript // Listen for the filechooser event const [fileChooser] = await Promise.all([ page.waitForEvent('filechooser'), page.click('button:has-text("Upload")'), // triggers the file selection ]) await fileChooser.setFiles({ name: 'test.csv', mimeType: 'text/csv', buffer: Buffer.from('name,value\ntest,123'), }) ``` ### Q4: How do I run tests without auto-starting the dev server? Edit `playwright.config.ts`, remove the `webServer` configuration, start the dev server manually, then run the tests: ```bash # Terminal 1 npm run dev # Terminal 2 npm run test:e2e ``` ### Q5: How do mock-mode and real-backend tests coexist? ``` tests/ ├── e2e/ │ ├── helpers/ │ │ ├── mock-api.ts ← mock mode │ │ └── api-client.ts ← real API calls │ ├── auth.spec.ts ← mock tests │ └── ... └── integration/ └── full-flow.spec.ts ← real-backend integration tests ``` Use separate `playwright.config.ts` files: - `playwright.config.ts` — mock mode (daily development, every commit) - `playwright.integration.config.ts` — real backend (pre-release, nightly) --- ## 9. Backend Integration Test Troubleshooting ### Problem: the test script fails entirely on its second run **Symptoms**: ``` POST http://localhost:5174/oauth2/login 401 (Unauthorized) ``` Backend logs: ``` WARN Account locked for user: admin until 1779441748 INFO [METRIC] oauth2_login_failures_total reason=bad_credentials ``` **Cause**: The OAuth2 system implements an account lockout mechanism. Once the number of failed logins reaches a threshold, the account is temporarily locked: - 5 failures → 1-minute lockout - 10 failures → 5-minute lockout - 15 failures → 30-minute lockout - 20+ failures → 1-hour lockout **Solutions**: #### Option 1: use a test script with automatic cleanup The backend test scripts (for example `test-admin-endpoints.ps1`) already reset the account lockout state automatically when they finish. For a local PostgreSQL database, you need to configure the database password: ```powershell # Edit the test script, find the cleanup section, and set the password $env:PGPASSWORD = "your_password" # change to the actual password ``` #### Option 2: manually reset the account lockout ```powershell # Use the reset script $env:PGPASSWORD = "your_password" .\scripts\backend\reset-account-lockout.ps1 $env:PGPASSWORD = $null # Or use SQL directly psql -U fulla_user -d fulla_db -h localhost -c "UPDATE users SET failed_login_count = 0, locked_until = 0 WHERE username='admin';" ``` #### Option 3: wait for the lockout to expire Depending on the number of failures, the account unlocks automatically after the corresponding time. **Preventive measures**: 1. **Use a dedicated test account**: never use the production admin account in tests 2. **Make sure credentials are correct**: check the username and password in the test script 3. **Clean up automatically after testing**: append cleanup code at the end of the test script For details, see [Account lockout mechanism](operate/account-lockout.md). --- ## Appendix A: From-scratch adoption checklist Use this checklist when adopting E2E testing in a new project: - [ ] `npm install -D @playwright/test` - [ ] `npx playwright install chromium` - [ ] Create `playwright.config.ts` - [ ] Create `tests/e2e/helpers/mock-api.ts` - [ ] Define mock data constants (matching the backend API response structure) - [ ] Implement `setupAuthenticatedMocks(page)` — intercept all authentication + business APIs - [ ] Implement `loginAsAdmin(page)` — a UI login helper - [ ] Create the first test file (auth is a good starting point) - [ ] Add `package.json` scripts - [ ] Configure the CI pipeline ## Appendix B: Admin frontend E2E test statistics | Metric | Value | |------|------| | Test files | 16 | | Test cases | 174 | | Execution time | ~1 minute | | Backend dependency | None (fully mocked) | | Browser | Chromium | | Parallel execution | Yes | | Mocked API endpoints | 15+ | --- > This document summarizes practices from the fulla Admin frontend project. Project source: `frontends/admin/tests/e2e/` --- # OAuth2 Admin Console - Test Cases Source: https://fulla.dev/docs/contribute/admin-test-cases # OAuth2 Admin Console - Test Cases > Admin backend-path: `/admin/` | Framework: Vue 3 + TailwindCSS | Playwright E2E ## Module 1: Login / Authentication ### 1.1 Admin Login Page (`/admin/login`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-LOGIN-001 | Valid admin credentials | Enter valid admin username/password, click Sign in | Redirect to Dashboard (`/admin/`) | P0 | | A-LOGIN-002 | Empty username | Leave username blank, click Sign in | Form does not submit (HTML5 required) | P1 | | A-LOGIN-003 | Empty password | Leave password blank, click Sign in | Form does not submit (HTML5 required) | P1 | | A-LOGIN-004 | Both fields empty | Click Sign in with empty fields | Form does not submit | P1 | | A-LOGIN-005 | Wrong password | Enter valid username + wrong password | Red error banner displayed, stays on login page | P0 | | A-LOGIN-006 | Non-existent user | Enter unregistered username + any password | Error message shown | P0 | | A-LOGIN-007 | Non-admin user | Enter credentials of user without admin role | Error message: requires admin role | P0 | | A-LOGIN-008 | SQL injection in username | Enter `' OR 1=1 --` as username | Error message, no data leak | P0 | | A-LOGIN-009 | XSS in username | Enter `` | Input rendered as text, no script execution | P0 | | A-LOGIN-010 | Whitespace-only username | Enter spaces only, click Sign in | Form does not submit | P1 | | A-LOGIN-011 | Loading state | Submit valid credentials | Button shows "Signing in...", disabled during request | P2 | | A-LOGIN-012 | Browser back after login | Login successfully, press browser Back | Redirect to Dashboard (auth guard active) | P1 | | A-LOGIN-013 | Direct access to protected page | Navigate to `/admin/users` without auth | Redirect to `/admin/login` | P0 | | A-LOGIN-014 | Session persistence | Login, close tab, reopen `/admin/` | Session restored, Dashboard shown | P1 | | A-LOGIN-015 | Concurrent login attempts | Rapidly click Sign in multiple times | Only one request sent (button disabled) | P2 | ### 1.2 Logout | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-LOGOUT-001 | Normal logout | Click "Sign out" in sidebar | Session cleared, redirect to login page | P0 | | A-LOGOUT-002 | Access after logout | Logout, navigate to `/admin/users` | Redirect to login page | P0 | | A-LOGOUT-003 | Browser back after logout | Logout, press browser Back | Redirect to login (no cached page) | P1 | --- ## Module 2: Dashboard (`/admin/`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-DASH-001 | Stats display on load | Navigate to Dashboard | 4 stat cards shown: Total Users, Applications, Active Tokens, Failures Today | P0 | | A-DASH-002 | System health indicators | View Dashboard | System Status (green/red dot), Database status, Redis status displayed | P0 | | A-DASH-003 | Quick action links | Click each Quick Action card | Navigates to correct page (Applications/Users/Roles/Scopes) | P1 | | A-DASH-004 | Loading state | Observe page during API call | Stats show "—" while loading, then update | P2 | | A-DASH-005 | API failure handling | Simulate `/health/ready` failure | Red error banner shown, System Status shows "Unhealthy" | P0 | | A-DASH-006 | Stats API failure | Simulate `/api/admin/dashboard/stats` failure | Error banner displayed with descriptive message | P0 | | A-DASH-007 | Failures today = 0 | When no failures today | Number displayed in normal text color (not red) | P2 | | A-DASH-008 | Failures today > 0 | When failures exist | Number displayed in red color | P2 | --- ## Module 3: Applications (`/admin/applications`) ### 3.1 Application List | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-APP-001 | List loads successfully | Navigate to Applications page | Table shows client_id, name, type, grant_types, created_at | P0 | | A-APP-002 | Empty list | When no clients exist | Table shows "No clients found" or empty state | P1 | | A-APP-003 | Navigate to detail | Click a client row | Navigates to `/admin/applications/:id` | P0 | | A-APP-004 | Delete client | Click Delete on a client, confirm dialog | Client removed from list, success message | P0 | | A-APP-005 | Delete client cancel | Click Delete, cancel confirm dialog | Client remains in list | P1 | | A-APP-006 | Reset secret from list | Click Reset Secret, confirm | Secret modal shows new secret | P0 | ### 3.2 Create Application | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-APP-CR-001 | Create CONFIDENTIAL client | Fill name, type=CONFIDENTIAL, redirect URIs, select grant types, submit | Client created, secret modal shown with new secret | P0 | | A-APP-CR-002 | Create with empty name | Leave name empty, submit | Form validation prevents submission or API error shown | P0 | | A-APP-CR-003 | No grant type selected | Deselect all grant types, submit | Error: "Please select at least one grant type" | P0 | | A-APP-CR-004 | Multiple grant types | Select authorization_code + refresh_token + client_credentials | Client created with comma-separated grant types | P1 | | A-APP-CR-005 | Duplicate client name | Create two clients with same name | Second creation succeeds (name is not unique key) or proper error | P1 | | A-APP-CR-006 | Very long redirect URI | Enter redirect URI > 2048 chars | Either succeeds or server returns validation error gracefully | P2 | | A-APP-CR-007 | Invalid redirect URI format | Enter `not-a-url` as redirect URI | Validation error shown | P1 | | A-APP-CR-008 | Device code grant type | Select `urn:ietf:params:oauth:grant-type:device_code` | Client created with device_code grant | P1 | | A-APP-CR-009 | Close modal without submit | Open create modal, click Cancel | Modal closes, no API call | P1 | | A-APP-CR-010 | Loading state during create | Submit create form | Button shows "Creating...", disabled | P2 | ### 3.3 Application Detail (`/admin/applications/:id`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-APP-DT-001 | Info tab loads | Open application detail | Client name, redirect URIs, grant types displayed in editable form | P0 | | A-APP-DT-002 | Save name change | Edit client name, click Save | Success message, name updated | P0 | | A-APP-DT-003 | Save with no changes | Click Save without modifying anything | Message: "No changes to save" | P1 | | A-APP-DT-004 | Edit redirect URIs | Change redirect URIs (multi-line), save | URIs saved as comma-separated, displayed correctly on reload | P0 | | A-APP-DT-005 | Invalid application ID | Navigate to `/admin/applications/non-existent-id` | Error message displayed | P1 | | A-APP-DT-006 | Scopes tab | Switch to Scopes tab | Available scopes shown with checkboxes, current scopes checked | P0 | | A-APP-DT-007 | Save scopes | Check/uncheck scopes, click Save | Scopes updated, success message | P0 | | A-APP-DT-008 | Reset secret | Click Reset Secret, confirm | New secret shown in modal | P0 | | A-APP-DT-009 | Copy to clipboard | Click copy button for secret | Secret copied, "Copied to clipboard" message | P2 | | A-APP-DT-010 | Credentials tab | Switch to Credentials tab | Client ID displayed, reset secret option available | P1 | --- ## Module 4: Users (`/admin/users`) ### 4.1 User List | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-USR-001 | List loads | Navigate to Users page | Table with Username, Email, Verified, MFA, Actions columns | P0 | | A-USR-002 | Verified badge | User has email_verified=true | Green "Verified" badge shown | P1 | | A-USR-003 | Unverified badge | User has email_verified=false | Yellow "Pending" badge shown | P1 | | A-USR-004 | MFA enabled | User has mfa_enabled=true | Green "Enabled" badge | P1 | | A-USR-005 | MFA disabled | User has mfa_enabled=false | Gray "Off" badge | P1 | | A-USR-006 | Navigate to user detail | Click "Details" link | Navigates to `/admin/users/:id` | P0 | | A-USR-007 | API error | Simulate GET /api/admin/users failure | Error banner displayed | P0 | ### 4.2 Role Assignment (List Page Modal) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-USR-RL-001 | Assign single role | Click "Assign Roles", enter "admin", save | Roles updated, modal closes | P0 | | A-USR-RL-002 | Assign multiple roles | Enter "admin, user" (comma-separated) | Both roles assigned | P0 | | A-USR-RL-003 | Empty role input | Click save with empty role input | No API call (button disabled or input validation) | P1 | | A-USR-RL-004 | Whitespace roles | Enter ", , admin, " | Only "admin" assigned (trimmed, filtered) | P1 | | A-USR-RL-005 | Non-existent role | Enter "superadmin" | API error or graceful handling | P1 | | A-USR-RL-006 | Cancel role assignment | Open modal, click Cancel | Modal closes, no changes | P1 | | A-USR-RL-007 | Loading state | Submit role assignment | Button shows "Saving...", disabled | P2 | ### 4.3 User Detail (`/admin/users/:id`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-USR-DT-001 | Info tab loads | Open user detail | Username, email, email_verified shown in editable form | P0 | | A-USR-DT-002 | Edit email | Change email, save | Email updated, success message | P0 | | A-USR-DT-003 | Toggle email verified | Toggle verified checkbox, save | Verification status updated | P0 | | A-USR-DT-004 | No changes save | Click Save without changes | "No changes" message | P1 | | A-USR-DT-005 | Roles tab | Switch to Roles tab | Available roles as checkboxes, current roles selected | P0 | | A-USR-DT-006 | Save roles | Check/uncheck roles, save | Roles updated, success message | P0 | | A-USR-DT-007 | Disable user | Click "Disable User", confirm dialog | User disabled, status updated | P0 | | A-USR-DT-008 | Enable user | Click "Enable User" | User enabled, status updated | P0 | | A-USR-DT-009 | Security tab | Switch to Security tab | Lock status, login attempts, locked_until shown | P1 | | A-USR-DT-010 | Locked user indicator | View locked user | "Locked" status with remaining time shown | P1 | | A-USR-DT-011 | Non-existent user | Navigate to `/admin/users/999999` | Error message displayed | P1 | | A-USR-DT-012 | Concurrent role edit | Two admins edit same user's roles simultaneously | Last write wins or conflict error | P2 | --- ## Module 5: Roles (`/admin/roles`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-ROLE-001 | List roles | Navigate to Roles page | Table with Name, Description, Users count, Actions | P0 | | A-ROLE-002 | Built-in role indicators | View admin/user roles | "built-in" badge shown | P1 | | A-ROLE-003 | Built-in roles cannot be deleted | View actions for built-in roles | No "Delete" button shown | P0 | | A-ROLE-004 | Create role | Click Create, enter name + description, submit | Role created, appears in table | P0 | | A-ROLE-005 | Create role empty name | Submit with empty name | Button disabled (form validation) | P0 | | A-ROLE-006 | Create duplicate role name | Create role with existing name | API error shown: role already exists | P0 | | A-ROLE-007 | Edit role description | Click Edit, change description, save | Description updated | P0 | | A-ROLE-008 | Delete custom role | Click Delete on custom role, confirm | Role deleted, removed from table | P0 | | A-ROLE-009 | Delete role cancel | Click Delete, cancel confirm dialog | Role remains | P1 | | A-ROLE-010 | Role with assigned users | Delete a role that has users assigned | Confirm dialog appears; after deletion, users lose that role | P1 | | A-ROLE-011 | Empty role list | When no custom roles exist | Only built-in roles shown (admin, user) | P2 | | A-ROLE-012 | XSS in role name | Enter `` as name | Name rendered as text, no script execution | P0 | | A-ROLE-013 | Very long role name | Enter name > 100 chars | Either succeeds or proper validation error | P2 | --- ## Module 6: Scopes (`/admin/scopes`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-SCP-001 | List scopes | Navigate to Scopes page | Table with Name, Description, Mapped Role, Default, Admin-only, Actions | P0 | | A-SCP-002 | Built-in scope indicators | View openid/profile/email/admin | "built-in" badge or non-deletable | P1 | | A-SCP-003 | Create scope | Fill name, description, mapped_role, toggle is_default/requires_admin_role, submit | Scope created, appears in table | P0 | | A-SCP-004 | Create scope empty name | Submit with empty name | Button disabled or validation error | P0 | | A-SCP-005 | Create duplicate scope | Create scope with existing name | API error: scope already exists | P0 | | A-SCP-006 | Edit scope | Click Edit, change description/mapped_role/toggles, save | Scope updated | P0 | | A-SCP-007 | Toggle is_default | Set is_default=true for a scope | Scope marked as default | P1 | | A-SCP-008 | Toggle requires_admin_role | Set requires_admin_role=true | Scope marked as admin-only | P1 | | A-SCP-009 | Delete scope | Click Delete on custom scope, confirm | Scope deleted | P0 | | A-SCP-010 | Delete built-in scope | Try to delete openid/admin | Delete button not shown or error | P0 | | A-SCP-011 | XSS in scope name | Enter `` as name | Rendered as text | P0 | --- ## Module 7: Tokens (`/admin/tokens`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-TOK-001 | List tokens | Navigate to Tokens page | Table with token_prefix, client_id, user_id, scope, created_at, expires_at | P0 | | A-TOK-002 | Filter by client_id | Enter client_id filter, click Apply | Only tokens for that client shown | P0 | | A-TOK-003 | Filter by user_id | Enter user_id filter, click Apply | Only tokens for that user shown | P0 | | A-TOK-004 | Clear filters | Click "Clear Filters" | Filters reset, all tokens shown | P1 | | A-TOK-005 | Revoke single token | Click Revoke on a token, confirm dialog | Token revoked, removed from list | P0 | | A-TOK-006 | Revoke by client | Click bulk action "Revoke by Client", confirm | All tokens for that client revoked | P0 | | A-TOK-007 | Revoke by user | Click "Revoke by User" (requires user_id filter), confirm | All tokens for that user revoked | P0 | | A-TOK-008 | Revoke by user without filter | Click "Revoke by User" without user_id filter | No action (guard: `if (!userIdFilter.value) return`) | P1 | | A-TOK-009 | Pagination | When tokens > per_page (50) | Page navigation works, page parameter sent in API | P1 | | A-TOK-010 | Empty token list | When no tokens exist | Table shows empty state | P2 | | A-TOK-011 | Confirm cancel | Click Revoke, cancel confirm dialog | Token not revoked | P1 | | A-TOK-012 | Timestamp formatting | Verify created_at/expires_at display | Formatted as locale string, not raw ISO | P2 | --- ## Module 8: Audit Logs (`/admin/logs`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-LOG-001 | List loads | Navigate to Audit Logs page | Table with timestamp, action, user, details | P0 | | A-LOG-002 | Empty logs | When no audit logs exist | Empty state displayed | P1 | | A-LOG-003 | Pagination | When logs > per_page | Pagination controls work | P1 | | A-LOG-004 | Filter by action type | Filter by specific action | Filtered results shown | P1 | | A-LOG-005 | Timestamp ordering | View multiple logs | Sorted by timestamp descending (newest first) | P1 | --- ## Module 9: Settings (`/admin/settings`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-SET-001 | Page loads | Navigate to Settings page | Current settings displayed in editable form | P0 | | A-SET-002 | Save settings | Modify setting value, click Save | Success message, settings updated | P0 | | A-SET-003 | Invalid setting value | Enter invalid value | Validation error shown | P1 | | A-SET-004 | No changes save | Click Save without changes | "No changes" or settings re-fetched | P2 | --- ## Module 10: Navigation & Layout | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-NAV-001 | Sidebar navigation | Click each nav item | Correct page loads, active item highlighted | P0 | | A-NAV-002 | Active state on detail pages | Navigate to `/admin/applications/:id` | "Applications" nav item highlighted | P1 | | A-NAV-003 | Top bar title | Navigate between pages | Top bar shows current page name | P2 | | A-NAV-004 | User info in sidebar | After login | Username initial avatar, name, email shown | P2 | | A-NAV-005 | Responsive layout | Resize to mobile width | Sidebar collapses or becomes hamburger menu | P1 | --- ## Module 11: Cross-Cutting Concerns ### 11.1 Error Handling | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-ERR-001 | Network error | Disable network during API call | Error banner with user-friendly message | P0 | | A-ERR-002 | 401 Unauthorized | Let session expire, make API call | Redirect to login page | P0 | | A-ERR-003 | 403 Forbidden | Admin-only operation by non-admin | Error message, no data exposed | P0 | | A-ERR-004 | 500 Server error | Trigger server error | Error banner, no crash | P0 | | A-ERR-005 | Success message auto-dismiss | Perform successful action | Success message disappears after 3 seconds | P2 | | A-ERR-006 | Error message auto-dismiss | Trigger error message | Error message disappears after 5 seconds | P2 | ### 11.2 Security | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-SEC-001 | CSRF protection (CORS same-origin) | Submit forms / send API requests | Bearer-token architecture: auth token is NOT in a cookie, so classic CSRF does not apply. Protection is enforced by backend CORS with strict exact-match Origin allowlist (no wildcards) — `main.cc` origin check. Cross-origin requests from non-allowlisted origins are rejected | P0 | | A-SEC-002 | Token storage | After login | Auth token not in URL or localStorage in plaintext | P0 | | A-SEC-003 | Route guard bypass | Manually enter `/admin/users` URL without auth | Redirected to login | P0 | | A-SEC-004 | Client secret display | View/create client secret | Secret shown only once, not persisted in page state | P0 | ### 11.3 Performance | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | A-PERF-001 | Large user list | Load 1000+ users | Page renders without freezing, pagination working | P1 | | A-PERF-002 | Dashboard concurrent requests | Load Dashboard | Two API calls fire in parallel (Promise.all) | P2 | | A-PERF-003 | Lazy-loaded routes | Navigate to each page | Only required component loaded (code splitting) | P2 | --- # CI/CD Pipeline Guide (CI/CD Guide) Source: https://fulla.dev/docs/contribute/ci-cd-guide # CI/CD Pipeline Guide (CI/CD Guide) This document describes the project's continuous integration and continuous delivery (CI/CD) mechanism, built on **GitHub Actions**. --- ## 1. Pipeline Overview The CI configuration lives in `.github/workflows/ci.yml` and consists of three fail-fast, chained jobs (including a reusable-workflow matrix): ``` Push/PR to master (and workflow_dispatch) │ ├── FAST gate │ ├── static-checks (ubuntu-24.04) — source-level guards: │ │ arch-guard / migration-check / api-diff / │ │ test naming / manage-script parity / OpenAPI checks / │ │ OpenAPI governance gate (three-layer consistency + version sync) │ └── frontend (_frontend.yml) — vitest unit/property tests, ESLint, │ production builds + bundle-size budget gate (#159), │ Playwright e2e, and the frontend Docker image build smoke │ (#160, path-filtered to frontends/** + deploy/docker/**) │ ├── openapi-governance (openapi-governance.yml, PR-triggered) — │ oasdiff breaking-change gate (base vs PR openapi.yaml; │ exemption list tools/openapi-governance/oasdiff-breaking-ignore.md) │ ├── MAIN gate │ └── build-test (_build-test.yml × {linux, windows, macos} matrix) │ ├── install system dependencies / Conan │ ├── configure and build (Conan + cmake --preset, with cache) │ ├── [linux] start Postgres/Redis containers and wait until ready │ ├── [linux] initialize the database schema │ ├── run ctest + release naming gate │ └── [on failure] upload test-log artifacts │ └── RELEASE gate └── sdk-smoke (_sdk-smoke.yml) — full-stack find_package smoke test ``` --- ## 2. Triggers ```yaml on: push: branches: ["master"] pull_request: branches: ["master"] workflow_dispatch: concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ``` - **Push to master**: every merge to master automatically triggers the full check suite. - **Pull Request**: triggered automatically on PR creation/update, serving as a pre-merge gate check. - **workflow_dispatch**: manual triggering is supported. - **Concurrency control**: a new run for the same branch cancels any in-progress older run. --- ## 3. Frontend Gate (`_frontend.yml`) The frontend gate runs for every CI run (it is part of the FAST gate, so it fails cheap and early). One Node job plus one Docker job: | Job | What it does | |---|---| | `frontend` | UI-kit byte-sync gate → vitest unit/property tests (both apps) → ESLint → production builds (`tsc && vite build`) → **bundle-size budget gate** (`scripts/check-frontend-size.mjs`, #159) → Playwright e2e (both apps, API-mocked) | | `frontend-image-smoke` (#160) | Path-filtered (`frontends/**`, `deploy/docker/**`, this workflow) `docker build` of **both frontend images with `push: false`** — the admin image (`frontends/admin/Dockerfile`) and the user frontend stage of `deploy/docker/Dockerfile` (`target: frontend-runtime`) — mirroring exactly what `release.yml` builds, so a context/lockfile/base-image regression fails the PR instead of the release. GHA layer cache keeps warm runs near the npm-ci cost. | Budget baselines live in `scripts/check-frontend-size.mjs` (raw JS bytes: total + entry chunk per app, +10% headroom). Raise a baseline only with written justification in the PR — the entry-chunk cap doubles as the i18n AOT guard: the vue-i18n message compiler re-entering the bundle costs ~90 KB on the main chunk and trips the gate mechanically. ## 4. Core Job in Depth: `build-test` `build-test` is a reusable workflow (`_build-test.yml`) invoked by `ci.yml` as a `{linux, windows, macos}` matrix. All three platforms run the same Conan + `cmake --preset` build and CTest suite; the database is enabled via matrix inputs only where needed. ### 4.1 Service Containers (linux matrix leg only) In the Linux matrix leg, CI starts Postgres and Redis in Docker containers and confirms readiness with real queries (not just `pg_isready`): | Service | Image | Port | Password | |---|---|---|---| | PostgreSQL | `postgres:17-alpine` | `5432` | `123456` | | Redis | `redis:7-alpine` | `6379` | None (simplified CI configuration)| > The PostgreSQL image matches the deploy default (`postgres:17-alpine`, since 2026-08-18) — CI therefore covers > the major version actually deployed (including how migrations such as V025 partitioning / V026 behave on 17). > **WARNING**: Redis runs without a password in CI, so the test configuration overrides it with the environment variable `FULLA_REDIS_PASSWORD=""`. The Windows/macOS matrix legs use `use_database=false` and fall back to the in-memory storage configuration (`config.ci.json`). ### 4.2 Build Cache Strategy To speed up CI builds, Conan dependencies are cached: | Cache | Cache key | Contents | |---|---|---| | **Conan dependency cache** | `conan-{OS}-v1-cpp17-{conanfile.py + conan.lock hash}` | The `~/.conan2` directory (third-party dependencies, including Drogon)| A cold build takes roughly **15-20 minutes**; with a cache hit this drops to **3-5 minutes**. ### 4.3 Database Initialization Before testing, migration scripts initialize the database: ```bash # Run all migration files in order for f in apps/server/migrations/V*.sql; do psql -h localhost -U fulla_user -d fulla_db -f "$f" done # Load seed data (dev/test environments) for f in apps/server/seed/*.sql; do psql -h localhost -U fulla_user -d fulla_db -f "$f" done ``` > **Note**: the legacy `sql/001_*.sql` through `sql/004_*.sql` files are deprecated and removed; all schema definitions are now managed centrally under `apps/server/migrations/`. ### 4.4 Test Execution ```bash ctest -V -C Release --output-on-failure --timeout 120 ``` - `-V` : verbose output - `--output-on-failure` : print test stdout on failure - `--timeout 120` : each test gets at most 2 minutes ### 4.5 Failure Log Upload When tests fail, CI automatically packages and uploads the following as artifacts (retained for 7 days): - `build/Testing/` — CTest test reports - `apps/server/logs/` — application runtime logs --- ## 5. Image Build and Signing The CI pipeline itself does not build Docker images. Multi-arch container image builds, GHCR pushes, cosign signing, and syft SBOMs are handled by `release.yml` when a SemVer tag (`vX.Y.Z`) is pushed. See [Releases & Supply Chain Security](https://github.com/voidvec/fulla#releases--supply-chain-security). --- ## 6. Reproducing the CI Environment Locally To simulate CI behavior locally: ```powershell # 1. Start the infrastructure (CI uses service containers; locally, use Docker) docker run -d -p 5432:5432 -e POSTGRES_USER=fulla_user -e POSTGRES_PASSWORD=123456 -e POSTGRES_DB=fulla_db postgres:17-alpine docker run -d -p 6379:6379 redis:7-alpine # 2. Initialize the database $env:PGPASSWORD = "123456" Get-ChildItem "apps\server\migrations\V*.sql" | Sort-Object Name | ForEach-Object { psql -h localhost -U fulla_user -d fulla_db -f $_.FullName } Get-ChildItem "apps\server\seed\*.sql" | ForEach-Object { psql -h localhost -U fulla_user -d fulla_db -f $_.FullName } # 3. Build and run tests (build.bat uses Conan + cmake --preset; Release lands in build/windows-msvc) .\scripts\backend\build.bat -release cd build\windows-msvc $env:FULLA_REDIS_PASSWORD = "" ctest -V -C Release --output-on-failure ``` --- ## 7. Multi-Platform Matrix Multi-platform CI has been consolidated into the `build-test` job in `ci.yml`; an `include` matrix runs all three platforms on the same reusable workflow (`_build-test.yml`). ### Quick Reference - **Workflow File:** `.github/workflows/ci.yml` (invokes `_build-test.yml`) - **Platforms:** Linux (ubuntu-24.04), Windows (windows-2022), macOS (macos-14) - **Trigger:** Push to master, pull requests, manual workflow dispatch - **Runtime:** ~15-20 minutes cold cache, ~3-5 minutes warm cache per platform ### Platform-Specific Features Per-platform differences are expressed entirely through matrix inputs (no copy-pasted pipelines): - **Linux:** system dependencies installed via apt; PostgreSQL/Redis run in Docker containers; performs database initialization and the release naming gate - **Windows:** Conan dependency management with the MSVC 2022 compiler; in-memory storage configuration (`use_ci_config`), no external DB - **macOS:** Homebrew (`brew update` only); arm64 builds (`-s arch=armv8`, runner is `macos-14`); in-memory storage configuration --- --- # Frontend i18n Guide — Selection, Scope, and Contributor Workflow Source: https://fulla.dev/docs/contribute/frontend-i18n # Frontend i18n Guide — Selection, Scope, and Contributor Workflow > Decision record: [ADR-0013](../adr/ADR-0013.md). This guide carries the evaluation evidence, the impact inventory, the adoption plan with acceptance criteria, and the day-to-day contributor workflow. The zh-CN mirror of this page is maintained in the same PR (docs dual-write convention). ## TL;DR - Both frontends speak **English (default) and Simplified Chinese** through **vue-i18n v11** message catalogs — one switcher drives page chrome *and* error messages. - **translate.js and other runtime machine-translation widgets are rejected** for the product UI: they ship page text to a third-party cloud, translate after first paint, and cannot guarantee OAuth terminology. Machine translation may assist translators offline, never the runtime. - Adding a language = one new catalog file + registration (see [Contributor workflow](#contributor-workflow)). ## 1. What was evaluated ### 1.1 translate.js (xnx3/translate) — automatic page translation | Dimension | Finding | |---|---| | License / health | MIT; ~3.1k stars; last release v4.0.0 (2026-02), repo active — **embedding would be license-clean** | | Mechanism | DOM walker post-load; batches text nodes to the author's cloud (`api.translate.zvo.cn`, `america.api…`); swaps translations back into the page | | Privacy | **Page text egress to a third-party service by default.** For an IdP whose pages render usernames, emails, and error detail, this is disqualifying | | Capacity / availability | Free channel has a **daily character cap**; runtime depends on external nodes; paid "private deployment" exists but is a commercial dependency | | UX | Translates *after* first paint (FOUC by design); re-translation on SPA route changes relies on a mutation listener with open Vue issues (#54, #94); rewrites `input` value attributes — hazard on login/MFA forms | | Quality | No terminology control: "consent", "scope", "authorization code" get whatever the engine guesses; untranslated/over-translated mix is common | | Size | ~47 KB gzip runtime (vs ~10–14 KB brotli for vue-i18n) | **Verdict: technically embeddable, rejected as the fulla UI mechanism.** The failure modes (data egress, offline breakage, terminology drift) land exactly where an identity provider cannot afford them. ### 1.2 Curated-catalog libraries for Vue 3 | Option | Fit for fulla | Notes | |---|---|---| | **vue-i18n v11 (intlify)** — *selected* | ★★★★★ | De-facto Vue standard (MIT, ~3.9M dl/wk). Composition API + `globalInjection` means templates use `$t()` with no per-component boilerplate; plain TS catalog modules stay type-checked; no extra build plugin needed at our catalog size | | i18next + i18next-vue | ★★★☆☆ | Superb ecosystem, but Vue binding is the side door (~96k dl/wk); two-layer architecture is overkill here | | LinguiJS v6 | ★★☆☆☆ | ICU + compile-time extraction, but **no official Vue runtime** — core-only integration | | Paraglide JS 2 (inlang) | ★★★★☆ | Most modern (tree-shaken compiled messages, full type safety); younger ecosystem, `setLocale` reloads by default — revisit if catalog size ever matters | | FormatJS / vue-intl | ★★☆☆☆ | Full ICU/CLDR but Vue binding ~5k dl/wk, ICU `}}` collides inside Vue templates | | typesafe-i18n | ★★★☆☆ | Tiny runtime, fully typed; repo transferred, slow maintenance; own message format | Selection logic: the error catalog (`services/messages/`) is *already* a per-locale registry keyed by code — vue-i18n is the same idea for UI chrome, with the largest ecosystem and hiring pool. ### 1.3 Locale matrix | Tier | Locales | Status | |---|---|---| | 1 (shipped) | `en` (default), `zh-CN` | Full catalogs (user app 188 leaf keys, admin 306), switcher, tests — matches the docs-site convention (`defaultLocale: 'en'` + `zh-CN` mirror) | | 2 (roadmap, on demand) | `zh-TW`, `ja`, `de`, `es`, `fr`, `pt-BR`, `ru` | The set that "makes the cut" in comparable OSS IdPs (Keycloak's most-complete translations); add per [workflow](#contributor-workflow) when a community need exists | ## 2. Impact inventory (what i18n touches) ### 2.1 Applications and routes **`frontends/user` (user portal)** — 15 routes: | Route | View | Purpose | |---|---|---| | `/login` | `pages/auth/LoginPage.vue` | Login + MFA, social buttons | | `/register` | `pages/auth/RegisterPage.vue` | Account creation | | `/forgot-password` | `pages/auth/ForgotPasswordPage.vue` | Reset request | | `/reset-password` | `pages/auth/ResetPasswordPage.vue` | Consume reset token | | `/verify-email` | `pages/auth/VerifyEmailPage.vue` | Email verification result | | `/callback` | `pages/oauth/CallbackPage.vue` | Code exchange; maps protocol error codes via the shared catalog | | `/callback/{github,google,wechat}` | `pages/oauth/{GitHub,Social}CallbackPage.vue` | Social login/link callbacks | | `/consent` | `pages/oauth/ConsentPage.vue` | OAuth consent screen + scope descriptions | | `/`, `/profile`, `/security`, `/authorized-apps` | `pages/account/*.vue` | Account area (inside `AppLayout`) | | — | `layouts/AppLayout.vue`, `layouts/AuthLayout.vue` | Nav, user menu, theme toggle, footer | **`frontends/admin` (admin console)** — 12 routes under `components/layout/AdminLayout.vue`: `LoginPage`, dashboard, applications (list + detail tabs), users (list + detail), roles, scopes, audit logs, tokens, device approval, settings. ### 2.2 Where strings lived before extraction 1. **Template literals** — hardcoded English in both apps, plus four admin spots in Chinese (grant-type descriptions ×2 files, one validation message, one Client-Credentials scope note). 2. **Script constants** — nav items, scope-description maps, grant-type option arrays, inline validation messages. 3. **Error catalog** — `services/messages/zh-CN.ts` (~48 codes incl. reserved `__unknown__`/`__network__`, RFC 6749/7009/8628/6750 + OIDC codes), previously the only Chinese surface, now bilingual. 4. **ARIA labels / attributes** — theme-toggle labels, alert dismiss, modal close; these are user-visible to assistive tech and are translated. ### 2.3 Cross-app invariants that constrain the work - `components/ui/*.vue` are **byte-identical across apps**, enforced by `scripts/check-ui-sync.mjs` in CI — shared components (`LocaleSwitcher.vue` included) are edited in both copies. Templates use `$t()` only (no script imports), which keeps sync trivial. - `services/errorAdapter.ts` + `services/messages/` are mirrored files (canonical: user app); `crossAppConsistency.property.test.ts` asserts both apps produce identical messages per `(code, locale)`. The mirrored files stay **dependency-free** (no vue-i18n, no DOM): the active locale travels through `services/locale.ts`, a plain module the i18n bootstrap pushes into. - Property 13 (`messageCatalog.property.test.ts`) requires **every backend Error_Code and protocol code** to have a clean, non-empty entry — in **every registered locale**. - E2E suites run pinned to `en-US` (`playwright.config.ts` `use.locale`); Chinese paths are covered by a dedicated `i18n.spec.ts` per app. ### 2.4 Out of scope (non-goals) - Backend response messages (frontends map error **codes** locally; `/callback` maps the protocol `error` code through the catalog and only shows a raw `error_description` as a secondary detail line when the server sent one — that string is backend-owned). - The docs website (already dual-locale via Docusaurus). - `` (brand names: "Fulla" / "Fulla Admin"), number/date formatting (`Intl` when a need appears). ## 3. Architecture ``` src/i18n/ index.ts # createI18n(legacy:false, globalInjection:true), detection, setLocale(), initI18n() en.ts # UI catalog (namespaces: common/ui/nav[/auth/oauth/account | admin…]) zh-CN.ts # mirror catalog i18nKeys.test.ts # key parity + call-site key existence (mechanical gate) src/services/ locale.ts # zero-dependency active-locale state (node-safe; unit-test friendly) messages/ # error catalog: en.ts + zh-CN.ts; getErrorMessage(code) defaults to the ACTIVE locale ``` - **Detection & persistence** (mirrors the `fulla-theme` store pattern): `localStorage['fulla-locale']` → `navigator.languages` best-match (`zh*` → `zh-CN`, else `en`) → `en`. `initI18n()` runs **synchronously before `app.mount`** — no language flash — and the inline script in `index.html` pre-syncs `<html lang>` for screen readers. - **One switcher drives everything (fully reactive since #158)**: `setLocale()` updates the composer locale, persists, and syncs `document.documentElement.lang`. The services layer stays dependency-free: `src/i18n/index.ts` injects a locale getter (`setLocaleGetter(() => i18n.global.locale.value)`), so `getErrorMessage(code)` called inside a render effect tracks the vue-i18n locale ref. Error state therefore stores the **`NormalizedError`, not the resolved string**, and pages/banners re-resolve the message at render time (`errorText` computed) — an already-surfaced error re-translates when the locale switches. Parameterized chrome copy passed as plain strings (`t('...', {param})`) keeps snapshot semantics by design; the e2e suite covers both behaviors. - **Usage**: templates `$t('auth.login.title')` (works in the byte-synced `components/ui` too); `<script setup>` `const { t } = useI18n()` — **no options** (global scope); plain `.ts` modules `i18n.global.t(...)`. Under `legacy: false`, `i18n.global.locale` is a ref — read `.value` in scripts; templates unwrap automatically. Reactive constants (nav items, option arrays) are `computed`. - **Errors**: `getErrorMessage(code)` defaults to the active UI locale; `DEFAULT_LOCALE = 'en'` is only the table fallback for an unregistered locale. - **Fonts**: both apps load Noto Sans SC so Chinese never falls back to a system font. - **Build plugin (`@intlify/unplugin-vue-i18n`, #159)**: the UI catalogs (`src/i18n/*.ts`, plain `export default` objects) are AOT-precompiled at build time and production builds bundle vue-i18n **runtime-only** — the message compiler is not shipped (~90-100 KB smaller main chunk) and per-key first-`t()` compilation cost is gone. Dev server and vitest keep the full build, so tests exercise the same message resolution. The `include` list in `vite.config.ts` names the two catalog files explicitly (never application code); the error catalog under `services/messages/` is a plain lookup table, never goes through vue-i18n, and stays out of the plugin. Two guards keep this honest: the **bundle-size budget gate** (`node scripts/check-frontend-size.mjs` after builds, wired into `_frontend.yml`) fails when total or entry-chunk JS exceeds the recorded baseline +10% — the compiler re-entering the bundle (~+90 KB main) trips it mechanically; and `vite preview` + both locales is the one-off smoke that proved the precompiled path (repeat it manually when touching the i18n build wiring). ## 4. Adoption plan and acceptance (executed) | Phase | Work | Acceptance criteria | Result | |---|---|---|---| | 1. Infrastructure | `vue-i18n@11` in both apps; `src/i18n/` + `services/locale.ts`; `services/messages/en.ts` + registry change; `LocaleSwitcher.vue` mounted (user: header + auth footer; admin: topbar + login card); Noto Sans SC in admin | build green ×2; switch persists + syncs `<html lang>`; `check-ui-sync.mjs` green | ✅ | | 2. Extraction | All views/layouts/components translated (en verbatim except 4 fixed admin spots); constants → `computed` | tsc + build + lint green ×2; CJK leftover audit clean; key-parity green | ✅ 188 + 306 leaf keys | | 3. Tests | Property 13 → all locales; errorAdapter property → en default + zh explicit; `i18nKeys.test.ts` per app; e2e zh→en; `i18n.spec.ts` ×2; `use.locale: 'en-US'` pinned | unit + e2e + lint green ×2 | ✅ user 34 unit / 118 e2e; admin 4 unit / 185 e2e + 6 env skips | | 4. Docs | This guide + ADR-0013, en + zh-CN mirrors, sidebar | docs build green; dual-write complete | ✅ | | 5. Full acceptance | Full frontend suite both apps | lint, build, unit/property, e2e green ×2, zero new skips | ✅ | **Overall acceptance (semantic)**: a default English session shows zero frontend-owned Chinese strings on every route, and a 简体中文 session shows zero frontend-owned English strings — enforced mechanically by the CJK audit (`grep -rn "[一-鿿]" src --include=*.vue --include=*.ts | grep -v "src/i18n/" | grep -v "src/services/messages/"` returns nothing) and by `i18n.spec.ts`. Named exceptions: backend-provided strings (e.g. a raw `error_description` detail line, success `message` fields), native endonyms in the locale menu, and the `'中文'` short label inside the byte-synced switcher. ## 5. Contributor workflow **Add a string**: pick/extend a namespace (`auth.login.title`), add the key to **both** `en.ts` and `zh-CN.ts` in the same PR, use `$t()`/`t()` at the call site. The `i18nKeys.test.ts` gate fails the build on en/zh key drift, unknown keys at call sites, and `ui.*` divergence between the two apps. Interpolation via named params (`{name}` appears in both locales); never concatenate translated fragments. **Error-catalog authoring constraints (Property 13 tripwires — never loosen the patterns)**: message text must not contain `{{`, `}}`, `${`, `%s`, `%d`, or the English words *exception*, *traceback*, or *stack trace* (single-brace `{name}` interpolation is fine). These run against **every** registered locale. **Add a locale** (tier-2): create `src/i18n/<locale>.ts` (translate from `en.ts`; machine-assisted translation is fine **with human review** — that is the only permitted use of MT), register it in `SUPPORTED_LOCALES` + `createI18n` + a switcher label, add `services/messages/<locale>.ts` mirroring every code, extend the parity test's locale list, and check CJK font coverage. Property 13 + the parity tests enforce completeness automatically. **Key conventions**: namespace = area (`common`, `ui`, `nav`, plus app-specific `auth`/`oauth`/`account` in user, `admin.<page>` in admin); `ui.*` keys (used by byte-synced `components/ui/*`) must resolve identically in **both** apps — enforced by the user app's `i18nKeys.test.ts` cross-check. --- # Testing Strategy and Execution Guide (Testing Guide) Source: https://fulla.dev/docs/contribute/testing-guide # Testing Strategy and Execution Guide (Testing Guide) This document describes the project's test layering strategy, the coverage of each test file, and how to run the full test suite locally. --- ## 1. Test Prerequisites Before running tests, make sure the following services are ready: | Service | Address | Notes | |---|---|---| | **PostgreSQL** | `localhost:5432` | Database: `fulla_db` / user: `fulla_user` / password: `123456` | | **Redis** | `localhost:6379` | Password: `123456` (consistent with `config.json`)| > **Serialization warning**: the full test suite (`full_test.bat` / > `full-test.sh`) and the benchmark stack (`benchmarks/fulla/setup.sh`) > share port 5555 and the same PostgreSQL database. Never run them in > parallel on one machine — start the benchmark setup only after the test > suite (including its endpoint scripts) has fully finished, and vice > versa. > **Quick-start infrastructure**: if you use Docker, you can start the postgres and redis containers separately: > ```powershell > docker run -d -p 5432:5432 -e POSTGRES_USER=fulla_user -e POSTGRES_PASSWORD=123456 -e POSTGRES_DB=fulla_db postgres:17-alpine > docker run -d -p 6379:6379 redis:7-alpine redis-server --requirepass 123456 > ``` --- ## 2. Test Layering Tests are compiled into **two categories of executables**: 1. **Per-library gtest binaries** (Domain layer, pure unit tests, no DB / no Drogon): - `libs/common/test/fulla-common-test` — `ConfigManager`, `ErrorCatalog`, `Result`, value objects - `libs/common/testing/test/fulla-common-testing-test` — deterministic verification of fake implementations (`FakeClock`/`FakeCryptoProvider`/`FakeLogger`, etc.) - `libs/oauth2/test/fulla-oauth2-test` — `TokenService`/`AuthorizationService`/`ClientService`/`JwkManager`/`Pkce`/`ScopeDecisionEngine`/`TokenCrypto` - `libs/identity/test/fulla-identity-test` — `AuthService`/`MfaService`/`TotpUtils`/`SessionManager`/`WebAuthnService`/social login (Google/WeChat/GitHub) - These use gtest (not `DROGON_TEST`) and are registered as independent ctest entries by each lib's `test/CMakeLists.txt` via `gtest_discover_tests`. 2. **Main test binary `tests/fulla-tests`** (`DROGON_TEST` framework, contains all layers that require Drogon/DB): | Level | Directory | Coverage | External dependencies | |---|---|---|---| | **Level 1 — Unit tests** | `tests/unit/` (`config/`, `error/`, `utils/`, `validation/`, `plugin/`, `schema/`, `subject/`, `initorder/`) | Pure logic: error envelopes, password hashing, PKCE/CryptoUtils, RuleSet validation, config loading, OpenAPI generation | None | | **Level 2 — Contract tests** | `tests/contract/` | Repository contract consistency across backends (Postgres/Redis/Memory): `IClientRepository`/`IGrantRepository`/`ITokenRepository`/`IConsentRepository`/`IUserRepository` | Memory always runs; Postgres/Redis are automatically skipped when `getPostgresClientOrNull()`/`getRedisClientOrNull()` return null | | **Level 3 — Integration tests** | `tests/integration/` (`auth/`, `token/`, `storage/`, `concurrency/`, `error/`, `plugin/`) | Full business flows, concurrency races, error envelopes, plugin assembly | Postgres / Redis (in memory-only mode `-DFULLA_MEMORY_TESTS_ONLY=ON`, the Memory subset runs) | | **Level 4 — Security tests** | `tests/security/` | SQL injection, XSS, command injection, CORS, token security, rate limiting | Postgres / Redis | | **Level 5 — E2E/functional** | `tests/e2e-backend/`, `tests/performance/` | Complete OAuth2 flows, performance benchmarks | Postgres + Redis + Drogon App | > Memory mode: configuring `-DFULLA_MEMORY_TESTS_ONLY=ON` lets the full suite run **without an external DB** (Postgres/Redis tests are automatically skipped) — this is how Windows CI does it. > For security test case counts, functional test case counts, and coverage lists, see the header comments of the test files in each directory; this section no longer hardcodes specific counts (counts grow with each iteration — the measured statistics in §7 are authoritative). ### DROGON_TEST assertion style — bare boolean operators are forbidden inside `CHECK`/`REQUIRE` [#MUST] drogon's `CHECK`/`REQUIRE` are **macros, not functions**: `CHECK_INTERNAL__` expands the expression to `(drogon::test::internal::Decomposer() <= expr)`, and macro argument substitution adds no parentheses, so a **bare `a || b` (or `a && b`) re-associates into `(Decomposer() <= a) || b`** — the result is silently wrong, with symptoms that look like "assertions randomly failing". Real case (PR #68 debugging): `CHECK(body.isMember("error") || body.isMember("code"))` failed, even though the raw body printed by `LOG_INFO` clearly contained the `error` key. **Rule**: whenever `||`/`&&` appears in the top-level argument of `CHECK(...)`/`REQUIRE(...)`, one of the following must hold: ```cpp // ✅ Split into two assertions (preferred — more precise failure messages) CHECK(body.isMember("error")); CHECK(body.isMember("code")); // ✅ Wrap the whole expression in parentheses (outer parentheses bind the chained expression as a single operand to <=) CHECK((a != std::string::npos || b != std::string::npos)); // ✅ Existing repo precedent: explicit (bool) cast (tests/e2e-backend/oauth2_flows/FunctionalTest.cc) CHECK((bool)(response.find("code=") != std::string::npos || response.find("error") != std::string::npos)); // ❌ Forbidden: bare top-level boolean chain (semantics broken after macro expansion) CHECK(body.isMember("error") || body.isMember("code")); ``` Note: operators nested **inside call/subscript/sub-expression parentheses** (e.g. `CHECK(f(a || b))`, `CHECK(x == (a || b))`) are unaffected — they are evaluated before being bound to `<=`. The `CHECK_THROWS`/`REQUIRE_THROWS` family goes through the `EVAL__` path and is also unaffected. **CI enforcement**: `tools/test/scripts/drogon_macro_bool_check.py` scans the `tests/` tree and fails on violations (a static-checks step, alongside the naming-convention check); `--selftest` can self-verify. ### Level 4 details — Security Tests | Test file | Coverage | |---|---| | `SecurityTest.cc` | SQL injection, XSS, command injection, input validation, CORS, token security, rate limiting, health-check security | Coverage highlights: input validation (injection/length/null values), authentication and authorization (invalid credentials, rate limiting), CORS in both directions, sensitive-data transmission, token security (invalid/missing authorization codes and refresh tokens), security headers (including HSTS), brute-force protection, health-check information leakage. ### Level 5 details — E2E / Functional Tests | Test file | Coverage | Dependencies | |---|---|---| | `IntegrationE2ETest.cc` | Simulates the complete OAuth2 authorization code flow: HTTP request → authorize → login → token exchange → UserInfo verification | Postgres + Redis + a running Drogon App | | `FunctionalTest.cc` | Complete OAuth2 flows, error handling, UTF-8/Emoji characters, health checks, RBAC, token lifecycle, input validation, rate limiting | Postgres + Redis | Coverage highlights: the complete authorization code flow, error scenarios, UTF-8/Emoji boundaries, RBAC unauthorized paths, token lifecycle exception paths, overlong input, rate-limit detection, endpoint availability. > Test case counts evolve with each version — **the `ctest -N` measurement is authoritative** (see §7 for the current full-suite baseline). --- ## 3. How to Run ### Option 1: via CTest (recommended) ```powershell # Run after the build completes (directory is build/<preset>; on Windows Release it is windows-msvc) cd build\windows-msvc ctest -C Release --output-on-failure --timeout 120 ``` > **Output policy**: by default only the logs of failed test cases and the final summary (`passed / failed / total`) are printed. To see the full output of every test case (including passing ones), add `--verbose` (`-V`); for complete silence with only the summary line, add `-Q`. `manage.sh test-backend -q` / `manage.ps1 test-backend -q` is equivalent to `-Q`. ### Option 2: run the test executable directly The test executable automatically starts a Drogon App instance internally (synchronized via a semaphore in `test_main.cc`); **there is no need to start the backend service manually**. ```powershell cd build\windows-msvc\tests\Release .\fulla-tests.exe ``` ### Option 3: use the manage scripts `manage.ps1` (Windows) / `manage.sh` (Linux/macOS) wraps the same build + test pipeline used by CI: ```powershell # Build and run the backend test suite (equivalent to manage.sh test-backend) .\manage.ps1 test-backend # Full loop: build + unit/integration tests + admin endpoint API tests .\manage.ps1 full-test # Run only the admin-endpoint / OAuth2-endpoint API scripts .\manage.ps1 test-admin-endpoints .\manage.ps1 test-oauth2-endpoints ``` ### The full-test pipeline: three execution layers (and what is deduplicated) `manage full-test` (backed by `scripts/backend/full_test.bat` / `full-test.sh` / `full-test-docker.sh`) stacks three layers. Knowing how they overlap explains what a full run actually executes: 1. **ctest layer** — `EndpointTests_OutOfProcess` (label `Endpoint`) is a regular ctest entry: it starts its own server, runs the 59 OAuth2 + 52 admin endpoint scripts against it, then stops it (`tests/CMakeLists.txt`). It reports `SKIP_RETURN_CODE=77` when its environment (server binary / shell) is unusable. 2. **Dual-config layer** — `test.bat` / `test.sh` run the *entire* ctest suite **twice**: once with the standard `config.json` (PostgreSQL) and once with `config.ci.json` (memory storage). This duplication is intentional — it is the release-confidence signal that both storage backends pass the same suite. 3. **Manual endpoint layer** — the pipeline's own "start server → run the endpoint scripts → stop server" steps. Since layer 1 already runs the same scripts in the same standard configuration, this layer is skipped automatically when the ctest run's JUnit report (`build/<preset>/Testing/junit-config-standard.xml`, written by `test.bat`/`test.sh`) proves `EndpointTests_OutOfProcess` ran green in that invocation (#119). On any doubt — report missing, entry missing, skipped, or unreadable — the manual layer runs as before, so environments where the ctest entry bails out (77) keep their endpoint coverage path. The same applies to the per-case `Contract.*` ctest entries: each is also executed as part of the `OAuth2Tests` binary run. Their individual ctest registrations exist only to provide a labeled entry point (`ctest -L Contract`); they do not add a second execution of those cases beyond the labeled entry. --- ## 4. Sample Test Output ``` All tests passed (N assertions in M tests) ``` If a failure occurs, the failed test name and assertion location are printed: ``` In test case SomeTestName SomeTestFile.cc:63 FAILED: CHECK(c.has_optional()) ``` **Common failure causes**: - Redis or PostgreSQL service not started → check that the services are reachable - Redis password mismatch → check the `passwd` field in `config.json` - Database not initialized → run the migration scripts under `apps/server/migrations/` (the backend also runs them automatically when `FULLA_AUTO_MIGRATE=true`) --- ## 5. Tests in CI On every push to `master` or PR, GitHub Actions CI automatically: 1. Starts the Postgres and Redis service containers 2. Initializes the database schema 3. Builds the project 4. Runs `ctest` See the [CI/CD Guide](../contribute/ci-cd-guide) for details. --- ## 6. Test Reports Historical security/functional test reports, bug status analyses, and connection-leak verification reports are process archives that were moved out of the repository as part of documentation governance (kept locally by maintainers). Current test status is defined by CI and the scope of this section: - **A fully green CI** is the merge gate (three-platform matrix; see the [CI/CD Guide](ci-cd-guide.md)). - For security and functional coverage see the §2 Level 4/5 details; counts are authoritative per `ctest -N`. - Actionable findings from the historical reports (e.g. security defects found in the April snapshot) have been fixed and preserved as regression test cases. --- ## 7. Test Coverage Summary ### Overall test status > The numbers below are **measured statistics** (Windows MSVC Release build, no external DB, Postgres/Redis tests skipped). In an environment with Postgres+Redis (e.g. Linux CI or local WSL+Docker), the skipped contract/integration tests activate and the ctest entry count increases further. | Test source | Passed | Failed | Total | Pass rate | |---------|------|------|------|--------| | **Per-library gtest binaries** (2026-06 baseline snapshot; the current full ctest count is 501 — the `ctest -N` measurement is authoritative) | 364 | 0 | 364 | 100% | | **Main test binary ctest entries** (including the Contract label + the full OAuth2Tests run) | 450 | 0 | 450 | 100% | > Note: the two rows overlap — the main binary contains all `DROGON_TEST` unit/integration tests (run as a single `OAuth2Tests` entry), while the per-library gtest binaries are pure Domain-layer unit tests compiled and run independently. Of the `450` ctest entries, 84 carry the `Contract` label (run them alone with `ctest -L Contract`). ### Code coverage Measured line coverage (gcov, gcc 13.3 Debug build, WSL Ubuntu 24.04, Postgres+Redis active; ORM-generated `models/` excluded; measured at 7ba8068 with all 5 test binaries executed): | Library | Line coverage | Notes | |---|---|---| | libs/common | 69.4% (318/458) | ErrorCatalog/ErrorTypes/ErrorContext/ConfigManager (driven by the per-lib gtest binary `fulla-common-test`); ConfigManager environment-related branches and some ErrorCatalog branches uncovered | | libs/identity | **96.9%** (590/609) | Auth/Mfa/WebAuthn/Social/Totp/Session | | libs/storage-memory | **97.1%** (431/444) | All methods of the Memory backend covered (the mandatory CI path) | | libs/oauth2 | **92.1%** (627/681) | TokenService/AuthService/ClientService/JwkManager/Pkce | | libs/storage-redis | 46.2% (306/663) | Contract tests cover the getClient/validate/grant/token/consent main paths; Lua scripts and transaction CRUD still to be covered | | libs/storage-postgres | 43.9% (727/1657) | Contract tests cover the main paths; the remaining blind spots are transaction/error-fallback branches (require fault injection to trigger) | | libs/drogon | **53.5%** (4807/8978) | admin 0%→55-69%, admin controllers 0%→91-100%; authorize/health/discovery/mfa/deviceauth/userselfservice/apidoc controllers reinforced; social OAuth controllers reinforced via mock injection (Google 38.3%, WeChat 30.6%, GitHub 32.5%); WebAuthn 39.2% (non-crypto stub, no authenticator needed) | | **Overall** | **57.9%** (7806/13490) | Sum of the per-library rows above (the OVERALL of `scripts/measure_coverage.py` is exactly that per-library sum); +9.4pp improvement over the 48.5% baseline | > The previous-round baseline was 48.5% (7091/14631); this round raised the overall figure to 57.9% through admin-layer HTTP integration tests + controller reinforcement + mock-injection tests for social OAuth/WebAuthn (`tests/common/SocialMockFixture.h` + the shared Fakes in `libs/identity/include/fulla/identity/testing/`). All of social OAuth's Google/WeChat/GitHub can run in memory mode via mock injection (the injection path writes no DB); the GitHub happy-path initially could not be covered in memory mode because `issueTokensForUser` called `getDbClient()` directly, and was subsequently refactored to persist through the `OAuth2Plugin::saveTokenPair` storage abstraction (see `Integration_P0_GitHubLogin_FakeExchange_ReturnsTokens` in `SocialLoginHttpTest.cc`), so the happy-path is now testable (GitHubController improved from 5.9% to 32.5%); WebAuthn is a non-crypto stub, fully testable in Postgres mode. Note: the 58.8% quoted by an earlier version of this document was a sum of stale per-library numbers (the 98.8% for common was outdated data — `libs/common/src` now has only 4 source files totaling 458 lines, measured at 69.4%); this table has been fully replaced with the values measured at 7ba8068. Remaining blind spots: storage-postgres transaction/error-fallback branches (require fault injection). #### ⚠ Measured coverage requires running all 5 test binaries The measured numbers depend on **all 5 test binaries** being executed (running only the main binary `fulla-tests` misses the domain-layer coverage contributed by the 4 per-lib gtest binaries, and common would be underestimated at ~60%): 1. `libs/common/test/fulla-common-test` (40 test cases) 2. `libs/common/testing/test/fulla-common-testing-test` (43 test cases) 3. `libs/identity/test/fulla-identity-test` (130 test cases) 4. `libs/oauth2/test/fulla-oauth2-test` (151 test cases) 5. `tests/fulla-tests` (450 ctest entries, including all `DROGON_TEST` unit/integration/contract/admin HTTP tests) Run these 5 binaries in sequence under the coverage build directory, then aggregate the `.gcda` files. #### ⚠ gcovr path-matching bug — use `scripts/measure_coverage.py` instead `gcovr 8.6` falsely reports **0%** for some files (e.g. `ClientManagementService.cc`): raw `gcov` clearly shows `Lines executed:55.79% of 328`, yet gcovr's `--print-summary` lists only the file name without a percentage (gcovr's source-path matching handles `.gcov` output containing absolute paths + Drogon headers inconsistently). This is a known gcovr path-matching issue, not a zero-count bug (gcov flushing works correctly; see below). **Reliable aggregation**: `scripts/measure_coverage.py` aggregates directly with `gcov -j` (JSON format, per file `{file, lines[{count, unexecuted_block}]}`), bypassing gcovr's text path matching. Usage: ```bash cd <repo> # First run all 5 binaries (see the previous section), then generate JSON + aggregate: find build/linux-coverage/libs -path "*/src/*" -name "*.gcda" ! -path "*/models/*" \ | xargs -I{} bash -c 'cd "$(dirname {})" && gcov -j "$(basename {})" >/dev/null 2>&1' find build/linux-coverage/libs -path "*/src/*" -name "*.gcov.json.gz" ! -path "*/models/*" \ | python3 scripts/measure_coverage.py ``` gcovr is still usable for per-file HTML reports (`--html-details`), but **for summary percentages `scripts/measure_coverage.py` is authoritative**. #### Coverage toolchain - `cmake/Coverage.cmake` (`oauth2_apply_gcov(target)`) adds `-fprofile-arcs -ftest-coverage` to every first-party library + test executable and explicitly links libgcov (GCC only; on Clang the profile runtime is provided automatically by the `-fprofile-arcs` link option, no libgcov). - `tests/test_main.cc` explicitly calls `__gcov_dump()` before both `std::_Exit()` sites: because `_Exit` bypasses `atexit`, libgcov's counter flush does not run automatically (otherwise gcov reads all-zero counts). This is a known interaction between the Drogon test framework's fast exit and gcov, requiring a manual flush in the test main. (Note: the 4 per-lib gtest binaries exit normally without `_Exit`, so they need no manual flush.) - Current phase target for coverage: 60% (57.9% measured at 7ba8068). The remaining blind spots concentrate in branches that are hard to test over HTTP: deep WebAuthn ceremony/crypto branches, UserSelfService (requires driving the auth pre-filter in reverse), storage-postgres transaction/error-fallback branches (require fault injection). Social OAuth controllers can now be covered in memory mode via the Fake injection of `SocialMockFixture.h` (the GitHub happy-path no longer depends on `getDbClient()` after the `saveTokenPair` storage-abstraction refactor). The ORM-generated `libs/storage-postgres/src/models/*.cc` files are excluded from the denominator. --- ## 8. Manual Validation & API Testing (Manual Validation) Beyond the automated test suite, the project also provides tools and scripts for manually validating endpoint functionality. ### 8.1 PowerShell automation script The project ships a complete OAuth2 endpoint test script: `scripts/backend/test-oauth2-endpoints.ps1`. **Usage:** ```powershell # Run the test, temporarily bypassing the execution policy powershell -ExecutionPolicy Bypass -File scripts/backend/test-oauth2-endpoints.ps1 ``` The script runs, in order: a health check, login, authorization code exchange, UserInfo access, and admin panel verification. ### 8.2 Multi-environment API testing (curl) Different command-line tools vary in their support for curl syntax: * **PowerShell (recommended)**: use `Invoke-RestMethod`. * **Git Bash**: supports standard Unix single-quote syntax. * **CMD**: requires double quotes and escaping `&` as `^&`. **Example: log in and get a JSON response** ```bash # Git Bash example curl -X POST http://127.0.0.1:5555/oauth2/login \ -d 'username=admin&password=admin&client_id=fulla-portal&redirect_uri=http://localhost:5173/callback&json=true' ``` --- ## 9. Troubleshooting ### 9.1 Common problems and remedies * **Server fails to start**: check whether port 5555 is occupied (`netstat -ano | findstr :5555`) and make sure the `config.json` path is correct. * **Login fails (400)**: confirm the username and password match and that the user exists in the database. Check that `redirect_uri` exactly matches the configuration. * **Token exchange fails**: the authorization code can be used only once and has a validity window. Make sure `client_id` and `client_secret` are correct. * **PowerShell script restriction**: if you see a "running scripts is disabled" message, use the `-ExecutionPolicy Bypass` parameter. ### 9.2 Debugging tips * **Log level**: when troubleshooting, temporarily set `log_level` in `config.json` to `DEBUG` (or even `TRACE`) for verbose output, and back to `INFO` once the issue is located. For the full six-level semantics and conventions see [observability.md §3.2](../operate/observability.md). * **Live logs**: use `Get-Content apps/server/logs/drogon.log -Wait -Tail 20` to monitor the running state. --- **Related documentation**: - [Security Architecture](../architecture/security-architecture.md) - security hardening and security architecture design - [Data Consistency](../architecture/data-persistence.md) - data consistency and the threat model - [API Reference](../domains/api-reference.md) - API interface documentation --- # OAuth2 User Frontend - Test Cases Source: https://fulla.dev/docs/contribute/user-frontend-test-cases # OAuth2 User Frontend - Test Cases > User frontend path: `/` | Framework: Vue 3 + TailwindCSS | Playwright E2E ## Module 1: Authentication ### 1.1 Login Page (`/login`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-LOGIN-001 | Valid credentials | Enter valid username/password, click Sign In | Redirect to Dashboard (`/`) | P0 | | U-LOGIN-002 | Empty username | Submit with empty username | HTML5 required validation prevents submit | P1 | | U-LOGIN-003 | Empty password | Submit with empty password | HTML5 required validation prevents submit | P1 | | U-LOGIN-004 | Wrong password | Enter valid user + wrong password | Error alert displayed | P0 | | U-LOGIN-005 | Non-existent user | Enter unregistered username | Error message shown | P0 | | U-LOGIN-006 | SQL injection | Enter `' OR 1=1 --` as username | Error message, no unauthorized access | P0 | | U-LOGIN-007 | XSS in username | Enter `<script>alert('xss')</script>` | Rendered as text, no script execution | P0 | | U-LOGIN-008 | Loading state | Submit valid credentials | Button shows loading spinner, disabled | P2 | | U-LOGIN-009 | Redirect after login | Login with `?redirect=/profile` in URL | Redirect to `/profile` after login | P0 | | U-LOGIN-010 | Already authenticated | Navigate to `/login` while logged in | Redirect to Dashboard | P0 | | U-LOGIN-011 | GitHub social login | Click "Sign in with GitHub" | Redirected to GitHub OAuth page | P1 | | U-LOGIN-012 | GitHub client_id not configured | When `VITE_GITHUB_CLIENT_ID` is empty | GitHub button still visible, link has no client_id | P2 | | U-LOGIN-013 | Link to register | Click "create a new account" link | Navigate to `/register` | P1 | | U-LOGIN-014 | Link to forgot password | Click "Forgot password?" | Navigate to `/forgot-password` | P1 | | U-LOGIN-015 | Browser autofill | Use browser autofill for credentials | Form submits correctly with autofilled values | P2 | ### 1.2 MFA Challenge | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-MFA-001 | MFA required flow | Login with MFA-enabled user | MFA challenge form shown (6-digit input) | P0 | | U-MFA-002 | Valid MFA code | Enter correct 6-digit TOTP code | Login succeeds, redirect to Dashboard | P0 | | U-MFA-003 | Invalid MFA code | Enter wrong 6-digit code | Error message displayed | P0 | | U-MFA-004 | Less than 6 digits | Enter "1234" | Submit button disabled (`mfaCode.length !== 6`) | P1 | | U-MFA-005 | More than 6 digits | Input limited to 6 chars (maxlength=6) | Cannot enter more than 6 digits | P1 | | U-MFA-006 | Non-numeric input | Enter letters in MFA field | Input limited by `inputmode="numeric"` | P1 | | U-MFA-007 | Back to login | Click "Back to login" link | MFA form hidden, login form shown | P1 | | U-MFA-008 | Loading state | Submit MFA code | Button shows loading state, disabled | P2 | | U-MFA-009 | Expired MFA token | Wait for mfa_token to expire, then submit code | Error message, may need to re-login | P1 | ### 1.3 Registration Page (`/register`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-REG-001 | Valid registration | Fill username, email, password (6+ chars), matching confirm, submit | Success message shown, auto-redirect to login after 2s | P0 | | U-REG-002 | Password too short | Enter 5-char password | Error: "Password must be at least 6 characters" | P0 | | U-REG-003 | Passwords don't match | Enter different passwords | Error: "Passwords do not match" | P0 | | U-REG-004 | Duplicate username | Register with existing username | Error message from API | P0 | | U-REG-005 | Duplicate email | Register with existing email | Error message from API | P0 | | U-REG-006 | Empty username | Submit with empty username | Form submits successfully (username is optional) | P1 | | U-REG-007 | Empty email | Submit with empty email | HTML5 required validation | P1 | | U-REG-008 | Invalid email format | Enter "not-an-email" | HTML5 email validation prevents submit | P1 | | U-REG-009 | SQL injection in username | Enter `'; DROP TABLE users;--` | Error or registration fails safely | P0 | | U-REG-010 | XSS in username | Enter `<script>alert(1)</script>` | Rendered as text | P0 | | U-REG-011 | Very long username | Enter username > 255 chars | API validation error or truncation handled | P2 | | U-REG-012 | Loading state | Submit valid form | Button shows "Creating...", disabled | P2 | | U-REG-013 | Success redirect timing | After successful registration | Success message visible, then redirect after 2s | P2 | | U-REG-014 | Link to login | Click "Sign in" link | Navigate to `/login` | P1 | | U-REG-015 | Already authenticated | Navigate to `/register` while logged in | Redirect to Dashboard | P1 | ### 1.4 Forgot Password (`/forgot-password`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-FP-001 | Valid email | Enter registered email, submit | Success message shown (anti-enumeration: always success) | P0 | | U-FP-002 | Unregistered email | Enter non-existent email | Still shows success message (anti-enumeration) | P0 | | U-FP-003 | Invalid email format | Enter "not-email" | HTML5 email validation | P1 | | U-FP-004 | Empty email | Submit with empty email | HTML5 required validation | P1 | | U-FP-005 | Loading state | Submit form | Button shows "Sending...", disabled | P2 | | U-FP-006 | Back to login link | Click "Back to Login" | Navigate to `/login` | P1 | | U-FP-007 | API error handling | Simulate network error during submit | Still shows success (anti-enumeration by design) | P1 | ### 1.5 Reset Password (`/reset-password`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-RP-001 | Valid reset token | Navigate with valid token, enter new password | Password reset succeeds, redirect to login | P0 | | U-RP-002 | Expired reset token | Navigate with expired token | Error: token expired or invalid | P0 | | U-RP-003 | Invalid reset token | Navigate with random token | Error message | P0 | | U-RP-004 | No token in URL | Navigate to `/reset-password` without token | Error or redirect to forgot-password | P1 | ### 1.6 Email Verification (`/verify-email`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-VE-001 | Valid verification token | Navigate with valid token | Email verified, success message | P0 | | U-VE-002 | Expired token | Navigate with expired token | Error message, option to resend | P1 | | U-VE-003 | Already verified | Verify already-verified email | Message: already verified | P1 | | U-VE-004 | Invalid token | Navigate with random token | Error message | P0 | --- ## Module 2: OAuth2 Flows ### 2.1 Authorization Callback (`/callback`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-CB-001 | Valid authorization code | Navigate with `?code=xxx` | Code exchanged, redirect to Dashboard | P0 | | U-CB-002 | Error from provider | Navigate with `?error=access_denied` | Error displayed: "access_denied" or description | P0 | | U-CB-003 | No code parameter | Navigate to `/callback` without params | Error: "No authorization code received" | P0 | | U-CB-004 | Loading spinner | Observe during code exchange | Spinner shown while "Completing sign in..." | P2 | | U-CB-005 | Invalid authorization code | Navigate with `?code=invalid_code` | Error message from token exchange failure | P0 | | U-CB-006 | Expired authorization code | Use code after 10-minute expiry | Error message | P1 | | U-CB-007 | Back to login link | Click "Back to Login" | Navigate to `/login` | P1 | ### 2.2 GitHub Callback (`/callback/github`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-GH-001 | Valid GitHub code | GitHub redirects with valid code | User authenticated via GitHub, redirected to Dashboard | P1 | | U-GH-002 | GitHub auth denied | User denies GitHub authorization | Error message displayed | P1 | | U-GH-003 | New GitHub user | First-time GitHub login | Account auto-created with GitHub profile info | P1 | ### 2.3 Consent Page (`/consent`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-CON-001 | Approve consent | Review requested scopes, click Approve | Authorization code returned to client | P0 | | U-CON-002 | Deny consent | Click Deny | Error returned to client, user redirected | P0 | | U-CON-003 | Scope display | View consent page | All requested scopes listed with descriptions | P0 | | U-CON-004 | No scopes requested | Consent page with no scopes | Minimal consent or handled gracefully | P2 | ### 2.4 Device Verification (`/device/verify`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-DV-001 | Valid device code | Enter valid device code, verify | Device authorized | P0 | | U-DV-002 | Invalid device code | Enter wrong code | Error: invalid or expired code | P0 | | U-DV-003 | Expired device code | Enter expired code | Error message | P1 | | U-DV-004 | Empty device code | Submit without entering code | Validation error | P1 | --- ## Module 3: Account Pages (Protected) ### 3.1 Dashboard (`/`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-DASH-001 | Dashboard loads | Login, view Dashboard | Welcome message with username, Account ID, Email, Roles displayed | P0 | | U-DASH-002 | No roles | User with no assigned roles | "None" displayed in roles section | P1 | | U-DASH-003 | Multiple roles | User with admin + user roles | Both role badges displayed | P1 | | U-DASH-004 | Quick links | Click Edit Profile / Security / Authorized Apps | Navigates to correct page | P0 | | U-DASH-005 | Unauthenticated access | Navigate to `/` without auth | Redirect to `/login?redirect=/` | P0 | | U-DASH-006 | Session restore | Reopen browser to `/` with valid session | Session restored via `auth.restoreSession()`, Dashboard shown | P0 | | U-DASH-007 | Session restore failure | Reopen browser with expired session | Redirect to login with redirect param | P1 | ### 3.2 Profile Page (`/profile`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-PROF-001 | Profile loads | Navigate to Profile | Username, Account ID, Email, Verification status, Roles shown | P0 | | U-PROF-002 | Email verified | User with verified email | Green "Verified" badge | P1 | | U-PROF-003 | Email unverified | User with unverified email | Yellow "Unverified" badge, "Resend verification" link shown | P0 | | U-PROF-004 | Resend verification | Click "Resend verification email" | Success: "Verification email sent!", disappears after 3s | P0 | | U-PROF-005 | Resend verification failure | Simulate API failure | Error message displayed | P1 | | U-PROF-006 | No email | User without email | "N/A" displayed for email, no resend link | P2 | | U-PROF-007 | API failure | Simulate GET /api/me failure | Error: "Failed to load profile" | P0 | | U-PROF-008 | Loading state | Observe during page load | "Loading..." placeholder shown | P2 | ### 3.3 Security Page (`/security`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-SEC-001 | Page loads | Navigate to Security | Password change form, MFA section, WebAuthn section displayed | P0 | | U-SEC-002 | Change password - valid | Enter old + new password (6+ chars, matching), submit | Success message, fields cleared | P0 | | U-SEC-003 | Change password - mismatch | New password != confirm | Error: "Passwords do not match" | P0 | | U-SEC-004 | Change password - too short | New password < 6 chars | Error: "Password must be at least 6 characters" | P0 | | U-SEC-005 | Change password - wrong old | Enter incorrect old password | Error message from API | P0 | | U-SEC-006 | Change password - empty fields | Submit with empty old password | Form validation or API error | P1 | | U-SEC-007 | MFA setup | Click "Setup MFA" | QR code and secret key displayed | P0 | | U-SEC-008 | MFA verify - valid code | Enter correct TOTP code after setup | Success: "MFA enabled successfully!", MFA now active | P0 | | U-SEC-009 | MFA verify - invalid code | Enter wrong code | Error message | P0 | | U-SEC-010 | MFA disable - valid password | Enter password, click Disable MFA | Success: "MFA disabled" | P0 | | U-SEC-011 | MFA disable - empty password | Click Disable without entering password | Error: "Password required to disable MFA" | P1 | | U-SEC-012 | MFA disable - wrong password | Enter wrong password | Error message from API | P0 | | U-SEC-013 | WebAuthn register | Click "Register Passkey" | Browser WebAuthn dialog shown | P1 | | U-SEC-014 | WebAuthn register cancel | Cancel browser dialog | Error: "Passkey registration was cancelled or timed out" | P1 | | U-SEC-015 | WebAuthn not supported | Access from unsupported browser | WebAuthn section hidden or "not supported" message | P1 | | U-SEC-016 | Delete account - correct username | Enter matching username, click Delete | Account deleted, redirected to login | P0 | | U-SEC-017 | Delete account - wrong username | Enter non-matching username | Error: "Username does not match" | P0 | | U-SEC-018 | Delete account - empty username | Click Delete without username | Validation prevents submission | P1 | | U-SEC-019 | Loading states | Submit any form | Buttons show loading state, disabled during request | P2 | ### 3.4 Authorized Apps (`/authorized-apps`) | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-APP-001 | List authorized apps | Navigate to Authorized Apps | Each app shows name, client_id, scopes, Revoke button | P0 | | U-APP-002 | Empty list | User has no authorized apps | "No authorized applications" empty state | P1 | | U-APP-003 | Revoke app | Click Revoke, confirm dialog | App removed from list, success message | P0 | | U-APP-004 | Revoke cancel | Click Revoke, cancel confirm | App remains in list | P1 | | U-APP-005 | Revoke failure | Simulate API failure on revoke | Error message displayed | P0 | | U-APP-006 | App without name | App has client_id but no name | client_id displayed as fallback name | P1 | | U-APP-007 | Success message auto-dismiss | After successful revoke | Success message disappears after 3s | P2 | | U-APP-008 | Loading state | During initial load | "Loading..." placeholder shown | P2 | --- ## Module 4: Navigation & Layout | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-NAV-001 | Top navigation links | Click Overview/Profile/Security/Authorized Apps | Correct page loads, active link highlighted | P0 | | U-NAV-002 | Logo link | Click logo | Navigate to Dashboard | P1 | | U-NAV-003 | User dropdown | Click user avatar | Dropdown with Profile, Security, Sign Out | P0 | | U-NAV-004 | Dropdown navigation | Click Profile in dropdown | Navigate to `/profile`, dropdown closes | P1 | | U-NAV-005 | Click outside dropdown | Open dropdown, click outside | Dropdown closes | P1 | | U-NAV-006 | Logout from dropdown | Click "Sign Out" | Session cleared, redirect to login | P0 | | U-NAV-007 | Sticky header | Scroll page content | Header remains visible at top | P2 | | U-NAV-008 | Responsive nav | Resize to mobile width | Nav collapses to hamburger or minimal layout | P1 | | U-NAV-009 | Active nav state | Navigate to `/security` | "Security" nav link highlighted with indigo background | P1 | --- ## Module 5: Cross-Cutting Concerns ### 5.1 Error Handling | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-ERR-001 | Network error | Disable network during API call | Error message displayed, no crash | P0 | | U-ERR-002 | 401 Unauthorized | Let session expire, make API call | Redirect to login page | P0 | | U-ERR-003 | 500 Server error | Trigger server error | Error message, no crash | P0 | | U-ERR-004 | Success message auto-dismiss | Perform successful action | Success message disappears after 3-4 seconds | P2 | | U-ERR-005 | Error normalization | Receive non-standard API error | Error normalized via `errorAdapter`, user-friendly message | P1 | ### 5.2 Security | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-SEC-001 | Route guard - protected pages | Navigate to `/profile` without auth | Redirect to `/login?redirect=/profile` | P0 | | U-SEC-002 | Route guard - guest pages | Navigate to `/login` while authenticated | Redirect to Dashboard | P0 | | U-SEC-003 | Token not in URL | After login | Access token not visible in URL | P0 | | U-SEC-004 | Password field masking | View all password fields | Type="password", characters masked | P1 | | U-SEC-005 | Anti-enumeration (forgot password) | Submit unregistered email | Same success message as registered email | P0 | | U-SEC-006 | CSRF on password change | Change password request | Proper auth headers included | P0 | | U-SEC-007 | localStorage cleared on logout | Logout, check localStorage | Auth tokens removed | P0 | | U-SEC-008 | localStorage cleared on account delete | Delete account | localStorage.clear() called | P0 | ### 5.3 Session Management | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-SESS-001 | Session restore on page reload | Refresh page while logged in | Session restored, no redirect to login | P0 | | U-SESS-002 | Session expire during use | Wait for token expiry, make API call | Redirect to login | P0 | | U-SESS-003 | Multiple tabs | Open app in two tabs, logout in one | Other tab redirects to login on next navigation | P1 | | U-SESS-004 | Token refresh | Access token expires, refresh token valid | New access token obtained seamlessly | P1 | ### 5.4 Performance | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-PERF-001 | Lazy-loaded routes | Check network tab during navigation | Only required chunks loaded (code splitting) | P2 | | U-PERF-002 | Large authorized apps list | User with 50+ authorized apps | Page renders without lag | P1 | | U-PERF-003 | WebAuthn credential list | User with many registered passkeys | Credentials listed efficiently | P2 | ### 5.5 Accessibility | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-A11Y-001 | Form labels | Inspect all form inputs | All inputs have associated labels | P1 | | U-A11Y-002 | Required field indicators | View registration form | Required fields marked (asterisk or "required" attribute) | P1 | | U-A11Y-003 | Keyboard navigation | Tab through login form | All interactive elements reachable, focus visible | P1 | | U-A11Y-004 | Submit on Enter | Press Enter in password field | Form submits | P1 | | U-A11Y-005 | Error announcement | Trigger form error | Error message readable by screen reader | P2 | | U-A11Y-006 | Autocomplete attributes | Inspect login form | autocomplete="username", "current-password" set correctly | P2 | --- ## Module 6: Edge Cases & Stress Scenarios | ID | Test Case | Steps | Expected Result | Priority | |----|-----------|-------|-----------------|----------| | U-EDGE-001 | Unicode username | Register with `用户名` as username | Handled correctly or validation error | P1 | | U-EDGE-002 | Email with + addressing | Register with `user+tag@example.com` | Accepted and handled correctly | P1 | | U-EDGE-003 | Very long password | Enter 1000-char password | Accepted or validation error with message | P2 | | U-EDGE-004 | Special characters in password | Enter `P@$$w0rd!#%^&*()` | Password accepted | P1 | | U-EDGE-005 | Browser back after logout | Logout, press browser Back | Redirect to login (no cached authenticated page) | P1 | | U-EDGE-006 | Direct URL to OAuth callback | Navigate to `/callback` directly | Error: "No authorization code received" | P1 | | U-EDGE-007 | Double-click submit | Rapidly double-click Sign In | Only one request sent (button disabled) | P2 | | U-EDGE-008 | Slow network | Login on slow 3G connection | Loading state shown, eventually completes or times out | P2 | | U-EDGE-009 | WebAuthn in HTTP context | Access via HTTP (not HTTPS) | WebAuthn gracefully unavailable | P1 | | U-EDGE-010 | Multiple MFA setup attempts | Click Setup MFA multiple times | Only one setup flow active at a time | P2 | --- # Versioning & Release Policy Source: https://fulla.dev/docs/contribute/versioning-and-release # Versioning & Release Policy fulla's version numbering scheme, bump decision rules, release cadence, pre-release and patch channels, and the standard operating procedure (SOP) for shipping a release. This document is the single source of truth for versioning governance. **The "how" of release engineering (CI pipeline, signing, SBOM) is implemented by [`.github/workflows/release.yml`](https://github.com/voidvec/fulla/blob/master/.github/workflows/release.yml); this document answers "when to release, what to bump, and why".** When the two conflict, this document wins — fix the pipeline. > Related documents: > - [SDK Runtime Contract](../sdk/sdk-runtime-contract.md) §2 declares the ABI / > source-level SemVer commitments and the deprecation process; this document > expands on their versioning-governance side. > - [CI/CD Guide](../contribute/ci-cd-guide) describes where the release pipeline > sits in the overall CI. --- ## 1. Version Numbering Scheme ### 1.1 SemVer 2.0.0 fulla follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html): ``` MAJOR.MINOR.PATCH[-prerelease] 1 . 0 . 0 -rc.1 ``` | Segment | Bump trigger (summary; see the decision table in §2) | Compatibility commitment | |---|---|---| | **MAJOR** | Breaking change | None — users must change their code | | **MINOR** | New functionality, backwards compatible | Source compatible | | **PATCH** | Backwards-compatible defect fixes | Source compatible | | **prerelease** | `-alpha.N` / `-beta.N` / `-rc.N` | No commitment | > The boundary of "source compatible" for v1.x is defined by > [SDK Runtime Contract](../sdk/sdk-runtime-contract.md) §2: it covers only the > **source-level API** of the public headers under `libs/*/include/fulla/**` and > makes no binary-ABI commitment. ### 1.2 Single source of truth for the version number (SSoT) | Component | Version source | Sync validation | |---|---|---| | C++ libraries + server | `MAJOR/MINOR/PATCH` in [`cmake/Version.cmake`](https://github.com/voidvec/fulla/blob/master/cmake/Version.cmake) | ✅ `tools/api-diff/api_diff.py` cross-checks `Version.cmake` / `CMakeLists.txt project(VERSION)` / `conanfile.py version` | | Docker images | Read from `Version.cmake` by `release.yml` | GHCR tag = `<version>` | | DB schema | The numbering of `apps/server/migrations/V0NN_*.sql` | **Not coupled to the product version** (see §6) | **The first step of any release is editing `cmake/Version.cmake`**; version drift across the three locations is intercepted by `api-diff` in the `version-check` job of `release.yml`. --- ## 2. Version Bump Decision Table Which bump does a change trigger — MAJOR / MINOR / PATCH? Decide with the table below. **When multiple rows match, take the highest level (MAJOR > MINOR > PATCH).** | Change type | → MAJOR | → MINOR | → PATCH | |---|:---:|:---:|:---:| | SDK public header **removed / renamed / signature changed / default argument changed** (judged BREAKING by api-diff) | ✅ | | | | **Behavioral semantics change** of a public API (meaning of return values, error codes, side effects, protocol field semantics) | ✅ | | | | Raise of the minimum C++ standard / compiler version | ✅ | | | | **Major-version** dependency upgrade of Drogon / Postgres / Redis | ✅ | | | | Config option **removed**, or **default value changed with no compatible old behavior** | ✅ | | | | **Breaking migration** of the DB schema (column drop / type change without backfill / rename) | ✅ | | | | New SDK API / new OAuth2 endpoint / new OIDC claim | | ✅ | | | **New optional parameter / field** on an existing API (with default value) | | ✅ | | | New optional config option (old configs keep working) | | ✅ | | | New optional dependency | | ✅ | | | Performance optimization (no public API change) | | ✅ | | | `feat:` conventional commit (no `!`) | | ✅ | | | `fix:` conventional commit — API behavior regressing back to "correct" | | | ✅ | | Security vulnerability fix (CVE-type, no API change) | | | ✅ | | Docs / tests / CI fixes (if a release is decided) | | | ✅ | | Purely `docs: / test: / chore: / build: / ci:` commits | | | No release | ### Conventional Commits → bump automatic mapping The default mapping from commit prefix to bump (an `!` suffix or a `BREAKING CHANGE:` footer forces MAJOR): ``` feat: → MINOR feat!: → MAJOR fix: → PATCH fix!: → MAJOR perf: → PATCH perf!: → MAJOR refactor: → no release (unless !) docs/test/chore/build/ci: → no release ``` A scope does not change the default mapping, but a maintainer may raise the level based on the scope (see §3). The commit parser in `cliff.toml` is already aligned with the table above. --- ## 3. The security-hardening "gray zone" — an explicit trade-off statement One category of changes arising from OAuth/OIDC compliance audits is special: they **tighten previously lenient behavior** (e.g. enforcing https redirect_uri, enforcing PKCE, requiring client_secret for the refresh grant of CONFIDENTIAL clients). Such changes: - **From a strict SemVer perspective**: breaking (downstreams relying on the old lenient behavior break). - **Industry practice**: mostly shipped within a MINOR bump, prominently flagged in the Release Notes. **fulla's trade-off:** > Security hardening ships within a **MINOR** and does **not force a MAJOR**. > Rationale: the previous "lenient behavior" was itself a spec violation (a bug); > fixing it is a return to correctness, not an intentional change of product > semantics. But every such change **must** be explicitly listed in the > **⚠️ Breaking (security hardening)** section of the Release Notes, together > with migration guidance. This is an **explicit trade-off**, not a vague compromise. If the impact of a particular hardening is assessed as genuinely broad (e.g. removing an entire grant type), it should still go through the MAJOR + pre-release channel (see §5). --- ## 4. Release Cadence A **hybrid model**: periodic MINOR + on-demand PATCH + emergency security hotfix. | Event | Trigger | |---|---| | **Scheduled MINOR** (new features) | Every **4–6 weeks**; or when ≥ 3 `feat:` commits have accumulated | | **PATCH** (bug fix) | When ≥ 5 `fix:` commits have accumulated; or when a user-reported bug has been fixed | | **Emergency security PATCH** | **Immediately** after a P0 / CVE vulnerability fix (without waiting for the cadence) | | **MAJOR** | When breaking changes have accumulated; must go through the pre-release channel (§5) | The cadence is **guidance, not dogma**: skipping a cycle when nothing release-worthy changed is perfectly fine; conversely, a P0 security fix always ships immediately. --- ## 5. Pre-release Channel Before an official MAJOR release, go through a staged pre-release ladder: ``` v2.0.0-alpha.1 → alpha.2 → … → v2.0.0-beta.1 → … → v2.0.0-rc.1 → … → v2.0.0 ``` | Stage | Semantics | Accepted changes | |---|---|---| | `alpha.N` | Functionality may be incomplete; CI not guaranteed to pass | Anything (including new features, behavior adjustments) | | `beta.N` | Feature freeze, feedback gathering | Bug fixes + non-breaking feedback-driven adjustments | | `rc.N` | Release candidate | **Only** P0/P1 bug fixes | | (suffix removed) | Official release | No new changes accepted | **Image tag rules**: - Official release → tagged `<version>` **and** `latest` - pre-release → tagged **only** `<version>` (e.g. `v2.0.0-rc.1`), **not** `latest` > **Current pipeline status**: the tag trigger pattern of `release.yml`, > `v[0-9]+.[0-9]+.[0-9]+`, **accepts no suffix**, so pre-release tags currently > do not trigger a release. Enabling the pre-release channel requires extending > that regex to match `v[0-9]+.[0-9]+.[0-9]+(-[a-z]+\.[0-9]+)?` and, in the > `github-release` job, deciding from whether the tag contains `-` whether to > mark the GitHub Release as "Pre-release" and skip the `latest` manifest merge. > This is a **pending pipeline change** of this policy (see §11). --- ## 6. DB Schema Versioning Is Decoupled from the Product Version fulla uses numbered migrations (`V001_*` … `V0NN_*`) whose numbers increment independently. - **Additive migration (new table / new column / new index)** = backwards compatible → triggers a **MINOR** assessment. - **Breaking migration (column drop / type change without backfill / rename)** → triggers a **MAJOR** assessment. - The schema version table only records the migration application history and does **not** map to `MAJOR.MINOR.PATCH`. --- ## 7. Release Branch and Patch Release After v1.2.0 is released, the mainline develops v1.3.0. If a P0 vulnerability is found in v1.2.0: ``` master: ──●──●──●──●──●──●──→ (developing v1.3.0) \ release/1.2: └──●(cherry-pick fix)──● tag v1.2.1 ``` **Conventions**: - Branch naming: `release/<MAJOR>.<MINOR>` (e.g. `release/1.2`). - The branch **accepts only cherry-picked bug fixes**, no new features. - Each patch release cuts a `v<MAJOR>.<MINOR>.<PATCH>` tag, which triggers `release.yml`. - **Maintenance window**: patches are maintained only for the **latest** release branch. The previous branch is EOL once a new minor is released (no LTS — see §8). --- ## 8. LTS (Long-Term Support) **No LTS at the current stage.** Only the latest minor's patch releases are maintained. Whether to introduce LTS (à la the Node.js / Kubernetes model) will be reconsidered when the downstream user base grows and upgrade costs become visible. --- ## 9. `latest` Tag Semantics and Production Deployment - `:latest` points to the **latest official release** (pre-releases excluded). - [`deploy/docker/docker-compose.prod.yml`](https://github.com/voidvec/fulla/blob/master/deploy/docker/docker-compose.prod.yml) uses `${FULLA_VERSION:-latest}`: a default for deployment convenience. - ⚠️ **Production deployments should pin an explicit version number** (`FULLA_VERSION=1.2.0`) instead of relying on `latest` — it rolls uncontrollably whenever a new version is published. --- ## 10. Deprecation Process Consistent with [SDK Runtime Contract](../sdk/sdk-runtime-contract.md) §2: 1. At the current MINOR release, annotate the deprecated API with `[[deprecated("Use X instead; removed in vN.0")]]`. 2. Record the deprecation + migration guidance in the **Deprecated** section of the Release Notes. 3. **Keep it for at least one MINOR cycle** (two recommended). 4. Remove it in the next MAJOR. Non-SDK deprecations (config options, endpoint parameters) follow the same "annotate → transition → remove" process, using Release Notes plus a LOG_WARNING at config load time as the annotation mechanism. --- ## 11. Release Standard Operating Procedure (SOP) ### 11.1 Official MINOR / PATCH (from master) ```sh # 1. Confirm master is green (CI fully passing) git checkout master && git pull # 2. Update the version number SSoT # Edit MINOR or PATCH in cmake/Version.cmake # 3. Validate the API surface (critical step) python3 tools/api-diff/api_diff.py # - additive drift (new headers / new declarations) → allowed for MINOR, ratify: # python3 tools/api-diff/api_diff.py --update-baseline # - breaking drift (removed / changed declarations) → MAJOR must be confirmed # bumped first; for changes that do not affect the consumed surface (private # members / include reordering, etc.), after review: # python3 tools/api-diff/api_diff.py --force --update-baseline # 4. Generate a CHANGELOG draft, then curate manually git cliff --unreleased --tag vX.Y.Z --prepend CHANGELOG.md # Manual editing essentials: # - Categorize into Added / Fixed / Changed / Security / Deprecated / ⚠️ Breaking # - Put security hardening into the ⚠️ Breaking (security hardening) section + migration guidance # - Drop entries with no information value # 5. Commit the version number + baseline + CHANGELOG git add cmake/Version.cmake tools/api-diff/*.baseline CHANGELOG.md git commit -m "chore(release): vX.Y.Z" # 6. Tag and push — triggers release.yml git tag vX.Y.Z git push origin master --tags ``` `release.yml` completes automatically: version-check → SDK tarball → multi-arch images → cosign signing → SBOM → GitHub Release (with git-cliff-generated notes + verification guidance). ### 11.2 Emergency Security PATCH (from a release branch) ```sh # 1. Cherry-pick the fix commit onto the release/<MAJOR>.<MINOR> branch git checkout release/1.2 git cherry-pick <fix-commit-sha> # 2. Bump PATCH on that branch (same steps 2–6 as 11.1, but targeting the release branch) ``` ### 11.3 MAJOR (via the pre-release channel) ```sh # 1. Accumulate breaking changes on master (or a dedicated candidate branch) # 2. Bump MAJOR, then tag prereleases in sequence: git tag v2.0.0-alpha.1 && git push --tags # → alpha stage # ... feedback iterations ... git tag v2.0.0-beta.1 && git push --tags # → beta stage git tag v2.0.0-rc.1 && git push --tags # → rc stage (only P0/P1 fixes) # 3. Once rc passes, drop the suffix for the official release: git tag v2.0.0 && git push --tags # 4. After the official release, create the release/2.0 branch git checkout -b release/2.0 v2.0.0 && git push origin release/2.0 ``` > ⚠️ As stated in §5: pre-release tags currently do **not trigger** > `release.yml`. Complete the pipeline change (see the §12 backlog) before > enabling this channel. --- ## 12. Backlog (Gaps Between This Policy and the Current State) | # | Item | Problem it solves | |---|---|---| | **T1** | Write this document (✅ this file) | No written bump rules before | | **T2** | Execute the first official release since v1.0.0 (v1.0.1 or v1.1.0) | 840 commits piled up after v1.0.0, unreleased | | **T3** | Extend the `release.yml` tag trigger pattern + `latest` skip logic, enabling the pre-release channel | Prerelease tags currently do not trigger a release | | **T4** | Add a `latest` warning cross-reference to the prod deployment doc (`docker-deployment.md`) | The `latest` default poses a rolling-update risk in production | | **T5** | Define the release branch naming convention and add a pointer in the README (create the branch when first actually needed) | The patch release process is not yet instantiated | T1 is this file; T2 is the immediate priority; T3–T5 can land when their scenarios first occur. --- # adr/ADR-0001.md Source: https://fulla.dev/docs/adr/ADR-0001 ## Context The repository carries both a directly deployable authorization-server product and a reusable protocol engine. Without constraining dependency direction, Domain code would be permeated by framework types and SDK consumers would be forced to pull in Drogon. ## Decision Deliver with the product as the mainline while distilling fulla::oauth2 and fulla::identity into two embeddable SDKs. The three Domain packages (common/oauth2/identity) are forbidden from including Drogon (jsoncpp is allowed); oauth2 and identity have no compile-time dependency on each other; all cross-package ports sink into common; apps/server is the sole assembly point responsible for dependency injection; [tools/arch-guard](https://github.com/voidvec/fulla/blob/master/tools/arch-guard) enforces all of the above in CI. ## Consequences and Current State The 7 library directories under libs/ (8 CMake packages, including `common/testing`) and include/fulla/*/ports/ are all implemented accordingly; arch-guard (tools/arch-guard) is a CI gate. Consumers can use just the engine plus any single storage backend. --- # adr/ADR-0002.md Source: https://fulla.dev/docs/adr/ADR-0002 ## Context In C++ static libraries, Drogon relies on static-initialization self-registration; whole-archive linking is fragile across platforms/consumers and drags in every symbol. ## Decision Controllers use `HttpController<T,false>` + an explicit `registerController`, with no reliance on whole-archive; OAuth2Plugin keeps its class name and the plugins blocks of every config (`config.{json,dev,ci,prod,bench}.json`) untouched (Option A). Boundary: if the plugin is ever distributed as a static library, consumers will still need whole-archive to pull in the self-registration symbols. ## Consequences and Current State SDK Smoke (a find_package full-stack consumer) validates this strategy in CI; examples/ provides reference hosts. --- # adr/ADR-0003.md Source: https://fulla.dev/docs/adr/ADR-0003 ## Context Error codes/messages/HTTP statuses scattered inside controllers caused drift; the application-side and protocol-side error body formats are inherently different. ## Decision For every error code (including OAuth2 protocol codes), the code/numeric/category/HTTP status/default message treats the compile-time ErrorCatalog as the sole authority; Application endpoints use the ErrorResponder error envelope, while protocol endpoints use the RFC 6749 error body (including the Filter layer); the frontend error catalog mirrors the backend's and is validated at build time (errorAdapter is a pure function). Principle: distinct failure causes must not be folded into the same code; exception: anti-enumeration scenarios (account lockout sharing the same code as a wrong password) are deliberate design. ## Consequences and Current State ErrorCatalog.cc (525 lines) + unit tests force documentation sync (the [api-reference](../domains/api-reference.md) error table is verified by tests); the 4006 added in [PR#85](https://github.com/voidvec/fulla/pull/85) went through exactly this process. --- # adr/ADR-0004.md Source: https://fulla.dev/docs/adr/ADR-0004 ## Context JWT access tokens widen the leak surface and are hard to revoke instantly; storing tokens/secrets in plaintext means a database leak equals a credential leak. ## Decision Access tokens remain opaque random strings, validated by resource servers via introspection (RFC 7662); no JWT access token is introduced (the id_token remains a JWT); the database stores only SHA-256(token) and secret hashes; every schema change goes through append-only V### versioned migrations. Implementation note: the decision table originally said Argon2id, but what actually landed is PBKDF2-SHA256 with 310K iterations (PasswordHasher reserves an upgrade slot). ## Consequences and Current State The introspection/revocation endpoints are public contract; V001–V026 evolved append-only; migration immutability is enforced by the migration-hygiene gate in [ci.yml](https://github.com/voidvec/fulla/blob/master/.github/workflows/ci.yml). --- # adr/ADR-0005.md Source: https://fulla.dev/docs/adr/ADR-0005 ## Context Coexisting username and email identifiers caused login ambiguity and unique-constraint conflicts. ## Decision Login identifiers are dispatched by whether they contain @ (the username charset and email are mutually exclusive, guaranteeing no ambiguity); email is required, and registration/login share normalizeEmail; username is optional but keeps its UNIQUE constraint (NULL exempt); the login path validates most leniently (regex applies only to registration/password change); the API field name stays username for compatibility; the OIDC name falls back to email. ## Consequences and Current State The V020 migration is in the repository; the login semantics in operational documents such as the [verification checklist](../operate/verification-checklist.md) follow this ADR. --- # adr/ADR-0006.md Source: https://fulla.dev/docs/adr/ADR-0006 ## Context Bare this captures in Drogon async callbacks and lock-free cross-thread access were the main source of historical defects (11 audit findings). ## Decision Any object carrying async callbacks uses enable_shared_from_this; lambdas capture self/weak_ptr and bare this is forbidden (as are coroutines); initialization uses Meyers Singleton / call_once to eliminate SIOF; the thread safety of drogon::CacheMap's own mutex is acknowledged and relied upon (the EventLoop* only drives expiring timers and does no loop orchestration); look-aside cache stampedes are accepted as non-linearizable consistency. ## Consequences and Current State This discipline is codified in [`db-operations.md`](https://github.com/voidvec/fulla/blob/master/.claude/rules/db-operations.md) (maintainer-facing rule source) as a project iron rule; all 11 concurrency-audit defects have been fixed with regression tests. --- # adr/ADR-0007.md Source: https://fulla.dev/docs/adr/ADR-0007 ## Context If MFA verify does not bind the first-factor context, cross-client confusion and a redirect_uri bypass can be combined into account takeover. ## Decision MFA verify must check: the client registration relationship + the redirect_uri whitelist + a pending binding consistent with the first-factor login (users.mfa_pending_client_id / mfa_pending_redirect_uri, cleared to NULL after verification to prevent replay). All new rejections use the unified AUTH_INVALID_CREDENTIALS 401, eliminating the client-registration enumeration oracle. Known accepted limitations: mfa_token has no expiry and can be overwritten concurrently. ## Consequences and Current State The V022 migration is in the repository; MfaController is implemented according to the pending binding. --- # adr/ADR-0008.md Source: https://fulla.dev/docs/adr/ADR-0008 ## Context If SPA login persists the PKCE verifier/token to storage, XSS immediately equals session hijacking. ## Decision First-party login keeps the AJAX /oauth2/login (json=true) flow that returns the authorization code directly, with the PKCE verifier held by a closure and never persisted; third parties use the authorize full-page redirect + a sessionStorage relay; the access_token lives only in memory and never reaches localStorage; the backend-hosted full-page login-page approach was shelved after review. The security wording follows the corrected framing: shrink the exposure window rather than eradicate leakage. ## Consequences and Current State [frontends/user](https://github.com/voidvec/fulla/tree/master/frontends/user)'s authService/http implement this; the third-party integration path is documented in the [OIDC guide](../domains/oidc-guide.md). --- # adr/ADR-0009.md Source: https://fulla.dev/docs/adr/ADR-0009 ## Context If drogon_ctl-generated model classes undergo manual renaming, regeneration drifts immediately; if already-applied migrations are modified, multi-environment schema validation breaks. ## Decision The 14 drogon_ctl-generated model class names/namespaces are never renamed (this overrides the unified naming style; only whole-directory relocation is allowed); merged V### migration files are frozen, and schema changes only append new versions. ## Consequences and Current State This decision was validated through both the repo refactor and the SDK refactor; the migration-hygiene gate in [ci.yml](https://github.com/voidvec/fulla/blob/master/.github/workflows/ci.yml) enforces migration immutability in CI; keeping the oauth2_* table/class names intact during the 2026-08 renaming project is precisely this ADR in action. --- # adr/ADR-0010.md Source: https://fulla.dev/docs/adr/ADR-0010 ## Context The homegrown Redis fixed-window rate-limiting Filter carried high maintenance costs and was coupled to storage. ## Decision Global-side rate limiting adopts the official Drogon Hodor plugin: token bucket, in-process CacheMap (zero external dependencies), IP/user/global tiers, a trust_ips whitelist, pure-JSON configuration; the homegrown Redis fixed window is retired. The rejection body is folded into the error envelope VALIDATION_RATE_LIMITED (429). Mounted only in config.prod.json; authentication-side brute-force protection is carried separately by F-018 failure-count rate limiting (the two operate on different surfaces). ## Consequences and Current State The config.prod.json plugins block is in active use; see the rate-limiting section of security-architecture. --- # adr/ADR-0011.md Source: https://fulla.dev/docs/adr/ADR-0011 ## Context Windows/macOS cannot containerize PG/Redis; running DB-backed tests on all three platforms would make the matrix infeasible. ## Decision DB-backed integration/contract tests run only on the Linux CI leg (with PG/Redis service containers); Windows/macOS run the memory-only configuration (FULLA_MEMORY_TESTS_ONLY=ON excludes SchemaSetup); tests bring up the full app in-process and seed it via SchemaManager; coverage of the social-login/WebAuthn controllers is not treated as an HTTP-test completion criterion. ## Consequences and Current State The [ci.yml](https://github.com/voidvec/fulla/blob/master/.github/workflows/ci.yml) three-platform matrix is tiered accordingly; the local full_test runs against a real DB. --- # adr/ADR-0012.md Source: https://fulla.dev/docs/adr/ADR-0012 ## Context The callback style is criticized as callback hell; C++20 coroutines can eliminate callbacks but bring ABI/debugging/stack-tracing costs. ## Decision Stay on C++17 with the callback style: coroutines are excluded because MSVC/gcc coroutine ABIs are still unstable, stack tracing and crash reporting break on coroutine frames, and Drogon's official CoroMapper is incompatible with this project's Mapper+Criteria discipline (including the JOIN ban). Re-evaluation trigger: mature coroutine support in the toolchains plus complex control flows that callbacks cannot express. ## Consequences and Current State Callback discipline is governed by the lifetime pattern of [ADR-0006](ADR-0006.md); [db-operations.md](https://github.com/voidvec/fulla/blob/master/.claude/rules/db-operations.md) explicitly bans CoroMapper. --- # adr/ADR-0013.md Source: https://fulla.dev/docs/adr/ADR-0013 ## Context Both Vue 3 SPAs (`frontends/user`, `frontends/admin`) ship with zero i18n: page chrome is hardcoded English while the shared error-message catalog (`src/services/messages/zh-CN.ts`) is hardcoded Chinese with `DEFAULT_LOCALE = 'zh-CN'` — the rendered UI is mixed-language today. The docs site already runs a dual-locale convention (Docusaurus `defaultLocale: 'en'` + a `zh-CN` mirror tree, same-PR dual writes). We evaluated automatic-translation widgets — primarily `translate.js` (xnx3/translate) — against curated message catalogs before investing in an extraction pass over all 27 views. Key findings on `translate.js` (v4, MIT, actively maintained): it walks the DOM after load and sends page text to the author's proprietary cloud (`api.translate.zvo.cn` et al.) for machine translation; the free channel has a daily character cap; translation happens after first paint (FOUC by design); the Vue adapters have open unresolved issues (#54, #94); and it rewrites `input` value attributes — a UX hazard on credential forms. ## Decision Adopt **curated message catalogs with vue-i18n v11** (Composition API, `legacy: false`, `globalInjection: true`) as the single i18n mechanism for both frontends. Locale set: **`en` (default) + `zh-CN`** now, extensible to a tier-2 set (`zh-TW`, `ja`, `de`, `es`, `fr`, `pt-BR`, `ru`) later. Runtime machine-translation widgets are **rejected** for the product UI. Rationale: fulla is a security-sensitive identity provider — consent/scope/authorization terminology must be curated, page content must not egress to third-party translation clouds, self-hosted/air-gapped deployments (the IdP norm) must work offline, and rendered text must be deterministic and reviewable in PRs. translate.js is MIT-licensed and could technically be embedded, but its data flow alone disqualifies it for this product. vue-i18n is the de-facto Vue standard (MIT, ~3.9M downloads/week), needs no extra build tooling at our catalog size (~10–14 KB brotli runtime), and matches the existing per-locale resource-file design of the error catalog. ## Consequences and Current State - One language switcher per app drives page chrome and error messages. Fully reactive since #158: the services layer resolves the locale through an injected getter (`setLocaleGetter(() => i18n.global.locale.value)`), and error state stores the `NormalizedError` (not the resolved string), so an already-rendered error re-translates when the locale switches. `DEFAULT_LOCALE` remains the fallback table (`en`). - Locale is detected from `localStorage['fulla-locale']` (mirrors the `fulla-theme` pattern) → `navigator.languages` → `en`, applied synchronously before mount (no flash), and synced to `<html lang>`. - Catalogs are dual-written (`en` + `zh-CN`) in the same PR, enforced by key-parity unit tests; the mirrored-services and `components/ui` byte-sync gates keep both apps identical where shared. - Machine translation remains permissible in the *translation workflow* (pre-translation with human review when adding a locale), never in the runtime. - One-time cost: extraction of all user-visible strings across 27 views plus e2e assertions that pinned Chinese error text move to the English catalog (default locale); both languages are then covered by a dedicated `i18n.spec.ts` per app. --- # OAuth/OIDC 规范性审查报告 — fulla Source: https://fulla.dev/docs/adr/oauth-oidc-compliance-audit > **Language note**: this is a historical trust archive kept in its original > Chinese. The 2026-08-07 OAuth/OIDC compliance-audit baseline (all 31 > findings fixed, regression-tested) is preserved verbatim for third-party > assessors. The current security design lives in > [Security Architecture](../architecture/security-architecture.md). > 本报告是 2026-08-07 对 fulla(时名 authforge)的完整 OAuth/OIDC 合规尽调 > **基线快照**:逐条附 file:line 证据与规范章节引文。报告发布后所有发现均已在 > 后续版本中修复(含 F-002 Critical 凭据哈希不一致等),并沉淀为回归测试; > 保留本档案作为第三方评估者可核查的信任资产。当前安全设计见 > [security-architecture.md](../architecture/security-architecture.md)。 # OAuth/OIDC 规范性审查报告 — fulla | 项目 | 内容 | |---|---| | 审查对象 | fulla (`test/coverage-push` 分支, 2026-08-07) | | 审查范围 | RFC 6749 / 6750 / 7662 / 7009 / 7636 / 8252 / 8628 / 8414 / 7519/7517/7515 / 7591/7592 / 9068 / 9700 + OIDC Core 1.0 / Discovery 1.0 / RP-Initiated Logout / Back-Channel Logout | | 审查方法 | 静态代码审查 + 规范条款逐条核验;所有判断附 `file:line` 证据 + 规范章节引文 | | 不在范围 | 动态渗透测试、性能/可用性评估、前端 SPA 实现审查 | | 评级分档 | 核心规范按 MUST/SHOULD 分档;OIDC profile 按「核心 MUST」「扩展 SHOULD」分档 | > 配套文档:审查计划(检查要点、检查方法、判定标准)见 `oauth-oidc-compliance-audit-plan.md`。本报告为评估结果(符合性评级、发现、整改)。 --- ## 1. 执行摘要 ### 1.1 总体符合度 fulla 在 **OAuth 2.0 核心(RFC 6749)的"快乐路径"**上基本合规:授权码与刷新令牌的生成、哈希存储、单次性、轮换、重用级联吊销都按规范实现;PKCE 的 S256 算法是规范正确的 `base64url(raw digest)`;introspect/revoke 的客户端认证模型近期(commit 246db32)已修正为 RFC 7662/7009 要求的客户端凭证模型。 但存在 **若干违反 MUST 的实质性偏差**,集中在三类: 1. **令牌端点客户端认证不完整** —— `refresh_token` grant 完全跳过客户端认证(违反 RFC 6749 §3.2.1 MUST)。 2. **client_secret 哈希写入/校验算法不一致** —— 注册/管理路径写无盐大写 SHA-256,校验路径算有盐小写 SHA-256(违反 RFC 6749 §10.6 凭证保护 MUST,且事实上导致动态注册的客户端无法认证)。 3. **OIDC 扩展能力大面积缺失** —— `prompt`/`max_age`/`auth_time`/`acr`/`amr`/`azp`/RP-Initiated Logout/nonce 防重放/id_token-on-refresh 均未实现,使本项目实际只能算"OAuth2 + 一个最小 id_token 签发",而非完整 OIDC Provider。 ### 1.2 风险等级分布 | 等级 | 数量 | 代表项 | |---|---|---| | **严重 (Critical)** | 1 | F-002 client_secret 哈希不一致导致动态注册客户端认证失败 | | **高 (High)** | 6 | refresh_token 无客户端认证、Redis 非常量时间比较、Redis refresh 存储空操作、authorization 端点错误未按 §4.1.2.1 重定向、device_authorization 未认证机密客户端、WWW-Authenticate Bearer 缺失 | | **中 (Medium)** | 9 | id_token 缺 auth_time/acr/amr/azp、refresh 不重发 id_token、PKCE 默认不强制、device slow_down 未发出、iss 硬编码、loopback 例外未实现、token 端点无限流、state 未 urlEncode、token_endpoint_auth_method 未持久化 | | **低 (Low)** | 7 | registration_endpoint 未广告、introspection 缺 jti/username、成功响应缺 Cache-Control、jti 不存在、claims_supported 与实际不符、OpenAPI grant_type 枚举缺 device_code、CORS 校验注释 | ### 1.3 最高优先级 5 项 | 编号 | 问题 | 风险 | 修复复杂度 | |---|---|---|---| | F-002 | client_secret 哈希写/读算法不一致 | Critical | 中(统一算法 + 数据迁移) | | F-003 | refresh_token grant 跳过客户端认证 | High | 低(加 `validateClient` 调用) | | F-004 | Redis 后端 client_secret 非常量时间比较 | High | 低 | | F-005 | Redis 后端 refresh_token 存储为空操作 | High | 中(实现 Redis save/getRefreshToken) | | F-007 | authorization 端点错误未按 §4.1.2.1 重定向 | High | 中(区分 redirectable vs client 错误) | --- ## 2. 评级尺度 | 评级 | 定义 | |---|---| | **符合** | 满足规范条款的 MUST 与 SHOULD,无功能/安全偏差 | | **部分符合** | 实现核心要求但缺字段、缺边角 MUST 或不满足 SHOULD | | **不符合** | 违反某条 MUST,或实现存在功能性/安全性错误 | | **未实现** | 规范定义了能力但代码无对应实现(含 stub、advertised-but-missing) | OIDC profile 分档(按用户要求): - **核心 MUST 档**:OIDC Core 中标 MUST 的条款(如 id_token 必备 claim、UserInfo 须校验 openid scope) - **扩展 SHOULD 档**:OIDC Core 的 SHOULD 条款,以及 Session Management / RP-Initiated Logout / Back-Channel Logout 等独立规范(这些虽非 OIDC Core 的 MUST,但是「可互操作的 OIDC Provider」的事实标配) 风险等级独立于评级:一个"未实现"的扩展 SHOULD 可能只是 Medium,而一个"不符合"的核心 MUST 通常是 High/Critical。 --- ## 3. 逐规范符合性评估 ### 3.1 RFC 6749 OAuth 2.0 Authorization Framework —— **部分符合** #### 3.1.1 §1.6 / §3.1.1 协议须运行于 HTTPS;redirect_uri 须 https —— **不符合(Medium)** - **检查方法**:Grep redirect_uri scheme 校验、loopback 例外。 - **证据**:`libs/oauth2/include/fulla/oauth2/model/Client.h:86-90` 仅做 `std::find` 精确字符串匹配;`libs/drogon/src/validation/RuleEngine.cc:120-133` 的 regex `^https?://...` 同时接受 http 与 https;无任何代码强制 https 或实现 RFC 8252 §7.3 / RFC 6749 §3.1.2.1 的 loopback 端口通配。 - **偏差**:redirect_uri 注册与匹配接受任意 scheme,未强制 https,未实现 loopback 例外。 - **依据**:RFC 6749 §3.1.2.1 "the redirection endpoint SHOULD require the use of TLS";§1.6 明确 TLS 为 MUST 级别的部署前提。 - **见**:F-014。 #### 3.1.2 §3.1.2.3 redirect_uri 须精确匹配 —— **符合** - **证据**:`Client.h:83-90` `isRegisteredRedirectUri` 用 `std::find(..., redirectUri) != ...end()`,注释明示"RFC 6749 §3.1.2.3 requires exact match, not prefix/pattern matching"。authorize 端 `libs/oauth2/src/protocol/ClientService.cc:32-44` 调用之;exchange 端 `PostgresGrantRepository.cc:181-189` 复核之。 - **判定**:精确匹配、无通配、无前缀。**符合**。 #### 3.1.3 §4.1.2 授权码:TTL ≤10min、一次性、绑定 —— **符合** - **TTL**:`libs/oauth2/src/protocol/TokenService.cc:151` `authCode.expiresAt = nowSeconds() + authCodeTtl_;`,默认 600s(`OAuth2Plugin.cc:53`),exchange 时校验(`TokenService.cc:226-230`)。 - **一次性**:`PostgresGrantRepository.cc:165-176` 原子 CAS `UPDATE oauth2_codes SET used=true WHERE code=$1 AND used=false RETURNING *`;Memory 后端 `MemoryGrantRepository.cc:66-95` 用锁 + `used` 标志。 - **绑定**:`TokenService.cc:144-151` 写入 `clientId/userId/scope/redirectUri/codeChallenge/codeChallengeMethod/nonce`;exchange 时校验 client_id(`:200-204`)、redirect_uri(`PostgresGrantRepository.cc:181-189`)、PKCE(`:206-219`)。 - **判定**:**符合**。 #### 3.1.4 §4.1.2.1 错误重定向 vs 直接错误 —— **不符合(High)** - **检查方法**:审 `AuthorizationEndpointController.cc` 各错误分支的响应方式。 - **证据**: - 无效 client_id → 直接 400(`AuthorizationEndpointController.cc:233-237`) - 无效 redirect_uri → 直接 400(`:257-261`) - scope 失败 → 直接 JSON(`:362-371`) - 仅 consent-deny 一处按规范 `?error=access_denied&state=` 重定向(`SessionController.cc:728-737`) - **偏差**:RFC 6749 §4.1.2.1 规定——若请求的 `redirect_uri` 缺失/无效或 `client_id` 未知,AS 应直接告知用户错误(不重定向);**但若 redirect_uri 与 client_id 均有效**,所有其他错误(access_denied、invalid_scope、unsupported_response_type、server_error 等)都**必须**以 `?error=&state=` 形式 302 重定向到 redirect_uri。本项目对所有非 consent-deny 错误一律直接 4xx,state 在这些情况下不回显。 - **依据**:RFC 6749 §4.1.2.1。 - **见**:F-007。 #### 3.1.5 §4.1.3 authorization_code 兑换 —— **部分符合(Medium)** - **redirect_uri 比较可被绕过**:`PostgresGrantRepository.cc:181-189` 与 `MemoryGrantRepository.cc:80-87` 均以 `if (!redirectUri.empty() && redirectUri != stored)` 守卫比较。RFC 6749 §4.1.3 规定"if the `redirect_uri` was included in the initial authorization request **then** it MUST be included in the token request"——即签发时带了 redirect_uri,兑换时就必须带且必须匹配。当前实现允许兑换时**省略** redirect_uri 从而跳过比较。`RuleSet::oauth2Token`(`RuleSet.cc:360-376`)对 `code` 必填、`client_id` 选填,未强制 redirect_uri 在该场景下的必填性。 - **client 绑定 / PKCE / 过期**:已实现(见 3.1.3)。 - **见**:F-009。 #### 3.1.6 §3.2.1 / §4.1.3 token 端点对所有机密客户端 MUST 认证 —— **不符合(High)** - **检查方法**:跟踪 4 种 grant 在 controller 入口的 `validateClient` 调用。 - **证据**: - authorization_code → `TokenService.cc:177-187` 先 `validateClient` ✅ - client_credentials → `TokenEndpointController.cc:733-775` 先 `validateClient` ✅ - device_code → `TokenEndpointController.cc:1227-1276` 先 `validateClient`(按 client_type 分支)✅ - **refresh_token → `TokenEndpointController.cc:679-711` 直接 `plugin->refreshAccessToken(refreshTokenStr, clientId, ...)`,无 `validateClient` 调用** ❌ - **偏差**:refresh_token grant 仅在 `TokenService.cc:387-391` 做 `storedRt->clientId != clientId` 字符串比较,未校验 client_secret。**任何机密客户端只要持有一个他不该有的 refresh_token 字符串,配上任意 client_id 即可刷新**(client_id 必须匹配,但 client_id 不保密,攻击者从被泄漏的 refresh_token 关联日志即可推得)。这违反 RFC 6749 §3.2.1("The authorization server MUST [...] authenticate the client if the client was issued credentials")。 - **依据**:RFC 6749 §3.2.1。 - **见**:F-003。 #### 3.1.7 §4.4 client_credentials —— **符合** - **仅限机密客户端**:`TokenEndpointController.cc:765-775` PUBLIC → `unauthorized_client`。 - **不发 refresh_token**:`:825` 注释明确,`:853` 响应省略。 - **scope 校验**:`:784-823` 超集 → `invalid_scope`;缺省取客户端注册的 scope 全集;注册 scope 为空且请求省略 → `invalid_scope`。 - **subject 为 `client:<id>`**(`:839`),符合 RFC 6749 §4.4.3 的 M2M 语义。 - **判定**:**符合**。 #### 3.1.8 §6 refresh_token —— **部分符合(中,配合 3.1.6 看)** - **轮换**:每次刷新都发新 access + 新 refresh(`TokenService.cc:400-417`)。 - **重用检测 + 级联吊销**:原子 CAS `atomicRevokeRefreshToken`(`PostgresTokenRepository.cc:404-446`)+ `revokeTokenFamily`(`:448-492`,按 family_id 批量吊销 refresh 与关联 access),审计 `refresh_token_reuse_detected`(`TokenService.cc:367-373`)。**符合 RFC 6749 §6 的推荐实践**。 - **绑定 client_id 校验**:`TokenService.cc:387-391` 有比较,但配合 3.1.6 的"无认证",仅是字符串比较,弱。 - **Redis 后端失效**:`RedisTokenRepository.cc:155-165` `saveRefreshToken`/`getRefreshToken` 是空操作(注释 line 153-154 明示),`atomicRevokeRefreshToken` 永远返回 nullopt,故 Redis 部署下轮换/级联**完全不工作**。 - **见**:F-005。 #### 3.1.9 §5.1 成功响应 —— **部分符合(Low)** - **token_type=Bearer / expires_in**:齐备(`TokenService.cc:293,297,432,434`;`TokenEndpointController.cc:850-851,1147-1148`)。 - **scope 回显**:client_credentials(`:852`)与 device_code(`:1150-1153`,仅非空时)回显;**authorization_code 与 refresh_token 不回显**(`TokenService.cc:291-299, 430-436`)。RFC 6749 §5.1 规定 scope 若与请求不同则 MUST 回显、相同则 OPTIONAL,缺失属轻微偏差。 - **Cache-Control: no-store**:RFC 6749 §5.1 RECOMMENDS 此头于所有 token 响应;本项目仅错误响应加(`OAuth2ErrorHandler.cc:74-75`),成功响应不加。 - **非标 `roles` 字段**:`TokenService.cc:299` 在 authorization_code 响应额外加 `roles`,非 RFC 6749 字段(可接受但应文档化)。 - **见**:F-019。 #### 3.1.10 §5.2 错误响应 —— **部分符合(Medium)** - **error 码集合**:覆盖 `invalid_request/invalid_client/invalid_grant/unauthorized_client/unsupported_grant_type/invalid_scope/server_error/access_denied/authorization_pending/expired_token`(`ErrorCatalog.cc:218-236`)。 - **HTTP 状态映射**:`invalid_client`→401、`server_error`→500、`access_denied`→403、其余→400(`OAuth2ErrorHandler.cc:93-107`、`ErrorCatalog.cc:221-233`)。基本符合 §5.2。 - **WWW-Authenticate on invalid_client**:协议端点(introspect/revoke)经 `OAuth2ErrorHandler::sendErrorResponse` 在 `authScheme` 非空时正确加 `WWW-Authenticate`(`OAuth2ErrorHandler.cc:85-88`)。但 `/oauth2/token` 的 inline grant 分支(client_credentials/device_code 的 `invalid_client`)直接构造 JSON,**不加** WWW-Authenticate(`TokenEndpointController.cc:718-725, 736-743, 757-762, 1238-1244, 1264-1271`)。违反 §5.2 "MUST include the WWW-Authenticate header"。 - **validation gate 误用应用信封**:`RuleSet::oauth2Token` 失败经 `HttpResponder` 返回应用信封 `VALIDATION_INVALID_INPUT`(`HttpResponder.cc:57-58, 86`),**不是** RFC 6749 §5.2 的 `error: "invalid_request"`。客户端按 OAuth2 协议解析错误时会失败。 - **见**:F-006、F-008。 #### 3.1.11 §10.4 / §10.6 凭证保护 —— **不符合(Critical,仅动态注册路径)** - **access/refresh token 哈希存储**:✅ UPPER-hex SHA-256(`TokenCrypto.cc:26-37`),原值不入库。 - **client_secret 哈希**:❌ 见 3.6.2。写入路径用无盐大写 SHA-256,校验路径算有盐小写 SHA-256。两套算法永远不会匹配。 - **常量时间比较**:Postgres ✅(`PostgresClientRepository.cc:14-27, 245-249`),Memory ✅(`MemoryClientRepository.cc:11-24, 163`,但比较的是**明文**),Redis ❌(`RedisClientRepository.cc:174-175` `==`)。 - **见**:F-002、F-004。 #### 3.1.12 §10.9 维护撤销集 —— **符合** - access/refresh token 持 `revoked` 标志,`validateAccessToken`(`TokenService.cc:443-479`)查 revoked + expiry。 - **判定**:撤销即时生效。**符合**。 --- ### 3.2 RFC 6750 Bearer Token Usage —— **部分符合** #### 3.2.1 §2.1 Bearer 头解析 —— **符合** `OAuth2AuthFilter.cc:39-52` 校验 `Authorization: Bearer ` 前缀,缺失/格式错→401。 #### 3.2.2 §2.3 query 传 token —— **部分符合(Low)** `AuthorizationFilter.cc:99-107` 接受 `?access_token=`,属 RFC 6750 §2.3 已废弃方式。允许但不推荐。 #### 3.2.3 §3 WWW-Authenticate: Bearer challenge —— **不符合(Medium)** - **证据**:资源端点(`/api/me`、`/api/admin/*`)401 经 `OAuth2AuthFilter`/`AuthorizationFilter` 返回应用信封 `AUTH_TOKEN_INVALID`,**不**按 RFC 6750 §3 发 `WWW-Authenticate: Bearer realm="...", error="invalid_token", error_description="..."`。 - **依据**:RFC 6750 §3 "If the request lacks any authentication information [...], the resource server SHOULD NOT respond with the WWW-Authenticate header. [...] otherwise [...] MUST include the WWW-Authenticate header"。 - **见**:F-006。 #### 3.2.4 §3.1 insufficient_scope —— **不符合(High)** - **证据**:`OAuth2AuthFilter.cc:73-75` 把 `scope` 写入 attributes 但**从不校验**。Grep 全 `libs/`、`apps/` 无任何资源端点按 scope 拒绝。`/api/admin/*` 的 RBAC 按**用户角色**而非 OAuth scope。 - **依据**:RFC 6750 §3.1 "If the access token [...] has insufficient scope",应返回 `insufficient_scope`。 - **影响**:scope 退化成"签发时校验客户端白名单"的元数据,丧失细粒度资源授权能力。 - **见**:F-010。 --- ### 3.3 RFC 7662 Token Introspection —— **部分符合** #### 3.3.1 §2.1 客户端认证 MUST —— **符合** `TokenEndpointController.cc:279-345`:`extractClientCredentials` 取 Basic 或 POST `client_id`/`client_secret`,缺失→`invalid_client`+WWW-Authenticate,`validateClient` 校验。**符合**。 #### 3.3.2 §2.2 响应字段 —— **部分符合(Low)** - **已发**:`active/client_id/token_type/exp/iat/nbf/sub/aud/iss/scope`(`:377-409`)。 - **缺失**:`username`、`jti`。`jti` 全代码库不存在(Grep 无结果)。 - **iss 不一致**:refresh_token 分支硬编码 `iss = "https://oauth.example.com"`(`PostgresTokenRepository.cc:567`),与配置 issuer 不符。 - **见**:F-016。 #### 3.3.3 §2.2 无效/过期/撤销令牌须 active=false(非 4xx)—— **符合** `:353-368` repo 返回 `nullopt` → 200 `{active:false}`;repo 层 revoked/expired 返回 `active=false` 对象(`:523-529, 553-558, 574-578`),controller 序列化为完整字段(含 active=false)。**符合**。 #### 3.3.4 §2.3 token_type_hint —— **符合** `RuleSet.cc:573` 接受但忽略。合规。 --- ### 3.4 RFC 7009 Token Revocation —— **符合** #### 3.4.1 §2.1 客户端认证 + 所有权 —— **符合** `TokenEndpointController.cc:420-523`:认证 + `introspection->clientId != clientId` → `unauthorized_client`(`:508-522`)。 #### 3.4.2 §2.1 支持 access 与 refresh —— **符合** `revokeAccessToken` 同时回退尝试 refresh 表(`PostgresTokenRepository.cc:652-677`),命名误导但行为正确。 #### 3.4.3 §2.2.1 成功/未知均返回 200;no-store —— **部分符合(Low)** `:494-544` 成功/未知均 200。**但** `createSuccessResponse`(`:235-240`)不加 `Cache-Control: no-store`。RFC 7009 §2.2.1 RECOMMENDS 此头。**见**:F-019。 --- ### 3.5 RFC 7636 PKCE —— **部分符合** #### 3.5.1 §4.3 code_challenge_method 仅 plain/S256 —— **部分符合(Medium)** - authorize 端**不校验**方法集合(`AuthorizationEndpointController.cc:108-109` 透传),任意字符串被接受并存储。仅在 exchange 端 `Pkce.cc:17-26` 拒绝非 `plain/S256`。 - **依据**:RFC 7636 §4.3 "If the server supports PKCE [...] the server SHOULD reject authorization requests that do not support the requested transformation"。 - **见**:F-013。 #### 3.5.2 §4.4/§4.6 S256 算法 —— **符合** `Pkce.cc:17-21` `computeCodeChallenge` 对 S256 = `base64url(sha256(verifier))`,对原始字节而非 hex 字符串编码。**这是规范正确的实现**(`TokenService.h` 顶部注释明确这是修复了旧 `generateSha256Hash` 的 base64(hex-string) bug 的新实现)。`OAuth2Plugin.cc:601-628` 的静态方法也委托到正确实现。 #### 3.5.3 §4.6 exchange 时校验 —— **符合** `TokenService.cc:206-219`:存了 `codeChallenge` 则必须带 `codeVerifier` 且通过 `validatePkceCodeVerifier`→`verifyCodeVerifier`。 #### 3.5.4 BCP §2.1.1 / RFC 9700 强制 PKCE —— **不符合(Medium)** - **证据**:`auth.require_pkce_for_public` 默认 OFF(`OAuth2Plugin.cc` 配置读取,`AuthorizationEndpointController.cc:416-441`、`SessionController.cc:555-572` 仅在 flag on 时强制)。且检查名"for public"但实际对任意 client 触发,未区分 client_type。 - **依据**:RFC 9700 §2.1.1 强烈建议对所有 authorization_code 客户端强制 PKCE。 - **见**:F-011。 #### 3.5.5 §4.1 code_verifier 格式校验 —— **部分符合(Low)** `isValidCodeVerifierFormat`(`Pkce.cc:40-63`)已实现 43-128/[A-Za-z0-9-._~],但**未在 exchange 路径调用**(仅重算比较)。RFC 7636 §4.6 不要求单独格式校验,故不算硬偏差。 --- ### 3.6 RFC 8252 Native Apps —— **未实现(按适用性,Medium)** - **loopback redirect 端口通配**(§7.3):未实现。redirect_uri 精确匹配,native app 每次新端口都需重新注册。 - **public client 必须用 PKCE**(§8.1):见 3.5.4,默认不强制。 - **判定**:未实现(按适用性评级 Medium;若项目不面向 native app,可标"不适用")。 - **见**:F-014。 --- ### 3.7 RFC 8628 Device Authorization Grant —— **部分符合** #### 3.7.1 §3.1.1/§3.4 device_authorization 端点机密客户端认证 —— **不符合(High)** - **证据**:`DeviceAuthController.cc:156` `plugin->validateClient(clientId, "", ...)` 用**空 secret** 调用。`MemoryClientRepository.cc:150-157`/`PostgresClientRepository.cc:218-225` 对机密客户端空 secret 返回 false,故机密客户端根本无法发起 device flow;公开客户端可发起。 - **依据**:RFC 8628 §3.1.1 要求机密客户端在 device_authorization 端点认证。 - **影响**:机密客户端无法用 device flow;公开客户端体验正常但未做 PKCE 关联。 - **见**:F-015。 #### 3.7.2 §3.2 响应字段 —— **部分符合(Low)** - `device_code/user_code/verification_uri/expires_in/interval` 齐(`DeviceAuthController.cc`)。 - `verification_uri_complete` 已补齐(#146,2026-09-02):`verification_uri + "?user_code=" + urlencode(user_code)`,审批页从 query 预填。默认 `verification_uri` 指向管理台审批页(`admin_console.url` 配置 + `/admin/devices`),可由 `custom_config.device_authorization.verification_uri` 覆盖(#146 前默认值 `http://localhost:5555/oauth2/device` 是无页面的死路径)。 - **user_code 字符集**:`"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"`(`:75`)剔除歧义字符,符合 §5.2。 - **token 端点 client_type 分支**:`TokenEndpointController.cc:1227-1276` 正确分支(PUBLIC 仅 client_id;CONFIDENTIAL 需 secret)。✅ #### 3.7.3 §3.5 polling 错误码 —— **不符合(Medium)** - **已发**:`authorization_pending/expired_token/access_denied/invalid_grant/invalid_request`(`:935-1086`,HTTP 400)✅。 - **slow_down 未发**:`ErrorCatalog.cc:232,483` 定义了 `slow_down`,但 polling 逻辑**从不**比较轮询频率,永远不返回 `slow_down`。Grep 确认无任何 emission 点。 - **依据**:RFC 8628 §3.5 "If the client is polling too quickly, the authorization server SHOULD return the `slow_down` error"。 - **见**:F-012。 --- ### 3.8 RFC 8414 + OIDC Discovery 1.0 —— **部分符合** #### 3.8.1 OIDC Discovery §4 字段完备性 —— **部分符合(Medium)** `DiscoveryController.cc::oidcDiscovery`(`:152-222`)已发:issuer/authorization_endpoint/token_endpoint/userinfo_endpoint/device_authorization_endpoint/jwks_uri/introspection_endpoint/revocation_endpoint/response_types_supported/grant_types_supported/subject_types_supported/id_token_signing_alg_values_supported/scopes_supported/token_endpoint_auth_methods_supported/claims_supported/code_challenge_methods_supported。 **缺失**: - `end_session_endpoint`(OIDC RP-Initiated Logout 必备)❌ - `registration_endpoint`(实际有 `/oauth2/register` 但未广告)❌ - `introspection_endpoint_auth_methods_supported` / `revocation_endpoint_auth_methods_supported`(仅 RFC 8414 `metadata()` 有,OIDC discovery 无)❌ - `response_modes_supported`(仅 metadata 有)❌ #### 3.8.2 OIDC Discovery §3 issuer 精确一致 —— **不符合(High)** - **配置 issuer**:`baseUrl` 来自 `customConfig["metadata"]["issuer"]`,默认 `http://localhost:5555`(`DiscoveryController.cc:160-167`)。**默认是 http,OIDC Discovery §3 规定 issuer 须使用 https**。 - **iss claim 来源不一致**:access token 的 `iss` 取自 DB 列 `oauth2_access_tokens.issuer`(`PostgresTokenRepository.cc:537`);**refresh token 内省 iss 硬编码 `"https://oauth.example.com"`**(`:567`)。签发时 issuer 写入何处未在本次审计深追,但 introspect 的 refresh 分支与配置 issuer 显然不符。 - **无尾斜杠归一化**:`baseUrl + "/oauth2/..."` 直接拼接(`:171-177`),若 operator 配置带尾斜杠会产生 `//oauth2/...`。 - **依据**:OIDC Discovery §3 "The issuer value MUST be exactly identical to the Issuer URL [...] The issuer value is a URL [...] using https"。 - **见**:F-016。 #### 3.8.3 RFC 8414 oauth-authorization-server —— **符合** `metadata()`(`:57-151`)字段齐全。 --- ### 3.9 OIDC Core 1.0 —— **部分符合(核心 MUST 大体满足,扩展 SHOULD 大面积缺失)** #### 3.9.1 §2 id_token claims(核心 MUST 档)—— **部分符合(Medium)** - **已发**:`iss/sub/aud/exp/iat/nonce`(`TokenService.cc:306-317`,nonce 仅在非空时)。 - **缺失**:`auth_time`、`acr`、`amr`、`azp`。 - **依据**: - OIDC Core §2 `auth_time`:REQUIRED when `max_age` 请求、否则 OPTIONAL。当前 max_age 不支持故可缺,但 §3.1.2.1 又把 max_age 列为支持项……故实际是"不支持 max_age 所以也不发 auth_time"——耦合缺失。 - `azp`(§2):当 aud 多值或与 client 不同时 REQUIRED。本项目 aud 永远等于 clientId,故 azp 可缺(合规)。 - `acr/amr`:OPTIONAL 但 claims_supported 应反映实际能力(当前未广告,可接受)。 - **见**:F-021。 #### 3.9.2 §3.1.2.1 / §3.1.3.7 请求参数 prompt / max_age(核心 MUST 档,OIDC Core 把它们列为 OP 须支持的请求参数)—— **未实现(High)** - **证据**:全仓 Grep `prompt`/`max_age` 在非测试代码中**零命中**。`AuthorizationEndpointController.cc` 不读这两个参数。 - **依据**:OIDC Core §3.1.2.1 把 `prompt`、`max_age`、`nonce` 列为 Authorization Endpoint 的请求参数;§3.1.3.7 规定 `auth_time` 与 `max_age` 校验为 MUST。 - **影响**:客户端无法请求 `prompt=none`(无交互静默登录失败应报错)、`prompt=login`(强制重新认证)、`max_age`(按年龄强制重认证)。这是 OIDC 互操作性的核心能力。 - **见**:F-022。 #### 3.9.3 §3.1.3.6 id_token 仅对 openid scope 签发 —— **符合** `TokenService.cc:301-303` `if (jwkManager_ && ... && scope.find("openid") != npos)`。 #### 3.9.4 §5.3 UserInfo —— **部分符合(Medium)** - **Bearer 校验**:✅ 经 `OAuth2AuthFilter`(`OAuth2AuthFilter.cc:39-55`)。 - **不校验 openid scope**:❌ `TokenEndpointController.cc:1297-1389` 与 filter 都不查 scope,**任何** access token(含 `client_credentials` 签发的 `sub=client:<id>` M2M token)都能取 userinfo。 - **CORS**:✅(修正先前误判)全局 `setupCors()`(`apps/server/src/bootstrap/CorsSetup.cc:68-79`)的 `postHandlingAdvice` 对所有响应(含 userinfo)加 `Access-Control-Allow-Origin`(精确白名单,无通配)。**符合** OIDC §7.2.1。 - **sub 用内部 userId**:§15 推荐 pairwise/stable 标识符,本项目用内部数字 id(非 pairwise)。 - **email_verified 声明但不返回**:`claims_supported` 含 `email_verified`(`DiscoveryController.cc:212`),但 userinfo 不返回(`TokenEndpointController.cc:1345-1383`)。 - **依据**:OIDC Core §5.3 "The UserInfo Endpoint MUST accept Access Tokens [...] The information returned [...] SHOULD be scoped to the OpenID Connect scopes"。 - **见**:F-023、F-024。 #### 3.9.5 §7.2.1 UserInfo CORS —— **符合**(见 3.9.4) #### 3.9.6 §12 refresh 时重发 id_token(核心 MUST 档,OIDC Core §12 列为 SHOULD)—— **未实现(Medium)** - **证据**:`TokenService.cc:430-436` refresh 响应只有 `access_token/token_type/expires_in/refresh_token`,无 `id_token` 分支。同样 `TokenEndpointController.cc:1145-1153` device_code 也不发 id_token。 - **影响**:OIDC 客户端在 refresh 后丢失 id_token,需重新走授权码流程。 - **见**:F-025。 #### 3.9.7 §10.1 nonce 单次使用(核心 MUST 档)—— **未实现(Medium)** - **证据**:nonce 仅 echo 进 id_token(`TokenService.cc:314-317`),无任何存储/去重。 - **依据**:OIDC Core §15.5.2 "nonce [...] MUST be [...] used only once [...] Clients MUST verify that the nonce [...] is equal to the nonce sent". 服务端虽不强制单次,但缺 replay 检测使 nonce 防护弱化。 - **见**:F-026。 #### 3.9.8 §15 pairwise sub —— **未实现(Low)** 用内部 userId,非 pairwise。可选配置,缺即 Low。 --- ### 3.10 OIDC RP-Initiated Logout / Session Management / Back-Channel Logout —— **未实现(扩展 SHOULD 档,Medium)** - **end_session_endpoint**:全代码库无(Grep `end_session/post_logout/RP-Initiated` 零命中),discovery 不广告。 - **现有 logout**:`SessionController::logout`(`SessionController.cc:898-969`)是**非标 Bearer token 撤销端点**,取 `Authorization: Bearer`,调 `revokeAccessToken`,返回 JSON,不重定向,不处理 `id_token_hint`/`post_logout_redirect_uri`/`state`。 - **Drogon session 不失效**:`logout` 不调 `req->session()->invalidate()`。 - **Back-Channel Logout stub**:`sendBackchannelLogoutNotifications`(`SessionController.cc:66-69`)是 `LOG_DEBUG << "... stub"` 空实现。 - **判定**:**未实现**(按用户要求的"扩展 SHOULD 分档",Medium)。 - **见**:F-027、F-028。 --- ### 3.11 RFC 7519/7517/7515(JWT/JWK/JWS 支撑核验)—— **部分符合** #### 3.11.1 JWT §4.1 标准 claim —— **部分符合(Low)** id_token 含 iss/sub/aud/exp/iat/nonce,缺 `jti`(全库无)。jti 缺失意味着无 JWT 内置防重放。 #### 3.11.2 JWK §4 公钥格式 —— **符合** `JwkManager::getJwks`(`:331-353`)发 `kty=RSA/use=sig/alg=RS256/kid/n/e`,`n/e` 由 `EVP_PKEY_get_bn_param` 取(`:305-314`),base64url。 #### 3.11.3 私钥保护 —— **符合** 仅取 n/e,无 d/p/q/dp/dq/qi。 #### 3.11.4 kid 选择/轮转 —— **未实现(Low)** `JwkManager` init-once(`JwkManager.h:37-55`、`.cc:33-41`),单 kid(默认 `key-1`,dev `ephemeral-dev-key`),无多 key、无轮转、无按 token 选 kid。`signJwt` 永远用同一 kid。 - **见**:F-029。 #### 3.11.5 alg 白名单 / alg=none 防护 —— **符合(单向签发)** `signJwt`(`JwkManager.cc:231-293`)硬编 `alg=RS256`,不解析外部 alg。本项目不验签外部 JWT,故无 alg=none 注入面。 --- ### 3.12 RFC 7591 / 7592 动态客户端注册 —— **部分符合** #### 3.12.1 RFC 7591 §3 动态注册 —— **部分符合(Medium)** - **形态**:`ClientRegistrationService.cc:37-184` 接受 `client_name/client_type/token_endpoint_auth_method/redirect_uris/grant_types`,返回 `client_id/client_secret/client_id_issued_at/client_secret_expires_at=0`。形态正确。 - **gated by admin**:`AuthorizationFilter` 前置(`ClientRegistrationController.cc:24-30`),非 RFC 7591 §3 的开放注册模型。可接受的设计选择但偏离规范默认。 - **client_secret 哈希 bug**:见 F-002,导致注册的客户端无法认证。 #### 3.12.2 RFC 7592 客户端自管理 —— **未实现(Low)** 无 `/oauth2/register/{client_id}` 路由,无 `registration_access_token`。客户端无法自管,仅 admin 通过 `/api/admin/clients/*`。 - **见**:F-030。 #### 3.12.3 token_endpoint_auth_method 持久化 —— **未实现(Medium)** `ClientRegistrationService.cc:57-58,181` 读取并回显,但**不入库**(`Oauth2Clients` 表无此列,`Oauth2Clients.h:52-64`)。token 端点永远 Basic→Post 回退,不按客户端声明方法。 - **见**:F-017。 --- ### 3.13 RFC 9068 JWT-format Access Tokens —— **不适用(未实现,按设计)** access token 为 opaque 随机串(`TokenCrypto.cc:9-24`,32 字节 base64url,仅哈希入库)。RFC 9068 不适用。**评级:未实现/不适用**,非缺陷。 --- ### 3.14 RFC 9700 / OAuth 2.0 Security BCP —— **部分符合** | 条款 | 状态 | 说明 | |---|---|---| | §2.1.1 强制 PKCE | ❌ Medium | 默认 off(F-011) | | §2.2.1 redirect_uri 精确匹配 | ✅ | 已满足 | | §2.4 token 端点限流 | ❌ Medium | 无限流(F-018) | | §4.9 凭证常量时间比较 | ❌ High | Redis 非常量时间(F-004) | | §4.9.1 client_secret 加盐哈希 | ❌ Critical | 写入路径无盐(F-002) | | §4.10 id_token/auth_time 校验 | ❌ Medium | 未实现(F-022) | | §4.11.1 state CSRF | ✅ | state 强制(自定义策略) | | §4.12.1 redirect_uri open redirect | ✅ | 精确匹配防住(state 拼接未 urlEncode 见 F-020) | --- ### 3.15 横向安全与运维 —— 跨规范聚合 | 主题 | 状态 | 见 | |---|---|---| | token 哈希一致性(UPPER-hex) | ✅ | — | | client_secret 写读哈希不一致 | ❌ Critical | F-002 | | Memory 后端明文存 client_secret | ❌ Medium | F-031 | | token 端点 / introspect / revoke 限流 | ❌ Medium | F-018 | | 日志脱敏(access/refresh 不进日志) | ✅ 大体 | 大部分变量是 hash;少量 LOG_INFO 打 hash 值(如 `PostgresTokenRepository.cc:378,383`)属可接受 | | CORS 全局白名单 | ✅ | `CorsSetup.cc` | | open redirect(state 拼接未 urlEncode) | ❌ Low | F-020 | --- ## 4. 发现清单(按编号) > 每条:规范依据 / 现象 / 根因 `file:line` / 风险 / 影响 / 整改建议。 ### F-001 [文档] OpenAPI grant_type 枚举缺 device_code 【Low】 - **依据**:RFC 8628 §3.4 + OpenAPI 准确性。 - **现象**:`openapi.yaml` 的 `/oauth2/token` grant_type enum 只列 `authorization_code,refresh_token,client_credentials`,缺 `urn:ietf:params:oauth:grant-type:device_code`(代码 `RuleSet.cc:255-261` 已支持)。 - **整改**:补全 enum。 - **阶段**:P2。 ### F-002 [Critical] client_secret 哈希写入与校验算法不一致,动态注册客户端无法认证 - **依据**:RFC 6749 §10.6 "The client password MUST be hashed [...] using a salt";OAuth 2.0 Security BCP §4.9.1。 - **现象**:注册/管理路径写无盐大写 SHA-256,校验路径算有盐小写 SHA-256,两者永不匹配。 - **根因**: - 写入:`ClientRegistrationService.cc:143` `hashToken(clientSecret)`(无盐,`TokenCrypto.cc:26-37` UPPER-hex sha256(secret));`ClientManagementService.cc:128-129,367-368` 同样。reset 路径 `:380` 甚至不更新 salt。 - 校验:`PostgresClientRepository.cc:231` `getSha256(clientSecret + salt)` 再 `tolower`;`RedisClientRepository.cc:165-175` 同。 - **影响**:所有经 `/oauth2/register` 或 `/api/admin/clients` 创建的机密客户端,在 token 端点永远 `invalid_client`。除非有未审的 seed/bootstrap 路径用有盐算法预置客户端(需 operator 确认),否则机密客户端流程整体不可用。 - **整改建议**: 1. 统一为单一算法(推荐 PBKDF2/Argon2id;最低限度统一为有盐 SHA-256,与校验路径一致)。 2. 写入路径同时更新 salt。 3. 提供一次性数据迁移脚本:检测 `client_secret` 列无盐哈希模式 → 触发管理员强制 reset。 - **阶段**:P0。 ### F-003 [High] refresh_token grant 跳过客户端认证 - **依据**:RFC 6749 §3.2.1。 - **现象**:见 3.1.6。 - **根因**:`TokenEndpointController.cc:679-711` 不调 `validateClient`,仅 `TokenService.cc:387-391` 做字符串 client_id 比较。 - **整改**:在 controller 入口对机密客户端加 `validateClient`(参考 client_credentials 分支 `:733-775`),公开客户端仅验 client_id 存在。 - **阶段**:P0。 ### F-004 [High] Redis 后端 client_secret 非常量时间比较 - **依据**:OAuth 2.0 Security BCP §4.9;RFC 6749 §10.6。 - **现象**:`RedisClientRepository.cc:174-175` `calculatedHash == storedHash` 用 `std::string::operator==`,非常量时间。 - **整改**:复用 `constantTimeMemcmp`(Postgres/Memory 已有实现,抽到 common)。 - **阶段**:P0。 ### F-005 [High] Redis 后端 refresh_token 存储为空操作,轮换/级联吊销失效 - **依据**:RFC 6749 §6。 - **现象**:`RedisTokenRepository.cc:155-165` `saveRefreshToken`/`getRefreshToken` 空 return;`atomicRevokeRefreshToken`(`:192-213`)因 getRefreshToken 空 op 永远返回 nullopt;reuse-detection + revokeTokenFamily 不触发。 - **影响**:Redis 部署下 refresh token 既不存储也不轮换,刷新请求永远 `invalid_grant`(或依赖某种 fallback;需运行时确认)。 - **整改**:实现 Redis refresh token 存取 + family_id 索引;或文档明确"Redis 后端不支持 refresh_token grant"。 - **阶段**:P0(若生产用 Redis)/ P1(若仅 Postgres 生产)。 ### F-006 [High] 资源端点 Bearer 401 不发 WWW-Authenticate challenge - **依据**:RFC 6750 §3。 - **现象**:`OAuth2AuthFilter.cc`/`AuthorizationFilter.cc` 返回应用信封 `AUTH_TOKEN_INVALID`,不按 RFC 6750 §3 发 `WWW-Authenticate: Bearer realm, error=invalid_token, error_description`。 - **整改**:在 filter 401 路径加 RFC 6750 §3 challenge 头。 - **阶段**:P1。 ### F-007 [High] authorization 端点错误未按 §4.1.2.1 重定向 - **依据**:RFC 6749 §4.1.2.1。 - **现象**:见 3.1.4。仅 consent-deny 重定向,其余错误(invalid_scope、server_error、PKCE-required)直接 4xx,state 不回显。 - **整改**:按 §4.1.2.1 区分(a)client_id 未知 / redirect_uri 无效 → 直接 4xx;(b)其余 → 302 `?error=&error_description=&state=` 重定向到已注册 redirect_uri。 - **阶段**:P1。 ### F-008 [Medium] token 端点 validation gate 用应用信封而非 OAuth2 error 码 - **依据**:RFC 6749 §5.2。 - **现象**:`HttpResponder.cc:57-58,86` 失败返回 `VALIDATION_INVALID_INPUT` 信封 + 400,非 `error: "invalid_request"`。 - **整改**:token 端点的 validation gate 走 `OAuth2ErrorHandler` 发标准 OAuth2 error 信封。 - **阶段**:P1。 ### F-009 [Medium] authorization_code 兑换时空 redirect_uri 跳过 §4.1.3 比较 - **依据**:RFC 6749 §4.1.3。 - **现象**:见 3.1.5。`PostgresGrantRepository.cc:181-189` 与 `MemoryGrantRepository.cc:80-87` 用 `if (!redirectUri.empty() && ...)` 守卫。 - **整改**:若签发时存了 redirect_uri,兑换时必须带且必须匹配(empty 应直接 `invalid_grant`)。 - **阶段**:P1。 ### F-010 [High] 资源端点不按 scope 拒绝(insufficient_scope) - **依据**:RFC 6750 §3.1。 - **现象**:见 3.2.4。 - **整改**:为受保护端点定义所需 scope,filter 校验,缺则 `insufficient_scope` 403。 - **阶段**:P1。 ### F-011 [Medium] PKCE 默认不强制 - **依据**:RFC 9700 §2.1.1。 - **现象**:见 3.5.4。 - **整改**:默认 `require_pkce=true`(至少对所有 authorization_code 客户端)。 - **阶段**:P1。 ### F-012 [Medium] device flow 永不返回 slow_down - **依据**:RFC 8628 §3.5。 - **现象**:见 3.7.3。`ErrorCatalog.cc:232` 定义但无 emission。 - **整改**:在 polling 分支按 `interval` 与最近 poll 时间比较,过快返回 `slow_down`。 - **阶段**:P1。 ### F-013 [Medium] authorize 端不校验 code_challenge_method 集合 - **依据**:RFC 7636 §4.3。 - **现象**:见 3.5.1。 - **整改**:authorize 入口校验 method ∈ \{plain, S256\},否则 `invalid_request` 直接 4xx(属 client 错误,可直接返回)。 - **阶段**:P1。 ### F-014 [Medium] 无 HTTPS 强制 / 无 loopback 例外 - **依据**:RFC 6749 §3.1.2.1;RFC 8252 §7.3。 - **现象**:见 3.1.1。 - **整改**:redirect_uri 注册时强制 https(生产);实现 RFC 8252 loopback 端口通配(`http://127.0.0.1:*` / `http://[::1]:*`)。 - **阶段**:P1。 ### F-015 [High] device_authorization 端点不认证机密客户端 - **依据**:RFC 8628 §3.1.1。 - **现象**:见 3.7.1。`DeviceAuthController.cc:156` 空 secret 调用。 - **整改**:device_authorization 端点按 client_type 分支(参考 token 端点 device_code 分支 `:1227-1276`)。 - **阶段**:P1。 ### F-016 [High] issuer 与 iss claim 不一致 / refresh introspect iss 硬编码 - **依据**:OIDC Discovery §3。 - **现象**:见 3.8.2。`PostgresTokenRepository.cc:567` 硬编 `https://oauth.example.com`。 - **整改**:refresh token 内省 iss 取配置 issuer;签发时统一写入 issuer 字段;discovery issuer 强制 https + 尾斜杠归一化。 - **阶段**:P0(iss 一致性)/ P1(https 强制)。 ### F-017 [Medium] token_endpoint_auth_method 不持久化 - **依据**:RFC 7591 §2 + RFC 6749 §3.2.1。 - **现象**:见 3.12.3。 - **整改**:加 DB 列;token 端点按客户端声明方法认证。 - **阶段**:P1。 ### F-018 [Medium] token/introspect/revoke 端点无限流/防爆破 - **依据**:RFC 9700 §2.4。 - **现象**:见 3.14。 - **整改**:加 per-client/IP 失败计数 + 指数退避(可复用 identity 的 account-lockout 机制)。 - **阶段**:P1。 ### F-019 [Low] 成功响应缺 Cache-Control:no-store - **依据**:RFC 6749 §5.1;RFC 7009 §2.2.1。 - **现象**:token 成功响应、revoke 成功响应不加 `Cache-Control: no-store`/`Pragma: no-cache`(仅错误响应加)。 - **整改**:所有 token 类响应统一加。 - **阶段**:P2。 ### F-020 [Low] authorize 成功重定向 state 未 urlEncode - **依据**:RFC 6749 §4.1.2 / §4.1.3(state 透传完整性)。 - **现象**:`AuthorizationEndpointController.cc:468-470` `location += "&state=" + state;` 未 urlEncode(login/consent 中间跳转已 urlEncode)。`code` 同样未 urlEncode。 - **整改**:state 与 code 统一 urlEncode。 - **阶段**:P2。 ### F-021 [Medium] id_token 缺 auth_time / acr / amr / azp - **依据**:OIDC Core §2。 - **现象**:见 3.9.1。 - **整改**:耦合 F-022 一起做(auth_time 依赖 prompt/max_age)。 - **阶段**:P1。 ### F-022 [High] OIDC prompt / max_age 未实现 - **依据**:OIDC Core §3.1.2.1 / §3.1.3.7。 - **现象**:见 3.9.2。 - **整改**:authorize 端解析 `prompt`(none/login/consent/select_account)与 `max_age`;按值控制交互/重认证;签发 id_token 时按需写 auth_time/acr。 - **阶段**:P1。 ### F-023 [Medium] UserInfo 不校验 openid scope - **依据**:OIDC Core §5.3。 - **现象**:见 3.9.4。 - **整改**:userinfo handler 校验 token scope 含 `openid`(client_credentials token 的 `sub=client:*` 应直接拒)。 - **阶段**:P1。 ### F-024 [Low] claims_supported 含 email_verified 但 userinfo 不返回 - **依据**:OIDC Discovery §3 accuracy。 - **整改**:要么 userinfo 返回 email_verified(已知 login 时有此值),要么从 claims_supported 移除。 - **阶段**:P2。 ### F-025 [Medium] refresh / device_code 不重发 id_token - **依据**:OIDC Core §12。 - **现象**:见 3.9.6。 - **整改**:refresh 时若原 scope 含 openid 且 jwkManager 可用,重签 id_token。 - **阶段**:P1。 ### F-026 [Medium] nonce 无单次使用/防重放存储 - **依据**:OIDC Core §15.5.2。 - **整改**:nonce 落库 + 兑换时去重(可复用 grant/code 的过期清理机制)。 - **阶段**:P1。 ### F-027 [Medium] RP-Initiated Logout 未实现(end_session_endpoint) - **依据**:OIDC RP-Initiated Logout 1.0。 - **现象**:见 3.10。 - **整改**:新增 `/oauth2/end_session` GET,处理 `id_token_hint`/`post_logout_redirect_uri`/`state`,discovery 广告 `end_session_endpoint`。 - **阶段**:P1。 ### F-028 [Medium] logout 不失效 Drogon session + backchannel stub - **依据**:OIDC Session Management / Back-Channel Logout 1.0。 - **现象**:`SessionController.cc:898-969` 不调 `session()->invalidate()`;`:66-69` backchannel 是 stub。 - **整改**:logout 调 session invalidate;backchannel 实现或显式声明不支持。 - **阶段**:P1。 ### F-029 [Low] JWKS 无密钥轮转 - **依据**:运维最佳实践(RFC 7517 无强制轮转要求)。 - **整改**:支持多 kid + current/previous;按 kid 选签发 key;JWKS 暴露全部公钥。 - **阶段**:P2。 ### F-030 [Low] RFC 7592 客户端自管理未实现 - **依据**:RFC 7592。 - **整改**:按需实现 `registration_access_token` + `/oauth2/register/{client_id}` CRUD;或文档明确仅 admin 管理。 - **阶段**:P2。 ### F-031 [Medium] Memory 后端明文存 client_secret - **依据**:RFC 6749 §10.6。 - **现象**:`MemoryClientRepository.cc:67` 注释 "we store plain text"。 - **整改**:Memory 后端也走哈希(仅用于测试场景则文档明确"测试用,不存敏感数据")。 - **阶段**:P2(若 Memory 仅测试)/ P1(若生产可选)。 --- ## 5. 分阶段整改计划 ### P0 — 严重/高优先(认证与令牌安全,1-2 周内) | 编号 | 工作项 | 验收标准 | 估计复杂度 | |---|---|---|---| | F-002 | 统一 client_secret 哈希算法(推荐 Argon2id/PBKDF2;最低有盐 SHA-256)+ 写入路径更新 salt + 数据迁移 | 动态注册客户端可在 token 端点成功认证;存量客户端有 reset 通道 | 中(含迁移) | | F-003 | refresh_token grant 入口加 `validateClient` | 机密客户端无 secret 时 401 invalid_client | 低 | | F-004 | Redis validateClient 用 constantTimeMemcmp | 单元测试覆盖常量时间 | 低 | | F-005 | 决策 Redis 是否支持 refresh_token;若支持则实现 save/get/atomicRevoke | Redis 部署下 refresh 流程端到端通过;或文档明确不支持 | 中 / 决策 | | F-016(iss 部分) | refresh introspect iss 取配置 issuer;签发统一写 issuer | introspect 返回的 iss 与 discovery issuer 字节一致 | 低 | ### P1 — 中优先(OIDC 完备性、协议合规加固,3-6 周) | 编号 | 工作项 | 验收标准 | |---|---|---| | F-006 | 资源端点 401 加 RFC 6750 §3 Bearer challenge | WWW-Authenticate: Bearer realm/error/error_description | | F-007 | authorize 错误按 §4.1.2.1 重定向 | redirectable 错误 302 带 error/state | | F-008 | token 端点 validation gate 走 OAuth2 error 信封 | 错误体含 `error: invalid_request` | | F-009 | exchange 时空 redirect_uri 强制校验 | 签发带 redirect_uri 时兑换必带且匹配 | | F-010 | 资源端点 scope 校验 + insufficient_scope | 按 scope 拒绝返回 403 insufficient_scope | | F-011 | PKCE 默认强制 | 默认 require_pkce=true | | F-012 | device flow slow_down | 过快轮询返回 slow_down | | F-013 | authorize 校验 code_challenge_method 集合 | 非 plain/S256 直接 4xx | | F-014 | HTTPS 强制 + loopback 例外 | 生产 redirect_uri 强制 https;支持 RFC 8252 loopback | | F-015 | device_authorization 认证机密客户端 | 机密客户端可发起 device flow | | F-017 | 持久化 token_endpoint_auth_method | 客户端按声明方法认证 | | F-018 | 端点限流 | 失败计数 + 退避 | | F-021+F-022 | 实现 OIDC prompt/max_age + auth_time/acr | prompt=none/max_age 互操作通过 | | F-023 | UserInfo 校验 openid scope | M2M token 取 userinfo 被拒 | | F-025 | refresh/device 重发 id_token | OIDC refresh 互操作通过 | | F-026 | nonce 防重放存储 | 重复 nonce 兑换被拒 | | F-027 | RP-Initiated Logout | /oauth2/end_session 端到端 | | F-028 | logout 失效 session + backchannel 决策 | session 真正失效 | ### P2 — 低优先(一致性、文档、加固,按需) F-001(OpenAPI enum)/ F-019(Cache-Control)/ F-020(state urlEncode)/ F-024(email_verified 一致)/ F-029(JWKS 轮转)/ F-030(RFC 7592)/ F-031(Memory 明文)。 --- ## 6. 已确认合规项(正向清单) 为平衡视角,下列核心能力经核实**符合规范**,构成 fulla 的合规基座: 1. 授权码生成:256-bit `RAND_bytes` + base64url + 仅哈希入库(`TokenCrypto.cc:9-24,26-37`) 2. 授权码单次性:原子 CAS(`PostgresGrantRepository.cc:165-176`) 3. 授权码 TTL:默认 600s(`OAuth2Plugin.cc:53`) 4. refresh token 轮换 + 重用检测 + family 级联吊销(Postgres;`TokenService.cc:353-385`、`PostgresTokenRepository.cc:404-492`) 5. PKCE S256 算法规范正确(`Pkce.cc:17-21`) 6. redirect_uri 精确匹配(`Client.h:83-90`) 7. state 强制 + 长度/字符校验(`AuthorizationEndpointController.cc:113-189`) 8. client_secret 比较常量时间(Postgres/Memory) 9. access/refresh token 哈希存储一致(UPPER-hex SHA-256) 10. introspect/revoke 客户端凭证认证模型(commit 246db32 修正后,`TokenEndpointController.cc:279-345,420-523`) 11. revoke 所有权校验 + 未知令牌返回 200(`TokenEndpointController.cc:489-544`) 12. JWKS 仅暴露公钥 n/e(`JwkManager.cc:305-353`) 13. id_token 仅对 openid scope 签发(`TokenService.cc:301-303`) 14. id_token nonce 端到端透传(`TokenService.cc:314-317`) 15. device_code 原子 consume 防竞态(`TokenEndpointController.cc:1065-1090`) 16. CORS 全局白名单(无通配,`CorsSetup.cc:11-30`) --- ## 7. 未核验项声明 以下项本次未深入核实,列出以备后续: - access token 签发时 `issuer` 字段写入 DB 的具体值与位置(F-016 的另一半)。 - 是否存在 seed/bootstrap 路径用有盐算法预置客户端(影响 F-002 的实际爆炸半径)。 - Redis 后端 `getAccessToken`/`saveAccessToken` 是否同样存在空操作(本次只确认了 refresh 的空操作)。 - 测试代码(`tests/`)与 seed 脚本(`apps/server/seed/`)未纳入审计。 - 运行时行为未验证(如 server_error 路径、并发竞态)。 - WebAuthn / GitHub/Google/WeChat 社交登录的 OAuth dance(OIDC federation 范畴)未审。 --- ## 8. 整改状态表(修复进度追踪) 核验基线:`master`(审计所引代码逐条比对过)。处置结论分三类:**修复**(代码已改)、**文档化**(不改码,文档说明)、**伪问题关闭**。 整改分支:`fix/oauth-oidc-compliance-batch-0-1`,三批次提交: - Batch 0+1 = `040639b`(P0 安全 + 协议正确性) - Batch 2 = `a7fd184`(OIDC 全量扩展) - Batch 3 = `c911ee9`(加固与清理) 每批次均 `manage.ps1 build-backend` + `test-backend` 全绿(**456/456 CTest 通过**,含 Postgres 与 CI/memory 两套配置)。 | Issue | 发现 | 结论 | 状态 | |---|---|---|---| | #21 | F-002 client_secret 哈希写/读算法不一致 | 修复:写入路径统一有盐小写 SHA-256(含 reset 轮换 salt) | ✅ Batch 0+1 (`040639b`) | | #22 | F-003 refresh_token grant 无客户端认证 | 修复:CONFIDENTIAL 需 secret(401 invalid_client),PUBLIC 仅验存在 | ✅ Batch 0+1 (`040639b`) | | #23 | F-004 Redis client_secret 比较非常量时间 | 修复:三后端统一 `constantTimeMemcmp`,删泄漏比较结果的 LOG_DEBUG | ✅ Batch 0+1 (`040639b`) | | #24 | F-005 Redis 后端 refresh 存储空操作 | 修复(弃用处置):启动 LOG_ERROR + refresh grant 返回 `unsupported_grant_type`;postgres+redis 缓存架构另立 issue #42 | ✅ Batch 0+1 (`040639b`) | | #25 | F-016 issuer 不一致 | 修复:签发写入配置 issuer(含 client_credentials/device 分支)+ 删三后端硬编码 + introspect 兜底 + discovery 尾斜线归一化 + http issuer 告警;比报告额外发现:`saveAccessToken` 从未写 issuer 列 | ✅ Batch 0+1 (`040639b`) | | #26 | F-007 authorize 错误不重定向 | 修复:client_id 未知/redirect_uri 无效仍直接 4xx;其余按 §4.1.2.1 302 重定向并回显 state | ✅ Batch 0+1 (`040639b`) | | #27 | F-010 资源不强制 scope | 修复(最小 scope 校验:userinfo→openid、/api/me→profile、/api/admin→admin + 403 insufficient_scope);完整资源-scope 模型另立 issue #43 | ✅ Batch 3 (`c911ee9`) | | #28 | F-006 Bearer 401 缺 WWW-Authenticate | 修复:资源端点 401 加 `WWW-Authenticate: Bearer error="invalid_token"` | ✅ Batch 0+1 (`040639b`) | | #29 | F-021+F-022 prompt/max_age/auth_time/acr | 修复(全量):prompt/max_age 解析 + 三签发路径透传 auth_time/amr + id_token auth_time/acr/amr claims | ✅ Batch 2 (`a7fd184`) | | #30 | F-027+F-028 RP-Initiated Logout | 修复:新增 `/oauth2/end_session`(GET+POST)+ session clear;backchannel 文档化不实现 | ✅ Batch 2 (`a7fd184`) | | #31 | F-015 device_authorization 不认证机密客户端 | 修复:按 client_type 分支认证 | ✅ Batch 0+1 (`040639b`) | | #32 | F-025 refresh/device 不重发 id_token | 修复:refresh/device 在 openid scope 时重签 id_token | ✅ Batch 2 (`a7fd184`) | | #33 | F-011 PKCE 默认不强制 | 修复:默认值改 true + 4 个 config 显式 true | ✅ Batch 0+1 (`040639b`) | | #34 | F-012 device flow 无 slow_down | 修复:`last_polled_at` 列 + interval 递增 5s | ✅ Batch 0+1 (`040639b`) | | #35 | F-008+F-009+F-013 token 错误信封/redirect_uri/挑战方法校验 | 修复:token gate 发 OAuth2 invalid_request 信封;空 redirect_uri 不再绕过;authorize 校验 code_challenge_method ∈ \{plain,S256\} | ✅ Batch 0+1 (`040639b`) | | #36 | F-014 redirect_uri 无 https 强制/loopback 例外 | 修复:https 强制 + 仅 IP 字面量 loopback(127.0.0.1/[::1])豁免 + `auth.allow_http_redirect_uri` 开关;seed/测试 localhost→127.0.0.1 | ✅ Batch 0+1 (`040639b`) | | #37 | F-017+F-023+F-026 | F-017 持久化 + 强制 token_endpoint_auth_method(NULL 保留回退);F-023 userinfo 校验 openid scope + email_verified(F-024);F-026(nonce 服务端防重放)**伪问题关闭**:OIDC §15.5.2 的 nonce 校验是客户端 MUST,服务端存储非规范强制,文档说明 | ✅ Batch 2 (`a7fd184`) | | #38 | F-018 端点无限流 | 修复:进程内滑动窗口限流(per IP+client_id,仅计失败,429 + Retry-After) | ✅ Batch 3 (`c911ee9`) | | #39 | P2 批量(F-001/F-019/F-020/F-024/F-029/F-030/F-031) | F-001/F-019/F-020 修复;F-024 随 F-023;F-029/F-030/F-031 文档化不改码 | ✅ Batch 3 (`c911ee9`) | 决策记录(用户拍板):F-002 选有盐 SHA-256;F-005 目标架构 postgres 存储 + redis 缓存,独立 Redis 模式废弃(issue #42);F-010 最小 scope 校验 + 独立 issue #43;OIDC 扩展全量实现 prompt/max_age;schema 直接改源头 migrations(无生产数据,不做增量迁移)。 **最终符合性**:审计报告中标记的 31 项发现全部处置完毕(28 项代码修复 + 3 项文档化 + F-026 伪问题关闭)。Issue #21-#39 已在 `fix/oauth-oidc-compliance-batch-0-1` 分支修复并验证,待合入后关闭(fine-grained PAT 无 close 权限,需手动关闭);#40 为总跟踪,#42/#43 为两个架构 follow-up。 --- **报告版本**:v1.3(追加整改后深度复查) | **审查日期**:2026-08-07 | **整改完成日期**:2026-08-08 | **复查日期**:2026-08-08 | **审查者**:ZCode | **代码基线**:`test/coverage-push` @ 09ebf8d | **整改+复查基线**:`fix/oauth-oidc-compliance-batch-0-1` @ 4b4e282 --- ## 9. 整改后深度复查(2026-08-08) > 按本计划 `oauth-oidc-compliance-audit-plan.md` §二「规范清单与逐项检查要点」的全部 14 个 RFC 规范、~90 个检查点,对整改后的代码(`fix/oauth-oidc-compliance-batch-0-1` @ 4b4e282)重新逐项核验。4 个并行核验 agent 覆盖 RFC 6749/6750、7662/7009/7636/8252、8628/8414/OIDC Discovery、OIDC Core/JWT-JWK/7591/9068/9700/横向安全,每项结论附 `file:line` 证据。 ### 9.1 复查结论概览 **合规:88/90 检查点 ✅ COMPLIANT**(含 1 项 ➖ N/A:RFC 9068 JWT access token——access token 为 opaque 设计)。R-1/R-4/R-5 三项新发现已快速修复(见下表处置列),R-2/R-3 记录在案留待 follow-up。所有 F-001–F-031 整改项在当前代码中**均已落地、无回退**,关键修复点(F-002 写读哈希一致、F-003 refresh 客户端认证、F-011 PKCE 默认强制、F-013/§4.6 S256 正确算法、F-016 issuer 一致、F-017 auth_method 强制、F-018 限流、F-019 no-store、F-020 urlEncode、F-022 prompt/max_age/auth_time/acr/amr、F-023 userinfo openid、F-025 refresh id_token、F-027 end_session)经直接代码复核确认存在且端到端连通。 **5 项待处置(均为新发现,非回退,无 1 项是整改前已报告的 F-xxx 漏修)**: | ID | 发现 | 风险 | 性质 | 处置 | |---|---|---|---|---| | **R-1** | `acr` claim 以 JSON **整数**签发(`TokenService.cc:367`、`OAuth2Plugin.cc:778` `Json::Int64`),但 OIDC Core §2 规定 `acr` 为**字符串**,且 discovery `acr_values_supported` 广告的是字符串 `"1"`/`"2"`——id_token 与 discovery 类型/值不匹配 | 中 | 新发现,整改引入 | ✅ **已修**:改为 `Json::String`("1"/"2"),两处签发点 + 2 个单测断言同步 | | **R-2** | `/oauth2/register` 路由在所有 config 的 `rbac_rules` 均无对应条目,`AuthorizationFilter` 默认拒绝 → 动态注册端点**对所有用户(含 admin)返回 403**;实际客户端创建走 `/api/admin/clients`(匹配 `/api/admin/.*`)。注册能力存在但端点不可达 | 低(功能不可用,但无安全影响) | 预先存在(master 上即如此),非本批引入;属文档/配置与实现不一致 | 📌 **记录**:非合规硬伤,是端点可达性问题。建议二选一——在 `rbac_rules` 加 `"/oauth2/register": ["admin"]`,或文档明确该端点仅供 admin、实际用 `/api/admin/clients`。留待 follow-up。 | | **R-3** | `RedisGrantRepository::saveAuthCode`(`RedisGrantRepository.cc:48`)不持久化 `code_challenge`/`code_challenge_method`/`nonce`/`auth_time`/`amr`——Redis 部署下 PKCE 校验被静默跳过、id_token 丢失 nonce/auth_time/amr | 高(**仅 Redis 部署**;Postgres/Memory 正确) | 预先存在(Redis grant 存储历来不完整);F-005 已声明独立 Redis 模式废弃,故实际爆炸半径=坚持用废弃 Redis 模式的人 | 📌 **记录**:由架构 follow-up issue #42 覆盖(目标架构 Postgres 存储 + Redis 仅作缓存,grant 走 Postgres)。本轮不修——独立 Redis 存储模式已废弃(启动 LOG_ERROR + refresh grant 返回 unsupported_grant_type)。 | | **R-4** | discovery `prompt_values_supported` 含 `select_account`(`DiscoveryController.cc:229`),但 authorize 端无对应分支(仅 none/login/consent 被处理,`AuthorizationEndpointController.cc:282-284`)——advertised-but-not-honored | 低 | 新发现,整改引入 | ✅ **已修**:从 discovery `prompt_values_supported` 与 OpenAPI prompt 参数描述移除 `select_account`(只广告实际支持的 none/login/consent) | | **R-5** | RFC 8414 `metadata()`(`DiscoveryController.cc:72-165`)缺 `subject_types_supported`(RFC 8414 §2 REQUIRED),且两份 discovery 文档均未广告 `registration_endpoint`(尽管 `/oauth2/register` 已实现) | 低 | 预先存在(metadata 路径)+ 一致性 | ✅ **已修**:metadata() 补 `subject_types_supported=["public"]`;metadata() 与 oidcDiscovery() 均补 `registration_endpoint` | ### 9.2 低风险提示(非缺陷,记录在案) - **RFC 7662 §2.2** introspection 不返回 `username`/`jti`——两者均 OPTIONAL,合规。 - **RFC 7009 §2.2.1** 跨客户端撤销返回 `unauthorized_client`(非静默 200)——偏向安全的可辩护解读,记录为设计决策。 - **Memory 后端** client_secret 明文比较(F-031 已文档化为 dev/test 专用)。 - **`AuthorizationFilter`** 仍接受 `?access_token=` query 传 token(RFC 6750 §2.3 已废弃但合规);`OAuth2AuthFilter` 已收紧为仅 header。 - **PKCE verifier 比较**(`Pkce.cc:35`)用 `==` 非常量时间——低危(verifier 高熵,时序攻击不实际)。 - **`access_denied`** 在 ErrorCatalog 映射为 403(RFC 6749 §5.2 列在 400)——仅用于资源侧/撤销所有权拒绝,authorize 端的 access_denied 走 302 重定向,无安全影响。 - **OAuth2Plugin 与 DiscoveryController 各自独立 fallback `http://localhost:5555`**——当前一致,但两处字面量可能漂移。 ### 9.3 处置结果 - **R-1(acr 类型)** ✅ **已修**:`TokenService.cc` + `OAuth2Plugin.cc` 两处 `Json::Int64(...)` → 字符串 `"1"`/`"2"`;2 个单测断言(`TokenServiceTest`)同步为 `asString()`。 - **R-4(select_account)** ✅ **已修**:从 `DiscoveryController.cc` 的 `prompt_values_supported` 与 `AuthorizationEndpointController.cc`/`openapi.yaml` 的 prompt 参数描述移除 `select_account`(只广告实际支持的 none/login/consent)。 - **R-5(discovery 字段)** ✅ **已修**:`DiscoveryController.cc` 的 RFC 8414 `metadata()` 补 `subject_types_supported=["public"]`;`metadata()` 与 `oidcDiscovery()` 均补 `registration_endpoint`。 - **R-2(register RBAC)** 📌 **记录在案**:非合规硬伤(端点可达性问题,预先存在)。建议 follow-up 二选一——`rbac_rules` 加 `"/oauth2/register": ["admin"]`,或文档明确实际用 `/api/admin/clients`。本轮不改码。 - **R-3(Redis grant)** 📌 **记录在案**:由架构 follow-up issue #42 覆盖(目标 Postgres 存储 + Redis 仅缓存,grant 走 Postgres)。本轮不修——独立 Redis 存储模式已废弃。 ### 9.4 未回退确认 逐项确认无整改前已报告的 F-xxx 出现回退:F-002 写读哈希一致(`ClientRegistrationService.cc:204-220` 写有盐小写 SHA-256 ↔ `PostgresClientRepository.cc:213-243` 读同算法常量时间比较)、F-003 refresh 认证(`TokenEndpointController.cc:1037-1134` 按 client_type 分支)、F-011 PKCE(代码与 4 个 config 默认 true)、F-013 method 校验(`AuthorizationEndpointController.cc:375-393`)、§4.6 S256 正确算法(`Pkce.cc:17-21` base64url(raw digest))、F-016 issuer 一致(无 `oauth.example.com` 残留)、F-017 强制(`enforceClientAuthMethod`)、F-018 限流(`RateLimiter` 失败计数 429)、F-019 no-store(`applyNoStoreHeaders` 全成功响应)、F-020 urlEncode(4 处签发重定向)、F-022 prompt/max_age/auth_time/amr(`AuthorizationEndpointController.cc` + `SessionController.cc:484-489` + `MfaController.cc:540-550`)、F-023 userinfo openid(`TokenEndpointController.cc:1879-1898`)、F-025 refresh/device id_token、F-027 end_session(`SessionController.cc:1105-1227` + session clear)——均存在且端到端连通。 **整体结论**:整改有效,14 个 RFC 规范的 ~90 检查点中 88 项合规、1 项不适用、R-1/R-4/R-5 三项新发现已修复、R-2/R-3 两项记录在案(R-2 留待 follow-up,R-3 由 issue #42 覆盖)。无整改回退,无新引入的安全回归。 --- # authforge → fulla 改名影响范围分析 Source: https://fulla.dev/docs/adr/rename-impact-fulla > **Language note**: historical archive, kept in its original Chinese. The > authforge→fulla rename impact analysis (executed via PR #93/#94/#95; > version series reset to v1.0.0). Current rules live in > [documentation-governance.md](../documentation-governance.md). > 本文档是改名决策的历史档案(入库版,本机细节已泛化)。其中的执行计划已全部 > 完成:仓库已改名 voidvec/fulla、版本序列重置为 v1.0.0、PyPI fulla-oauth2 已 > 发布。当前权威规则见 [documentation-governance.md](../documentation-governance.md)。 # authforge → fulla 改名影响范围分析 > 分析日期:2026-08-25(第一轮 + 同日第二轮补充审查,见 §2bis)| 方法:`git grep` 全仓实测 + 外部 registry/API 实查 > 前置文档:rename-candidates.md(本地维护副本)(命名调研,fulla 排名第 9) > > **版本策略(2026-08-25 已定)**:改名视为**新产品身份**,版本序列**重置为 v1.0.0**(而非 v2.0.0)。依据:SemVer 约束的是包身份而非仓库——`fulla-oauth2`/`fulla-*` 镜像/`fulla` CMake 包均为新身份,1.0.0 起步是唯一自然选择;项目零外部用户,v2.0.0 的"破坏性升级"信号无处安放,反而让新项目首发 2.0 显得来历不明。先例:OpenSearch fork 自 ES 7.10 仍以 1.0.0 首发。直接 1.0.0(不用 0.x)是因为代码库已有 353 ctest + 完整 CI 矩阵 + benchmark 体系,0.x 会低估成熟度。红线(不可随之重置)见 §6 末尾。 ## 0. 结论摘要 改名是**大规模但低风险**的工程:全仓 1658 个文件、约 1.1 万处 `authforge` 出现,其中约 **90% 是纯机械替换**(源码标识符、脚本、文档、CI),真正需要**决策和迁移策略**的只有 8 个层面: | # | 非机械层面 | 性质 | |---|---|---| | 1 | Redis 键前缀 `authforge:cache:*` + Prometheus 指标 `authforge_cache_*` | 运行时数据面:前缀变更=升级时缓存整体失效;指标变更=监控面板/告警断点 | | 2 | PyPI 已发布包 `authforge-oauth2`(1.4.x 在线)+ GHCR 镜像 `voidvec/authforge-*` | 已发布工件:旧名永久存在,需 deprecation + 新名并行 | | 3 | api-diff 门禁基线(`tools/api-diff/api-baseline.txt`,820 处) | CI 门禁:改名=全局 API 破坏,基线必须重生成并 --force 批准 | | 4 | C++ 公共 API 面(`#include <authforge/...>` 路径 + `namespace authforge`) | 语义化版本:版本序列重置为 **v1.0.0**(新产品身份,见文首决策) | | 5 | Go module path `github.com/voidvec/authforge/clients/go` | module 路径=URL:旧路径不可长期依赖 | | 6 | 本机目录改名 `D:\...\authforge` | 工作区身份/构建缓存/多工具路径全部失效 | | 7 | `OAUTH2_*` 环境变量前缀(20+ 变量、1260 处、22 个代码文件读取) | 第二套命名体系,规范化决策见 §2bis B | | 8 | 前端品牌面(文案 + **e2e 断言** + 页面标题 + package.json 名) | UI 品牌替换与测试断言必须同 PR,漏改直接挂 e2e | **三条好消息**(实测排除的担忧): - **数据库零影响**:DB 名/用户是 `oauth2_db`/`oauth2_user`(deploy/docker/docker-compose.debug.yml:7-9),不含项目名;SQL 迁移/种子文件**零命中**; - **协议面零影响**:cookie 名、issuer、User-Agent 中**无任何 authforge 字样**——JWT/session/OIDC 协议行为完全不变,存量令牌不受影响; - **运行时配置零耦合**:`apps/server/config/*.json`(5 份)中 0 处 authforge,filters 段为空数组——过滤器名(`"authforge::drogon::filters::..."` 共 56 处字符串字面量)只在代码内自洽,随命名空间一并机械替换即可。 --- ## 1. 实测总量(2026-08-25,git 跟踪文件) - **1658 个文件**含 `authforge`(不区分大小写),合计约 **11,000+ 处**; - 目录分布:benchmarks 821(其中 ~770 为**历史测量结果数据**,见 §8)、libs 328、.qoder 镜像 wiki 201(可再生)、tests 108、docs 52、apps 21、clients 18、deploy 17、其余为脚本/CI/配置/README; - 标识符变体 TOP:`authforge`(9826)、`authforge-sdk`(279)、`authforge-server`(161)、`authforge-oauth2`(64)、`authforge-tests`(53)、`authforge-drogon`(43)、`authforgepackage`(41)、`authforge_package`(38) 等。 ## 2. 分层影响清单 ### L1 品牌与身份资产(改名决策本体) | 资产 | 现状 | 改为 fulla 的影响 | |---|---|---| | GitHub 仓库 | `voidvec/authforge` | Settings 改名后 web/git 链接 301 重定向(issues/PRs/stars/tags 全保留);**重定向在别人抢注旧名时失效**——改名后旧名空置即有此风险,可接受 | | GitHub org | `voidvec`(不含项目名) | **无影响**。注意:GitHub 用户名/org `fulla` **已被占用**(早前实测 404 检查 github=200),若想要 fulla 同名 org 需变体(`fulla-iam`、`getfulla` 等)或沿用 voidvec | | 域名 | — | `fulla.dev` ✅ 已注册(2026-08-26);`fulla.com` ❌ 已被占 | | PyPI | `authforge-oauth2` 在线(实测 HTTP 200) | 新包 `fulla-oauth2`(裸名 `fulla` 在 PyPI 也空,可考虑直接占);旧包可继续存在但应在新版描述中标注 deprecated | | npm | 无已发布包 | 前端 `oauth2-admin`/`oauth2-frontend` 均非发布包,无影响。注意裸名 `fulla` 在 npm 被 2019 年死包占用——未来若发 JS SDK 用 `fulla-sdk` | | Go module | `github.com/voidvec/authforge/clients/go` | 必须改为 `github.com/voidvec/fulla/clients/go`;旧路径靠 GitHub 301 短期可解析,**不可长期依赖**,旧版应打 deprecated 注释 | | Docker 镜像 | `ghcr.io/voidvec/authforge-{backend,frontend,admin}` | GHCR 包名不随仓库改名自动迁移:新构建发布为 `fulla-*`,旧包留在原地;compose 拉取方需改引用 | ### L2 C++ 源码标识符(机械替换的主体,但=公共 API 破坏) - **命名空间**:`namespace authforge` 覆盖 **350 个文件**(libs/ + apps/ + tests/); - **公共头文件路径**:8 个目录 `libs/{common,drogon,identity,oauth2,storage-memory,storage-postgres,storage-redis}/include/authforge/`(含 testing)→ 所有 `#include <authforge/...>` 变更,SDK 消费方全破; - **CMake**:`project(authforge ...)`(CMakeLists.txt:3)、目标 `authforge::*`(如 `authforge::identity`,CMakeLists.txt:57)、导出包 `cmake/AuthForgePackage.cmake` + `AuthForgePackageConfig.cmake.in`、安装布局 `lib/cmake/authforge-*/`(release.yml:8 注释); - **公共 CMake 选项**(下游用户可见):`AUTHFORGE_WERROR`、`AUTHFORGE_ENABLE_LTO`、`AUTHFORGE_CMAKE_PRESET`、`AUTHFORGE_VERSION` 等; - **字符串字面量**:Drogon 过滤器注册名 56 处(`"authforge::drogon::filters::AuthorizationFilter"`×39、`OAuth2AuthFilter`×17 等)——与 config.json 零耦合(实测 filters 段为空),代码内自洽; - **ORM 模型路径**:`paths.env:68` `MODELS_INC_REL_DIR=include/authforge/storage/postgres/models`——orm-gen 流程与生成的 include 路径联动; - **conanfile.py**:`AuthForgeConan` 类名与包引用。 ### L3 构建与二进制名 - `paths.env:46` `SERVER_BINARY_NAME=authforge-server`、测试二进制 `authforge-tests`(CI 多处默认值)+ 各库测试目标 `authforge-common-test`、`authforge-identity-test` 等(add_executable 实测 6+); - CMakePresets.json:LTO preset 描述与 `AUTHFORGE_ENABLE_LTO` 缓存变量; - 所有构建缓存因路径/目标名变化**全部失效,需清空重建**。 ### L4 CI / 门禁 - `ci.yml`:测试 exe 名(authforge-tests.exe)、`AUTHFORGE_WERROR`、SDK 头 SemVer 守卫(ci.yml:53,注释明言守卫 `libs/*/include/authforge`)——改名触发该守卫,以 v1.0.0 新版本序列放行; - `_build-test.yml`:docker 容器名 `authforge-postgres`/`authforge-redis`(仅 CI 内部,无持久化影响); - **api-diff 门禁**:`tools/api-diff/api-baseline.txt` 含 820 处 authforge——基线是导出 API 符号快照,改名后 diff 会全量飘红;处理=重新生成基线 + `--force` 批准(先例:backchannel logout PR 已有 drift 批准流程); - `release.yml`:安装布局 `lib/cmake/authforge-*/`、可能的 GHCR 发布名; - `arch_guard.py`、`api_diff.py` 工具自身的路径规则。 ### L5 运行时数据面(升级瞬间的影响) | 项 | 现值 | 影响 | |---|---|---| | Redis 键前缀 | `authforge:cache:token:access:` / `token:revoked:` / `token:introspect:` / `client:` / `user:*` 等 | 前缀改 `fulla:` 后旧键全部孤儿化=**一次性全量缓存失效**,升级窗口内回源 DB 有小风暴(QPS 高时注意);旧键带 TTL 自然过期,无需清理脚本 | | Prometheus 指标 | `authforge_cache_total`、`authforge_cache_invalidation_failures_total` | 指标名变更= Grafana 面板/告警规则同步改,否则监控盲窗 | | 数据库 | `oauth2_db` / `oauth2_user` | **零影响** | | cookie / issuer / JWT / UA | 无 authforge 字样 | **零影响**,存量令牌与会话完全兼容 | ### L6 部署与运维 - `deploy/docker/docker-compose.prod.yml`:三镜像引用 + `AUTHFORGE_VERSION` 环境变量名(5 处); - deploy/ 共 17 个文件(compose ×3、k8s manifests 等); - k8s 部署中的镜像名/资源名(.qoder wiki 的 Kubernetes 部署页有 79 处,可作清单参考)。 ### L7 客户端 SDK(已发布工件) - **Python**:`clients/python/pyproject.toml` `name = "authforge-oauth2"`(PyPI 实测在线)→ 新包 `fulla-oauth2`;旧包发一个带 deprecation 说明的封版或仅改 README; - **Go**:module path 变更(见 L1);Go proxy 缓存旧路径版本,消费方 `go get` 新路径即可,旧模块建议加 `// Deprecated:` 注释; - **前端**:品牌文案/标题/e2e 断言/package.json 名的完整清单与陷阱见 §2bis A(8 文件 11 处); ### L8 文档、镜像目录与历史数据 - docs/ 52 个文件 + README×3 + AGENTS.md/CLAUDE.md/CONTRIBUTING/SECURITY:机械替换; - `.qoder/` 201 个文件是**可再生的镜像 wiki**(含路径含 `(authforge__common)` 的文件名):整体 sed 或由工具重生成; - `.codebuddy/`、`.claude/`、`.kiro/`、`.zcode/` 中的规则镜像同步; - `benchmarks/` 821 个命中文件中约 **770 个是历史测量结果**(benchmarks/results/ 359、baseline、各 sweep)——**不应改写**:它们是"authforge@某 commit"的测量记录;仅 `benchmarks/authforge/` 工具目录(~30 文件)需要 `git mv` 为 `benchmarks/fulla/` + .gitignore:33 路径同步; - `CHANGELOG.md` 历史条目**不改写**(历史事实);新条目以新名书写。 ### L9 本机与工作区(维护者机器特有,细节不入库档) - 仓库目录改名后: - AI 工具工作区身份 key 随目录名变化,需迁移或重建索引; - 辅助工具的本地扫描历史/coverage 路径失效; - 辅助克隆(基准环境)的路径同步; - 本机 native PostgreSQL 的 DB 名不变(oauth2_*),无数据迁移。 ### L10 不可改 / 不应改(负面清单) 1. git 历史与 tag(v1.x.y)——永久保留; 2. CHANGELOG 历史条目、benchmarks/results 历史数据——测量事实; 3. PyPI 旧版本、GHCR 旧镜像——只能 deprecate 不能消除; 4. `docs/branding/rename-candidates.md` 调研报告本身(authforge 是调研对象)。 ## 2bis 第二轮补充审查(2026-08-25,五个专项) ### A. 前端品牌面(L7 细化,实测 8 文件 11 处) - **品牌文案**:`AppLogo.vue`(admin+user)、`LoginPage.vue:37` h1 "AuthForge Admin"、`:94` 页脚 "AuthForge Identity Platform · Enterprise OAuth2/OIDC Server"、user 侧 `AppLayout.vue:130`/`AuthLayout.vue:73` 同款页脚、`design-tokens.css` 注释; - **e2e 断言联动(陷阱)**:`frontends/admin/tests/e2e/auth.spec.ts:16` `toContainText('AuthForge Admin')` —— 品牌文案改动必须同步此断言,否则 16 条 admin e2e 直接红; - **页面标题**:admin `index.html` `<title>oauth2admin`、user `OAuth2 App` 及 `frontends/user/.env` `VITE_APP_NAME=OAuth2 App` → 统一 Fulla 系("Fulla Admin"/"Fulla"); - **package.json 名**:`oauth2-admin`/`oauth2-frontend` → `fulla-admin`/`fulla-user`(顺带与目录名 user 对齐;均为非发布包,无 registry 迁移成本); - user 有 `.env`/`.env.example`(含 VITE_GITHUB_CLIENT_ID 等与改名无关项,随迁检查),admin 无 .env;admin 有 favicon.svg,**user 无 favicon**——顺手补齐(品牌 logo 机会)。 ### B. 基础设施与 DB 命名规范化(借改名窗口从头开始) 无生产环境,方法 = **改配置 + db-reset 重建**,不写 `ALTER DATABASE RENAME` 迁移 SQL(迁移编号红线不变)。对照表: | 现值 | 建议新值 | 出现点 | |---|---|---| | `oauth2_db` / `oauth2_db_prod` | `fulla_db` / `fulla_db_prod` | 5 份 config 的 dbname、compose×3、`_build-test.yml:217` | | `oauth2_user` | `fulla_user` | 同上 | | `container_name: oauth2-{admin,frontend,backend,postgres,redis,prometheus}` | `fulla-*` | `deploy/docker/docker-compose.yml:7-115` | | `OAUTH2_*` 环境变量(20+ 个、1260 处、22 个代码文件读取) | `FULLA_*` | env_common.sh、compose、CI、bench 脚本(注意只改**全大写下划线形态**,见 §3 新增规则) | | compose 服务键名(admin/frontend/backend/postgres/redis/prometheus) | **保持** | `config.prod.json` 的 db host `"postgres"`/redis host `"redis"` 引用服务键名 | | 卷名 pgdata/redisdata/promdata | **保持** | compose project 前缀已隔离 | | redis 密码两套(主 compose `redis_secret_pass` vs debug `123456`) | 顺手统一 | compose×2 | | `POSTGRES_PASSWORD=123456` | dev 默认可留;prod 已有 `${POSTGRES_PASSWORD}` 注入机制 | — | `OAUTH2_`→`FULLA_` 的理由:这批变量中 `OAUTH2_PROJECT_VERSION`/`OAUTH2_ENV`/`OAUTH2_SERVER_DIR` 本就是**项目级而非协议级**,前缀实际起品牌作用;一个品牌一个前缀,避免 fulla 时代继续背着 oauth2 前缀的二次不一致。代价:替换面 +1260 处,但模式单一(纯前缀替换)。 ### C. README 与仓库门面 - README.md / README.zh-CN.md 当前定位 "Full-Stack OAuth2/OIDC Authorization Server" —— **借机重写**为终极定位("高性能 C++ 开源 IAM 核心 + 商业增强模块"),更新模块划分图(libs/* SDK 分层、apps/server、frontends、clients)、Quick Start;benchmark 章节与 "5/5 scenarios lead" 徽章内容保留(指向 benchmarks/competitors); - **owner 不一致(既有问题,改名时一并修)**:仓库内 `github.com/lucaswang420/authforge` 引用 **36 处**(README 徽章等)vs 实际 remote/go module/GHCR 的 `voidvec` —— 统一为 voidvec(`git remote -v` 实测 origin); - **GitHub 仓库元数据**:description 含 "the auth forge for your apps" 双关语需重写;topics 存在拼写错误 `rabc`→`rbac`,可补充 iam/authorization-server 等;homepageUrl 当前为空 → 注册 fulla.dev 后填入。 ### D. 五环境配置项审查(config.\{json,dev,ci,prod,bench\}) - config.json / dev / bench 三份实质相同(dbname oauth2_db、host 127.0.0.1、port 5555)—— 改名落地后可另行决策是否合并为 overlay,减少三份漂移面(非改名必需); - config.prod.json:db host `"postgres"`、redis host `"redis"`(compose 服务键名)—— 服务键名不改则此处只改 dbname; - config.ci.json:无 dbclient 段,DB 连接由 CI 以 `OAUTH2_DB_*` env 注入 → **前缀改名的联动点**; - 五份 listener 端口统一 5555,一致性良好,无需动。 ## 3. 大小写映射表(机械替换的替换规则) | 旧 | 新 | 例 | |---|---|---| | `authforge` | `fulla` | 命名空间、路径、二进制名 | | `Authforge` | `Fulla` | `AuthforgePackage` → `FullaPackage` | | `AuthForge` | `Fulla` | `AuthForgePackage.cmake` → `FullaPackage.cmake`、`AuthForgeConan` → `FullaConan` | | `AUTHFORGE` | `FULLA` | `AUTHFORGE_WERROR` → `FULLA_WERROR` | | `OAUTH2_`(全大写下划线前缀) | `FULLA_` | `OAUTH2_DB_HOST` → `FULLA_DB_HOST` 等 20+ 变量(§2bis B) | | `oauth2_db` / `oauth2_user` / 容器名 `oauth2-*` | `fulla_db` / `fulla_user` / `fulla-*` | 见 §2bis B 对照表 | | `authforge-oauth2`(PyPI) | `fulla-oauth2` | — | | `authforge::` | `fulla::` | 过滤器注册字符串同步 | > `fulla` 是真词(可为子串,如英语 FullAuto),反向替换无风险;但正向替换时注意 `authforgepackage`/`authforge_package` 这类**无分隔符拼接变体**(41+38 处)必须列入替换模式,不能只替裸词。 > > **关键区分**:只改 `OAUTH2_`(全大写下划线,环境变量前缀);**不改** `OAuth2` 驼峰形态——`OAuth2Plugin` 插件类名、`OAuth2AuthFilter`、openapi 里的协议词、"Enterprise OAuth2/OIDC Server" 副标题里的 OAuth2 都是**功能/协议名**,不是项目名,保留。 > > **子串边界教训(Phase 2 实测踩坑,已修复)**:`oauth2_user` 是表名 `oauth2_user_consents` 与 operationId `oauth2_userinfo` 的**子串**——朴素 sed 会把这两类 token 也改掉(32+25 处,含 V006 迁移、model.json、模型 .cc 的 tableName、openapi、Python SDK 函数名),症状是 ORM 重生成后模型类名错乱(FullaUserConsents)与运行时 SQL 表名失配。修复原则:DB 表名、索引名、operationId、URL 路径一律不改;替换后必须按 token 直方图(`grep -hoE 'fulla_[a-z_]+' | sort | uniq -c`)逐类复核边界。 ## 4. 兼容性矩阵(谁破谁不破) | 消费方/资产 | 是否破坏 | 缓解 | |---|---|---| | C++ SDK 消费方(include 路径+命名空间+CMake 包) | ❌ 破坏(当前仅内部消费) | fulla v1.0.0 新序列 + 迁移说明(一段 sed 即可) | | Python SDK 用户 | ❌ 包名变更 | 新包发布 + 旧包 README 标 deprecated | | Go SDK 用户 | ❌ module 路径变更 | 新路径 + 旧版 Deprecated 注释 | | Docker 部署方 | ❌ 镜像名变更 | 新镜像 fulla-* + 文档公告 | | 存量 JWT/session/cookie | ✅ 兼容 | 协议面无项目名(实测) | | 存量数据库 | ✅ 兼容 | DB 名无项目名(实测) | | Redis 缓存 | ⚠️ 一次性失效 | 升级窗口回源风暴,TTL 自然清理 | | Grafana/告警 | ⚠️ 指标断点 | 面板同步改名 | ## 5. 建议执行顺序(13 步) 1. **前置外部资产**:域名与包名等外部资产于改名日前置办理(具体清单见本地维护副本); 2. 开改名分支,写替换脚本(按 §3 映射表,含拼接变体),先 `git mv` 八个 `include/authforge/` 目录与 `benchmarks/authforge/`; 3. 跑替换(排除 benchmarks/results、CHANGELOG 历史区、docs/branding/);替换模式 = §3 映射表全表,**含 `OAUTH2_`→`FULLA_` 前缀替换与 §2bis B 的 DB/容器名规范化**(只替全大写 `OAUTH2_`,不动驼峰 `OAuth2` 类名); 4. 重生成 ORM 模型(orm-gen,路径联动)与 **api-diff 基线**,`--force` 批准记录在 PR 描述; 5. 清空全部构建目录,全量构建 + `full_test` 8 步后端流水线 + 前端(admin 16 e2e + user 8 e2e); 6. CI 三件套(ci/release/_build-test/_sdk-smoke)中的 exe 名/容器名/环境变量同步; 7. deploy/ 三份 compose + k8s manifests:镜像名 → `ghcr.io/voidvec/fulla-*`,`AUTHFORGE_VERSION` → `FULLA_VERSION`;同时落 §2bis B 的基础设施规范化(DB 名/用户/container_name/redis 密码统一;compose 服务键名与卷名不动); 8. 清除旧版本序列:删除五个旧 tag(v1.0.0–v1.4.1)及对应 GitHub Releases(`git tag -d` + `git push --delete`,Releases 需在 GitHub 侧显式删除)——git 提交历史与 CHANGELOG.md 历史区**原样保留**,CHANGELOG 顶部加更名分界说明;随后版本定为 **v1.0.0** 走发版六处版本同步流程(openapi.yaml info.title、pyproject 等)+ openapi tags/SDK drift 检查(PR #85 教训:openapi tags 丢失会挂 SDK 门); 9. PyPI 发布 `fulla-oauth2` 1:1 首版;旧包 README 加 deprecated 指引; 10. GitHub 仓库改名(放最后,重定向即刻生效);GHCR 新镜像名随 release.yml 首次发布; 11. 前端品牌与仓库门面(§2bis A/C):AppLogo/LoginPage 文案、index.html 标题、`VITE_APP_NAME`、package.json 名、**auth.spec.ts e2e 断言同步**、user 侧 favicon 补齐;README 双语重写(新定位 + 模块划分 + owner 统一为 voidvec);GitHub 元数据更新(description、topics 含 `rabc`→`rbac` 修正、homepage=fulla.dev); 12. 维护者本机工作区改名与各工具本地索引迁移; 13. 旧工件收尾:Grafana 面板、告警规则、外部文档/榜单(若有)公告更名。 ## 6. 风险清单(TOP 8) 1. **api-diff 基线重生成**是唯一"改错了会放走真回归"的环节——基线重生成前后各跑一次全量测试; 2. **过滤器注册字符串漏改**(56 处字面量藏在 .cc 深处)会导致运行时过滤器失配启动失败——靠全量 e2e 兜底; 3. **Redis 前缀变更的回源风暴**——高 QPS 生产环境选择低峰升级; 4. **拼接变体漏替换**(authforgepackage 等 80+ 处无分隔符形态)——替换脚本必须含这些模式并 grep 验证归零; 5. **npm/PyPI/GitHub 的 fulla 裸名占用不对称**(PyPI 空、npm 死包、GitHub org 被占)——包名策略先行统一再动手,避免发一半改主意; 6. **前端 e2e 品牌断言**(auth.spec.ts:16 `toContainText('AuthForge Admin')`)与文案不同步会挂 admin e2e —— 文案与断言必须同 PR; 7. **owner 不一致**(lucaswang420×36 处 vs voidvec)——统一方向错了会让 README 徽章/克隆链接全断,以 `git remote -v` 的 voidvec 为准; 8. **`OAUTH2_` 替换误伤**:sed 模式若写成宽松的 `OAUTH2` 会把 `OAuth2Plugin`/协议词一并破坏 —— 前缀替换必须锚定全大写下划线形态(§3 关键区分)。 ### 红线(版本重置 ≠ 这些也重置) 1. **DB migration 编号**:schema_migrations 序号是 schema 演进史,与产品版本无关——重置它会让所有已初始化的 dev/测试库校验失败,是整个改名过程中**唯一可能真正搞坏数据**的操作; 2. **git 提交历史**:不 rebase、不 squash——PR #47–#85 的评审与决策历史是工程资产,只动 tag 指针; 3. **CHANGELOG 历史区与 benchmarks/results 历史数据**:authforge 时代的发布与测量事实,原样保留,仅新条目用新名。 ## 7. 实施计划(PR 切分与验证门) > 配套审计:[repo-professionalization-audit.md](repo-professionalization-audit.md)(Phase 1 的逐文件依据) | Phase | 分支/PR | 内容 | 验证门 | 执行者 | |---|---|---|---|---| | **P0** 资产占位 | —(无代码) | 域名与包名等外部资产前置办理 | 资产到手 | **用户手动** | | **P1** 专业仓库清理 | `chore/professional-repo-cleanup` | 审计清单执行:untrack `.qoder/.codebuddy/.zcode/.kiro`(kiro specs 先迁 docs/history)、删过期 MEMORY.md、.gitignore 增补、AGENTS.md 角色表、绝对路径泛化 | 纯文件操作;`git status` 干净 + CMake configure 冒烟 | 代理可执行 | | **P2** 改名机械替换 | `feat/rename-fulla`(基于 P1) | §3 映射全表替换(含 `OAUTH2_`→`FULLA_`、§2bis B 基础设施规范化)+ 8×`include/authforge` 与 `benchmarks/authforge` 的 git mv + config 五份 | **Release 构建 + 353 ctest 全绿** + 前端 tsc/build | 代理可执行 | | **P3** 门禁与基线 | 并入 P2 或紧随 | api-baseline 重生成 + `--force` 批准记录;arch-guard;前端 e2e 16+8(含 auth.spec.ts 断言联动) | CI 全绿 + 本地全量前端测试 | 代理可执行 | | **P4** 定版与门面 | `release/v1.0.0` | 删旧 tag v1.0.0–v1.4.1 + Releases;v1.0.0 六处版本同步 + openapi tags/SDK drift 检查;README 双语重写;前端品牌文案;GitHub 元数据(description/topics/rbac/homepage) | 发版六点本地预检清单全过 | 代理可执行(tag 删除需用户确认) | | **P5** 外部与本机收尾 | —(无 PR) | GitHub 仓库改名;GHCR 新镜像首推;PyPI 发布;本机目录改名 + ZCode 工作区迁移 + WSL 克隆同步;Grafana/告警同步 | Release workflow 全绿 | **用户手动**(远程操作+凭据) | **依赖关系**:P0 与 P1 并行;P2→P3→P4 严格串行;P5 最后。P1 必须先于 P2 合入(否则改名 diff 混入 347 个出库文件的噪音,评审不可读)。 **回滚**:P1–P4 均为可 revert 的普通 PR;P5 的 GitHub 仓库改名可再改回(重定向链保持)。 --- # 专业仓库改造审计 — 入库范围清理清单 Source: https://fulla.dev/docs/adr/repo-professionalization-audit > **Language note**: historical archive, kept in its original Chinese. The > 2026-08-25 repo-professionalization audit (executed via > chore/professional-repo-cleanup). Current rules live in > [documentation-governance.md](../documentation-governance.md). # 专业仓库改造审计 — 入库范围清理清单 > 审计日期:2026-08-25 | 方法:`git ls-files` 全量盘点 + 体积/敏感内容/生成物交叉筛查 > 背景:仓库转入专业化运营(配合 authforge→fulla 更名,见 [rename-impact-fulla.md](rename-impact-fulla.md) §7 Phase 1) ## 一、必须从远程去除的文件(untrack + gitignore,磁盘保留) | # | 目录/文件 | 规模 | 性质 | 处置 | |---|---|---|---|---| | 1 | `.qoder/` | 257 文件 / ~150KB(含 1.1MB repowiki 元数据) | Qoder AI 工具工作区:repowiki 为**生成的仓库 wiki**(201 页,改名时还会产生 800+ 处替换噪音) | `git rm -r --cached` + ignore | | 2 | `.codebuddy/` | 45 文件 | `.claude/rules/`+skills 的**镜像副本**(内容一致,纯冗余、有漂移风险) | 同上 | | 3 | `.zcode/` | 21 文件 | ZCode 工具专属:skills 镜像 + 2 个旧 plans(`plans/` 已在 .gitignore,这 2 个是规则生效前的漏网) | 同上 | | 4 | `.kiro/` | 23 文件 | Kiro 工具专属 specs —— 但其中 **6 个 spec 的 design/requirements/tasks 是有价值的工程设计文档**,先迁移再去除 | **先迁 `docs/history/design/kiro-specs/`**(`.config.kiro` 工具状态文件不迁),再 untrack + ignore | | 5 | `.claude/MEMORY.md` | 1 文件 | 2025-04 断代的**过期个人自动化记忆**(自称"OAuth2 插件示例项目",与现状严重失配) | 直接 `git rm`(删除) | **合计**:约 347 个文件出库。所有工具目录磁盘副本保留(本地工具流不受影响),仅退出版本库。 **去除理由归纳**:专业仓库入库标准 = 对所有贡献者有复现/协作价值的产物。AI 工具的个人工作区、可再生成物、多副本镜像均不符合;`.claude/`(规则权威源)作为唯一例外保留,见下。 ## 二、保留清单(审查过但有明确专业理由,防御性记录) | 项 | 规模 | 保留理由 | |---|---|---| | `.claude/`(除 MEMORY.md) | 37 文件(rules 4 / skills 19 / agents 7 / commands 6 / settings) | AGENTS.md 声明的**规则权威源**,等价于贡献者工作流文档;settings.json 权限/钩子配置合理 | | `.vscode/` | 5 文件 | 共享 IDE 配置(launch/tasks/c_cpp + `settings.local.json.example` 模板);`*.local.json` 已 ignore | | `benchmarks/results/` | 885 文件 / 仅 756KB | README 明示"从提交的 JSON 再生对比报告"的**可复现性设计**,非垃圾数据 | | `docs/backend/api/swagger-ui/` vendored bundle | ~3.9MB | 离线可用的 API 文档(标准 vendoring 做法) | | `tools/api-diff/api-baseline.txt` | 300KB | CI API 门禁基线(改名时按计划重生成) | | `clients/go/generated/`、`frontends/*/package-lock.json`、`conan.lock` | — | 生成 SDK 是交付物本体;锁文件是专业仓库标配 | ## 三、需要调整(不出库,但要改) 1. **`.gitignore` 增补**:`.qoder/`、`.codebuddy/`、`.zcode/`、`.kiro/`、`.workbuddy/`(`.workbuddy/` 当前未忽略,git status 长期裸奔); 2. **`AGENTS.md`「各 AI 工具目录的角色」表**:从"多工具镜像同步"改为"`.claude/` 唯一权威 + 其余工具目录本地化"; 3. **机器绝对路径泛化(共 3 处,已执行)**:`.claude/skills/full-backend-test/SKILL.md:20` 的 `cd /d/work/...` → `cd "$(git rev-parse --show-toplevel)"`;`docs/history/design/http-integration-test-coverage-plan.md:10` 的本地 Drogon 检出路径 → 通用描述;`docs/history/design/superpowers/specs/2026-04-14-multiplatform-ci-design.md:519` 的 `file:///D:/...` 本地链接 → 纯文本引用(初扫误报的 `docs/ops/deployment-windows-docker-desktop.md` 复核后无个人路径,未改); 4. (随改名 PR 处理,不在本清单执行)README owner 统一、GitHub topics `rabc` 拼写等见 rename-impact §2bis C。 ## 四、审计确认无问题项 - **零密钥/证书/凭据入库**:无 `*.pem/*.key/*.crt` 跟踪;`PRIVATE KEY` 零命中;`frontends/user/.env`(含 GitHub client id 等)已被 .gitignore 正确拦截; - **零构建产物/IDE 垃圾**:dist/build/node_modules/coverage 均未跟踪; - **`.claude/settings.local.json` 未入库**(仅 `.vscode` 有 `.example` 模板,处理正确); - 现有 `.gitignore` 本身已相当完善(本审计仅增补上述 5 条)。 ## 五、执行记录(Phase 1 实际命令) ```bash git switch -c chore/professional-repo-cleanup # 基于 origin/master (e973a4f8) git mv .kiro/specs/<6 个 spec 目录> docs/history/design/kiro-specs/ # 迁移设计文档 git rm docs/history/design/kiro-specs/*/.config.kiro # 工具状态文件不迁 git rm -r --cached .qoder .codebuddy .zcode # 出库(磁盘保留) git rm .claude/MEMORY.md # 过期记忆删除 # + .gitignore 增补、AGENTS.md 角色表更新、绝对路径泛化 ``` --- # Documentation Governance v4 — Content Adjudication · Bilingual Docusaurus Site Source: https://fulla.dev/docs/documentation-governance # Documentation Governance v4 — Content Adjudication · Bilingual Docusaurus Site > Status: v4 (2026-08-27). v1 directory-level triage → v2 per-file content > adjudication from four parallel full-text reads (file:line evidence) → > v3 final IA (three-layer model, Phases A/B done, pre-launch quality pass) > → **v4: English-primary site with a zh-CN locale toggle** (docs/ is the > English canonical; the Chinese tree lives under website/i18n/zh-CN/), > Phase C deep-dives complete. > Principle: **what ships in the repo is for strangers (they can get one > thing done with it); what stays local is for maintainers and agents.** ## 1. In-repo criteria (unchanged) 1. **Actionable**: a stranger can get one thing done (deploy / integrate / troubleshoot / contribute); 2. **Decision-worthy**: records design decisions and rationale needed to understand the system (ADRs); 3. **Trust-worthy**: outward commitments (security posture, compliance audits, versioning policy, measurement reports and methodology). ## 2. Per-file adjudication (136 files + root docs) — historical record ### 2.1 docs/backend/ (23 md files + swagger-ui static assets) | File | Verdict | Key evidence | |---|---|---| | architecture-overview.md | **SITE-READY** | All facts matched reality; add a line on the identity package and cache decorator | | ci-cd-guide.md | **SITE-READY** | Verified against the 8 real workflows; add one-liners for clients-sdk/security | | docker-deployment.md | **SITE-READY** | Matches compose reality; pool numbers updated per bench conclusions (64) | | sdk-integration-guide.md | **SITE-READY** | Only the L8 `.kiro` source path was dead | | sdk-runtime-contract.md | **SITE-READY** | Only the L7 `.kiro` path was dead | | versioning-and-release.md | **SITE-READY** | Single governance home; L294 "840 commits" todo superseded by the version reset | | api-reference.md | **REWRITE** | L226 end_session "no signature check" contradicted #78 (its own error table lists 4006); L254 Google route wrong; §6 taught the stale openapi.json workflow, conflicting with the governance gate | | configuration-guide.md | **REWRITE** | L38 PG15→17; L69 cache layer described as "future" (shipped, config.json:152); L132 end_session stale semantics; L176 JWKS path wrong; env-var table listed only 5 of 30+ | | data-persistence.md | **REWRITE** | §3 vs §6 self-contradicted on Redis-storage deprecation (L96 vs L205); schema stuck at V002 (now V026); `fulla:cache:` keyspace absent; absorb data-consistency + add delayed double-delete section | | observability.md | **REWRITE** | Missing #80 cache-invalidation metrics; `oauth2_*` vs `fulla_*` naming never clarified; audit examples dated 2026-01 | | oidc-guide.md | **REWRITE** | L21/34 JWKS routes wrong (actual `/.well-known/jwks.json` — copying the doc fails); missing end_session/backchannel integration duties, auth_time/acr/amr, official SDK channel | | rbac-guide.md | **REWRITE** | L62 "roles in JWT — future" already implemented (TokenService.cc:334); scope layer (V023 dual-gate) entirely missing; manual SQL grants stale (admin API exists) | | security-architecture.md | **REWRITE** | L36 secret-transport claim violated F-017 (Basic header default); threat table predated PR#85 fixes (#78 forged logout / #79 cache race / #54 soft-delete bypass) | | testing-guide.md | **REWRITE** | L274 counts 364+450 (actual 501); L86-119 April snapshots; L195-263 four dangling "[DOC] (archived)" references | | data-consistency.md | **MERGE → data-persistence** | Narrow but correct; missing #79 delayed double-delete | | docker-guide.md | **MERGE → docker-deployment** | 60% overlap; L19 container names `oauth2-{service}` missed the rename; keep its uniques: naming table / debug containers / full_test_docker | | google-guide.md + wechat-guide.md | **MERGE → social-login.md** | Isomorphic twins; google L45 no-button vs L53 click-button self-contradiction; lead with the wired-up GitHub flow | | plugin-integration.md | **MERGE → sdk-integration-guide** | A quickstart subset of the latter's §3 | | security-hardening.md | **MERGE → security-architecture** | Rate-limit numbers mismatched config.prod.json across the board (3/2/5 vs documented 5/5/10); drop April snapshots and dangling refs; note Hodor is prod-only | | database-encoding-guide.md | **LOCAL** | Single-machine SQL_ASCII investigation; contains dangerous pg-catalog DELETE advice | | documentation-standards.md | **LOCAL** | Repo-directory meta-rules, belongs with CONTRIBUTING; rewritten by this governance | | ddd-domain-model.md | **ARCHIVE** | Self-described "unreviewed proposal"; honest mapping, an evolution draft — not a current-state doc | ### 2.2 docs/ops/ + admin/ + frontend/ + performance-optimization/ (16 files) | File | Verdict | Key evidence | |---|---|---| | ops/account-lockout.md | **SITE-READY** | Solid; credential caliber needed unifying (conflict #1) | | ops/postgresql-major-upgrade.md | **SITE-READY** | Fresh and accurate; fix L122 deploy name; **add to docs/README index (was missing)** | | ops/deployment.md | **REWRITE (light)** | L571-583 initdb.d manual migration unexecutable in prod (migrations baked into the image); L783 Prometheus direct-connect vs loopback binding conflict; performance section already synced | | ops/deployment-windows-docker-desktop.md | **REWRITE** | Test counts 55/51 stale (actual 59/52); "80% pass = success" bad caliber; 6 machine-local paths; admin123 credential conflict | | ops/verification-checklist.md | **REWRITE** | Nonexistent nginx in the dev container table; `oauth2_migrations` table name wrong (actual schema_migrations); hardcoded password WinDockerTest2024!; table-count threshold >=7 stale; two different admin emails in one file | | ops/security-checklist.md | **MERGE → backend/security-hardening** | Remediation-closeout memo; L76-84 two copy-paste-accident filter-branch commands | | admin/e2e-testing-guide.md | **REWRITE (light)** | L903 dead link; appendix "7 files/53 cases" vs actual 16/174; §9 duplicated account-lockout wholesale | | admin/test-cases.md | **SITE-READY** | No defects; healthy spec correspondence | | frontend/test-cases.md | **SITE-READY** | No defects; covers the current feature surface | | performance-optimization/ (all 7) | **LOCAL** | Prompts were AI-session artifacts; wave reports / instrumentation / non-code plans / memory investigations are internal evidence chains (baseline generations tangled — publishing would present three mutually contradictory QPS worldviews); user-facing conclusions were already extracted into ops/deployment §performance; upstream-drogon-session-issue.md is the sole record of the upstream constraint — link it once the issue is filed, then ARCHIVE | ### 2.3 docs/history/ (60 files): the ADR mine **v1 correction**: superpowers/specs/ were not all session artifacts — 2 of them are real design documents. - **ADR conversion list (11 + 1 alternate, by priority)**: 1. **Product + dual-SDK architecture and dependency iron rules** (sdk-refactor §2/§4.1/§5.2: Domain bans Drogon, oauth2/identity do not depend on each other, ports sink into common, arch-guard enforces) 2. **Drogon self-registering-symbol linkage strategy** (sdk-refactor §5.5/§5.7: explicit registerController instead of whole-archive + plugin-zero-change option A) 3. **ErrorCatalog single authority and dual-channel errors** (error-code AD-1..6 + auth-flow-gap "no-folding principle" and the G7 anti-enumeration exception) 4. **Opaque access token + credential-hash storage + migration immutability** (production_hardening_spec §五; **note**: the decision table said Argon2id, the shipped implementation is PBKDF2-SHA256 310K — corrected during ADR conversion) 5. **Email as the primary login identifier** (email-first §7, five decisions; V020 in tree) 6. **Async callback lifetime patterns** (concurrency audit, four threads; CacheMap thread-safety conclusions) 7. **MFA second-factor session binding** (mfa-fix: pending binding + same-code anti-enumeration; V022 in tree) 8. **First-party SPA login credential-exposure control** (mfa_auth_code_pkce §6 revised + current authService.ts: AJAX+PKCE closure, tokens never in localStorage, hosted-login-page idea shelved) 9. **ORM generated-model exemption + migration freeze** (repo-refactor §0/§1.2 — survived two refactors) 10. **Rate-limiter choice: Hodor** (superpowers/specs: token-bucket three-tier limiting; config.prod uses it) 11. **Integration-test platform tiering** (http-integration-plan: DB-backed tests Linux-only, in-process app, social surfaces unreachable) 12. (Alternate) **PUBLIC/CONFIDENTIAL client authentication classes** (client-secret spec) Plus: distill "why C++17 bans coroutines" from async-refactor-assessment into a one-page ADR (every contributor asks). - **ARCHIVE kept: 11 files** (history/README + bugfix/audit originals + 6 superseded designs); - **LOCAL: 37 files** (all checkbox-style tasks/requirements/plans and process drafts); - **DELETE: 1 file**: PRD/frontend_design.md (a strict subset of, and earlier snapshot than, frontend/oauth2_frontend_design.md — diff-verified). ### 2.4 productization-evolution/ + branding/ (25 files): two hard v1 corrections - **LOCAL: 21 files** (including content-strategy "de-AI-flavor + advertorial process" — self-harming if public; progress-status enumerating unfixed security items #71/#73 — don't amplify; research containing unpublished pricing $499/$5000); - **Exception 1 (SITE, benchmark zone): in-progress/competitor-benchmark-design.md stays in-repo** — three public entry points link it (README-badge COMPARISON.md, both READMEs, benchmarks/competitors/README); the content is publishable methodology (three-sames principle / official-config provenance / honest revision log); the environment disclosure (WSL2 8 vCPU/16GB) is reproducibility-required and already public; removing it breaks three public links. New home: `docs/benchmark/`; - **Exception 2 (SITE, trust archive): done/oauth-oidc-compliance-audit.md stays in-repo** — an RFC compliance audit with all 31 findings fixed; publicly referenced from CHANGELOG.md:453; a trust asset for assessers (labeled "2026-08-07 baseline snapshot"); - **branding/rename-impact-fulla.md → ARCHIVE in-repo, sanitized first**: generalize machine paths and workspace details; compress the P0 "squat the assets" step; CHANGELOG:40 references its §3 — removal would break the link; - **branding/repo-professionalization-audit.md → ARCHIVE in-repo**: AGENTS.md references it publicly as the in-repo standard; - **branding/rename-candidates.md → LOCAL (highest sensitivity)**: exposes the unregistered status of fulla.dev + a 9-name availability list + self-critical assessments — intelligence for squatters until the assets are secured; - Hygiene: `.mimosa/` session JSONs had leaked into docs/productization-evolution/ (untracked) → .gitignore `.mimosa/`. ## 3. Cross-document conflict register (Phase A must-fix list) All conflicts found by the deep reads **had to be resolved before launch**, otherwise the site would amplify them bilingually: | # | Conflict | Settled by | |---|---|---| | 1 | **Three admin default-credential calibers** ('admin' vs admin123 vs admin/admin123+fulla-admin-console dual client) | Measured against apps/server/seed/dev_admin_user.sql; unified site-wide | | 2 | end_session "no signature check" (api-reference L226, configuration-guide L132) vs the error table listing 4006 | Code enforces verification (#78); both passages rewritten | | 3 | Redis cache layer "future" (configuration-guide L69) vs shipped (architecture-overview L12 pointed at the wrong section too) | config.json cache block is authoritative; configuration-guide gained a cache section | | 4 | CHANGELOG claimed all metrics renamed `fulla_*` vs code emitting `oauth2_*` (the authforge_* ones were renamed) | **CHANGELOG wording fixed in that PR** | | 5 | JWKS path in three versions | DiscoveryController.cc:60 settles it | | 6 | PG version 15 vs 17 | 17 | | 7 | Google route /google/login vs /api/google/login | The controller settles it | | 8 | rbac-guide single-gate roles vs api-reference dual-gate (role+scope) vs "JWT roles — future" | Current state = dual gate + roles issued | | 9 | Test counts 364+450 vs 501; e2e "7 files/53 cases" vs 16/174 | Scripts and specs measured | | 10 | verification-checklist's oauth2-nginx(dev)/oauth2_migrations/WinDockerTest2024!/unprefixed table names | compose/V001/actual config settle it | | 11 | docs/README.md summary "session unbounded leak ~730B" vs investigation v2's retraction (TTL-bounded 750B) | The latter; absorbed in the README rewrite | | 12 | Two rate limiters (Hodor global vs F-018 failure counting) with their coexistence never explained | Explained together in the rewrite | ## 4. Docusaurus content source (decision unchanged, inventory updated) **Site source = the repo's docs trees, zero copies.** Zone mapping (all rows now executed): | Zone | Source | Status | |---|---|---| | intro | README capability map + Quick Start (README Path A/B) | done | | architecture | architecture-overview + security-architecture + data-persistence | done | | domains | api-reference, oidc-guide, rbac-guide, social-login, **token-lifecycle, session-management, multi-tenancy (Phase C)** | done | | sdk | sdk-integration-guide + sdk-runtime-contract + official Python/Go clients | done | | operate | deployment, docker-deployment, deployment-windows-docker-desktop, configuration-guide, observability, account-lockout, postgresql-major-upgrade, verification-checklist | done | | benchmark | COMPARISON.md (repo) + competitor-benchmark-design.md (docs/benchmark/) | done | | adr | ADR-0001..0012 + three trust archives | done | | API | openapi.yaml rendered at runtime by the server (swagger-ui static assets are server-hosted, not site content) | n/a | Bilingual policy (v4): **English is the primary site language** (fulla.dev default locale = en — `/` serves English, `/zh-CN` serves Chinese, navbar language switcher). `docs/` is the **English canonical** (single source of truth); the Chinese tree mirrors it at `website/i18n/zh-CN/docusaurus-plugin-content-docs/current/` (identical layout, identical URLs). **Translation discipline**: a PR that changes an English doc under docs/ must update the Chinese counterpart in the same PR. The three historical archives keep their Chinese body in both locales with an English language-note header. The GitHub-facing README.md is English; README.zh-CN.md is Chinese and links to fulla.dev/zh-CN. ### 4A. Final information architecture (settled 2026-08-26, bilingualized 2026-08-27) Content assets live in **three layers**, each with clear boundaries and entry criteria: **Layer 1: `docs/` (English canonical, in-repo, on-site) + `website/i18n/zh-CN/` (Chinese translation)** — the site content source (Docusaurus default locale reads `../docs`, the zh-CN locale reads the i18n tree; identical structure, no drift). Only content where "a stranger can get one thing done / understand one decision / establish one trust": ``` docs/ (English) and website/i18n/zh-CN/.../current/ (Chinese), mirrored: ├── intro.md # site entry (routing table) ├── README.md # GitHub-side index (excluded from the site) ├── documentation-governance.md # this document ├── architecture/ # evaluate + deep-dives: architecture-overview / security-architecture / data-persistence ├── domains/ # domain guides: api-reference / oidc-guide / rbac-guide / social-login / │ # token-lifecycle / session-management / multi-tenancy (Phase C) ├── sdk/ # C++ SDK: sdk-integration-guide / sdk-runtime-contract ├── operate/ # operations: deployment / docker-deployment / deployment-windows-docker-desktop / │ # configuration-guide / observability / account-lockout / │ # postgresql-major-upgrade / verification-checklist ├── contribute/ # contributing: testing-guide / ci-cd-guide / versioning-and-release / │ # admin-test-cases / user-frontend-test-cases / admin-e2e-testing-guide ├── benchmark/ # competitor-benchmark methodology (results live in the repo's benchmarks/) └── adr/ # ADR-0001..0012 + three historical archives (Chinese body, bilingual note) ``` **Layer 2: in-repo non-docs assets (tracked, not site content)** — referenced by absolute link, never copied: | Asset | Role | |---|---| | `README.md` / `README.zh-CN.md` | GitHub front door (capability map, Quick Start, badges) | | `benchmarks/competitors/results/COMPARISON.md` | Benchmark results (evaluate-zone link) | | `apps/server/openapi.yaml` | API contract SSoT (api-reference points at it) | | `.claude/rules/`, `TECH_SPECS.md`, `AGENTS.md` | Maintainer contracts (linkable from contribute, not site content) | **Layer 3: `docs-local/` (untracked, on disk)** — maintainer/agent process archives (history, productization-evolution, branding, performance-optimization, perf reports). gitignored; criterion: process documents that satisfy none of the three layer-1 criteria. **Wiki division of labor**: the GitHub wiki is an auto-generated Chinese snapshot mirror (repowiki conversion), not bidirectionally synced; its Home points to fulla.dev as the authoritative source (Chinese readers go straight to /zh-CN). When site and wiki overlap, `docs/` wins. **Quick boundary test for a new document**: ask "does a stranger need it?" — yes → the matching docs/ zone (English) + the zh tree; only maintainers/ agents → docs-local/; it's results data, not documentation → a repo data directory (e.g. `benchmarks/`); it's an outward commitment → docs/ + versioned (referenced from CHANGELOG). ## 5. Execution phases (v4 progress) - **Phase A (content repair & reorganization) — done** (8783c8e5 + 6049e3d3 + 7e5cd55a + d33d1ec3 + 45ca1f30): all 12 register conflicts closed (including the second sweep of verification-checklist / deployment-windows residuals); six MERGE groups landed; 12 ADRs converted (status/source/ duplicate-title normalized); LOCAL classes removed from the repo (both exceptions rehomed; **execution gap closed**: the compliance audit / sanitized rename-impact / professionalization-audit archives, initially left in docs-local, moved into docs/adr/ with the two CHANGELOG links fixed); docs/README rewritten; language unified to simplified Chinese (superseded by v4's bilingual model). - **Phase B (site skeleton) — done** (8a7e3b96): website/ + audience-zoned sidebar + Pages deployment + broken-link gate. Close-out: Pages source switched to GitHub Actions (user action) + professional landing/theme. - **Phase C (content completion + bilingualization) — done** (docs/phase-c-i18n): three new deep-dives (token-lifecycle / session-management / multi-tenancy) in both languages; api-reference §6 rewritten around the OpenAPI governance gate; **English-primary i18n**: docs/ converted to the English canonical (~40 files translated), the Chinese tree moved to website/i18n/zh-CN/, navbar locale switcher, editUrlLocalized edit links per locale; the deployment doc's session section de-duplicated (single home for the sizing table = session-management). Standing obligation: same-PR dual writes (en change ⇒ zh sync). ## 6. Acceptance criteria (unchanged, extended) All four v1 criteria hold, plus: ⑤ the 12-item conflict register fully closed; ⑥ zero broken links from CHANGELOG-referenced docs (compliance report, rename-impact §3); ⑦ (v4) every doc exists in both locales and both builds pass the broken-link gate.