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

summaryrefslogtreecommitdiffhomepage
path: root/apps/api
diff options
context:
space:
mode:
authorLeitwolf2026-10-29 13:08:07 +0300
committerGitHub2026-10-29 13:08:07 +0300
commit4b414909ac818a9178d72ffa6e1c396d5fa27a59 (patch)
tree9801dc805a397052264cc5bc0a6c56cf870b179a /apps/api
parentf22418235a0a87bc05b3bf9079fcdefba002d3b2 (diff)
parent285e1024da64234b30b0fa425ffd6bf348119db4 (diff)
downloadprogmaweb-4b414909ac818a9178d72ffa6e1c396d5fa27a59.tar.gz
Merge pull request #47 from Progmasoft/feature/viget-tazel-plugins
Add the Tazel plugin catalog of ViGet and open the Gradle catalog
Diffstat (limited to 'apps/api')
-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.cs155
-rw-r--r--apps/api/ViGet/PostgresTazelPluginCatalog.cs328
-rw-r--r--apps/api/ViGet/TazelPluginCatalog.cs179
-rw-r--r--apps/api/ViGet/TazelPluginDescriptor.cs138
-rw-r--r--apps/api/ViGet/TazelPluginPublishing.cs151
-rw-r--r--apps/api/ViGet/ViGetCommand.cs85
-rw-r--r--apps/api/ViGet/ViGetEndpoints.cs113
-rw-r--r--apps/api/ViGet/ViGetRegistration.cs58
12 files changed, 1248 insertions, 1 deletions
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..1b42f20
--- /dev/null
+++ b/apps/api/ViGet/JavaProperties.cs
@@ -0,0 +1,155 @@
+// 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();
+ }
+ }
+
+ /// <summary>Whether a character is white space of the format: space, tab or form feed.</summary>
+ private static bool IsBlank(char character) => character is ' ' or '\t' or '\f';
+
+ /// <summary>Whether an unescaped character ends a key.</summary>
+ private static bool IsKeyEnd(char character) => character is '=' or ':' || IsBlank(character);
+
+ /// <summary>Resolves the escapes of a key or a value.</summary>
+ /// <param name="text">The text as the file writes it.</param>
+ /// <returns>The text it stands for.</returns>
+ /// <exception cref="FormatException">A <c>\u</c> escape is not followed by four hexadecimal digits.</exception>
+ 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..9ae20b0
--- /dev/null
+++ b/apps/api/ViGet/PostgresTazelPluginCatalog.cs
@@ -0,0 +1,328 @@
+// 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;
+
+ /// <summary>The steps, in order. The version of a step is its position, counted from one.</summary>
+ 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;
+ }
+
+ /// <summary>Runs statements that return nothing.</summary>
+ /// <param name="connection">The connection to run them on.</param>
+ /// <param name="transaction">The transaction they belong to.</param>
+ /// <param name="sql">The statements.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>A task that completes when the statements have run.</returns>
+ 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
+{
+ /// <summary>The columns of a plugin row, in the order <see cref="ReadPlugin"/> reads them.</summary>
+ private const string PluginColumns = "publisher, name, description, authors, license, homepage, repository";
+
+ /// <inheritdoc/>
+ public async ValueTask<IReadOnlyList<TazelPlugin>> ListAsync(CancellationToken cancellationToken)
+ {
+ await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken);
+ List<TazelPlugin> plugins = [];
+ await using (NpgsqlCommand command = new(
+ $"SELECT {PluginColumns} FROM viget_tazel_plugins ORDER BY publisher, name", connection))
+ await using (NpgsqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken))
+ {
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ plugins.Add(ReadPlugin(reader, []));
+ }
+ }
+
+ List<TazelPlugin> withVersions = [];
+ foreach (TazelPlugin plugin in plugins)
+ {
+ withVersions.Add(plugin with
+ {
+ Versions = await VersionsAsync(connection, null, plugin.Publisher, plugin.Name, cancellationToken)
+ });
+ }
+
+ return withVersions;
+ }
+
+ /// <inheritdoc/>
+ public async ValueTask<TazelPlugin?> FindAsync(string publisher, string name, CancellationToken cancellationToken)
+ {
+ await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken);
+ TazelPlugin? plugin;
+ await using (NpgsqlCommand command = new(
+ $"SELECT {PluginColumns} FROM viget_tazel_plugins WHERE publisher = $1 AND name = $2", connection))
+ {
+ command.Parameters.AddWithValue(publisher);
+ command.Parameters.AddWithValue(name);
+ await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
+ plugin = await reader.ReadAsync(cancellationToken) ? ReadPlugin(reader, []) : null;
+ }
+
+ return plugin is null
+ ? null
+ : plugin with { Versions = await VersionsAsync(connection, null, publisher, name, cancellationToken) };
+ }
+
+ /// <inheritdoc/>
+ public async ValueTask<AddVersionResult> AddVersionAsync(
+ TazelPluginDescriptor descriptor,
+ string sha256,
+ long size,
+ DateTimeOffset publishedAt,
+ CancellationToken cancellationToken)
+ {
+ await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken);
+ await using NpgsqlTransaction transaction = await connection.BeginTransactionAsync(cancellationToken);
+ await using (NpgsqlCommand taken = new(
+ """
+ SELECT count(*) FROM viget_tazel_plugins
+ WHERE upper(publisher) = upper($1) AND upper(name) = upper($2) AND (publisher <> $1 OR name <> $2)
+ """,
+ connection,
+ transaction))
+ {
+ taken.Parameters.AddWithValue(descriptor.Publisher);
+ taken.Parameters.AddWithValue(descriptor.Name);
+ if ((long)(await taken.ExecuteScalarAsync(cancellationToken))! > 0)
+ {
+ return AddVersionResult.NameTaken;
+ }
+ }
+
+ await using (NpgsqlCommand plugin = new(
+ $"""
+ INSERT INTO viget_tazel_plugins ({PluginColumns}, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ ON CONFLICT (publisher, name) DO UPDATE SET
+ description = excluded.description,
+ authors = excluded.authors,
+ license = excluded.license,
+ homepage = excluded.homepage,
+ repository = excluded.repository,
+ updated_at = excluded.updated_at
+ """,
+ connection,
+ transaction))
+ {
+ plugin.Parameters.AddWithValue(descriptor.Publisher);
+ plugin.Parameters.AddWithValue(descriptor.Name);
+ plugin.Parameters.AddWithValue((object?)descriptor.Description ?? DBNull.Value);
+ plugin.Parameters.AddWithValue(descriptor.Authors.ToArray());
+ plugin.Parameters.AddWithValue((object?)descriptor.License ?? DBNull.Value);
+ plugin.Parameters.AddWithValue((object?)descriptor.Homepage ?? DBNull.Value);
+ plugin.Parameters.AddWithValue((object?)descriptor.Repository ?? DBNull.Value);
+ plugin.Parameters.AddWithValue(publishedAt);
+ await plugin.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ await using (NpgsqlCommand version = new(
+ """
+ INSERT INTO viget_tazel_plugin_versions (publisher, name, version, sha256, api_version, size, published_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ ON CONFLICT (publisher, name, version) DO NOTHING
+ """,
+ connection,
+ transaction))
+ {
+ version.Parameters.AddWithValue(descriptor.Publisher);
+ version.Parameters.AddWithValue(descriptor.Name);
+ version.Parameters.AddWithValue(descriptor.Version);
+ version.Parameters.AddWithValue(sha256);
+ version.Parameters.AddWithValue(descriptor.ApiVersion);
+ version.Parameters.AddWithValue(size);
+ version.Parameters.AddWithValue(publishedAt);
+ if (await version.ExecuteNonQueryAsync(cancellationToken) == 0)
+ {
+ // The plugin row is left as it was: the transaction is rolled back with the refusal.
+ return AddVersionResult.VersionExists;
+ }
+ }
+
+ await transaction.CommitAsync(cancellationToken);
+ return AddVersionResult.Added;
+ }
+
+ /// <inheritdoc/>
+ public async ValueTask<bool> SetYankedAsync(
+ string publisher,
+ string name,
+ string version,
+ bool yanked,
+ CancellationToken cancellationToken)
+ {
+ await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken);
+ await using NpgsqlCommand command = new(
+ """
+ UPDATE viget_tazel_plugin_versions SET yanked = $4
+ WHERE publisher = $1 AND name = $2 AND version = $3
+ """,
+ connection);
+ command.Parameters.AddWithValue(publisher);
+ command.Parameters.AddWithValue(name);
+ command.Parameters.AddWithValue(version);
+ command.Parameters.AddWithValue(yanked);
+ return await command.ExecuteNonQueryAsync(cancellationToken) == 1;
+ }
+
+ /// <summary>A plugin from a row of <see cref="PluginColumns"/>.</summary>
+ /// <param name="reader">A reader on the row.</param>
+ /// <param name="versions">The versions of the plugin.</param>
+ /// <returns>The plugin.</returns>
+ private static TazelPlugin ReadPlugin(NpgsqlDataReader reader, IReadOnlyList<TazelPluginVersion> versions) => new(
+ reader.GetString(0),
+ reader.GetString(1),
+ reader.IsDBNull(2) ? null : reader.GetString(2),
+ reader.GetFieldValue<string[]>(3),
+ reader.IsDBNull(4) ? null : reader.GetString(4),
+ reader.IsDBNull(5) ? null : reader.GetString(5),
+ reader.IsDBNull(6) ? null : reader.GetString(6),
+ versions);
+
+ /// <summary>The versions of a plugin, newest publication first.</summary>
+ /// <param name="connection">The connection to read on.</param>
+ /// <param name="transaction">The transaction to read in, if any.</param>
+ /// <param name="publisher">The publisher of the plugin.</param>
+ /// <param name="name">The name of the plugin.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>The versions.</returns>
+ private static async Task<IReadOnlyList<TazelPluginVersion>> VersionsAsync(
+ NpgsqlConnection connection,
+ NpgsqlTransaction? transaction,
+ string publisher,
+ string name,
+ CancellationToken cancellationToken)
+ {
+ await using NpgsqlCommand command = new(
+ """
+ SELECT version, sha256, api_version, size, published_at, yanked FROM viget_tazel_plugin_versions
+ WHERE publisher = $1 AND name = $2 ORDER BY published_at DESC, version DESC
+ """,
+ connection,
+ transaction);
+ command.Parameters.AddWithValue(publisher);
+ command.Parameters.AddWithValue(name);
+ await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
+ List<TazelPluginVersion> versions = [];
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ versions.Add(new TazelPluginVersion(
+ reader.GetString(0),
+ reader.GetString(1),
+ reader.GetInt32(2),
+ reader.GetInt64(3),
+ reader.GetFieldValue<DateTimeOffset>(4),
+ reader.GetBoolean(5)));
+ }
+
+ return versions;
+ }
+}
diff --git a/apps/api/ViGet/TazelPluginCatalog.cs b/apps/api/ViGet/TazelPluginCatalog.cs
new file mode 100644
index 0000000..ea7ff7a
--- /dev/null
+++ b/apps/api/ViGet/TazelPluginCatalog.cs
@@ -0,0 +1,179 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+namespace Progmasoft.Progmaweb.Api.ViGet;
+
+/// <summary>One published version of a Tazel plugin.</summary>
+/// <param name="Version">The version.</param>
+/// <param name="Sha256">The SHA-256 digest of its JAR, in lower-case hex.</param>
+/// <param name="ApiVersion">The plugin API of Tazel it was built for.</param>
+/// <param name="Size">The size of its JAR in bytes.</param>
+/// <param name="PublishedAt">When it was published.</param>
+/// <param name="Yanked">Whether it was yanked: Tazel takes it only when a project names it.</param>
+internal sealed record TazelPluginVersion(
+ string Version,
+ string Sha256,
+ int ApiVersion,
+ long Size,
+ DateTimeOffset PublishedAt,
+ bool Yanked);
+
+/// <summary>A Tazel plugin of the catalog, described by the descriptor of its newest publication.</summary>
+/// <param name="Publisher">The Progmasoft Account it is published under.</param>
+/// <param name="Name">Its name.</param>
+/// <param name="Description">What it does.</param>
+/// <param name="Authors">Its authors.</param>
+/// <param name="License">An SPDX license expression.</param>
+/// <param name="Homepage">An https address of the plugin.</param>
+/// <param name="Repository">An https address of its sources.</param>
+/// <param name="Versions">Its versions, newest publication first.</param>
+internal sealed record TazelPlugin(
+ string Publisher,
+ string Name,
+ string? Description,
+ IReadOnlyList<string> Authors,
+ string? License,
+ string? Homepage,
+ string? Repository,
+ IReadOnlyList<TazelPluginVersion> Versions)
+{
+ /// <summary>The newest version that is not yanked, or the newest of all when every one is.</summary>
+ public TazelPluginVersion Latest =>
+ Versions.Where(version => !version.Yanked).DefaultIfEmpty(Versions[0]).First();
+}
+
+/// <summary>What adding a version to the catalog did.</summary>
+internal enum AddVersionResult
+{
+ /// <summary>The version was added.</summary>
+ Added,
+
+ /// <summary>The plugin has this version already; a version is never replaced.</summary>
+ VersionExists,
+
+ /// <summary>A plugin of the publisher has the same name in other letter case.</summary>
+ NameTaken,
+}
+
+/// <summary>The Tazel plugins of ViGet: their descriptions and versions. The JARs live in files beside it.</summary>
+internal interface ITazelPluginCatalog
+{
+ /// <summary>Every plugin, ordered by publisher and name.</summary>
+ ValueTask<IReadOnlyList<TazelPlugin>> ListAsync(CancellationToken cancellationToken);
+
+ /// <summary>A plugin by its exact publisher and name.</summary>
+ ValueTask<TazelPlugin?> FindAsync(string publisher, string name, CancellationToken cancellationToken);
+
+ /// <summary>Adds a version, and describes the plugin with the descriptor of that version.</summary>
+ ValueTask<AddVersionResult> AddVersionAsync(
+ TazelPluginDescriptor descriptor,
+ string sha256,
+ long size,
+ DateTimeOffset publishedAt,
+ CancellationToken cancellationToken);
+
+ /// <summary>Yanks a version, or takes the yank back.</summary>
+ /// <returns>Whether the plugin has the version.</returns>
+ ValueTask<bool> SetYankedAsync(
+ string publisher,
+ string name,
+ string version,
+ bool yanked,
+ CancellationToken cancellationToken);
+}
+
+/// <summary>A catalog that lives as long as the process, for development and tests.</summary>
+internal sealed class InMemoryTazelPluginCatalog : ITazelPluginCatalog
+{
+ /// <summary>Guards <see cref="plugins"/>.</summary>
+ private readonly Lock gate = new();
+ /// <summary>The plugins by their exact publisher and name.</summary>
+ private readonly Dictionary<(string Publisher, string Name), TazelPlugin> plugins = [];
+
+ /// <inheritdoc/>
+ public ValueTask<IReadOnlyList<TazelPlugin>> ListAsync(CancellationToken cancellationToken)
+ {
+ lock (gate)
+ {
+ return ValueTask.FromResult<IReadOnlyList<TazelPlugin>>(plugins.Values
+ .OrderBy(plugin => plugin.Publisher, StringComparer.Ordinal)
+ .ThenBy(plugin => plugin.Name, StringComparer.Ordinal)
+ .ToList());
+ }
+ }
+
+ /// <inheritdoc/>
+ public ValueTask<TazelPlugin?> FindAsync(string publisher, string name, CancellationToken cancellationToken)
+ {
+ lock (gate)
+ {
+ return ValueTask.FromResult(plugins.GetValueOrDefault((publisher, name)));
+ }
+ }
+
+ /// <inheritdoc/>
+ public ValueTask<AddVersionResult> AddVersionAsync(
+ TazelPluginDescriptor descriptor,
+ string sha256,
+ long size,
+ DateTimeOffset publishedAt,
+ CancellationToken cancellationToken)
+ {
+ lock (gate)
+ {
+ (string, string) key = (descriptor.Publisher, descriptor.Name);
+ if (!plugins.ContainsKey(key) && plugins.Keys.Any(existing =>
+ string.Equals(existing.Publisher, descriptor.Publisher, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(existing.Name, descriptor.Name, StringComparison.OrdinalIgnoreCase)))
+ {
+ return ValueTask.FromResult(AddVersionResult.NameTaken);
+ }
+
+ List<TazelPluginVersion> versions = plugins.TryGetValue(ke