Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf112026-10-03 18:42:07 +0300
committerLeitwolf112026-10-03 18:42:07 +0300
commit01ddcb6071ebdfb08b5ca40c868420b726f4f225 (patch)
treeb522c1cd53ba1990e2122a099e7f85971476afaf
parentb1cf9a3bea0c81c1cb3ed2e0ddce2b9a56e175ef (diff)
parentbc1b0b080b398e52b3201034f92398750f415ed2 (diff)
downloadprogmaweb-01ddcb6071ebdfb08b5ca40c868420b726f4f225.tar.gz
Merge remote-tracking branch 'origin/main' into ci/docfx-require-documentation
-rw-r--r--.github/pull_request_template.md3
-rw-r--r--README.md26
-rw-r--r--docs/ACCOUNT-API.md151
-rw-r--r--docs/ARCHITECTURE.md115
-rw-r--r--docs/DEVELOPMENT.md136
-rw-r--r--docs/LIMITATIONS.md85
-rw-r--r--docs/LOCALIZATION.md106
-rw-r--r--docs/README.md20
-rw-r--r--ops/README.md25
-rw-r--r--ops/mail/README.md4
10 files changed, 667 insertions, 4 deletions
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index f4d6812..3dd25c4 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -12,7 +12,8 @@ is affected.
- [ ] Next.js check, frontend tests, lint, and build were run where relevant.
- [ ] ASP.NET Core build and API tests were run where relevant.
-- [ ] en-US/de-DE, light/dark, accessibility, and small-screen behavior were considered for UI changes.
+- [ ] All four languages (en-US, de-DE, ru-RU, he-IL with right-to-left text), light/dark, accessibility, and
+ small-screen behavior were considered for UI changes.
- [ ] Auth, session, publisher identity, registry paths, and deployment compatibility were reviewed if affected.
- [ ] No credentials, generated output, or unrelated files are included.
diff --git a/README.md b/README.md
index db69dac..8132611 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,23 @@ duplicates. Email uniqueness is also evaluated case-insensitively by the API.
ViGet does not maintain a separate publisher name. A package coordinate's `<Publisher>` segment is exactly the
canonical Progmasoft `<Account>` name, with the same spelling and case.
+## Languages
+
+Every page is published in English, German, Russian and Hebrew. A visitor who has not chosen a language gets the
+language their browser prefers, and English when the browser prefers none of the four. A choice made in the language
+menu is remembered across the three hosts. Hebrew text runs right to left while the page layout stays as it is. See
+[Localization](docs/LOCALIZATION.md).
+
+## Documentation
+
+- [Architecture](docs/ARCHITECTURE.md): processes, hosts, routing and trust boundaries.
+- [Account API](docs/ACCOUNT-API.md): endpoints, errors, cookies and limits.
+- [Localization](docs/LOCALIZATION.md): language selection, text direction and adding a language.
+- [Development](docs/DEVELOPMENT.md): local setup, checks and what CI enforces.
+- [Known limitations](docs/LIMITATIONS.md): what is missing or temporary. Read this before operating the service;
+ accounts are currently kept in memory and do not survive a restart of the API.
+- [Operations](ops/README.md) and the [mail boundary](ops/mail/README.md).
+
## Architecture
- `apps/web` is a Next.js App Router application. Host-aware routing keeps the public organization, account, and ViGet
@@ -54,10 +71,10 @@ pnpm dev
Start the account API in another terminal:
```text
-dotnet run --project apps/api/Progmaweb.Api.csproj
+dotnet run --project apps/api/Progmaweb.Api.csproj --urls http://127.0.0.1:5085
```
-The frontend defaults to `http://localhost:5085` for server-side API requests. Set `PROGMAWEB_API_ORIGIN` when the API
+The frontend defaults to `http://127.0.0.1:5085` for server-side API requests. Set `PROGMAWEB_API_ORIGIN` when the API
uses another origin. Browser requests use the same-origin `/api` boundary so production can proxy them without exposing
an internal address.
@@ -77,13 +94,16 @@ pnpm build
dotnet build apps/api/Progmaweb.Api.csproj --configuration Release
dotnet test --project apps/api-tests/Progmaweb.Api.Tests.csproj --configuration Release
dotnet test --project apps/api-tests/Progmaweb.Api.Tests.csproj --configuration Release --coverlet --coverlet-output-format cobertura
+dotnet tool restore
+dotnet docfx docs/api/docfx.json --warningsAsErrors
```
Frontend tests exercise the published locale contract and account/ViGet metadata without starting a production service.
The frontend LCOV and API Cobertura reports are retained as one-day artifacts, then uploaded to Codecov by separate
jobs. Only those upload jobs receive `id-token: write`; dependency installation, builds, and tests do not receive OIDC
permissions, and fork-originated pull requests skip the upload jobs. CodeQL and Codacy provide static analysis;
-Dependabot proposes dependency updates without merging them automatically.
+Dependabot proposes dependency updates without merging them automatically. The DocFX command builds the API reference
+and fails on a broken link or an unresolved reference. [Development](docs/DEVELOPMENT.md) explains each check.
## License
diff --git a/docs/ACCOUNT-API.md b/docs/ACCOUNT-API.md
new file mode 100644
index 0000000..022c958
--- /dev/null
+++ b/docs/ACCOUNT-API.md
@@ -0,0 +1,151 @@
+<!-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> -->
+<!-- SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 -->
+
+# Account API
+
+The account service is the ASP.NET Core application in `apps/api`. Browsers reach it through the same origin as the
+page, under `/api/`; the Next.js server forwards those requests to the service on loopback port 5085.
+
+All bodies are JSON with camel-case property names. Errors use the problem-details format (`application/problem+json`).
+Every response carries `Cache-Control: no-store`.
+
+## Summary
+
+| Method and path | Purpose | Rate limited |
+| --- | --- | --- |
+| `POST /api/v1/accounts/register` | create a password account and sign it in | yes |
+| `POST /api/v1/accounts/login` | sign in with email address and password | yes |
+| `POST /api/v1/accounts/logout` | end the current session | no |
+| `GET /api/v1/accounts/me` | return the signed-in account | no |
+| `GET /api/v1/accounts/oauth/google/start` | begin sign-in with Google | yes |
+| `GET /api/v1/accounts/oauth/google/complete` | finish sign-in with Google | yes |
+| `GET /api/v1/status` | report that the service is online | no |
+| `GET /health` | liveness probe for the service manager | no |
+
+## Field rules
+
+| Field | Rule |
+| --- | --- |
+| `accountName` | 8 to 128 ASCII letters or digits, beginning with an upper-case letter. Unique without regard to case; the spelling the owner chose is kept. `Progmasoft` and `Leitwolf` are reserved in every case. |
+| `email` | a single mailbox without a display name, at most 254 characters. Unique without regard to case. Ownership is not verified. |
+| `password` | 12 to 256 characters, without a null character. No composition rule; long passphrases are welcome. |
+
+Surrounding white space of an account name or email address is ignored.
+
+## Register
+
+`POST /api/v1/accounts/register`
+
+```json
+{ "accountName": "ExampleUser1", "email": "user@example.com", "password": "a long passphrase" }
+```
+
+**201 Created.** The session cookie is set, `Location` names the account, and the body is the account:
+
+```json
+{ "accountName": "ExampleUser1", "email": "user@example.com", "createdAt": "2026-10-03T12:00:00+00:00" }
+```
+
+**400 Bad Request.** A validation problem whose `errors` object names each refused field (`accountName`, `email`,
+`password`) with a message. A name or address that is already taken is reported the same way, on its field.
+
+## Sign in
+
+`POST /api/v1/accounts/login`
+
+```json
+{ "email": "user@example.com", "password": "a long passphrase" }
+```
+
+**200 OK.** The session cookie is set and the body is the account, as above.
+
+**401 Unauthorized.** One message for every failure. The response does not say whether the address or the password
+was wrong, and an unknown address costs the same password verification as a wrong password, so timing does not reveal
+which addresses are registered.
+
+An account that was created through Google has no password and cannot sign in here.
+
+## Sign out
+
+`POST /api/v1/accounts/logout`
+
+**204 No Content.** The session is revoked and the cookie deleted. The response is the same when no session existed.
+
+## Current account
+
+`GET /api/v1/accounts/me`
+
+**200 OK** with the account, or **401 Unauthorized** when the request has no valid session. An expired session is
+revoked when it is seen.
+
+## Sign-in with Google
+
+The flow is a server-side authorization-code flow. The browser is redirected; it never receives Google tokens.
+
+1. `GET /api/v1/accounts/oauth/google/start` sends the browser to Google. When a new account should be created, the
+ request carries `?accountName=<name>`. An invalid name redirects back to
+ `/register?google=invalid-account-name` without contacting Google.
+2. Google returns to `/api/v1/accounts/oauth/google/callback`, which the authentication middleware handles. The
+ identity is kept for at most ten minutes in a separate cookie, `__Host-ProgmasoftExternal`.
+3. `GET /api/v1/accounts/oauth/google/complete` decides the outcome and removes that cookie.
+
+| Situation | Result |
+| --- | --- |
+| the Google identity is already linked to an account | signed in; redirect to `/<Account>/dashboard` |
+| the identity is new and a valid, free account name was supplied | account created and signed in |
+| the identity is new and no account name was supplied | redirect to `/register?google=account-name-required` |
+| the supplied account name is invalid | redirect to `/register?google=invalid-account-name` |
+| the supplied account name is taken | redirect to `/register?google=account-name-unavailable` |
+| the email address of the identity belongs to another account | redirect to `/login?google=failed` |
+| Google reported no usable identity, or the callback failed | redirect to `/login?google=failed` |
+
+A Google identity is never linked to an existing account by email address. Password registrations do not verify the
+address, so linking by address could give one person's account to another.
+
+The service needs a Google client identifier and secret to run at all; see [Known limitations](LIMITATIONS.md).
+
+## Status and health
+
+`GET /api/v1/status` returns `{ "service": "progmaweb-account", "status": "online", "version": "1.0.0" }`.
+`GET /health` is the ASP.NET Core health check for the service manager and is not a public contract.
+
+## Sessions and cookies
+
+| Cookie | Purpose | Attributes |
+| --- | --- | --- |
+| `__Host-ProgmasoftSession` | the account session | `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/`, expires with the session |
+| `__Host-ProgmasoftExternal` | carries the Google identity between callback and completion | `HttpOnly`, `Secure`, `SameSite=Lax`, ten minutes |
+
+A session token is 256 random bits. The service stores only its SHA-256 digest, so a copy of the store does not
+contain a usable token. A session lasts 12 hours by default; `AccountSessions:LifetimeHours` changes that, within 1 to
+168 hours.
+
+Passwords are hashed with the ASP.NET Core Identity password hasher and are never stored or logged in plain text.
+
+## Limits
+
+- **Rate limit.** The endpoints marked above share one limit per client address: 12 requests in a sliding window of
+ three minutes. A request over the limit gets **429 Too Many Requests**.
+- **Body size.** A request body larger than 16 KiB gets **413 Content Too Large**.
+- **Hosts.** The service answers only the host names listed in `AllowedHosts`; any other host gets **400**.
+
+The client address comes from `X-Forwarded-For`, which the service accepts from loopback proxies only.
+
+## Configuration
+
+| Setting | Meaning | Default |
+| --- | --- | --- |
+| `AccountSessions:LifetimeHours` | hours a session stays valid, clamped to 1–168 | `12` |
+| `AccountSessions:CookieName` | name of the session cookie | `__Host-ProgmasoftSession` |
+| `Authentication:Google:ClientId` | Google OAuth client identifier | none; required |
+| `Authentication:Google:ClientSecret` | Google OAuth client secret | none; required |
+| `AllowedHosts` | host names the service answers | see `appsettings.json` |
+| `ASPNETCORE_URLS` | listener address | `http://127.0.0.1:5085` in production |
+
+Environment variables use a double underscore for the colon, for example `Authentication__Google__ClientId`. Secrets
+belong in the process environment or a secret manager and are never committed.
+
+## What the API does not do
+
+It does not verify email addresses, send mail, reset passwords, list accounts or delete accounts. See
+[Known limitations](LIMITATIONS.md).
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..ef5a427
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,115 @@
+<!-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> -->
+<!-- SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 -->
+
+# Architecture
+
+Progmaweb is two processes behind one reverse proxy:
+
+- **`apps/web`**, a Next.js App Router application. It renders every page of three public hosts and forwards API
+ calls.
+- **`apps/api`**, an ASP.NET Core minimal API. It owns accounts, passwords and sessions.
+
+Both listen on loopback only. Nginx is the only public listener and terminates TLS.
+
+```text
+browser
+ | HTTPS
+ v
+Nginx (progmasoft.com, www, account.progmasoft.com, viget.progmasoft.com)
+ | http://127.0.0.1:3010
+ v
+Next.js server (apps/web)
+ | /api/* -> http://127.0.0.1:5085 (rewrite, server side)
+ v
+ASP.NET Core account API (apps/api)
+```
+
+## Hosts
+
+One build serves three hosts. The host of a request, not a separate deployment, decides which pages it sees.
+
+| Host | Serves |
+| --- | --- |
+| `progmasoft.com` | the organization homepage; the only host that is meant to be indexed as the organization |
+| `www.progmasoft.com` | a permanent redirect to the apex host, issued by Nginx |
+| `account.progmasoft.com` | the account landing page, sign-in, registration, recovery and the dashboard |
+| `viget.progmasoft.com` | the ViGet package catalog and the Kotlin DSL plugin catalog |
+
+`*.localhost` names (`account.localhost`, `viget.localhost`) behave like the production hosts during development.
+
+## Host-aware routing
+
+`apps/web/proxy.ts` runs before every page request and applies these rules in order:
+
+1. **Language parameter.** `?lang=<locale>` with a published locale stores the language cookie and redirects to the
+ same URL without the parameter. See [Localization](LOCALIZATION.md).
+2. **Account root.** `/` on the account host is rewritten to the internal `/account` page, so the account landing page
+ has the short URL.
+3. **ViGet.** On the ViGet host `/` is rewritten to `/viget` and `/dslplugins` to `/viget/dslplugins`. `/login`,
+ `/register` and `/recover` redirect to the account host.
+4. **Internal trees stay internal.** `/viget/...` on any other host redirects to the ViGet host, and `/account` on any
+ other host redirects to the account host.
+
+Internal rewrites use `http:` on purpose. TLS ends at Nginx, and Next.js would otherwise try TLS against its own
+plain-HTTP loopback listener.
+
+The host is read from `X-Forwarded-Host` first and `Host` second, without the port and in lower case.
+
+## Pages
+
+| Route | Page | Indexed |
+| --- | --- | --- |
+| `/` (apex) | organization homepage | yes |
+| `/account` (shown as `/` on the account host) | account landing page | no |
+| `/login`, `/register`, `/recover` | authentication and recovery | no |
+| `/<Account>/dashboard` | dashboard of the signed-in account | no |
+| `/viget` (shown as `/` on the ViGet host) | package catalog | yes |
+| `/viget/dslplugins` (shown as `/dslplugins`) | DSL plugin catalog | yes |
+
+`robots.txt` and `sitemap.xml` are generated per host: the ViGet host lists its two catalog pages, every other host
+lists the apex homepage only. Nginx additionally sends `X-Robots-Tag: noindex` for the whole account host.
+
+Pages are rendered on the server for every request, because the language depends on the request. Two components run
+in the browser: the authentication form and the dashboard.
+
+## Account flow
+
+1. The registration or sign-in form posts JSON to `/api/v1/accounts/register` or `/login` on the same origin.
+2. Next.js rewrites `/api/*` to the account API on loopback. The browser never learns the internal address.
+3. The API validates the input, stores or verifies the account, opens a session and sets the session cookie.
+4. The browser is sent to `/<Account>/dashboard`. The dashboard calls `/api/v1/accounts/me`; without a valid session
+ it returns to `/login`, and with a session of another account it moves to that account's dashboard.
+
+Sign-in with Google is a server-side authorization-code flow that starts at `/api/v1/accounts/oauth/google/start` and
+returns through the API; the browser never handles Google tokens. The [Account API](ACCOUNT-API.md) describes each
+step.
+
+## Trust boundaries
+
+- **Browser to Nginx.** Everything from the browser is untrusted, including the language cookie, the `lang`
+ parameter and the `Accept-Language` header. Each is validated against the list of published locales.
+- **Nginx and Next.js to the API.** The API trusts forwarded headers only from loopback and reads one proxy hop. A
+ caller that reached the API directly could otherwise forge the client address that the rate limiter uses.
+- **API to its store.** The store keeps password hashes and session digests, never passwords or session tokens.
+- **What the browser never receives.** Password hashes, session digests, deployment secrets and internal addresses.
+
+The API has no bulk-delete, reset-all or seed-password endpoint. Maintenance of production accounts is an operations
+task, not a public route.
+
+## Response headers
+
+Nginx adds HSTS, `X-Content-Type-Options`, `X-Frame-Options: DENY`, a referrer policy and a permissions policy on
+every host. Next.js sets the same content headers for the responses it produces. The API sets its own, stricter set:
+a content security policy that allows nothing, `Cache-Control: no-store`, and no referrer.
+
+## Shared account identity
+
+An account name is the public identity of its owner on every Progmasoft service. ViGet does not keep a separate
+publisher name: the `<Publisher>` segment of a package coordinate is exactly the canonical account name, with the same
+spelling and case.
+
+## ViGet status route
+
+`viget.progmasoft.com/api/v1/status` is proxied by Nginx to a separate legacy process on loopback port 5080. It is not
+part of this repository's API. Every other `/api/` path on the ViGet host is refused. See
+[Known limitations](LIMITATIONS.md).
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
new file mode 100644
index 0000000..66822de
--- /dev/null
+++ b/docs/DEVELOPMENT.md
@@ -0,0 +1,136 @@
+<!-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> -->
+<!-- SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 -->
+
+# Development
+
+## Requirements
+
+| Tool | Version | Used for |
+| --- | --- | --- |
+| Node.js | 24 or newer | the Next.js application and its tests |
+| pnpm | 11 or newer; the repository pins 11.24.0 | the JavaScript workspace |
+| .NET SDK | 10 | the account API, its tests and DocFX |
+
+`just` is optional. The `justfile` only names the commands below; it adds no logic of its own.
+
+## Repository layout
+
+```text
+apps/web Next.js application: pages, components, localization, host routing
+apps/api ASP.NET Core account API
+apps/api-tests xUnit tests of the API, run against the application in memory
+docs this documentation and the DocFX project in docs/api
+ops Nginx, systemd and mail configuration with their runbooks
+```
+
+## Run it locally
+
+Install the JavaScript dependencies once, then start the frontend:
+
+```text
+pnpm install --frozen-lockfile
+pnpm dev
+```
+
+Start the account API in a second terminal on the port the frontend expects. The two Google settings must not be
+empty, or the API answers every request with 500; placeholder values are enough for everything except sign-in with
+Google itself.
+
+```text
+Authentication__Google__ClientId=local-dev.apps.googleusercontent.com
+Authentication__Google__ClientSecret=local-dev-placeholder
+dotnet run --project apps/api/Progmaweb.Api.csproj --urls http://127.0.0.1:5085
+```
+
+Set the two variables in the way your shell sets environment variables before running the last line.
+
+The frontend forwards `/api/*` to `PROGMAWEB_API_ORIGIN`, which defaults to `http://127.0.0.1:5085`. Set it when the
+API listens elsewhere; `.env.example` shows the variable.
+
+Open the three surfaces through their development host names:
+
+| URL | Surface |
+| --- | --- |
+| `http://localhost:3000/` | organization homepage |
+| `http://account.localhost:3000/` | account pages |
+| `http://viget.localhost:3000/` | ViGet catalogs |
+
+Accounts created locally live in the memory of the API process and are gone when it stops.
+
+Real sign-in with Google needs the identifier and secret of a Google OAuth client in those two variables. Never commit
+the downloaded Google client file.
+
+## Checks
+
+Run these before opening a pull request. They are the same commands CI runs.
+
+```text
+pnpm check # route types and TypeScript
+pnpm test # Node tests of localization and metadata
+pnpm lint # ESLint, no warnings allowed
+pnpm build # production build
+pnpm format # Prettier (writes)
+
+dotnet build Progmaweb.slnx --configuration Release
+dotnet test --project apps/api-tests/Progmaweb.Api.Tests.csproj --configuration Release
+```
+
+`just verify` runs the type check, the production build, the API build and the API tests in one go.
+
+The .NET projects treat every warning as an error and restore in locked mode in CI. After changing a NuGet reference,
+restore without `--locked-mode` once and commit the updated `packages.lock.json`.
+
+A user-interface change is not verified by these commands alone. Look at it in a browser: all four languages, both
+themes, a narrow window, and keyboard focus.
+
+## Tests
+
+- **Frontend** (`apps/web/tests`): the list of published locales, the shape of every message tree, language
+ negotiation, cookie attributes, text direction and page metadata. The tests import the TypeScript sources directly
+ and do not start a server.
+- **API** (`apps/api-tests`): the endpoints through an in-memory application, including validation, duplicate names
+ and addresses, session cookies, the Google flow and security headers; and the account service on its own. The rate
+ limit has no automated test.
+
+## Continuous integration
+
+| Workflow | Runs on | Enforces |
+| --- | --- | --- |
+| Frontend | changes under `apps/web` and the workspace files | type check, tests with coverage, lint, build |
+| Backend | changes under `apps/api`, `apps/api-tests`, `docs/api` and the tool manifest | locked restore, build, tests with coverage, the DocFX gate |
+| Security | every push and pull request, and weekly | dependency review of the pull request, `pnpm audit` at high severity, NuGet vulnerability audit |
+| CodeQL | pushes, pull requests, and weekly | static analysis of the workflows, TypeScript and C# |
+
+Coverage is uploaded to Codecov by separate jobs. Only those jobs receive an OIDC token; installation, build and test
+jobs do not, and pull requests from forks skip the upload. Dependabot proposes weekly updates for GitHub Actions, npm
+and NuGet and never merges them itself.
+
+`main` is protected: changes arrive through pull requests with green checks.
+
+### The dependency audit exception
+
+`pnpm-workspace.yaml` tells the audit to ignore one advisory, `GHSA-vfj7-8cjw-p6xm` (`braces` 3.0.3 and older). No
+fixed release exists, and the package is reached only through the lint toolchain; it is not part of the built or
+deployed application. Every other advisory of high severity still fails the check. Remove the entry as soon as a
+release without the advisory is available, and do not add another entry without the same analysis written next to it.
+
+## API reference
+
+`docs/api` is a DocFX project that builds a reference from the C# sources. `.config/dotnet-tools.json` pins the DocFX
+version, so a local run and CI use the same tool.
+
+```text
+dotnet tool restore
+dotnet docfx docs/api/docfx.json --warningsAsErrors
+```
+
+or `just api-docs`. The output goes to `docs/api/_site`, which is ignored by Git. A broken link or an unresolved
+reference fails the build. The gate does not yet require documentation comments on the C# declarations; see
+[Known limitations](LIMITATIONS.md).
+
+## Conventions
+
+- Public documents, comments, commit messages and pull request text are written in American English.
+- Every file carries the SPDX header of the repository; `REUSE.toml` covers the formats that cannot hold a comment.
+- A user-visible string is added to every locale in the same change. See [Localization](LOCALIZATION.md).
+- Secrets, generated output and downloaded credentials are never committed.
diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md
new file mode 100644
index 0000000..107c48f
--- /dev/null
+++ b/docs/LIMITATIONS.md
@@ -0,0 +1,85 @@
+<!-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> -->
+<!-- SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 -->
+
+# Known limitations
+
+This page lists what Progmaweb does not do yet, so that nobody has to discover it in production. Each entry says what
+is missing, what follows from it, and what would close it.
+
+## Accounts are kept in memory
+
+The account API registers `InMemoryAccountStore` as its only store. Accounts and sessions live in the memory of the
+API process.
+
+- **Consequence.** Every restart of the API service deletes all accounts and signs everyone out. A deployment that
+ restarts the API has this effect.
+- **In operations.** Do not restart the API unless its code or configuration changed. A release that changes only the
+ frontend should restart only the frontend service; see [Operations](../ops/README.md).
+- **What closes it.** A durable implementation of `IAccountStore`. The interface already states the uniqueness and
+ session rules such a store must keep.
+
+## The API does not run without Google settings
+
+When `Authentication:Google:ClientId` or `Authentication:Google:ClientSecret` is empty, the API answers every request
+with 500, including registration, sign-in with a password and the status route. The validation of the Google options
+fails inside the authentication middleware, before any endpoint runs.
+
+- **Consequence.** The API cannot be started for local work without setting both values, and the "Google sign-in is
+ unavailable" answer of the start endpoint is never reached in that state.
+- **Workaround.** Set both values. Placeholders are enough when Google sign-in itself is not being tested; see
+ [Development](DEVELOPMENT.md).
+- **What closes it.** Registering the Google scheme only when it is configured.
+
+## Email addresses are not verified
+
+Registration accepts any well-formed address. Nothing proves that the person who registered owns it.
+
+- **Consequence.** An address can be registered by someone other than its owner. For that reason a Google identity is
+ never linked to an existing account by address.
+- **What closes it.** A verification message and a confirmed state on the account.
+
+## The API sends no mail
+
+The mail host described in the [mail boundary](../ops/mail/README.md) is prepared for verification and recovery
+messages, but the API contains no code that sends mail.
+
+- **Consequence.** There is no automated password recovery. The recovery page asks the user to write to support from
+ the address registered to the account.
+
+## Passwords cannot be changed
+
+The dashboard shows a "Change password" button that does nothing yet, and the API has no endpoint to change a
+password.
+
+## The sign-in page ignores `returnTo`
+
+The dashboard sends a signed-out visitor to `/login?returnTo=...`. The sign-in form does not read the parameter and
+always continues to the dashboard of the account that signed in.
+
+## ViGet has no packages and no registry API here
+
+The catalog pages are real and intentionally empty: no package or plugin has been published. Publishing is closed.
+
+`viget.progmasoft.com/api/v1/status` is answered by a separate legacy process, not by this repository's API. Until
+that route moves here, the legacy process has to keep running.
+
+## The rate limit is per process
+
+The authentication rate limit is counted in the memory of one API process, by client address. It is reset by a
+restart and would not be shared between several instances.
+
+## Documentation comments are not required
+
+The DocFX gate fails on a broken link or an unresolved reference. It does not report a declaration without a
+documentation comment, and most C# declarations have none, so the generated reference lists members without
+descriptions.
+
+## The Hebrew text has not been reviewed by a native speaker
+
+The Hebrew translation was written and revised without a native reader. It may contain wording a native speaker would
+not choose.
+
+## One audit advisory is ignored
+
+The dependency audit ignores a single advisory that has no fixed release and affects only the lint toolchain. The
+entry and its removal condition are described in [Development](DEVELOPMENT.md).
diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md
new file mode 100644
index 0000000..7ba409d
--- /dev/null
+++ b/docs/LOCALIZATION.md
@@ -0,0 +1,106 @@
+<!-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> -->
+<!-- SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 -->
+
+# Localization
+
+Every page of Progmaweb is published in four languages. All of them live in `apps/web/lib/localization.ts`.
+
+| Locale | Language | Text direction |
+| --- | --- | --- |
+| `en-US` | English | left to right |
+| `de-DE` | German | left to right |
+| `ru-RU` | Russian | left to right |
+| `he-IL` | Hebrew | right to left |
+
+A page has one URL in every language. There are no language-specific paths and no `hreflang` alternatives, because a
+language is a preference of the visitor, not a second address of the page.
+
+## How a page picks its language
+
+For each request the server decides in this order (`apps/web/lib/locale.server.ts`):
+
+1. **The visitor's choice.** A valid `progmasoft_locale` cookie always wins.
+2. **The browser.** Without that cookie the `Accept-Language` header is negotiated: the published language with the
+ highest quality wins, and the order of the header breaks ties. Only the primary language is compared, so `de-AT`
+ selects German. `iw`, the former code for Hebrew, is accepted.
+3. **The fallback.** A browser that prefers no published language gets `en-US`.
+
+Examples of the negotiation:
+
+| `Accept-Language` | Page language |
+| --- | --- |
+| `he-IL,he;q=0.9,en;q=0.8` | Hebrew |
+| `de-AT,de;q=0.9` | German |
+| `tr-TR,tr;q=0.9,de;q=0.5` | German, the first preference that is published |
+| `tr-TR,tr;q=0.9` | English, the fallback |
+| `en;q=0.3,ru;q=0.9` | Russian; quality decides, not position |
+| absent | English |
+
+The header comes from the browser and is not trusted: only its first 1024 characters are read, and a malformed or
+zero quality never selects a language.
+
+## How a visitor changes the language
+
+The language menu in the header calls the server action `selectLocale` (`apps/web/lib/locale.actions.ts`). The action
+stores the cookie, and Next.js renders the current route again and applies the result in place. Nothing is reloaded:
+scroll position, theme and form input stay as they are.
+
+A link can carry the same choice as `?lang=<locale>`. The proxy stores the cookie and redirects to the URL without the
+parameter, so a preference never becomes a second indexed address. The menu falls back to this link when the action
+cannot reach the server.
+
+A value that is not a published locale is ignored in both paths.
+
+## The language cookie
+
+| Attribute | Value |
+| --- | --- |
+| Name | `progmasoft_locale` |
+| Domain | `.progmasoft.com` on production hosts, so the choice follows the visitor to the account and ViGet hosts; host-only elsewhere |
+| Lifetime | one year |
+| Flags | `HttpOnly`, `SameSite=Lax`, and `Secure` over TLS |
+
+Both paths write the cookie through `localeCookieOptions`. Two writers with different attributes would leave two
+cookies behind, one shadowing the other.
+
+## Hebrew and text direction
+
+Hebrew does not mirror the page. The layout, the navigation and code keep their left-to-right arrangement; only text
+follows the language.
+
+- The root element carries `data-text-direction="rtl"`, not `dir="rtl"`.
+- Blocks of prose (headings and paragraphs) run right to left and align to the right.
+- Short pieces such as links, buttons and list items take the direction of their own first letter, so a product name
+ on its own still reads left to right.
+- Code and form inputs stay left to right.
+- The onward arrow comes from `forwardArrow(locale)` and points left in Hebrew.
+- A left-to-right mark (`‎`) follows `Visual X#` in Hebrew strings. Without it the `#` would jump to the other
+ side of the name inside right-to-left text.
+
+The rules are at the end of `apps/web/app/globals.css`.
+
+## Message tree
+
+`messages` holds one tree per locale with identical shape. Pages and components read it through
+`getMessages(locale)`; there is no lookup by string key and no fallback from one language to another, so a missing
+message is a type error or a failing test rather than an English sentence on a German page.
+
+The tests in `apps/web/tests/localization.test.mjs` enforce that
+
+- every locale has exactly the keys and array lengths of `en-US`, with no empty string;
+- every locale has a name in its own language for the menu;
+- negotiation, cookie attributes and text direction behave as described above.
+
+## Adding a language
+
+1. Add the locale to `supportedLocales`, its own name to `localeNames`, and its primary language to
+ `localeByLanguage`.
+2. If the language is written right to left, extend `textDirection`.
+3. Add a complete message tree. Copy the `en-US` tree and translate every string; keep product names and technical
+ identifiers such as `Visual.XSharp.kts` unchanged.
+4. Extend the tests: the list of published locales and a negotiation case for the new language.
+5. Run `pnpm check`, `pnpm test`, `pnpm lint` and `pnpm build`, then read every page in the new language in a
+ browser, in both themes and at a narrow width.
+
+Have a native speaker read the text before it is published. The Hebrew text has not had that review yet; see
+[Known limitations](LIMITATIONS.md).
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..f799195
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,20 @@
+<!-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> -->
+<!-- SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 -->
+
+# Progmaweb documentation
+
+These documents describe Progmaweb as it is built today. Where something is planned but not implemented, the text says
+so; [Known limitations](LIMITATIONS.md) collects those places.
+
+| Document | Read it when you want to know |
+| --- | --- |
+| [Architecture](ARCHITECTURE.md) | which process serves which host, how a request travels, and where the trust boundaries are |
+| [Account API](ACCOUNT-API.md) | every endpoint of the account service: requests, responses, errors, cookies and limits |
+| [Localization](LOCALIZATION.md) | which languages are published, how a page picks one, how Hebrew is laid out, and how to add a language |
+| [Development](DEVELOPMENT.md) | how to run, test and document the project locally, and what each CI check enforces |
+| [Known limitations](LIMITATIONS.md) | what is deliberately missing or temporary, and what that means for operations |
+| [Operations](../ops/README.md) | the release layout on the server and the activation contract |
+| [Mail boundary](../ops/mail/README.md) | the mail host that belongs to the account service |
+| [API reference](api/index.md) | the DocFX project that builds a reference from the C# sources |
+
+The [repository README](../README.md) is the short introduction; start there if you are new.
diff --git a/ops/README.md b/ops/README.md
i