docs(auth): add authentication design and implementation plan
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,495 @@
|
||||
# ThothII Authentication Design
|
||||
|
||||
## Objective
|
||||
|
||||
Add first-party authentication and authorization to ThothII without bundling an identity
|
||||
manager. The same product build must support:
|
||||
|
||||
1. a standalone PC or Mac with local users stored in protected operator files; and
|
||||
2. a server deployment using a standards-based OIDC provider, with Authentik as the first
|
||||
certified provider for the PSD integration.
|
||||
|
||||
The design replaces implicit trust in a local browser with an explicit authenticated session,
|
||||
keeps the host operator interface exclusively under `tht`, and extends the existing workspace
|
||||
validation and installation diagnostics with authentication checks.
|
||||
|
||||
## Confirmed decisions
|
||||
|
||||
- The only host CLI is `tht`. No `thothii-admin`, `thothctl`, or separate authentication binary
|
||||
is introduced.
|
||||
- Production authentication modes are `local` and `oidc`.
|
||||
- `none` and `mock` remain test/development-only. Existing `upstream` support remains as a
|
||||
deprecated migration adapter until direct OIDC deployment has passed PSD acceptance.
|
||||
- Local passwords use Argon2id. Passwords are never stored or logged in plaintext.
|
||||
- A local user can select **Remember me**. The resulting session survives browser and ThothII
|
||||
restarts until its idle or absolute expiry.
|
||||
- OIDC login is provider-neutral. Authentik is the provider certified by automated and PSD
|
||||
acceptance tests in the first release.
|
||||
- OIDC must return a `groups` claim in the ID token as a JSON array of strings. Missing,
|
||||
malformed, indirect, or overage-style group claims fail authentication.
|
||||
- OIDC groups are mapped to ThothII roles in installation configuration. Unmapped groups are
|
||||
ignored silently, without errors or warnings.
|
||||
- Every configured group must be proven to exist in the identity manager. OIDC itself cannot
|
||||
enumerate groups, so this proof uses a provider-specific group-catalog adapter. The first
|
||||
adapter is `authentik`.
|
||||
- Authentication configuration is installation-global. Workspace validation still reports its
|
||||
status and workspace connection tests include its live connectivity checks.
|
||||
- Browser tokens are never stored in `localStorage`, `sessionStorage`, or JavaScript-readable
|
||||
cookies. The browser receives only an opaque `HttpOnly` session cookie.
|
||||
|
||||
## Non-goals for the first release
|
||||
|
||||
- Bundling Authentik, Keycloak, LDAP, or another identity manager with ThothII.
|
||||
- Implementing LDAP authentication directly in ThothII.
|
||||
- Persisting OIDC access, ID, or refresh tokens after login.
|
||||
- Supporting arbitrary provider management APIs through a user-programmable HTTP adapter.
|
||||
- Building a web UI for local-user administration. Local users are managed through `tht auth`.
|
||||
- Adding fine-grained workspace-specific ACLs. Authorization remains installation-wide.
|
||||
- Guaranteeing group-catalog validation for an arbitrary OIDC provider without a supported
|
||||
catalog adapter.
|
||||
|
||||
## Runtime architecture
|
||||
|
||||
The Fastify backend owns authentication, browser sessions, and authorization. The React frontend
|
||||
only renders login state and sends same-origin requests. The Python harness receives a trusted,
|
||||
already-authorized principal from the backend exactly as it does today.
|
||||
|
||||
```text
|
||||
browser
|
||||
-> local login -----------------------> Fastify auth service
|
||||
-> OIDC Authorization Code + PKCE ----> Fastify auth service ----> OIDC provider
|
||||
|
|
||||
+--> durable opaque sessions
|
||||
+--> group -> role -> permission mapping
|
||||
+--> authorized ThothII routes
|
||||
|
||||
tht auth / tht doctor
|
||||
-> host configuration and local-user files
|
||||
-> container-local AuthDiagnoser
|
||||
-> OIDC discovery/JWKS
|
||||
-> Authentik group API
|
||||
```
|
||||
|
||||
## Installation files and storage
|
||||
|
||||
Every installation descriptor gains one non-secret authentication location:
|
||||
|
||||
```yaml
|
||||
authentication:
|
||||
configDirectory: /absolute/operator-controlled/thothii-auth
|
||||
```
|
||||
|
||||
The directory is mounted read-only into `core` at `/run/thothii-auth`. It contains:
|
||||
|
||||
```text
|
||||
auth.yaml non-secret mode, URL, lifetime, OIDC, and group-role configuration
|
||||
users.yaml local user IDs, Argon2id hashes, roles, enabled state, and auth revision
|
||||
```
|
||||
|
||||
The host directory must be private to the operator. On POSIX its mode is `0700`, and both files
|
||||
are regular, single-link, non-symlink files with mode `0600`. Windows uses an equivalent
|
||||
owner-only ACL. `tht` performs bounded reads, known-field YAML decoding, and atomic replacement.
|
||||
|
||||
Durable browser session state lives under `/data/auth`, backed by a new Compose volume named
|
||||
`auth-state` for local installations and by the existing server `/data` bind for server
|
||||
installations:
|
||||
|
||||
```text
|
||||
/data/auth/sessions/<sha256-of-cookie-token>.json
|
||||
/data/auth/oidc/<sha256-of-state>.json
|
||||
```
|
||||
|
||||
Raw cookie tokens, derived CSRF tokens, and raw OIDC tokens are never written to disk. Session
|
||||
files contain only the principal, authorization snapshot, timestamps, and revision numbers.
|
||||
|
||||
## Authentication configuration contract
|
||||
|
||||
`auth.yaml` is strict, versioned YAML. The local form is:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
mode: local
|
||||
publicUrl: http://127.0.0.1:8080
|
||||
session:
|
||||
regularTtlSeconds: 43200
|
||||
regularIdleSeconds: 7200
|
||||
rememberTtlSeconds: 2592000
|
||||
rememberIdleSeconds: 604800
|
||||
oidcTtlSeconds: 28800
|
||||
local:
|
||||
usersFile: users.yaml
|
||||
```
|
||||
|
||||
The OIDC/Authentik form is:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
mode: oidc
|
||||
publicUrl: https://thothii.example.org
|
||||
session:
|
||||
regularTtlSeconds: 43200
|
||||
regularIdleSeconds: 7200
|
||||
rememberTtlSeconds: 2592000
|
||||
rememberIdleSeconds: 604800
|
||||
oidcTtlSeconds: 28800
|
||||
oidc:
|
||||
issuer: https://authentik.example.org/application/o/thothii/
|
||||
clientId: thothii
|
||||
clientSecretRef: THT_OIDC_CLIENT_SECRET
|
||||
scopes:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
groupsClaim: groups
|
||||
groupCatalog:
|
||||
driver: authentik
|
||||
baseUrl: https://authentik.example.org
|
||||
apiTokenRef: THT_AUTHENTIK_API_TOKEN
|
||||
authorization:
|
||||
groupRoles:
|
||||
TOT Users:
|
||||
- user
|
||||
TOT Admin:
|
||||
- admin
|
||||
```
|
||||
|
||||
`publicUrl` has no path, query, fragment, or embedded credential. OIDC mode requires HTTPS except
|
||||
for an explicit loopback test configuration. The callback is always
|
||||
`<publicUrl>/api/auth/oidc/callback`; arbitrary redirect URIs and arbitrary post-login redirects
|
||||
are not accepted.
|
||||
|
||||
The referenced secret names are read from the existing mounted ThothII secret bundle. Secret
|
||||
values never appear in `auth.yaml`, command arguments, JSON diagnostics, logs, or frontend data.
|
||||
|
||||
## Local-user registry
|
||||
|
||||
`users.yaml` has this exact shape:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
users:
|
||||
- id: 6ba7b810-9dad-4ed1-80b4-00c04fd430c8
|
||||
username: admin
|
||||
displayName: Local administrator
|
||||
passwordHash: $argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4
|
||||
roles:
|
||||
- admin
|
||||
enabled: true
|
||||
authRevision: 1
|
||||
```
|
||||
|
||||
- `id` is an immutable UUIDv4 generated by `tht` and is the local OIDC-like `subject`.
|
||||
- `username` is 3–64 ASCII characters, starts with an alphanumeric character, and then uses only
|
||||
alphanumerics plus `.`, `_`, `@`, or `-`. Lookup is ASCII case-insensitive while display spelling
|
||||
is retained. `displayName` remains unrestricted Unicode text after control-character rejection.
|
||||
- Passwords are accepted from a TTY or an explicit `--password-file`, never a command argument.
|
||||
- Password length is 12 to 1024 UTF-8 bytes. The upper bound prevents accidental memory abuse;
|
||||
no composition rule is imposed.
|
||||
- New hashes use Argon2id v19 with 64 MiB memory, three passes, parallelism one, a 16-byte random
|
||||
salt, and a 32-byte output. Parameters remain embedded in the PHC string for future rehashing.
|
||||
- Every password, role, enabled-state, or logout-all change increments `authRevision`.
|
||||
- At least one enabled administrator must remain. Commands that would disable or demote the last
|
||||
administrator fail before writing.
|
||||
|
||||
The Go CLI hashes with `golang.org/x/crypto/argon2`; Node verifies with the Node 24 native Argon2
|
||||
API. Shared fixed test vectors prove cross-language compatibility.
|
||||
|
||||
## Roles and permissions
|
||||
|
||||
External groups and local records map to two stable roles:
|
||||
|
||||
```text
|
||||
user
|
||||
session.use
|
||||
|
||||
admin (inherits user)
|
||||
session.read_all
|
||||
session.manage_all
|
||||
settings.manage
|
||||
workspace.manage
|
||||
workspace.secrets.manage
|
||||
pi.manage
|
||||
auth.diagnostics.read
|
||||
```
|
||||
|
||||
Session ownership remains enforced through `issuer + subject`. `session.use` never permits access
|
||||
to another user's session. `isAdmin` remains temporarily available as the derived value
|
||||
`roles.includes("admin")` for compatibility, but route authorization is based on permissions.
|
||||
|
||||
Route policy is:
|
||||
|
||||
| Surface | Required authority |
|
||||
|---|---|
|
||||
| `/health`, local login, OIDC start/callback | Public protocol endpoint |
|
||||
| `/me`, effective settings/models, workspace read, own sessions and SSE | Authenticated `user` |
|
||||
| `scope=all`, operations on another user's session | `session.read_all` / `session.manage_all` |
|
||||
| Settings writes | `settings.manage` |
|
||||
| Workspace registry pull/bootstrap/validation/test | `workspace.manage` |
|
||||
| Workspace secret writes/deletes | `workspace.secrets.manage` |
|
||||
| Pi management routes | `pi.manage` |
|
||||
| Authentication diagnostics | `auth.diagnostics.read` or host operator through `tht` |
|
||||
|
||||
An OIDC user whose token contains no mapped group is authenticated but receives no role. Protected
|
||||
application routes return `403` with `code: "auth_not_authorized"`.
|
||||
|
||||
## Durable browser sessions
|
||||
|
||||
Successful authentication creates a cryptographically random 256-bit cookie token. The server
|
||||
stores only its SHA-256 digest as the session filename. The record contains:
|
||||
|
||||
```ts
|
||||
interface AuthSessionRecord {
|
||||
version: 1;
|
||||
issuer: string;
|
||||
subject: string;
|
||||
displayName?: string;
|
||||
method: "local" | "oidc" | "upstream";
|
||||
roles: readonly ("user" | "admin")[];
|
||||
permissions: readonly Permission[];
|
||||
userAuthRevision?: number;
|
||||
authConfigRevision: string;
|
||||
remembered: boolean;
|
||||
createdAt: string;
|
||||
lastSeenAt: string;
|
||||
idleExpiresAt: string;
|
||||
absoluteExpiresAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
Session behavior is:
|
||||
|
||||
- Ordinary local login: session cookie with no `Max-Age`, 2-hour idle expiry, 12-hour absolute
|
||||
expiry. Closing the browser removes the browser cookie.
|
||||
- Local **Remember me**: persistent cookie, 7-day idle expiry, 30-day absolute expiry. It survives
|
||||
browser and ThothII restarts.
|
||||
- OIDC: maximum eight-hour ThothII session, never longer than the validated ID-token expiry. The
|
||||
identity provider may independently remember its SSO login.
|
||||
- `lastSeenAt` is written at most once every five minutes to bound filesystem writes.
|
||||
- Expired records are pruned at startup and every fifteen minutes.
|
||||
- Local sessions are invalid as soon as `authRevision` differs, the user is absent/disabled, or
|
||||
the configured role set changes.
|
||||
- All sessions are invalid on the first request after `authConfigRevision` differs following an
|
||||
authentication configuration reload.
|
||||
- Logout deletes the server record and expires the cookie.
|
||||
- Backup restore intentionally invalidates remembered sessions; session records are not restored
|
||||
as active credentials.
|
||||
|
||||
The cookie is named `thothii_session`, is `HttpOnly`, `SameSite=Lax`, `Path=/`, has no `Domain`,
|
||||
and uses `Secure` whenever `publicUrl` is HTTPS. Local loopback HTTP deliberately omits `Secure` so
|
||||
the browser can use the cookie.
|
||||
|
||||
## CSRF and browser boundary
|
||||
|
||||
Cookie authentication makes CSRF protection mandatory for every state-changing application
|
||||
route. The backend derives a separate 256-bit CSRF token from the raw session cookie with
|
||||
domain-separated HKDF-SHA-256; the derivation is one-way and nothing additional is persisted.
|
||||
`/me` returns the derived token to the same-origin frontend, which keeps it in memory and sends it
|
||||
as `X-ThothII-CSRF` for `POST`, `PUT`, `PATCH`, and `DELETE` requests.
|
||||
|
||||
The backend requires all of the following for a cookie-authenticated state change:
|
||||
|
||||
1. a valid session;
|
||||
2. a constant-time match of the CSRF token;
|
||||
3. a matching `Origin` when the browser supplies one;
|
||||
4. `Sec-Fetch-Site: same-origin` when Fetch Metadata is present.
|
||||
|
||||
The local login POST requires a same-origin `Origin`; OIDC login uses OIDC `state`, `nonce`, and
|
||||
PKCE. Frontend and API are supported as one browser origin. Development uses a Vite `/api` proxy
|
||||
rather than credentialed cross-origin requests.
|
||||
|
||||
## Local authentication flow
|
||||
|
||||
The backend exposes:
|
||||
|
||||
```text
|
||||
GET /auth/config public mode and login capabilities, no secrets
|
||||
POST /auth/local/login username, password, remember
|
||||
POST /auth/logout authenticated + CSRF
|
||||
GET /me principal, roles, permissions, CSRF token
|
||||
```
|
||||
|
||||
Login errors use one generic `invalid_credentials` response for unknown, disabled, and wrong-
|
||||
password users. A dummy Argon2 verification runs for unknown users. Rate limits apply per source
|
||||
address and normalized username, and concurrent Argon2 operations are bounded.
|
||||
|
||||
## Generic OIDC flow
|
||||
|
||||
OIDC uses `openid-client` 6.8.5, Authorization Code Flow, PKCE S256, `state`, and `nonce`. The
|
||||
backend performs provider discovery, validates issuer, signature, audience, expiry, nonce, and
|
||||
authorization response, then reads the ID-token claims.
|
||||
|
||||
`groupsClaim` is mandatory and resolves to a direct array of non-empty strings. The first release
|
||||
does not follow distributed claims, provider overage links, or Graph-style group expansion. Such a
|
||||
token fails with `oidc_groups_claim_invalid` rather than silently granting ordinary access.
|
||||
|
||||
Mapped roles are the union of all exactly matched group names. Additional token groups are ignored
|
||||
without logging or warnings. No OIDC token is sent to the frontend or persisted after principal
|
||||
and session creation.
|
||||
|
||||
## Authentik group-catalog adapter
|
||||
|
||||
The certified Authentik adapter calls its documented API with a dedicated service-account bearer
|
||||
token. For each configured mapping key it requests:
|
||||
|
||||
```text
|
||||
GET <baseUrl>/api/v3/core/groups/?name=<encoded-name>&include_users=false&page_size=2
|
||||
```
|
||||
|
||||
The adapter requires exactly one exact-name result. Zero results produce
|
||||
`oidc_mapped_group_missing`; more than one produces `oidc_mapped_group_ambiguous`. It never
|
||||
enumerates or compares unrelated groups, so extra Authentik groups produce neither warning nor
|
||||
error.
|
||||
|
||||
Outbound requests use HTTPS, fixed operator-controlled origins, five-second timeouts, no redirect
|
||||
following, bounded JSON bodies, and redacted errors. The API token has only group-view permission
|
||||
and is distinct from the OIDC client secret.
|
||||
|
||||
Authentik is connected to LDAP in PSD, but ThothII validates the groups visible in Authentik. A
|
||||
group present only in LDAP and not represented in Authentik is correctly treated as missing.
|
||||
|
||||
## Authentication diagnostics
|
||||
|
||||
One backend `AuthDiagnoser` returns a redacted machine contract:
|
||||
|
||||
```ts
|
||||
interface AuthDiagnostics {
|
||||
ready: boolean;
|
||||
mode: "local" | "oidc" | "upstream" | "none" | "mock";
|
||||
checks: readonly AuthDiagnostic[];
|
||||
}
|
||||
|
||||
interface AuthDiagnostic {
|
||||
level: "error" | "info";
|
||||
code: AuthDiagnosticCode;
|
||||
message: string;
|
||||
field?: string;
|
||||
}
|
||||
```
|
||||
|
||||
`AuthDiagnosticCode` is the closed union:
|
||||
|
||||
```text
|
||||
auth_ready
|
||||
auth_config_incomplete
|
||||
auth_config_invalid
|
||||
auth_session_store_invalid
|
||||
local_user_registry_invalid
|
||||
local_admin_missing
|
||||
oidc_secret_missing
|
||||
oidc_discovery_unreachable
|
||||
oidc_issuer_mismatch
|
||||
oidc_jwks_unreachable
|
||||
oidc_group_catalog_unreachable
|
||||
oidc_group_catalog_unauthorized
|
||||
oidc_mapped_group_missing
|
||||
oidc_mapped_group_ambiguous
|
||||
oidc_groups_claim_invalid
|
||||
oidc_device_flow_unavailable
|
||||
```
|
||||
|
||||
Static checks cover configuration, file safety, secret references, local administrators, role
|
||||
names, group mappings, URL policy, and session storage. Live OIDC checks cover discovery, issuer,
|
||||
JWKS, Authentik API authentication, and every configured group.
|
||||
|
||||
The same implementation is consumed by:
|
||||
|
||||
- application startup for fatal static configuration errors;
|
||||
- `POST /workspaces/validate` for static completeness;
|
||||
- `POST /workspaces/:id/test` for live connectivity and group existence;
|
||||
- `tht auth check [--json]`;
|
||||
- aggregate `tht doctor [--json]`.
|
||||
|
||||
Workspace test results retain existing DWH/Qdrant/embedding diagnostics and add an
|
||||
`authentication` section. Overall `activatable` is false when authentication is not ready.
|
||||
|
||||
An Authentik-backed `tht auth check --interactive` uses OIDC Device Authorization when the
|
||||
provider advertises it. It prints the verification URI and user code, waits for completion, and
|
||||
validates a real ID token including `groups`. Absence of a device endpoint is reported explicitly;
|
||||
ordinary browser login remains usable for generic OIDC providers.
|
||||
|
||||
## Host CLI contract
|
||||
|
||||
The host-facing command surface added to `tht` is:
|
||||
|
||||
```text
|
||||
tht auth configure --mode local [--public-url URL] [--admin-user USER \
|
||||
--admin-display-name NAME --password-file FILE]
|
||||
tht auth configure --mode oidc --public-url URL --issuer URL --client-id ID \
|
||||
--authentik-base-url URL --user-group GROUP --admin-group GROUP
|
||||
tht auth status [--json]
|
||||
tht auth check [--interactive] [--json]
|
||||
|
||||
tht auth user list [--json]
|
||||
tht auth user add USERNAME --role user|admin [--display-name NAME] [--password-file FILE]
|
||||
tht auth user set-password USERNAME [--password-file FILE]
|
||||
tht auth user enable USERNAME
|
||||
tht auth user disable USERNAME
|
||||
tht auth user grant USERNAME --role user|admin
|
||||
tht auth user revoke USERNAME --role user|admin
|
||||
tht auth user logout-all USERNAME --yes
|
||||
```
|
||||
|
||||
Interactive password prompts disable terminal echo and require confirmation. JSON stdout remains
|
||||
pristine; progress and prompts go to stderr. User commands refuse OIDC mode. Configuration writes
|
||||
never overwrite an existing valid configuration without explicit confirmation.
|
||||
|
||||
`tht setup` creates the protected authentication directory and invokes local or OIDC
|
||||
configuration before starting the stack. `tht doctor` adds an `authentication` check and redacts
|
||||
the OIDC client secret, Authentik API token, passwords, hashes, cookie values, and CSRF values.
|
||||
|
||||
## Node and dependency baseline
|
||||
|
||||
The implementation aligns the runtime and CI on Node `24.16.0`. Docker uses the multi-platform
|
||||
official image digest:
|
||||
|
||||
```text
|
||||
node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7
|
||||
```
|
||||
|
||||
Required added dependencies are:
|
||||
|
||||
- backend `openid-client` 6.8.5;
|
||||
- backend `@fastify/cookie` 11.1.2;
|
||||
- backend `@fastify/rate-limit` 11.2.0;
|
||||
- Go `golang.org/x/crypto` 0.55.0.
|
||||
- Go `golang.org/x/term` 0.45.0.
|
||||
|
||||
No Node Argon2 native addon or Authentik SDK is added. Node upgrade acceptance requires backend
|
||||
and frontend typechecks, builds, unit tests, Playwright, Docker smoke, Pi runtime smoke, and the L2
|
||||
live-session smoke. A regression blocks the upgrade and the authentication release; it is not
|
||||
waived merely to gain native Argon2.
|
||||
|
||||
## Deployment and migration
|
||||
|
||||
- Local profiles move from implicit `AUTH_MODE=none` to configured `mode: local` in `auth.yaml` and require an
|
||||
initial administrator before startup is considered valid.
|
||||
- Server profiles move from trusted-proxy `AUTH_MODE=upstream` to `mode: oidc` in `auth.yaml` after Authentik
|
||||
setup. `upstream` remains available during the migration window but is marked deprecated.
|
||||
- `auth.yaml` is the sole production source of truth for `local` and `oidc`. `AUTH_MODE` remains
|
||||
accepted only for `none`, `mock`, and deprecated `upstream` when no auth configuration exists.
|
||||
- Existing session/artifact storage is not migrated or re-owned by authentication work.
|
||||
- Reverse proxies must preserve the configured public origin and callback path. ThothII trusts
|
||||
forwarded scheme/host only under the existing explicit server proxy boundary.
|
||||
- PSD acceptance uses Authentik connected to corporate LDAP, two real groups mapped to `user` and
|
||||
`admin`, one ordinary test user, and one administrative test user.
|
||||
|
||||
## Documentation and acceptance
|
||||
|
||||
The release must document:
|
||||
|
||||
- standalone local setup, initial admin, Remember me, timeout behavior, password recovery, and
|
||||
session invalidation;
|
||||
- the mandatory direct `groups` claim contract and failure behavior;
|
||||
- group-to-role and role-to-permission mapping, including exact case sensitivity;
|
||||
- Authentik provider, scope/property mapping, service account, API token, callback, group mapping,
|
||||
token rotation, and PSD LDAP relationship;
|
||||
- why unmapped identity-manager groups are ignored without warnings;
|
||||
- generic OIDC support versus provider-specific group-catalog certification;
|
||||
- all `tht auth` commands and JSON contracts;
|
||||
- authentication checks inside workspace validation and `tht doctor`;
|
||||
- reverse-proxy and same-origin cookie requirements.
|
||||
|
||||
Release acceptance requires cross-language Argon2 vectors, local remembered-session restart tests,
|
||||
local invalidation tests, authorization matrix tests, CSRF tests, OIDC protocol tests with a local
|
||||
fake OP, Authentik API contract tests, a real Authentik integration test, workspace diagnostic
|
||||
tests, frontend login tests, Compose smoke tests, and the existing full regression suites.
|
||||
Reference in New Issue
Block a user