diff options
| author | Leitwolf11 | 2026-10-03 18:42:07 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-10-03 18:42:07 +0300 |
| commit | 01ddcb6071ebdfb08b5ca40c868420b726f4f225 (patch) | |
| tree | b522c1cd53ba1990e2122a099e7f85971476afaf | |
| parent | b1cf9a3bea0c81c1cb3ed2e0ddce2b9a56e175ef (diff) | |
| parent | bc1b0b080b398e52b3201034f92398750f415ed2 (diff) | |
| download | progmaweb-01ddcb6071ebdfb08b5ca40c868420b726f4f225.tar.gz | |
Merge remote-tracking branch 'origin/main' into ci/docfx-require-documentation
| -rw-r--r-- | .github/pull_request_template.md | 3 | ||||
| -rw-r--r-- | README.md | 26 | ||||
| -rw-r--r-- | docs/ACCOUNT-API.md | 151 | ||||
| -rw-r--r-- | docs/ARCHITECTURE.md | 115 | ||||
| -rw-r--r-- | docs/DEVELOPMENT.md | 136 | ||||
| -rw-r--r-- | docs/LIMITATIONS.md | 85 | ||||
| -rw-r--r-- | docs/LOCALIZATION.md | 106 | ||||
| -rw-r--r-- | docs/README.md | 20 | ||||
| -rw-r--r-- | ops/README.md | 25 | ||||
| -rw-r--r-- | ops/mail/README.md | 4 |
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. @@ -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 |