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

summaryrefslogtreecommitdiffhomepage
path: root/apps
diff options
context:
space:
mode:
authorLeitwolf112026-10-29 09:53:57 +0300
committerLeitwolf112026-10-29 09:53:57 +0300
commit58fa0d19d223aaebf9973ebd446c4bb855351ef9 (patch)
tree6fce1c3d9f2255e80ec92c3040e7280c0ce131ae /apps
parentf22418235a0a87bc05b3bf9079fcdefba002d3b2 (diff)
downloadprogmaweb-58fa0d19d223aaebf9973ebd446c4bb855351ef9.tar.gz
Add the Tazel plugin catalog of ViGet to the API
ViGet opens in stages: first only Progmasoft publishes Tazel plugins, later every account. This adds the catalog and the server-side publishing for the first stage; nothing publishes through the API yet. - ViGet/: the catalog of Tazel plugins (in memory, or in PostgreSQL beside the accounts with its own numbered schema steps), the descriptor read from the JAR (META-INF/tazel-plugin.properties, with a reader of the Java properties format, and the ProjectPlugin service entry), and publishing: the publisher must be in ViGet:TazelPublishers (Progmasoft for now) and an existing account of that exact name, a version is never replaced, names that differ only in letter case are refused, and ViGet needs a description, an author and a license. The JAR is kept at dslplugins/tazel/<Publisher>/<Name>/<Version>/<Name>-<Version>.jar below ViGet:ArtifactRoot. - Read endpoints under /api/v1/viget/dslplugins/tazel: the list, a plugin, and index.json, the index Tazel reads. - 'viget publish-tazel-plugin <jar>' and 'viget yank-tazel-plugin' run with the configuration of the service instead of the service. - The account store can find an account by name. The pages, the Nginx routes and the deployment follow.
Diffstat (limited to 'apps')
-rw-r--r--apps/api-tests/AccountServiceTests.cs6
-rw-r--r--apps/api-tests/AccountStoreSelectionTests.cs5
-rw-r--r--apps/api-tests/ViGetTests.cs269
-rw-r--r--apps/api/Accounts/IAccountStore.cs7
-rw-r--r--apps/api/Accounts/InMemoryAccountStore.cs13
-rw-r--r--apps/api/Accounts/PostgresAccountStore.cs6
-rw-r--r--apps/api/Program.cs16
-rw-r--r--apps/api/ViGet/JavaProperties.cs149
-rw-r--r--apps/api/ViGet/PostgresTazelPluginCatalog.cs309
-rw-r--r--apps/api/ViGet/TazelPluginCatalog.cs177
-rw-r--r--apps/api/ViGet/TazelPluginDescriptor.cs134
-rw-r--r--apps/api/ViGet/TazelPluginPublishing.cs148
-rw-r--r--apps/api/ViGet/ViGetCommand.cs85
-rw-r--r--apps/api/ViGet/ViGetEndpoints.cs107
-rw-r--r--apps/api/ViGet/ViGetRegistration.cs57
15 files changed, 1487 insertions, 1 deletions
diff --git a/apps/api-tests/AccountServiceTests.cs b/apps/api-tests/AccountServiceTests.cs
index 28fd44f..0ff7b33 100644
--- a/apps/api-tests/AccountServiceTests.cs
+++ b/apps/api-tests/AccountServiceTests.cs
@@ -151,6 +151,12 @@ public sealed class AccountServiceTests
public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken) =>
ValueTask.FromResult<AccountRecord?>(passwordAccount.Id == id ? passwordAccount : null);
+ public ValueTask<AccountRecord?> FindByAccountNameAsync(
+ string normalizedAccountName,
+ CancellationToken cancellationToken) =>
+ ValueTask.FromResult<AccountRecord?>(
+ passwordAccount.NormalizedAccountName == normalizedAccountName ? passwordAccount : null);
+
public ValueTask<AccountRecord?> FindByGoogleSubjectAsync(
string googleSubject,
CancellationToken cancellationToken) =>
diff --git a/apps/api-tests/AccountStoreSelectionTests.cs b/apps/api-tests/AccountStoreSelectionTests.cs
index 05ed31b..1087575 100644
--- a/apps/api-tests/AccountStoreSelectionTests.cs
+++ b/apps/api-tests/AccountStoreSelectionTests.cs
@@ -134,6 +134,11 @@ public sealed class ExpiredSessionCleanupTests
public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken) =>
throw new NotSupportedException();
+ public ValueTask<AccountRecord?> FindByAccountNameAsync(
+ string normalizedAccountName,
+ CancellationToken cancellationToken) =>
+ throw new NotSupportedException();
+
public ValueTask StoreSessionAsync(SessionRecord session, CancellationToken cancellationToken) =>
throw new NotSupportedException();
diff --git a/apps/api-tests/ViGetTests.cs b/apps/api-tests/ViGetTests.cs
new file mode 100644
index 0000000..603ef2f
--- /dev/null
+++ b/apps/api-tests/ViGetTests.cs
@@ -0,0 +1,269 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using System.IO.Compression;
+using System.Net;
+using System.Security.Cryptography;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Progmasoft.Progmaweb.Api.Accounts;
+using Progmasoft.Progmaweb.Api.ViGet;
+
+namespace Progmasoft.Progmaweb.Api.Tests;
+
+/// <summary>The Tazel plugin catalog of ViGet: the descriptor, publishing, the endpoints and the command.</summary>
+public sealed class ViGetTests : IDisposable
+{
+ private readonly string artifactRoot = Path.Combine(Path.GetTempPath(), "viget-" + Guid.NewGuid().ToString("N"));
+
+ /// <inheritdoc/>
+ public void Dispose()
+ {
+ if (Directory.Exists(artifactRoot))
+ {
+ Directory.Delete(artifactRoot, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void PropertiesAreReadAsJavaReadsThem()
+ {
+ IReadOnlyDictionary<string, string> properties = JavaProperties.Parse(
+ "# a comment\n" +
+ "! another\n" +
+ "publisher=Progmasoft\n" +
+ "homepage = https\\://example.com/a\n" +
+ "description:Builds C \\u0026 C++,\\\n" +
+ " on every host.\n" +
+ "key\\ with\\ spaces value\n" +
+ "empty=\n");
+ Assert.Equal("Progmasoft", properties["publisher"]);
+ Assert.Equal("https://example.com/a", properties["homepage"]);
+ Assert.Equal("Builds C & C++,on every host.", properties["description"]);
+ Assert.Equal("value", properties["key with spaces"]);
+ Assert.Equal(string.Empty, properties["empty"]);
+ Assert.Throws<FormatException>(() => JavaProperties.Parse("broken=\\u12"));
+ }
+
+ [Fact]
+ public void TheDescriptorOfAPluginJarIsReadAndChecked()
+ {
+ Assert.Null(TazelPluginDescriptor.TryRead(Jar(), out TazelPluginDescriptor? descriptor));
+ Assert.Equal(
+ new TazelPluginDescriptor(
+ "Progmasoft", "CMake", "0.1.0", 2, "Builds CMake projects.", ["First Author", "Second Author"],
+ "MPL-2.0", "https://example.com/cmake", null),
+ descriptor!,
+ new DescriptorComparer());
+
+ Assert.Equal("The JAR has no META-INF/services/com.progmasoft.api.tazel.dsl.plugin.ProjectPlugin.",
+ TazelPluginDescriptor.TryRead(Jar(service: false), out _));
+ Assert.Equal("The descriptor needs an exact semantic version.",
+ TazelPluginDescriptor.TryRead(Jar(version: "1.0"), out _));
+ Assert.Equal("The descriptor needs a name that is one identifier.",
+ TazelPluginDescriptor.TryRead(Jar(name: "c-make"), out _));
+ Assert.Equal("The homepage of the descriptor is not an https address.",
+ TazelPluginDescriptor.TryRead(Jar(homepage: "http\\://example.com"), out _));
+ Assert.Equal("The file is not a JAR.", TazelPluginDescriptor.TryRead("not a zip"u8.ToArray(), out _));
+ }
+
+ [Fact]
+ public async Task OnlyAnAllowedPublisherWithAnAccountPublishes()
+ {
+ (TazelPluginPublishing publishing, InMemoryAccountStore accounts, ITazelPluginCatalog catalog) = Publishing();
+ CancellationToken cancellationToken = TestContext.Current.CancellationToken;
+
+ PublishResult noAccount = await publishing.PublishAsync(Jar(), cancellationToken);
+ Assert.Equal("No Progmasoft Account is named 'Progmasoft'.", noAccount.Error);
+
+ await AddAccountAsync(accounts, "Progmasoft");
+ await AddAccountAsync(accounts, "Someone");
+ PublishResult notAllowed = await publishing.PublishAsync(Jar(publisher: "Someone"), cancellationToken);
+ Assert.Equal(
+ "Publishing Tazel plugins is open to Progmasoft for now; 'Someone' cannot publish yet.",
+ notAllowed.Error);
+
+ byte[] jar = Jar();
+ PublishResult published = await publishing.PublishAsync(jar, cancellationToken);
+ Assert.True(published.Published, published.Error);
+ string file = Path.Combine(artifactRoot, "dslplugins", "tazel", "Progmasoft", "CMake", "0.1.0", "CMake-0.1.0.jar");
+ Assert.Equal(jar, await File.ReadAllBytesAsync(file, cancellationToken));
+ TazelPlugin plugin = (await catalog.FindAsync("Progmasoft", "CMake", cancellationToken))!;
+ Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(jar)), plugin.Versions.Single().Sha256);
+ Assert.Equal("MPL-2.0", plugin.License);
+
+ PublishResult again = await publishing.PublishAsync(Jar(description: "Another build."), cancellationToken);
+ Assert.Equal("Progmasoft.CMake 0.1.0 is published already.", again.Error);
+ Assert.Equal(jar, await File.ReadAllBytesAsync(file, cancellationToken));
+
+ PublishResult folded = await publishing.PublishAsync(Jar(name: "CMAKE", version: "0.2.0"), cancellationToken);
+ Assert.Equal(
+ "Progmasoft has a plugin whose name differs from 'CMAKE' only in letter case.", folded.Error);
+ Assert.False(File.Exists(Path.Combine(
+ artifactRoot, "dslplugins", "tazel", "Progmasoft", "CMAKE", "0.2.0", "CMAKE-0.2.0.jar")));
+ }
+
+ [Fact]
+ public async Task ViGetNeedsADescriptionAnAuthorAndALicense()
+ {
+ (TazelPluginPublishing publishing, InMemoryAccountStore accounts, _) = Publishing();
+ await AddAccountAsync(accounts, "Progmasoft");
+ PublishResult result = await publishing.PublishAsync(Jar(license: null), TestContext.Current.CancellationToken);
+ Assert.Equal(
+ "ViGet needs a description, at least one author and a license in the descriptor.", result.Error);
+ }
+
+ [Fact]
+ public async Task TheIndexIsWhatTazelReads()
+ {
+ await using AccountApiFactory factory = new();
+ ITazelPluginCatalog catalog = factory.Services.GetRequiredService<ITazelPluginCatalog>();
+ CancellationToken cancellationToken = TestContext.Current.CancellationToken;
+ Assert.Null(TazelPluginDescriptor.TryRead(Jar(), out TazelPluginDescriptor? descriptor));
+ await catalog.AddVersionAsync(descriptor!, new string('a', 64), 10, DateTimeOffset.UnixEpoch, cancellationToken);
+ await catalog.AddVersionAsync(
+ descriptor! with { Version = "0.2.0" }, new string('b', 64), 10, DateTimeOffset.UnixEpoch.AddDays(1),
+ cancellationToken);
+ await catalog.SetYankedAsync("Progmasoft", "CMake", "0.2.0", true, cancellationToken);
+ using HttpClient client = factory.CreateClient();
+
+ using JsonDocument index = JsonDocument.Parse(await client.GetStringAsync(
+ "/api/v1/viget/dslplugins/tazel/Progmasoft/CMake/index.json", cancellationToken));
+ Assert.Equal("Progmasoft", index.RootElement.GetProperty("publisher").GetString());
+ Assert.Equal("CMake", index.RootElement.GetProperty("name").GetString());
+ JsonElement[] versions = [.. index.RootElement.GetProperty("versions").EnumerateArray()];
+ Assert.Equal(["0.2.0", "0.1.0"], versions.Select(version => version.GetProperty("version").GetString()));
+ Assert.True(versions[0].GetProperty("yanked").GetBoolean());
+ Assert.Equal(2, versions[1].GetProperty("apiVersion").GetInt32());
+ Assert.Equal(new string('a', 64), versions[1].GetProperty("sha256").GetString());
+
+ using JsonDocument detail = JsonDocument.Parse(await client.GetStringAsync(
+ "/api/v1/viget/dslplugins/tazel/Progmasoft/CMake", cancellationToken));
+ Assert.Equal("0.1.0", detail.RootElement.GetProperty("latestVersion").GetString());
+ Assert.Equal(
+ "/dslplugins/tazel/Progmasoft/CMake/0.1.0/CMake-0.1.0.jar",
+ detail.RootElement.GetProperty("versions")[1].GetProperty("path").GetString());
+
+ using JsonDocument list = JsonDocument.Parse(
+ await client.GetStringAsync("/api/v1/viget/dslplugins/tazel", cancellationToken));
+ Assert.Equal("CMake", list.RootElement.GetProperty("plugins")[0].GetProperty("name").GetString());
+
+ Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(
+ "/api/v1/viget/dslplugins/tazel/Progmasoft/Missing/index.json", cancellationToken)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(
+ "/api/v1/viget/dslplugins/tazel/Progmasoft/not-a-name/index.json", cancellationToken)).StatusCode);
+ }
+
+ [Fact]
+ public async Task TheCommandPublishesAndYanks()
+ {
+ (TazelPluginPublishing publishing, InMemoryAccountStore accounts, ITazelPluginCatalog catalog) = Publishing();
+ await AddAccountAsync(accounts, "Progmasoft");
+ ServiceProvider services = new ServiceCollection()
+ .AddSingleton(publishing)
+ .AddSingleton(catalog)
+ .BuildServiceProvider();
+ string jar = Path.Combine(artifactRoot, "upload.jar");
+ Directory.CreateDirectory(artifactRoot);
+ await File.WriteAllBytesAsync(jar, Jar(), TestContext.Current.CancellationToken);
+ StringWriter output = new();
+ StringWriter errors = new();
+
+ Assert.Equal(0, await ViGetCommand.RunAsync(
+ services, ["publish-tazel-plugin", jar], output, errors, TestContext.Current.CancellationToken));
+ Assert.Contains("Published Progmasoft.CMake 0.1.0", output.ToString(), StringComparison.Ordinal);
+ Assert.Equal(0, await ViGetCommand.RunAsync(
+ services, ["yank-tazel-plugin", "Progmasoft", "CMake", "0.1.0"], output, errors,
+ TestContext.Current.CancellationToken));
+ Assert.True((await catalog.FindAsync("Progmasoft", "CMake", TestContext.Current.CancellationToken))!
+ .Versions.Single().Yanked);
+ Assert.Equal(1, await ViGetCommand.RunAsync(
+ services, ["publish-tazel-plugin", jar], output, errors, TestContext.Current.CancellationToken));
+ Assert.Equal(2, await ViGetCommand.RunAsync(
+ services, ["publish"], output, errors, TestContext.Current.CancellationToken));
+ }
+
+ private (TazelPluginPublishing, InMemoryAccountStore, ITazelPluginCatalog) Publishing()
+ {
+ InMemoryAccountStore accounts = new();
+ InMemoryTazelPluginCatalog catalog = new();
+ TazelPluginPublishing publishing = new(
+ catalog,
+ accounts,
+ Options.Create(new ViGetOptions { ArtifactRoot = artifactRoot }),
+ TimeProvider.System);
+ return (publishing, accounts, catalog);
+ }
+
+ private static async Task AddAccountAsync(InMemoryAccountStore accounts, string name) =>
+ await accounts.CreateAsync(
+ new AccountRecord(
+ Guid.NewGuid(),
+ name,
+ AccountNamePolicy.NormalizeForLookup(name),
+ $"{name}@example.com",
+ $"{name}@example.com".ToUpperInvariant(),
+ null,
+ null,
+ DateTimeOffset.UnixEpoch),
+ TestContext.Current.CancellationToken);
+
+ private static byte[] Jar(
+ string publisher = "Progmasoft",
+ string name = "CMake",
+ string version = "0.1.0",
+ string? description = "Builds CMake projects.",
+ string? license = "MPL-2.0",
+ string homepage = "https\\://example.com/cmake",
+ bool service = true)
+ {
+ using MemoryStream bytes = new();
+ using (ZipArchive archive = new(bytes, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ List<string> lines =
+ [
+ "publisher=" + publisher,
+ "name=" + name,
+ "version=" + version,
+ "apiVersion=2",
+ "author.0=First Author",
+ "author.1=Second Author",
+ "homepage=" + homepage
+ ];
+ if (description is not null)
+ {
+ lines.Add("description=" + description);
+ }
+
+ if (license is not null)
+ {
+ lines.Add("license=" + license);
+ }
+
+ using (StreamWriter writer = new(archive.CreateEntry(TazelPluginDescriptor.DescriptorEntry).Open()))
+ {
+ writer.Write(string.Join('\n', lines) + "\n");
+ }
+
+ if (service)
+ {
+ using StreamWriter writer = new(archive.CreateEntry(TazelPluginDescriptor.ServiceEntry).Open());
+ writer.Write("com.example.CMakePlugin\n");
+ }
+ }
+
+ return bytes.ToArray();
+ }
+
+ /// <summary>Compares descriptors by value, with their authors as sequences.</summary>
+ private sealed class DescriptorComparer : IEqualityComparer<TazelPluginDescriptor>
+ {
+ public bool Equals(TazelPluginDescriptor? left, TazelPluginDescriptor? right) =>
+ left is not null && right is not null &&
+ left with { Authors = [] } == right with { Authors = [] } &&
+ left.Authors.SequenceEqual(right.Authors);
+
+ public int GetHashCode(TazelPluginDescriptor descriptor) => descriptor.Name.GetHashCode(StringComparison.Ordinal);
+ }
+}
diff --git a/apps/api/Accounts/IAccountStore.cs b/apps/api/Accounts/IAccountStore.cs
index efb7c5b..ccb4c02 100644
--- a/apps/api/Accounts/IAccountStore.cs
+++ b/apps/api/Accounts/IAccountStore.cs
@@ -31,6 +31,13 @@ internal interface IAccountStore
/// <param name="cancellationToken">Cancels the operation.</param>
/// <returns>The account, or <see langword="null"/> when it does not exist.</returns>
ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken);
+ /// <summary>Finds an account by its normalized name.</summary>
+ /// <param name="normalizedAccountName">
+ /// Upper-case account name, as <see cref="AccountNamePolicy.NormalizeForLookup"/> produces it.
+ /// </param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>The account, or <see langword="null"/> when no account has this name.</returns>
+ ValueTask<AccountRecord?> FindByAccountNameAsync(string normalizedAccountName, CancellationToken cancellationToken);
/// <summary>Stores a session, replacing one with the same token digest.</summary>
/// <param name="session">The session to store.</param>
/// <param name="cancellationToken">Cancels the operation.</param>
diff --git a/apps/api/Accounts/InMemoryAccountStore.cs b/apps/api/Accounts/InMemoryAccountStore.cs
index 8606443..2f9af9d 100644
--- a/apps/api/Accounts/InMemoryAccountStore.cs
+++ b/apps/api/Accounts/InMemoryAccountStore.cs
@@ -82,6 +82,19 @@ internal sealed class InMemoryAccountStore : IAccountStore
}
/// <inheritdoc/>
+ public ValueTask<AccountRecord?> FindByAccountNameAsync(
+ string normalizedAccountName,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ lock (gate)
+ {
+ return ValueTask.FromResult(
+ accountIdsByName.TryGetValue(normalizedAccountName, out Guid id) ? accountsById[id] : null);
+ }
+ }
+
+ /// <inheritdoc/>
public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
diff --git a/apps/api/Accounts/PostgresAccountStore.cs b/apps/api/Accounts/PostgresAccountStore.cs
index a0e3444..3a3ea82 100644
--- a/apps/api/Accounts/PostgresAccountStore.cs
+++ b/apps/api/Accounts/PostgresAccountStore.cs
@@ -98,6 +98,12 @@ internal sealed class PostgresAccountStore(NpgsqlDataSource dataSource) : IAccou
FindAccountAsync("google_subject", googleSubject, cancellationToken);
/// <inheritdoc/>
+ public ValueTask<AccountRecord?> FindByAccountNameAsync(
+ string normalizedAccountName,
+ CancellationToken cancellationToken) =>
+ FindAccountAsync("normalized_account_name", normalizedAccountName, cancellationToken);
+
+ /// <inheritdoc/>
public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken) =>
FindAccountAsync("id", id, cancellationToken);
diff --git a/apps/api/Program.cs b/apps/api/Program.cs
index 476cbdb..873c120 100644
--- a/apps/api/Program.cs
+++ b/apps/api/Program.cs
@@ -9,8 +9,12 @@ using Microsoft.AspNetCore.HttpOverrides;
using Progmasoft.Progmaweb.Api.Accounts;
using Progmasoft.OAuth;
using Progmasoft.Progmaweb.Api.Security;
+using Progmasoft.Progmaweb.Api.ViGet;
-WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+// `viget ...` runs a command of ViGet with the configuration of the service instead of the service itself; its
+// arguments are not configuration.
+bool vigetCommand = ViGetCommand.IsCommand(args);
+WebApplicationBuilder builder = WebApplication.CreateBuilder(vigetCommand ? [] : args);
builder.Services.Configure<AccountSessionOptions>(builder.Configuration.GetSection(AccountSessionOptions.SectionName));
builder.Services.AddSingleton(TimeProvider.System);
@@ -21,6 +25,8 @@ builder.Services.AddSingleton<AccountService>();
builder.Services.AddSingleton<SessionService>();
// Progmasoft OAuth, the OAuth 2.1 and OpenID Connect provider on its own host; see OAuth/OAuthRegistration.cs.
builder.Services.AddProgmasoftOAuth(builder.Configuration, accountStore, builder.Environment.IsProduction());
+// ViGet: the Tazel plugin catalog beside the accounts; see ViGet/ViGetEndpoints.cs.
+builder.Services.AddViGet(builder.Configuration, accountStore);
builder.Services.AddAuthorization();
AuthenticationBuilder authentication = builder.Services
.AddAuthentication()
@@ -103,6 +109,13 @@ builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize =
WebApplication app = builder.Build();
+if (vigetCommand)
+{
+ Environment.ExitCode = await ViGetCommand.RunAsync(
+ app.Services, args[1..], Console.Out, Console.Error, CancellationToken.None);
+ return;
+}
+
if (accountStore is AccountStoreKind.Memory)
{
AccountStoreRegistration.WarnThatAccountsAreInMemory(app.Logger);
@@ -134,6 +147,7 @@ app.MapGet("/api/v1/status", () => Results.Ok(new
}));
app.MapAccountEndpoints();
app.MapProgmasoftOAuth();
+app.MapViGetEndpoints();
app.Run();
diff --git a/apps/api/ViGet/JavaProperties.cs b/apps/api/ViGet/JavaProperties.cs
new file mode 100644
index 0000000..e55991e
--- /dev/null
+++ b/apps/api/ViGet/JavaProperties.cs
@@ -0,0 +1,149 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using System.Globalization;
+using System.Text;
+
+namespace Progmasoft.Progmaweb.Api.ViGet;
+
+/// <summary>Reads the text of a Java <c>.properties</c> file, as <c>java.util.Properties.load</c> does.</summary>
+/// <remarks>
+/// A logical line may continue over several lines with a trailing backslash; <c>#</c> and <c>!</c> start a comment
+/// line; a key ends at the first unescaped <c>=</c>, <c>:</c> or white space; and <c>\t</c>, <c>\n</c>, <c>\r</c>,
+/// <c>\f</c> and <c>\uXXXX</c> are escapes, while a backslash before any other character stands for that character.
+/// A later line with the same key replaces an earlier one.
+/// </remarks>
+internal static class JavaProperties
+{
+ /// <summary>Reads the properties of a text.</summary>
+ /// <param name="text">The text of the file, already decoded.</param>
+ /// <returns>The properties by key.</returns>
+ /// <exception cref="FormatException">A <c>\u</c> escape is not followed by four hexadecimal digits.</exception>
+ public static IReadOnlyDictionary<string, string> Parse(string text)
+ {
+ Dictionary<string, string> properties = new(StringComparer.Ordinal);
+ foreach (string line in LogicalLines(text))
+ {
+ int index = 0;
+ while (index < line.Length && IsBlank(line[index]))
+ {
+ index++;
+ }
+
+ if (index == line.Length || line[index] is '#' or '!')
+ {
+ continue;
+ }
+
+ int keyStart = index;
+ while (index < line.Length && !IsKeyEnd(line[index]))
+ {
+ index += line[index] == '\\' ? 2 : 1;
+ }
+
+ string key = Unescape(line[keyStart..Math.Min(index, line.Length)]);
+ while (index < line.Length && IsBlank(line[index]))
+ {
+ index++;
+ }
+
+ if (index < line.Length && line[index] is '=' or ':')
+ {
+ index++;
+ while (index < line.Length && IsBlank(line[index]))
+ {
+ index++;
+ }
+ }
+
+ properties[key] = Unescape(line[index..]);
+ }
+
+ return properties;
+ }
+
+ /// <summary>Joins the physical lines that a trailing backslash continues.</summary>
+ private static IEnumerable<string> LogicalLines(string text)
+ {
+ string[] lines = text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
+ StringBuilder current = new();
+ bool continuing = false;
+ foreach (string physical in lines)
+ {
+ // A continued line loses the white space it starts with.
+ string line = continuing ? physical.TrimStart(' ', '\t', '\f') : physical;
+ int backslashes = 0;
+ for (int position = line.Length - 1; position >= 0 && line[position] == '\\'; position--)
+ {
+ backslashes++;
+ }
+
+ continuing = backslashes % 2 == 1;
+ current.Append(continuing ? line[..^1] : line);
+ if (!continuing)
+ {
+ yield return current.ToString();
+ current.Clear();
+ }
+ }
+
+ if (current.Length > 0)
+ {
+ yield return current.ToString();
+ }
+ }
+
+ private static bool IsBlank(char character) => character is ' ' or '\t' or '\f';
+
+ private static bool IsKeyEnd(char character) => character is '=' or ':' || IsBlank(character);
+
+ private static string Unescape(string text)
+ {
+ StringBuilder result = new(text.Length);
+ for (int index = 0; index < text.Length; index++)
+ {
+ char character = text[index];
+ if (character != '\\' || index + 1 == text.Length)
+ {
+ result.Append(character);
+ continue;
+ }
+
+ char escaped = text[++index];
+ switch (escaped)
+ {
+ case 't':
+ result.Append('\t');
+ break;
+ case 'n':
+ result.Append('\n');
+ break;
+ case 'r':
+ result.Append('\r');
+ break;
+ case 'f':
+ result.Append('\f');
+ break;
+ case 'u':
+ if (text.Length - index - 1 < 4 ||
+ !int.TryParse(
+ text.AsSpan(index + 1, 4),
+ NumberStyles.AllowHexSpecifier,
+ CultureInfo.InvariantCulture,
+ out int code))
+ {
+ throw new FormatException("A \\u escape needs four hexadecimal digits.");
+ }
+
+ result.Append((char)code);
+ index += 4;
+ break;
+ default:
+ result.Append(escaped);
+ break;
+ }
+ }
+
+ return result.ToString();
+ }
+}
diff --git a/apps/api/ViGet/PostgresTazelPluginCatalog.cs b/apps/api/ViGet/PostgresTazelPluginCatalog.cs
new file mode 100644
index 0000000..07fa86f
--- /dev/null
+++ b/apps/api/ViGet/PostgresTazelPluginCatalog.cs
@@ -0,0 +1,309 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using Npgsql;
+
+namespace Progmasoft.Progmaweb.Api.ViGet;
+
+/// <summary>The tables of <see cref="PostgresTazelPluginCatalog"/> and the steps that bring a database to them.</summary>
+/// <remarks>
+/// The same numbered steps as the account tables, in a version table of their own, <c>viget_schema_versions</c>.
+/// A step is never edited after it has been released.
+/// </remarks>
+internal static class ViGetSchema
+{
+ /// <summary>Key of the advisory lock that lets one process at a time change these tables.</summary>
+ private const long MigrationLockKey = 0x5669476574;
+
+ private static readonly string[] Steps =
+ [
+ // 1: Tazel plugins and their versions.
+ //
+ // A plugin is named by its publisher and its name as they are spelled; the unique index on their upper
+ // case keeps two plugins from differing only in letter case. A version is never removed, only yanked.
+ """
+ CREATE TABLE viget_tazel_plugins (
+ publisher text NOT NULL,
+ name text NOT NULL,
+ description text,
+ authors text[] NOT NULL,
+ license text,
+ homepage text,
+ repository text,
+ updated_at timestamptz NOT NULL,
+ PRIMARY KEY (publisher, name)
+ );
+
+ CREATE UNIQUE INDEX viget_tazel_plugins_folded_key ON viget_tazel_plugins (upper(publisher), upper(name));
+
+ CREATE TABLE viget_tazel_plugin_versions (
+ publisher text NOT NULL,
+ name text NOT NULL,
+ version text NOT NULL,
+ sha256 text NOT NULL,
+ api_version integer NOT NULL,
+ size bigint NOT NULL,
+ published_at timestamptz NOT NULL,
+ yanked boolean NOT NULL DEFAULT false,
+ PRIMARY KEY (publisher, name, version),
+ FOREIGN KEY (publisher, name) REFERENCES viget_tazel_plugins (publisher, name)
+ );
+ """
+ ];
+
+ /// <summary>The version a database has after every step.</summary>
+ public static int CurrentVersion => Steps.Length;
+
+ /// <summary>Takes the steps a database has not taken yet, in one transaction.</summary>
+ /// <returns>The number of steps that were taken.</returns>
+ /// <exception cref="InvalidOperationException">The database has taken steps this program does not know.</exception>
+ public static async Task<int> ApplyAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken)
+ {
+ await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken);
+ await using NpgsqlTransaction transaction = await connection.BeginTransactionAsync(cancellationToken);
+ await ExecuteAsync(connection, transaction, $"SELECT pg_advisory_xact_lock({MigrationLockKey})", cancellationToken);
+ await ExecuteAsync(
+ connection,
+ transaction,
+ """
+ CREATE TABLE IF NOT EXISTS viget_schema_versions (
+ version integer PRIMARY KEY,
+ applied_at timestamptz NOT NULL DEFAULT now()
+ )
+ """,
+ cancellationToken);
+
+ int version;
+ await using (NpgsqlCommand read = new(
+ "SELECT coalesce(max(version), 0) FROM viget_schema_versions", connection, transaction))
+ {
+ version = (int)(await read.ExecuteScalarAsync(cancellationToken))!;
+ }
+
+ if (version > Steps.Length)
+ {
+ throw new InvalidOperationException(
+ $"The ViGet tables are at schema version {version}, and this program knows version {Steps.Length}.");
+ }
+
+ int taken = 0;
+ for (int step = version; step < Steps.Length; step++)
+ {
+ await ExecuteAsync(connection, transaction, Steps[step], cancellationToken);
+ await using NpgsqlCommand record = new(
+ "INSERT INTO viget_schema_versions (version) VALUES ($1)", connection, transaction);
+ record.Parameters.AddWithValue(step + 1);
+ await record.ExecuteNonQueryAsync(cancellationToken);
+ taken++;
+ }
+
+ await transaction.CommitAsync(cancellationToken);
+ return taken;
+ }
+
+ private static async Task ExecuteAsync(
+ NpgsqlConnection connection,
+ NpgsqlTransaction transaction,
+ string sql,
+ CancellationToken cancellationToken)
+ {
+ await using NpgsqlCommand command = new(sql, connection, transaction);
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ }
+}
+
+/// <summary>The Tazel plugins of ViGet in PostgreSQL, beside the accounts.</summary>
+/// <param name="dataSource">Connections to the database of the accounts.</param>
+internal sealed class PostgresTazelPluginCatalog(NpgsqlDataSource dataSource) : ITazelPluginCatalog
+{