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

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf112026-10-15 17:36:58 +0300
committerLeitwolf112026-10-15 17:36:58 +0300
commit9d1b19bc01d5d3d08e3528d69924a86e7e2bfc2a (patch)
tree2aa84134e872ae1ada5b9ef5f3e29b0041e9d9b7
parentc525fa05bbe85f3e7337d4a43c21ee6c864c839a (diff)
downloadwebsite-9d1b19bc01d5d3d08e3528d69924a86e7e2bfc2a.tar.gz
Serve the site API from ASP.NET Core 10
The language website ran a Spring Boot application on JVM 25 for two endpoints, and it was the only JVM on the server: a quarter of a gigabyte of resident memory for /api/v1/site and /health. The server already runs ASP.NET Core 10 for Progmaweb. The API is now Progmasoft.Tala.Website.Api, a minimal ASP.NET Core 10 application under the namespace Progmasoft.Tala.Website. It answers as before: /api/v1/site returns the site's own state as the same JSON, POST is not allowed there, /health answers with an empty 200, and every other path is 404. It listens on 127.0.0.1, port 5086 unless PORT says otherwise, as the Spring process did, so Nginx is unchanged. - Progmasoft.Tala.Website.slnx, Directory.Build.props and global.json set up the solution with warnings as errors, documentation comments required and NuGet lock files. - Progmasoft.Tala.Website.Api.Tests runs the endpoints in an in-memory host with xUnit v3 on Microsoft.Testing.Platform. - ops/systemd/tala-site-api.service runs the published DLL on the shared runtime from /srv/xsharp/website/current/site-api, with the hardening of the unit it replaces. - CI builds, tests, publishes and reports coverage with dotnet; CodeQL analyzes C# instead of Kotlin; Renovate watches NuGet instead of Gradle.
-rw-r--r--.codacy.yml4
-rw-r--r--.github/ISSUE_TEMPLATE/diagnostic.yml4
-rw-r--r--.github/codeql/codeql-config.yml3
-rw-r--r--.github/pull_request_template.md4
-rw-r--r--.github/renovate.json54
-rw-r--r--.github/workflows/ci.yml27
-rw-r--r--.github/workflows/codeql.yml22
-rw-r--r--.gitignore2
-rw-r--r--Directory.Build.props18
-rw-r--r--Progmasoft.Tala.Website.slnx6
-rw-r--r--REUSE.toml9
-rw-r--r--backend/Progmasoft.Tala.Website.Api.Tests/Progmasoft.Tala.Website.Api.Tests.csproj21
-rw-r--r--backend/Progmasoft.Tala.Website.Api.Tests/SiteEndpointTests.cs86
-rw-r--r--backend/Progmasoft.Tala.Website.Api.Tests/packages.lock.json158
-rw-r--r--backend/Progmasoft.Tala.Website.Api/Progmasoft.Tala.Website.Api.csproj19
-rw-r--r--backend/Progmasoft.Tala.Website.Api/Program.cs50
-rw-r--r--backend/Progmasoft.Tala.Website.Api/SiteInformation.cs29
-rw-r--r--backend/Progmasoft.Tala.Website.Api/appsettings.json9
-rw-r--r--backend/Progmasoft.Tala.Website.Api/packages.lock.json6
-rw-r--r--backend/build.gradle.kts62
-rw-r--r--backend/gradle.lockfile153
-rw-r--r--backend/gradle/verification-metadata.xml2143
-rw-r--r--backend/settings.gradle.kts4
-rw-r--r--backend/src/main/kotlin/com/progmasoft/visual/xsharp/website/SiteController.kt56
-rw-r--r--backend/src/main/kotlin/com/progmasoft/visual/xsharp/website/WebsiteApplication.kt20
-rw-r--r--backend/src/main/resources/application.properties8
-rw-r--r--backend/src/test/kotlin/com/progmasoft/visual/xsharp/website/SiteControllerTest.kt53
-rw-r--r--global.json9
-rw-r--r--ops/systemd/tala-site-api.service (renamed from ops/systemd/xsharp-site-api.service)9
29 files changed, 457 insertions, 2541 deletions
diff --git a/.codacy.yml b/.codacy.yml
index 47c6743..4905f40 100644
--- a/.codacy.yml
+++ b/.codacy.yml
@@ -5,7 +5,7 @@
exclude_paths:
- 'frontend/node_modules/**'
- 'frontend/dist/**'
- - 'backend/build/**'
- - 'backend/.gradle/**'
+ - 'backend/**/bin/**'
+ - 'backend/**/obj/**'
- 'coverage/**'
diff --git a/.github/ISSUE_TEMPLATE/diagnostic.yml b/.github/ISSUE_TEMPLATE/diagnostic.yml
index 28e20e4..29be55f 100644
--- a/.github/ISSUE_TEMPLATE/diagnostic.yml
+++ b/.github/ISSUE_TEMPLATE/diagnostic.yml
@@ -13,7 +13,7 @@ body:
label: Affected area
options:
- Vue website or page content
- - Kotlin/Spring site API
+ - ASP.NET Core site API
- Localization or theme
- Routing, metadata, or indexing
- Deployment or Nginx configuration
@@ -45,7 +45,7 @@ body:
id: environment
attributes:
label: Environment
- description: OS/browser, website commit, Node.js version, or Java 25 version when relevant.
+ description: OS/browser, website commit, Node.js version, or .NET 10 version when relevant.
validations:
required: true
- type: textarea
diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml
index 20ed569..90424aa 100644
--- a/.github/codeql/codeql-config.yml
+++ b/.github/codeql/codeql-config.yml
@@ -5,5 +5,6 @@ name: Tala website owned sources
paths-ignore:
- frontend/node_modules
- frontend/dist
- - backend/build
+ - backend/**/bin
+ - backend/**/obj
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 653c9c9..a90aac6 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -10,9 +10,9 @@ Describe the Tala website behavior or content changed. This repository is not th
## Verification
- [ ] Frontend checks, tests, formatting, and build were run where relevant.
-- [ ] Kotlin/Spring tests were run where relevant.
+- [ ] ASP.NET Core tests were run where relevant.
- [ ] en-US/de-DE, light/dark, accessibility, and small-screen behavior were considered for UI changes.
-- [ ] Internal links, forum availability messaging, and the legacy ViGet redirect were checked if affected.
+- [ ] Internal links, and forum availability messaging were checked if affected.
- [ ] No credentials, generated output, or unrelated files are included.
Commands and results (including anything not run):
diff --git a/.github/renovate.json5 b/.github/renovate.json5
index 6d8623b..78d6dc6 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -35,12 +35,12 @@
ignorePaths: ["**/node_modules/**"],
// What Dependabot watched here, and nothing more for now.
- enabledManagers: ["github-actions", "npm", "gradle", "gradle-wrapper"],
+ enabledManagers: ["github-actions", "npm", "nuget"],
// One pull request for each kind of dependency.
packageRules: [
{ matchManagers: ["github-actions"], groupName: "GitHub Actions" },
{ matchManagers: ["npm"], groupName: "Frontend packages" },
- { matchManagers: ["gradle", "gradle-wrapper"], groupName: "Backend plugins and libraries" },
+ { matchManagers: ["nuget"], groupName: "Backend packages" },
],
}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 70631c6..e1be3db 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -54,22 +54,21 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
+ - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
with:
- distribution: temurin
- java-version: "25"
- - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- with:
- gradle-version: "9.6.0"
- cache-provider: basic
- # dokkaGenerate is the documentation gate: an undocumented public
- # declaration or a broken reference fails the build.
- - run: gradle test jacocoTestReport bootJar dokkaGenerate --no-daemon
- working-directory: backend
+ dotnet-version: 10.0.x
+ cache: true
+ cache-dependency-path: "**/packages.lock.json"
+ # The build is the documentation gate: GenerateDocumentationFile with
+ # warnings as errors fails on a public declaration without its comment.
+ - run: dotnet restore Progmasoft.Tala.Website.slnx --locked-mode
+ - run: dotnet build Progmasoft.Tala.Website.slnx --configuration Release --no-restore
+ - run: dotnet test --project backend/Progmasoft.Tala.Website.Api.Tests/Progmasoft.Tala.Website.Api.Tests.csproj --configuration Release --no-build --coverlet --coverlet-output-format cobertura --results-directory TestResults
+ - run: dotnet publish backend/Progmasoft.Tala.Website.Api/Progmasoft.Tala.Website.Api.csproj --configuration Release --no-restore --output publish
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: website-backend-coverage
- path: backend/build/reports/jacoco/test/jacocoTestReport.xml
+ path: TestResults/coverage.cobertura.xml
if-no-files-found: error
retention-days: 1
@@ -105,7 +104,7 @@ jobs:
contents: read
id-token: write
steps:
- # Keep OIDC-capable jobs isolated from Gradle build scripts.
+ # Keep OIDC-capable jobs isolated from the backend build.
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: website-backend-coverage
@@ -113,7 +112,7 @@ jobs:
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
use_oidc: true
- files: coverage/backend/jacocoTestReport.xml
+ files: coverage/backend/coverage.cobertura.xml
flags: website-backend
fail_ci_if_error: true
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index c5dde81..b565115 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -25,31 +25,27 @@ jobs:
build_mode: none
- language: javascript-typescript
build_mode: none
- - language: java-kotlin
+ - language: csharp
build_mode: manual
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
- if: matrix.language == 'java-kotlin'
+ - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
+ if: matrix.language == 'csharp'
with:
- distribution: temurin
- java-version: "25"
- - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- if: matrix.language == 'java-kotlin'
- with:
- gradle-version: "9.6.0"
+ dotnet-version: 10.0.x
- uses: github/codeql-action/init@7999b86c43a865dc79d8923397f35af22de63401 # v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build_mode }}
config-file: ./.github/codeql/codeql-config.yml
queries: security-extended
- - name: Build Kotlin backend for extraction
- if: matrix.language == 'java-kotlin'
- run: gradle classes --no-daemon
- working-directory: backend
+ - name: Build the backend for extraction
+ if: matrix.language == 'csharp'
+ run: |
+ dotnet restore Progmasoft.Tala.Website.slnx --locked-mode
+ dotnet build Progmasoft.Tala.Website.slnx --configuration Release --no-restore
- uses: github/codeql-action/analyze@7999b86c43a865dc79d8923397f35af22de63401 # v4
with:
category: /language:${{ matrix.language }}
diff --git a/.gitignore b/.gitignore
index 3a9dcfc..8f9dffa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,5 @@ coverage/
out/
build/
*.user
+TestResults/
+publish/
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..21e7adf
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,18 @@
+<!-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> -->
+<!-- SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 -->
+
+<Project>
+ <PropertyGroup>
+ <AnalysisLevel>latest-recommended</AnalysisLevel>
+ <Authors>Progmasoft</Authors>
+ <Company>Progmasoft</Company>
+ <ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
+ <Copyright>Copyright © 2026 Progmasoft &lt;support@progmasoft.com&gt;</Copyright>
+ <Deterministic>true</Deterministic>
+ <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+ </PropertyGroup>
+</Project>
diff --git a/Progmasoft.Tala.Website.slnx b/Progmasoft.Tala.Website.slnx
new file mode 100644
index 0000000..d0dcaf4
--- /dev/null
+++ b/Progmasoft.Tala.Website.slnx
@@ -0,0 +1,6 @@
+<Solution>
+ <Folder Name="/backend/">
+ <Project Path="backend/Progmasoft.Tala.Website.Api/Progmasoft.Tala.Website.Api.csproj" />
+ <Project Path="backend/Progmasoft.Tala.Website.Api.Tests/Progmasoft.Tala.Website.Api.Tests.csproj" />
+ </Folder>
+</Solution>
diff --git a/REUSE.toml b/REUSE.toml
index e57d0dc..9a1ed0b 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -1,12 +1,15 @@
version = 1
-SPDX-PackageName = "Visual XSharp Website"
+SPDX-PackageName = "Tala Website"
SPDX-PackageSupplier = "Organization: Progmasoft"
SPDX-PackageDownloadLocation = "https://github.com/Progmasoft/website"
[[annotations]]
path = [
- "backend/gradle.lockfile",
- "backend/gradle/verification-metadata.xml",
+ "Progmasoft.Tala.Website.slnx",
+ "global.json",
+ "backend/Progmasoft.Tala.Website.Api/appsettings.json",
+ "backend/Progmasoft.Tala.Website.Api/packages.lock.json",
+ "backend/Progmasoft.Tala.Website.Api.Tests/packages.lock.json",
"forum/visual-xsharp.svg",
"forum/visual-xsharp-mark.svg",
"frontend/.prettierrc.json",
diff --git a/backend/Progmasoft.Tala.Website.Api.Tests/Progmasoft.Tala.Website.Api.Tests.csproj b/backend/Progmasoft.Tala.Website.Api.Tests/Progmasoft.Tala.Website.Api.Tests.csproj
new file mode 100644
index 0000000..6f81034
--- /dev/null
+++ b/backend/Progmasoft.Tala.Website.Api.Tests/Progmasoft.Tala.Website.Api.Tests.csproj
@@ -0,0 +1,21 @@
+<!-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> -->
+<!-- SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 -->
+
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <IsPackable>false</IsPackable>
+ <IsTestProject>true</IsTestProject>
+ <OutputType>Exe</OutputType>
+ <RootNamespace>Progmasoft.Tala.Website.Api.Tests</RootNamespace>
+ </PropertyGroup>
+ <ItemGroup>
+ <FrameworkReference Include="Microsoft.AspNetCore.App" />
+ <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
+ <PackageReference Include="coverlet.MTP" Version="8.0.0" />
+ <PackageReference Include="xunit.v3.mtp-v2" Version="4.0.0" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\Progmasoft.Tala.Website.Api\Progmasoft.Tala.Website.Api.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/backend/Progmasoft.Tala.Website.Api.Tests/SiteEndpointTests.cs b/backend/Progmasoft.Tala.Website.Api.Tests/SiteEndpointTests.cs
new file mode 100644
index 0000000..39cb8d4
--- /dev/null
+++ b/backend/Progmasoft.Tala.Website.Api.Tests/SiteEndpointTests.cs
@@ -0,0 +1,86 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using System.Net;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Xunit;
+
+namespace Progmasoft.Tala.Website.Api.Tests;
+
+/// <summary>The endpoints of the site, served from an in-memory host.</summary>
+public sealed class SiteEndpointTests : IAsyncLifetime
+{
+ private WebApplication? application;
+ private HttpClient? client;
+
+ private HttpClient Client => client ?? throw new InvalidOperationException("the host has not started");
+
+ public async ValueTask InitializeAsync()
+ {
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ application = builder.Build();
+ SiteApplication.Map(application);
+ await application.StartAsync(TestContext.Current.CancellationToken);
+ client = application.GetTestClient();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ client?.Dispose();
+ if (application is not null) await application.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task SiteInformationIsAboutTalaRatherThanThePackageRegistry()
+ {
+ using HttpResponseMessage response =
+ await Client.GetAsync(new Uri("/api/v1/site", UriKind.Relative), TestContext.Current.CancellationToken);
+ string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
+ Assert.Equal(
+ """{"name":"Tala","phase":"development","defaultLocale":"en-US","supportedLocales":["en-US","de-DE","ru-RU","he-IL"],"forumOpen":false}""",
+ body);
+ Assert.DoesNotContain("viget", body, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task HealthHasAnEmptySuccessfulResponse()
+ {
+ using HttpResponseMessage response =
+ await Client.GetAsync(new Uri("/health", UriKind.Relative), TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task SiteInformationIsReadOnly()
+ {
+ using StringContent empty = new(string.Empty);
+ using HttpResponseMessage response = await Client.PostAsync(
+ new Uri("/api/v1/site", UriKind.Relative), empty, TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
+ }
+
+ [Theory]
+ [InlineData("/api/v1/packages")]
+ [InlineData("/api/v1/accounts")]
+ [InlineData("/repo")]
+ [InlineData("/")]
+ public async Task RegistryAndAccountEndpointsAreAbsent(string path)
+ {
+ using HttpResponseMessage response =
+ await Client.GetAsync(new Uri(path, UriKind.Relative), TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public void TheServiceListensOnTheFormerPortUnlessToldOtherwise() => Assert.Equal(5086, SiteApplication.DefaultPort);
+}
diff --git a/backend/Progmasoft.Tala.Website.Api.Tests/packages.lock.json b/backend/Progmasoft.Tala.Website.Api.Tests/packages.lock.json
new file mode 100644
index 0000000..0e245b4
--- /dev/null
+++ b/backend/Progmasoft.Tala.Website.Api.Tests/packages.lock.json
@@ -0,0 +1,158 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {
+ "coverlet.MTP": {
+ "type": "Direct",
+ "requested": "[8.0.0, )",
+ "resolved": "8.0.0",
+ "contentHash": "r3+GPhzJd8G/qlw8UFOYGECZSFvpg0j+yRTGbSr8ikk1ed4Hui+Y56ghPcBT43S3DWYKEzZLGmZ/KFS28k1zHg==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "2.0.2"
+ }
+ },
+ "Microsoft.AspNetCore.Mvc.Testing": {
+ "type": "Direct",
+ "requested": "[10.0.11, )",
+ "resolved": "10.0.11",
+ "contentHash": "BJi3rpYETlh0wd14Lg5Is9MWvEf55TAGd7yo+xGHuEZp7ololyvftbQZ1i7ZuITA7DgNPJED6EMhFSWycq7SBg==",
+ "dependencies": {
+ "Microsoft.AspNetCore.TestHost": "10.0.11",
+ "Microsoft.Extensions.DependencyModel": "10.0.11"
+ }
+ },
+ "xunit.v3.mtp-v2": {
+ "type": "Direct",
+ "requested": "[4.0.0, )",
+ "resolved": "4.0.0",
+ "contentHash": "svYjct2c3VbLyUSB2r9VWiIkYwemwXHhOgIVAor8RvupcZBFcdiGdmq8G519ZCu30yjPCr0EP3Hy4mvgEXcXpQ==",
+ "dependencies": {
+ "xunit.analyzers": "2.0.0",
+ "xunit.v3.assert": "[4.0.0]",
+ "xunit.v3.core.mtp-v2": "[4.0.0]"
+ }
+ },
+ "Microsoft.ApplicationInsights": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
+ },
+ "Microsoft.AspNetCore.TestHost": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "IVGNbZVxLuL80y7cmXYfhIFoTGneHkp4D7xXarYe6V/wjo8/2qiSLUwWax1/h2eHtJ3Y5cuqmtpOyWMnXIsUMA=="
+ },
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
+ },
+ "Microsoft.Extensions.DependencyModel": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "PJPtFYsZ+r+uz9qqXWUTEyKeJ1EiBGIJtqavkg9ZXijjGSFAk4Fgi5sqIxj+uAyLZwEKgexDUQXhWhvU6l3+og=="
+ },
+ "Microsoft.Testing.Extensions.Telemetry": {
+ "type": "Transitive",
+ "resolved": "2.3.3",
+ "contentHash": "nY8ceQyPWB9TRE1WE5Oe/sks2e10SxgPv61vBHsFYpgCeDtNwhpMZwmL29GklO3/etTdOsLdrCmNr4zJWaR2fg==",
+ "dependencies": {
+ "Microsoft.ApplicationInsights": "2.23.0",
+ "Microsoft.Testing.Platform": "2.3.3"
+ }
+ },
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": {
+ "type": "Transitive",
+ "resolved": "2.3.3",
+ "contentHash": "dceVNxnfTEjKnrIreLcMfkgrzzBY8kgCCJX5NAv7Lq/6vPlTP4VB5zxKeuYy0yfCoBtWuPkLjUG6qtOBMfpypA==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "2.3.3"
+ }
+ },
+ "Microsoft.Testing.Platform": {
+ "type": "Transitive",
+ "resolved": "2.3.3",
+ "contentHash": "ENbH4BQh9riXtOKc25KKITfiGGWhWMBJA7pZuNaF9zxzzSaVkp5AMeZxGQ6sXYaR6xb724NLz985Is6XIYqLSg=="
+ },
+ "Microsoft.Testing.Platform.MSBuild": {
+ "type": "Transitive",
+ "resolved": "2.3.3",
+ "contentHash": "iVAvNbZ5JPDZSrTcIrCDoCsAkzjDxwDEt2mDuMd8ng1P6e2P2NCd0g0BsoBVG+nJLSmK//V+yeEvjCa3E2fxHw==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "2.3.3"
+ }
+ },
+ "Microsoft.Win32.Registry": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
+ },
+ "System.Security.AccessControl": {
+ "type": "Transitive",
+ "resolved": "6.0.1",
+ "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw=="
+ },
+ "xunit.analyzers": {
+ "type": "Transitive",
+ "resolved": "2.0.0",
+ "contentHash": "2UtauxWDa9C6bT7MvFfZkNoFulfflb00jnDU2xeVO9Y58l4Ah2Mv/HiMs4b0zdpK/SfAxpajkgKNMN8zBKa+7Q=="
+ },
+ "xunit.v3.assert": {
+ "type": "Transitive",
+ "resolved": "4.0.0",
+ "contentHash": "QxYfC+98lCMe7Kl9iWDUeUn+gmiPJ2Sz/r+trSdp1mvKyDMXj8S1kYdfkFNJSHyUSQ6KWomenSDgfWhGpR8zEQ=="
+ },
+ "xunit.v3.common": {
+ "type": "Transitive",
+ "resolved": "4.0.0",
+ "contentHash": "cjaNGmOVA5QJxcp6uuSsDhbt0lNmxezFo226mbYOuXm9E9Owq8bYTKxa9wnAMZRnbvyCmCYhAvc/+UAzf0M2vA==",
+ "dependencies": {
+ "Microsoft.Bcl.AsyncInterfaces": "6.0.0"
+ }
+ },
+ "xunit.v3.core.mtp-v2": {
+ "type": "Transitive",
+ "resolved": "4.0.0",
+ "contentHash": "2I7apws+6HPz5aYi4choAn7c8P4jPAvwbfAtLSy0JPH89oeY/z1Zqz65Cm3HJmput5l56Z1sJOinTxsAQmbyWQ==",
+ "dependencies": {
+ "Microsoft.Testing.Extensions.Telemetry": "2.3.3",
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.3.3",
+ "Microsoft.Testing.Platform": "2.3.3",
+ "Microsoft.Testing.Platform.MSBuild": "2.3.3",
+ "xunit.v3.extensibility.core": "[4.0.0]",
+ "xunit.v3.runner.inproc.console": "[4.0.0]"
+ }
+ },
+ "xunit.v3.extensibility.core": {
+ "type": "Transitive",
+ "resolved": "4.0.0",
+ "contentHash": "+tTe9VX2vwrUHGI48FE4ly956Ry084wPH4I/7g5D36AkAMGjQRZZDVz8eH2GwX/CLS9PDQRSca534WyrAYsyzw==",
+ "dependencies": {
+ "xunit.v3.common": "[4.0.0]"
+ }
+ },
+ "xunit.v3.runner.common": {
+ "type": "Transitive",
+ "resolved": "4.0.0",
+ "contentHash": "1IEIAVRgnPo9nihd9D0TvxxsLKVRySa+K0wLy/m0SJ4RMdveRCUj/mICFboruO+ILUJ10fUfCCyp7MC5/y7cGw==",
+ "dependencies": {
+ "Microsoft.Win32.Registry": "[5.0.0]",
+ "System.Security.AccessControl": "[6.0.1]",
+ "xunit.v3.common": "[4.0.0]"
+ }
+ },
+ "xunit.v3.runner.inproc.console": {
+ "type": "Transitive",
+ "resolved": "4.0.0",
+ "contentHash": "Sp5AALIlZUf2U5pEt2LHs+ebn18eAPSFnXEouJTltLez5p+NQvBm7kzsKSkZOM6iyoS6nuN/wKdsJt2LvixjsQ==",
+ "dependencies": {
+ "xunit.v3.extensibility.core": "[4.0.0]",
+ "xunit.v3.runner.common": "[4.0.0]"
+ }
+ },
+ "progmasoft.tala.website.api": {
+ "type": "Project"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/backend/Progmasoft.Tala.Website.Api/Progmasoft.Tala.Website.Api.csproj b/backend/Progmasoft.Tala.Website.Api/Progmasoft.Tala.Website.Api.csproj
new file mode 100644
index 0000000..fdf879c
--- /dev/null
+++ b/backend/Progmasoft.Tala.Website.Api/Progmasoft.Tala.Website.Api.csproj
@@ -0,0 +1,19 @@
+<!-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> -->
+<!-- SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 -->
+
+<Project Sdk="Microsoft.NET.Sdk.Web">
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <RootNamespace>Progmasoft.Tala.Website.Api</RootNamespace>
+ <AssemblyName>Progmasoft.Tala.Website.Api</AssemblyName>
+ <Version>2.0.0</Version>
+ <!-- A public declaration without its documentation comment fails the build. -->
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <!-- The service is a single small process; the server already provides the shared runtime. -->
+ <SelfContained>false</SelfContained>
+ <InvariantGlobalization>true</InvariantGlobalization>
+ </PropertyGroup>
+ <ItemGroup>
+ <InternalsVisibleTo Include="Progmasoft.Tala.Website.Api.Tests" />
+ </ItemGroup>
+</Project>
diff --git a/backend/Progmasoft.Tala.Website.Api/Program.cs b/backend/Progmasoft.Tala.Website.Api/Program.cs
new file mode 100644
index 0000000..7d21879
--- /dev/null
+++ b/backend/Progmasoft.Tala.Website.Api/Program.cs
@@ -0,0 +1,50 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using Progmasoft.Tala.Website.Api;
+
+WebApplication application = SiteApplication.Build(args);
+application.Run();
+
+namespace Progmasoft.Tala.Website.Api
+{
+ /// <summary>
+ /// The language website's API: the site's own public state and a liveness probe. It has no account, package,
+ /// publication or registry endpoint; Progmaweb owns those.
+ /// </summary>
+ public static class SiteApplication
+ {
+ /// <summary>The port the service listens on when the <c>PORT</c> variable names none.</summary>
+ public const int DefaultPort = 5086;
+
+ /// <summary>
+ /// Builds the application. It listens on the loopback interface only; Nginx is the public side.
+ /// </summary>
+ /// <param name="args">The command-line arguments of the process.</param>
+ /// <returns>The configured application, not yet running.</returns>
+ public static WebApplication Build(string[] args)
+ {
+ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+ int port = int.TryParse(Environment.GetEnvironmentVariable("PORT"), out int configured)
+ && configured is > 0 and < 65536
+ ? configured
+ : DefaultPort;
+ builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
+ builder.Logging.ClearProviders();
+ builder.Logging.AddConsole();
+
+ WebApplication application = builder.Build();
+ Map(application);
+ return application;
+ }
+
+ /// <summary>Maps the endpoints of the site onto <paramref name="routes"/>.</summary>
+ /// <param name="routes">The application or route group that receives the endpoints.</param>
+ public static void Map(IEndpointRouteBuilder routes)
+ {