Skip to content

Migrate the license server to .NET 10 - #7

Open
Gaël Fraiteur (gfraiteur) wants to merge 42 commits into
masterfrom
topic/net10-migration
Open

Gaël Fraiteur (gfraiteur) wants to merge 42 commits into
masterfrom
topic/net10-migration

Conversation

@gfraiteur

Copy link
Copy Markdown
Member

Summary

  • Migrates from .NET Framework 4.7.2 to .NET 10. ASP.NET WebForms and LINQ to SQL do not exist there, so the presentation and persistence layers are rewritten as Razor Pages and EF Core, while the licensing rules are ported unchanged.
  • Splits the code into PostSharp.LicenseServer.Core (the rules and the data model), PostSharp.LicenseServer.Web (the pages, the endpoints and the composition root) and a test project. Folder renames were committed without content changes, so git log --follow still works.
  • Replaces the usage graph. It loaded YUI 3.18.1 over plain http:// from a CDN retired in 2014, which any HTTPS deployment blocked as mixed content. It now uses Chart.js, served from the application itself so an isolated network needs no outbound access, with its data passed as JSON rather than concatenated into a script body.
  • Makes the database engine selectable through LicenseServer:DatabaseProvider: SQL Server for a production installation, SQLite for evaluation and for the test suite. The integration tests host the real application against a database held in memory, so they exercise production routing, binding, authorization and endpoints rather than a parallel arrangement.
  • Makes the engine testable by injecting what used to be ambient statics: the settings, the clock, the license parser, the email sender, the request lock and the audit signer. 193 tests, none of which need SQL Server.
  • Replaces the MSBuild Zip target, which needed a Visual Studio web publishing extension, with eng/Package.ps1. It produces the same PostSharp.LicenseServer.zip; publishing for the Windows runtime keeps the Linux and macOS native libraries out of it, taking it from 47 MB to 12 MB.

Defects found while porting

  • The audit signature never worked. GetSignature called the parameterless HMAC.Create(), which throws on .NET 5 and later and which, on .NET Framework, returned an HMAC-SHA1 under a randomly generated key for every call. The chain was therefore never verifiable by anyone. It is now HMAC-SHA256 under a key generated on first start and kept in App_Data\audit-signing.key.
  • Foreign keys are populated later by EF Core than by LINQ to SQL. A lease is signed before it is tracked, so a naive port would silently have signed the license and overwritten-lease fields as empty. They are now assigned explicitly, with two regression tests.
  • Audit timestamps were shifted by the server's UTC offset, because values SQL Server returns as Unspecified were serialized as if they were UTC.

Each of these is covered by a test that was checked by reintroducing the defect and confirming that only the intended tests fail. The same was done for the Close-before-Open ordering of LeaseCountingPointKind and for the schema mappings.

Compatibility

The database schema is unchanged, so an existing database is used as it is, with no migration step. There are deliberately no EF migrations: CreateTables.sql remains the source of truth, and SchemaCompatibilityTests stands in for one by generating the DDL from the model and holding it to the column types, key generation and constraint names that script produces.

Four things do change for somebody upgrading, all covered in the README:

  • The ASP.NET Core Hosting Bundle is now required. This is the one prerequisite the previous version did not have, and the most visible change for customers.
  • Settings move from Web.config to appsettings.json, keeping their names. See docs/configuration.md.
  • Connection strings may need Encrypt=False, because the modern SQL client encrypts by default.
  • Exported audit timestamps are now correct on servers that do not run in UTC, so they differ from earlier exports by the server's offset.

Two behavioural notes:

  • The request lock narrows from machine-wide to process-wide. That is correct for the supported deployment of one worker process per database, but a web garden or a multi-node setup could over-allocate. LeaseLockMode.SqlApplicationLock is reserved for that case and currently fails with a clear message rather than misbehaving quietly.
  • Administrative pages and lease requests both stay open by default, as they shipped before, so that an upgrade cannot lock an administrator out of their own server. They can now be restricted through LicenseServer:AdminRoles, and the server warns at startup while they are not.

The load simulator is ported so that it builds, but stays disabled: it needs a client that can download a lease, which is being written in SharpCrafters.Backstage.

🤖 Generated with Claude Code

Introduce global.json, nuget.config, Directory.Build.props and
Directory.Packages.props in preparation for the migration to .NET 10, and
stop tracking IDE state (.idea, .vs, *.user) and the checked-in nuget.exe,
which `dotnet restore` replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Move src/PostSharp.LicenseServer to src/PostSharp.LicenseServer.Web and
test/PostSharp.LicenseServer.Test to tests/PostSharp.LicenseServer.Simulator,
reflecting what each project is: a web front end and a load simulator.

This commit contains no content changes, so that Git records the moves as
renames and `git log --follow` keeps working across the migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add PostSharp.LicenseServer.Core, which holds the lease allocation rules that
were previously spread across the LINQ to SQL data context and the WebForms
handlers, and a test project that exercises them against an in-memory SQLite
database.

The ambient statics that made the engine untestable are now injected: the
settings, the clock, the license parser, the email sender, the request lock and
the audit signer all sit behind interfaces, with in-memory implementations for
tests.

Three defects surfaced while porting:

- The audit signature was computed with the parameterless HMAC.Create(), which
  throws on .NET 5 and later and which, on .NET Framework, used a randomly
  generated key for every call. The chain was never verifiable. It is now
  HMAC-SHA256 under a persisted key.
- Audit timestamps were serialized as UTC from values SQL Server returns as
  Unspecified, which shifted them by the server's UTC offset. The kind is now
  set when the value is read.
- Foreign keys were populated by the LINQ to SQL navigation setters before a
  lease was signed. EF Core does not do this, so they are assigned explicitly;
  otherwise the license and overwritten-lease fields would silently be signed
  as empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add 130 tests over the lease allocation rules, the audit log and the
configuration, running against an in-memory SQLite database so the suite needs
no SQL Server.

Two invariants that a reader of the diff could not see are pinned deliberately,
and both were checked by reintroducing the defect and confirming that only the
intended tests fail:

- The foreign keys of a lease must be assigned before it is signed, because
  EF Core populates them later than LINQ to SQL did.
- Close must sort before Open at the same instant, which is what the values of
  LeaseCountingPointKind encode, otherwise a machine handed from one lease to
  the next is briefly counted twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rewrite the seven .aspx pages and the master page as Razor Pages, and the three
.ashx handlers as minimal API endpoints. The handler URLs are kept literally,
because deployed PostSharp clients address them directly, as are the status
codes and response bodies they check. The old page URLs now redirect
permanently to the new ones.

The usage graph no longer loads YUI 3.18.1 over plain HTTP from a content
delivery network retired in 2014, which any HTTPS deployment blocked as mixed
content. It uses Chart.js, served from the application itself so an isolated
network needs no outbound access. Its data travels as JSON in its own element
rather than being concatenated into a script body.

Incidental fixes the rewrite made possible:

- The graph rejects an unknown license and an out-of-range window instead of
  answering 500.
- The demo data generator is confined to a development environment. It was
  previously reachable in production, on a deployment whose administrative
  pages are unrestricted by default.
- Administrative pages can be restricted to Windows groups through
  LicenseServer:AdminRoles. The default stays open, as it shipped before, but
  now says so in the log at startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The engine is chosen by LicenseServer:DatabaseProvider, which accepts SqlServer
(the default, and the supported engine for a production installation) or
Sqlite. SQLite lets the server be evaluated without SQL Server, and lets the
test suite host the real application against a database held in memory.

The integration tests therefore exercise the production pipeline -- routing,
model binding, authorization, the endpoints and the pages -- rather than a
parallel arrangement built for testing. Only the license parser, the email
sender, the audit key and Windows authentication are substituted, because they
need a signed license key, an SMTP server, a key file and a domain controller
respectively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the MSBuild Zip target, which depended on a Visual Studio web
publishing extension and rewrote web.config at packaging time, with
eng/Package.ps1. It runs the tests, publishes and produces the same
PostSharp.LicenseServer.zip artifact. Publishing for the Windows runtime keeps
the Linux and macOS native libraries out of the package, which takes it from
47 MB to 12 MB.

Port the load simulator so that it builds against .NET 10, and leave it
disabled: it needs a client that can download a lease, which is being written
in SharpCrafters.Backstage. The reason is now recorded where the throw is.

Rewrite the README, which still described Visual Studio 2015, and move the
settings documentation out of the XML comment it lived in and into
docs/configuration.md. Both state the four things that change for somebody
upgrading: the hosting bundle is now required, settings moved to
appsettings.json, connection strings may need Encrypt=False, and audit
timestamps are no longer shifted on servers that do not run in UTC.

The pages follow the reader's colour scheme, rather than rendering dark text
on whatever background the browser chooses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There are no EF migrations, because CreateTables.sql is the source of truth and
the server never creates or alters a SQL Server schema. These tests stand in
for one: they generate the DDL from the model and check it against what
CreateTables.sql produces -- the text and datetime column types, which
identifier the application assigns and which the database generates, the
constraint names, and the absence of cascading deletes.

Without them a mapping could drift silently and be discovered by a customer, on
their data. Verified by reintroducing the two mappings' absence and confirming
that exactly the tests guarding them fail.

No server is contacted, so the checks run anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread src/PostSharp.LicenseServer.Web/Endpoints/LicenseServerEndpoints.cs Fixed
Comment thread src/PostSharp.LicenseServer.Web/Endpoints/LicenseServerEndpoints.cs Fixed
Take the design tokens, the logo and the page chrome from the PostSharp website
(metalama-website) so that the license server looks like the rest of the
product rather than like a 2011 administration page.

- The tokens are copied from _sass/0-base/_tokens.scss as plain custom
  properties, so no build step is needed: the purple and cyan accents, the dark
  surfaces, the type scale and the radii.
- The header reproduces the website's company bar: the deepest surface, 64px
  tall, with the PostSharp logo on the left. The footer carries the same legal
  line as the website.
- Buttons follow the brand's signature: sharp, uppercase, two-pixel border.
  Headings are weight 400, because size does the work.
- The usage graph moves onto the brand palette and takes its axis colours from
  the tokens, so it is part of the page rather than a white box dropped on it.

The brand is dark-first and has no light palette, so neither has this any
more.

Monosten Pro, the website's display face, is deliberately NOT copied. It is a
commercially licensed webfont marked "do not redistribute", licensed to the
metalama.net owner, and this application is published under the MIT license and
runs on customers' own servers. It is named first in the font stack and falls
back to a monospace face, which keeps the character of the headings without
redistributing anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The application already targeted .NET 10, but three things still assumed
Windows. Each is now a choice rather than an assumption.

- Authentication was IIS or Negotiate. It is now IISIntegrated, Negotiate or
  None, so a host with no domain to authenticate against can still serve
  leases; they are recorded without a user name, exactly as an anonymous
  request already was. Left unset, the scheme is detected from how the
  application is hosted, and which one was chosen is logged at startup,
  because guessing wrong is quiet rather than loud.
- The release package was published for win-x64. It is now portable and runs
  wherever the .NET 10 runtime does. That takes it from 12 MB to 48 MB, which
  is the cost of carrying every platform's native libraries in one artifact.
- The simulator imported Microsoft.Win32 for a type that comes from the
  PostSharp SDK.

Add a container deployment: a Dockerfile, and a compose file that starts the
server, a SQL Server database and a job that creates the schema from
CreateTables.sql. It is a test deployment, and docs/docker.md says plainly
which four things make it one.

Add .gitattributes, so the working tree is the same on every platform.

Verified by running the stack: the schema job creates the database from the
unmodified CreateTables.sql, and the server answers on Linux against real SQL
Server 2022. That also exercises the rewritten queries -- the anti-join, the
seat-count grouping and the counting-point timeline -- on SQL Server for the
first time, where they had previously only run on SQLite, and confirms that
timestamps come back tagged as UTC from the engine that returns them
unspecified.

The audit-log timestamp test no longer asks for Windows time zone identifiers,
and now proves what it was meant to: that a lease which has been through the
database still serializes as UTC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Audit integrity, build-server validation, export scalability, redirect compatibility, and grace-limit defects remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Migrates the license server from .NET Framework/WebForms/LINQ to SQL to .NET 10, Razor Pages, and EF Core while preserving its database and client protocols.

Changes:

  • Splits licensing logic, web hosting, tests, and simulator into SDK-style projects.
  • Adds selectable SQL Server/SQLite persistence, dependency injection, audit signing, authentication, and comprehensive tests.
  • Replaces legacy WebForms UI, YUI graphing, packaging, and deployment infrastructure.
File summaries
File Description
src/PostSharp.LicenseServer.Core/ New licensing, persistence, locking, email, security, options, and time abstractions.
src/PostSharp.LicenseServer.Web/ New ASP.NET Core host, endpoints, Razor Pages, configuration, database script, and static assets.
tests/PostSharp.LicenseServer.Tests/ Unit and integration coverage with SQLite and test doubles.
tests/PostSharp.LicenseServer.Simulator/ Ported manual load simulator.
src/PostSharp.LicenseServer/ Removed legacy WebForms and LINQ-to-SQL implementation.
test/PostSharp.LicenseServer.Test/ Removed legacy .NET Framework simulator configuration.
Directory.Build.props Defines common .NET project settings and metadata.
Directory.Packages.props Centrally defines package versions.
PostSharp.LicenseServer.slnx Defines the new solution structure.
global.json Pins the .NET 10 SDK feature band.
nuget.config Configures NuGet.org as the package source.
Dockerfile Adds the Linux container build.
docker-compose.yml Adds a test SQL Server deployment.
eng/Package.ps1 Adds release publishing and ZIP packaging.
README.md Documents installation and upgrade procedures.
docs/configuration.md Documents runtime configuration.
docs/docker.md Documents container deployment.
.dockerignore Excludes unnecessary Docker build context.
.gitattributes Standardizes text and binary handling.
.gitignore Ignores new build, database, and IDE artifacts.
.claude/launch.json Adds a local launch configuration.
.idea/ Removes tracked Rider metadata.
PostSharp.LicenseServer.sln.DotSettings.user Removes user-specific IDE settings.
Review details

Files not reviewed (15)

  • .idea/.idea.PostSharp.LicenseServer/.idea/.gitignore: Generated file
  • .idea/.idea.PostSharp.LicenseServer/.idea/encodings.xml: Generated file
  • .idea/.idea.PostSharp.LicenseServer/.idea/indexLayout.xml: Generated file
  • .idea/.idea.PostSharp.LicenseServer/.idea/vcs.xml: Generated file
  • .idea/config/applicationhost.config: Generated file
  • src/PostSharp.LicenseServer/Admin/AddLicense.aspx.designer.cs: Generated file
  • src/PostSharp.LicenseServer/Admin/Cancel.aspx.designer.cs: Generated file
  • src/PostSharp.LicenseServer/Admin/Details.aspx.designer.cs: Generated file
  • src/PostSharp.LicenseServer/Admin/Export.aspx.designer.cs: Generated file
  • src/PostSharp.LicenseServer/Admin/GenerateDemoData.aspx.designer.cs: Generated file
  • src/PostSharp.LicenseServer/DataClasses.designer.cs: Generated file
  • src/PostSharp.LicenseServer/Default.aspx.designer.cs: Generated file
  • src/PostSharp.LicenseServer/Graph.aspx.designer.cs: Generated file
  • src/PostSharp.LicenseServer/Properties/Settings.Designer.cs: Generated file
  • src/PostSharp.LicenseServer/Site.Master.designer.cs: Generated file
  • Files reviewed: 158/191 changed files
  • Comments generated: 9
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/PostSharp.LicenseServer.Core/Data/LeaseRepository.cs Outdated
Comment thread src/SharpCrafters.Backstage.LicenseServer.Core/Data/LeaseRepository.cs Outdated
Comment thread src/SharpCrafters.Backstage.LicenseServer.Core/Services/LeaseService.cs Outdated
Comment thread src/PostSharp.LicenseServer.Core/Services/LeaseService.cs Outdated
Comment thread src/PostSharp.LicenseServer.Web/Endpoints/LegacyUrlRedirects.cs Outdated
Comment thread src/PostSharp.LicenseServer.Web/Endpoints/LicenseServerEndpoints.cs Outdated
Comment thread src/PostSharp.LicenseServer.Web/Endpoints/LicenseServerEndpoints.cs Outdated
Comment thread src/PostSharp.LicenseServer.Web/Pages/Admin/Export.cshtml.cs Outdated
Comment thread src/PostSharp.LicenseServer.Web/Pages/Graph.cshtml.cs Outdated
Nine defects were raised on the pull request. Each was real, and each had been
inherited from the legacy implementation rather than introduced by the
migration.

The audit chain was the substantial one, and two findings shared a root cause:
leases were signed before they were inserted. The signed payload therefore
carried a lease identifier of zero while the exported line carried the one the
database assigned, so a signature could not be recomputed from an export; and
leases saved together all chained from the same predecessor, so removing one of
them broke no later signature. Leases are now signed after the insert, in the
order the database assigned, with the insert and the signature sharing a
transaction so that a lease is never readable without its signature. A test
recomputes a whole chain from the exported lines, which is what the chain is
for.

The rest:

- A build agent was served any license whose key merely parsed, so an
  ineligible or expired one could be handed out, and the lease it was given was
  never clamped to the end of the license. Build agents now go through the same
  validation as anybody else, and keep only their exemption from consuming a
  seat.
- A license with no seat limit that reached the grace period dereferenced a
  null maximum and answered 500 instead of denying the request. There is no
  capacity to exceed on such a license, so there is no grace period either.
- Both redirects dropped the path base, so they left the application when it is
  installed below an IIS site root.
- The audit export accepted a year outside the range of a date, passing
  validation and then failing while the date was constructed.
- The audit export built the whole file in memory twice before answering. It is
  written to the response as the rows arrive, as the legacy handler did.
- The usage graph floored the grace capacity where the allocator rounds it up,
  so a one-seat license with 20% grace was drawn as allowing one seat while the
  server granted two.
- The user and machine names reaching the slow-request log are stripped of
  control characters, so they cannot forge a line in a plain-text log.

The build-agent, unlimited-license, export-range and path-base fixes each have
a test, checked by reintroducing the defect and confirming the intended test
fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread src/PostSharp.LicenseServer.Web/Endpoints/LicenseServerEndpoints.cs Fixed
Comment thread src/PostSharp.LicenseServer.Web/Endpoints/LicenseServerEndpoints.cs Fixed
The audit log deliberately reduces user and machine names to hashes, so that an
exported file can be shared without disclosing who works where. Writing those
same names verbatim into the application log contradicted that, and let a
caller forge a line in a plain-text log through the query string.

The log now records the same hash the audit log uses, so an administrator can
still correlate the two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The license server joins the Backstage 2027.0 family as the Backstage.LicenseServer
product, whose repository and packages are named SharpCrafters.Backstage.LicenseServer.
This commit only moves files, so that rename detection works and the next commit shows
the real changes.

The load simulator goes: it compiles only against the PostSharp SDK that the next commit
removes, and SharpCrafters.Backstage already carries LicenseServerLoadSimulator, which
drives this server through its own client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The namespaces, assembly names and the solution follow the folders renamed in the
previous commit. The published entry point becomes
SharpCrafters.Backstage.LicenseServer.dll, which the Dockerfile, the packaging script
and the documentation are updated for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The product is Backstage.LicenseServer of the Backstage 2027.0 family, defined in
PostSharp.Engineering 2023.2.455. `./Build.ps1 build` and `./Build.ps1 test` replace the
direct dotnet invocations, and the TeamCity configuration is generated from the product
definition rather than written by hand.

global.json and nuget.config are now generated by `./Build.ps1 prepare`, so they leave
source control. eng/Package.ps1 goes with them: the release archive is built by the
PackAndZip target of the web project and collected as the public artifact of the product,
which is what the deployment uploads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The PostSharp SDK is replaced by the licensing component of the product family this server
now belongs to. The public API of SharpCrafters.Backstage covers everything the server
reads from a license key, including the rules that derive a value from several fields, so
the parser projects LicenseRegistrationProperties rather than reading the fields itself.
The platform initialization the SDK required disappears with it.

Two things the package does not provide are written here instead. The lease response is
serialized by the server, because a client only ever parses one; the format is the one the
SDK produced and is now pinned by a test. The hash that anonymises a name in the audit log
is reproduced, because the method of Backstage is internal; the golden values in
LeaseAuditLineTests prove it produces what PostSharp produced.

The parser takes its licensing authority as an argument, so the tests sign real license
keys with a key pair of their own and exercise the component end to end -- no license key
signed by the production authority can live in a public repository.

Two differences between the two libraries are handled explicitly:

- The product names differ: the enumeration of PostSharp calls a product Ultimate where
  Backstage calls it PostSharpUltimate. An installation upgraded from a previous version
  holds the PostSharp spelling in ProductCode, while a client of the Backstage generation
  asks by the Backstage one, so a request naming a product would have found nothing.
  ProductCodes reconciles the two when matching, and new rows are written with the
  Backstage spelling.

- A key that carries no grace period now gets thirty days rather than none, which is the
  default Backstage applies. LicenseKeyData.GraceDays is not nullable, so an absent field
  cannot be told from a field set to thirty and the previous value cannot be restored.
  Keys issued with an explicit grace period are unaffected. The grace percentage, which
  Backstage leaves null, keeps the thirty percent PostSharp applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The image used to build the sources itself, which no longer works: the licensing component
comes from a private feed, and the build needs the global.json and nuget.config that
`./Build.ps1 prepare` generates and that are not in source control. Neither belongs in a
container a customer may build.

The Dockerfile now has a single stage and copies artifacts/app, which is where the
PackAndZip target leaves the contents of the release archive, so the container runs exactly
what is released.

The documentation follows, including the two differences an upgrade meets now that license
keys are parsed by SharpCrafters.Backstage: the default grace period and the product name
recorded in the ProductCode column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The archive carries the whole dependency tree of an ASP.NET Core application, and the sign
service descends into a container and signs every executable it finds. Without a filter it
would sign MailKit, the Azure libraries and the rest with our certificate, asserting
authorship of code that is not ours.

Also records that no default publisher matches the archive, so the upload to S3 still has
to be added to the product definition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tests generated a key pair of their own, which verified the parser against a signature
scheme rather than against the one the product uses. They now sign with the test licensing
authority of SharpCrafters.Backstage, reached through TestLicenseKeyProvider in
SharpCrafters.Backstage.Testing, and verify against the very authority object that signed.
The ready-made keys that Backstage issues for its own tests parse here too, which is what
shows the two agree on the whole of the format.

That turned up a defect. An authority provider throws when it is asked for a key identifier
it does not hold, so a license key whose signature named an authority nobody issued escaped
TryParse as a KeyNotFoundException instead of being reported as invalid -- a 500 on the Add
License page, for a value an administrator pastes into a web form. The parser now checks the
identifier before verification.

xunit moves to 2.9.3, which is the version SharpCrafters.Backstage.Testing requires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A load simulation needs a license key that the server serves, and every key the production
authority signs is one that was sold. LicenseServer:TestLicensingAuthorities names the
authorities a development server accepts besides that one, so a key pair generated for the
occasion is enough to drive the server.

The server refuses to start with the setting outside the Development environment rather
than ignoring it: whoever holds the private half of such a pair can mint license keys that
a server configured this way honours. It also refuses a key that takes one of the
production identifiers, and builds each authority at startup so that a malformed key fails
there rather than in the middle of a lease request.

The key pair itself stays out of source control. App_Data is now ignored wherever it
appears, not only at the repository root, so the generated audit signing key cannot be
committed either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither showed up in the test suite, and both fail on a real server.

The audit log export answered with a truncated response and HTTP 500 for any range that
held a lease. Lease.Write writes the fields one by one, so the StreamWriter flushed
synchronously once its buffer filled, and Kestrel refuses a synchronous write to a
response body. The line is now built in memory and written with WriteLineAsync; it is the
whole log, not one line of it, that must not be assembled in memory.

The usage page answered with HTTP 500 when a user held two open leases on one machine. The
timeline kept a list of machines per user, skipped an opening point for a machine already
in it, and removed on every closing point, so the second close found nothing and threw. It
now counts the open leases per machine, so the closes balance the opens. A lease that ends
no later than it starts is dropped before the points are built, because its closing point
sorts before its opening one.

The tests that missed them are the more interesting part.

- Every earlier export test ran against an empty database, where the endpoint returns an
  empty body without streaming anything. Adding leases is not enough either: three lines
  fit in the writer's buffer and never flush. The new test writes sixty, and runs against
  a response body that refuses a synchronous write, because TestServer accepts one
  whatever its AllowSynchronousIO property says.

- LeaseCountingPointsTests recorded two open leases on one machine as data the timeline was
  entitled to refuse, on the assumption that the lease service never produces it. It does:
  a server whose clock moves backwards grants a second lease while the first is still open,
  and restarting a server with TimeAcceleration set is enough. That test is replaced by
  ones that count the seats.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The contract with the clients was spread between the endpoint that implements it, the
client that speaks it and the tests that pin it. docs/protocol.md describes it in one
place: the three endpoints, the arguments of a lease request and what each one decides,
the format of a lease and the lenient parsing a client applies to it, every status code
and denial message, the renewal policy that keeps the load proportional to developers
rather than to builds, the accelerated clock, and the format and signature chain of the
audit log.

The behaviours a deployed client depends on are listed together at the end, because the
reason not to change them is not visible from the code that implements them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chart was labelled "Concurrent users" and drew seats. The two differ for a user working
on more machines than one seat covers: the allocator charges that person a second seat,
and the chart counted two. It now counts the people holding a lease, which is what its
axis, its legend and the two reference lines are expressed in -- the capacity of a license
key is a number of concurrent users.

Each point of the timeline now carries both numbers. LeaseCount keeps the seats, which is
what the allocator compares to the capacity when it decides whether to grant a lease, and
UserCount carries the people. Nothing but the chart reads the second one today.

This makes the chart differ from the "In use" column of the license list, which counts
seats. A license can therefore be at its capacity with the chart below the line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A seat is one user together with the machines that user works on, up to MachinesPerUser of
them; a user working on more consumes one seat per that many machines, rounded up. The
word was used throughout without ever being defined, which is what made it look like a
concept the product does not have. The definition now sits on SeatCounter, on the setting,
and in the configuration and protocol documents. The licence agreement states the same
rule the other way round, as authorized users each entitled to a number of devices.

GetActiveLeads becomes GetActiveSeats and LeaseCountingPoint.LeaseCount becomes SeatCount,
so that the names say what they hold. GetActiveSeats counted the leases a user holds
instead of the machines they work on: a user holding two leases on one machine was charged
for two machines, which denies a colleague a lease the licence has the capacity for. The
lease service avoids that by prolonging rather than granting a second lease, but a server
whose clock has moved backwards grants one, and a load simulation produced it within
minutes of a restart. The timeline behind the graph already counted machines.

This also reverts the previous commit. The usage chart draws seats again, so the line and
the two limits above it are in the same unit and the chart agrees with the "In use" column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page reports a number of seats, so it now says what one is. The rule is stated with the
number of machines this server is configured with rather than with the default, so an
installation that changed the setting is not told something untrue.

ConcurrentUsers on the page model becomes Seats, which is what it has always held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The legend drew a filled rectangle for each series, which matches neither the shape of what
is on the chart nor the dash pattern that tells the capacity apart from the grace
allowance. Each sample is now a line.

The dash pattern and the width are carried over from the dataset explicitly, because the
legend item Chart.js generates does not take them: without that the three samples are
solid lines differing only in colour, while the lines they stand for are solid, dashed and
dotted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sentence carried two ideas and a parenthesised plural: "one user together with the
machines that user works on, up to 2 of them" and "one seat per 2 machine(s), rounded up".
It now states one idea per sentence, in the plainest words the rule allows, and the noun
agrees with the number so that a server configured with one machine per seat does not read
"1 machines".

The same wording is used wherever the rule is stated, so the product explains it one way:
the details page, SeatCounter, the MachinesPerUser setting, and the configuration and
protocol documents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel was a paragraph containing forms. A paragraph cannot contain a form, so the
browser closed it at the first one: the panel rendered as an empty bordered rectangle and
the buttons and the sentence below it landed outside, unstyled. It is a div now.

The actions class it carries had no rule at all, so the two buttons of a disabled license
stacked. It now lays them out in a row with the sentence that explains them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread docs/protocol.md Outdated
published by IIS with Windows authentication answers 401 to an anonymous request. An installation
that enables Windows authentication therefore records who borrowed each lease.

Setting `LicenseServer:RequireAuthenticatedLeaseRequests` makes the server refuse an anonymous lease

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authentication is used to gate the admin interface only. Normal access is designed to be anonymous.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected in 6444349. The section now opens with "A lease request is anonymous by design" and says that authentication is there to gate the administrative interface; the note about credentials arriving is kept only to explain why a client sends them at all, which is that IIS with Windows authentication answers 401 to an anonymous request.

I had it backwards: the old text read as though authentication existed to attribute leases, with the admin pages as an afterthought.

Comment thread docs/protocol.md Outdated

The suffix is load-bearing on the server side as well. Before the server compares a machine name to
its list of build servers it strips a trailing `-` followed by hexadecimal digits, so
`buildagent-1f2e` matches the configured name `buildagent`. A machine name that legitimately ends in

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be discussed and fixed - probably on the client side

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected in 6444349, and the sentence you flagged was wrong rather than merely worth discussing.

Exactly one trailing -<hex> group is stripped, and every client appends the hash, so a machine whose own name ends in hexadecimal keeps it: build-01 arrives as build-01-1f2e and is compared as build-01. BuildServerDetectionTests pins both directions. My claim that such a name is shortened would only hold for a client that sends no hash, and no shipped client does.

What is worth discussing is what is underneath it: the name and the hash travel in one argument, so the server has to find the hash by its shape. A separate argument would remove the guess. It can only be added beside machine rather than in place of it, since deployed clients send what they send. Say the word and I will file it against SharpCrafters.Backstage, where the client lives.

Comment thread docs/configuration.md Outdated
**Both default to open**, which is how the license server has always shipped, so that an upgrade
cannot lock an administrator out of their own server. The administrative pages are the only way to
add or revoke a license, so setting `AdminRoles` is worth doing; until it is set, the server says so
in its log every time it starts.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mention that the administrator is reponsible for securing the Admin folder; this is not taken in charge by the app. And say how, or let's disuss.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6444349, as a new section "Securing the administrative pages", linked from the installation steps in the README.

It says the responsibility is the administrator's and that the server does not do it on its own, then gives two ways that can be combined: AdminRoles, which covers every page under /Admin and the export endpoint but needs a scheme that supplies Windows groups, and a URL authorization rule for the Admin path in the published web.config for anyone not authenticating against a domain. It notes that the rule needs the IIS URL Authorization role service, which is not installed by default, and that behind another web server or in a container the path has to be restricted by whatever sits in front.

One caveat: I have not run the IIS rule against a real IIS. It is the standard form, but it is untested in this work.

Authentication: a lease request is anonymous by design, and authentication is there to gate
the administrative interface. The protocol document had it the other way round, as though
authentication existed to attribute leases.

Securing the administrative pages: the configuration document now says that it is the
administrator's responsibility and how to do it, with the roles setting and with a URL
authorization rule under IIS for whoever does not authenticate against a domain. The
installation steps point at it.

The machine argument: the claim that a machine name ending in hexadecimal is shortened was
wrong. Exactly one group is stripped and every client appends the hash, so such a name
survives. What is true is that the name and the hash travel in one argument and the server
has to find the hash by its shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The compose file described itself as a test deployment and behaved like one: the database
password sat in it in plain text and the audit signing key was a fixed value, so every
deployment made from it shared one. It is now an ordinary deployment. The password comes
from MSSQL_SA_PASSWORD in the environment and compose refuses to start without it, and the
server generates its own audit signing key.

docker-compose.test.yml is the opt-in: applied beside the main file, it puts the server in
the Development environment, sets LicenseServer:SeedTestLicenses and accelerates the clock.

SeedTestLicenses has the server issue itself the license keys it serves, which is what a
trial and a load simulation need and what no production authority will ever sign for them.
The server generates a licensing authority into its data directory, trusts it and adds one
license key per product family. It refuses to start with the setting outside the
Development environment, as it already did for TestLicensingAuthorities.

The data directory is what has to outlive the container. It holds the audit signing key,
whose loss restarts the signature chain, and now the test authority, whose loss stops the
license keys already in the database from verifying. The image declares /app/App_Data as a
volume so that a container started without a mount does not keep them in its own writable
layer, and LicenseServer:DataDirectory moves the directory elsewhere.

Verified against Docker under WSL: the default stack comes up with no license and a clock
at 1, the test override seeds both licenses and serves a lease, and both keys survive the
container being replaced with the volume kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The actions that change a license were three buttons in a panel at the bottom of the page.
They are now one Manage button at the top, left of Back, which opens a menu, and each
action states what it does in a dialog before it runs.

They did not work. The page is reached as /Admin/Details?id=900001, and a form does not
inherit the query string of the page that contains it, so every action posted to
/Admin/Details?handler=Disable, matched no license and answered 404 as a blank page. The
forms name the license now, and a test posts each of them the way a browser does, with the
action and the antiforgery token the page itself supplies.

The menu is a <details> element and the confirmation is added to forms that submit without
it, so a browser that does not run admin-actions.js keeps every action and loses only the
confirmation. The dialog clears its return value before it opens, because a dialog closed
with the Escape key keeps the value of the previous close in some browsers, which would
confirm an action nobody confirmed.

A disabled license now says so in a callout. The state used to be legible only from which
buttons the page was showing, and those are behind a closed menu now.

Verified in the browser: the three actions reach the license, Keep it and the outside click
leave it alone, the menu opens under its button at phone width instead of off the screen,
and the delete button is dark on orange, which orange on purple was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/health/live reports the state of the process and runs no check. /health adds the database and the
licenses. /version reports the build that is deployed and the version of the licensing library it
parses license keys with. All three are anonymous, like Lease.ashx and unlike the administrative
pages: a load balancer holds no Windows credentials, and a probe answered with 401 reads as a server
that is down.

The database check queries the license table rather than opening a connection. The server never
creates its own schema on SQL Server, so a database that accepts connections but was never given
CreateTables.sql is the deployment mistake worth catching, and a connection test calls it healthy.

The license check reports whether any license can serve a lease now, which is what distinguishes a
server that is running from a server that is working: one whose licenses have all expired answers
every request with 403 and looks perfectly well from the outside. It warns and never fails. Nothing
that watches a probe can add a license, and the server goes on serving the leases it has already
granted, so the endpoint stays at 200 and names the problem in its body and in a warning in the log.
Only the process and the database fail the probe.

Neither check returns the message of the exception that made it fail. A database exception carries
the name of the server and sometimes the whole connection string, and these endpoints are anonymous,
so the reason goes to the log and only a development server puts it in the response.

LicenseAvailabilityService restates rules that LeaseService owns, so four cases assert that the two
agree: free capacity, full with grace left, grace seats used up, and grace period over each answer
the same as allocating a lease does. The grace limit is now computed in one place for both. The
service reads and never writes, because the allocator starts the grace period of a license when it
falls back on it and a probe that did the same would run that clock against an idle server.

Verified against a running server: healthy with licenses, degraded with none, 503 with the schema
dropped, and no password or host name in the body of a failure outside Development.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread docs/configuration.md Outdated
SQL Server is the supported engine for a production installation. Create its schema by running
`Database\CreateTables.sql`; the server never creates or alters it, so an upgrade cannot surprise you.

SQLite is offered for evaluation, and is what the test suite uses. Its database is created on first

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Say that SQLLite is supported for tests. Rewrite AI slop.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Write how the DB engine is configured.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in aeaf8b0. The Database section now says that SQLite is supported for tests and for evaluation, that the test suite of this repository and the development configuration of the web project both use it, and that it is not for a server that serves a team, with the reason: SQLite accepts one writer at a time, and every lease request writes.

The section is rewritten against the copywriting rules of the engineering skill, as is the rest of the document, the three other documents, and the comments and the XML documentation of the whole repository. What changed: the subject now comes first in each sentence, one sentence carries one idea, the text names the mechanism instead of describing it by analogy, and the abbreviations and the bold text are gone.

— Claude for Gaël Fraiteur (@gfraiteur)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in aeaf8b0. The Database section now presents LicenseServer:DatabaseProvider and the connection string as one pair that must agree, because the engine named in the first setting interprets the second one. It gives a JSON block for each engine:

{
  "LicenseServer": { "DatabaseProvider": "SqlServer" },
  "ConnectionStrings": {
    "SharpCrafters_LicenseServerConnectionString": "Server=db.example.com,1433;Database=PostSharpLicenseServer;Integrated Security=True;Encrypt=False"
  }
}
{
  "LicenseServer": { "DatabaseProvider": "Sqlite" },
  "ConnectionStrings": {
    "SharpCrafters_LicenseServerConnectionString": "DataSource=licenseserver.db"
  }
}

— Claude for Gaël Fraiteur (@gfraiteur)

Comment thread docs/configuration.md
Comment thread docs/configuration.md Outdated
| `LicenseServer:AdminRoles` | empty | The Windows groups allowed to reach the administrative pages, for example `["DOMAIN\\PostSharp Administrators"]`. |
| `LicenseServer:RequireAuthenticatedLeaseRequests` | `false` | Whether a lease request must be authenticated. |

The authentication scheme decides how the server learns who is borrowing a license, which is what it

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect. The user info is given by the query string, the server trusts that. Authentication is used for AuthenticedUser otherwise only to secure access to the service in general and to the admin page specifically.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected in aeaf8b0. The text was wrong, and it was wrong in the protocol document as well, which said that authentication is how the server learns who borrows a license.

The Access section now opens with the source of the two values:

A lease request carries the user name and the machine name in its query string. The server trusts these two values and records them in the UserName and Machine columns, and it counts seats from them. Authentication does not provide them.

It then states the two purposes of authentication: it records the caller in the AuthenticatedUser column, next to the user name that the request declares, and it restricts access. RequireAuthenticatedLeaseRequests requires the caller of Lease.ashx to be authenticated, and AdminRoles restricts the administrative pages to the members of the groups it names.

docs/protocol.md now says the same thing, in its section on authentication.

— Claude for Gaël Fraiteur (@gfraiteur)

The documents and the comments were written in a register the copywriting rules of the engineering
skill exclude: rhetorical openings, idiom, sentences that carry three ideas between dashes, bold for
emphasis, and "i.e.". They now state the subject in the first clause, use one idea per sentence, name
the mechanism, and assume a reader who does not speak English natively.

The configuration document answers the review. It says that SQLite is supported for tests and for
evaluation, and that it is not for a server that serves a team, because SQLite accepts one writer at
a time. It presents DatabaseProvider and the connection string as one pair that must agree, with an
example for each engine. It opens with a complete appsettings.json.

The description of authentication was wrong, in the configuration document and in the protocol
document. A lease request declares the user and the machine in its query string, the server trusts
both values, and it counts seats from them. Authentication does not provide them. It records the
caller in AuthenticatedUser, and it restricts access: RequireAuthenticatedLeaseRequests for
Lease.ashx, AdminRoles for the administrative pages.

Rewriting found two more inaccuracies. FixLease was documented as signing the lease, which it stopped
doing when signing moved after the insert. The protocol document listed the order of the four parts
of a lease as a compatibility constraint, and the client matches the parts by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The server signed each lease of the audit log with a chained HMAC, and nothing verified it. The
feature is removed: ILeaseSigner, HmacLeaseSigner, IAuditKeyProvider, FileAuditKeyProvider, the
property Lease.HMAC and its mapping, the setting LicenseServer:AuditHmacKey and its validation.

Saving a lease is one insert again. The signature was computed after the insert, because the audit
line starts with the identifier that the database assigns, so a save ran two statements inside a
transaction. The repository now calls SaveChanges once.

An exported line has seven fields instead of eight. The eighth field carried the signature. It could
not be verified even in principle: the legacy implementation called the parameterless HMAC.Create(),
which generates a random key at every call.

An existing database is unaffected. The column HMAC is nullable, so the server writes to such a
database without a schema change, and the values already written stay as they are. CreateTables.sql
keeps the column, with a comment, so that the script creates the table that deployed installations
have. SchemaCompatibilityTests adds the column to the test database and inserts a lease through the
repository.

Verified against the database of the last end-to-end run, which has 268 leases with the column
populated: every exported line has seven fields, and a lease request inserted lease 269.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Subheadings for the two mechanisms that restrict the administrative pages, instead of a paragraph
that opens with "The first mechanism" and one that opens with "The second mechanism".

The document no longer mentions a load balancer. The operating requirements of this server are low:
a developer sends about one request per day, and a client that cannot reach the server keeps the
lease it holds, so an interruption of a few hours affects nobody. The Monitoring section says this,
and it says that the purpose of monitoring is to learn that the server needs attention, and not to
fail over. The same text is removed from the health check, from the endpoints and from their tests.

The Concurrency section still names the deployments that run several worker processes against one
database, because they can grant more leases than the capacity of the license, but it now recommends
one worker process first and names the deployments second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread docs/configuration.md Outdated
at `/Admin/Export.ashx` returns the whole audit log. There are two mechanisms, and you can combine
them.

The first mechanism is `LicenseServer:AdminRoles`. It covers every page under `/Admin` and the export

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use subtitles for mechanisms.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ab6f85d. The section "Restricting the administrative pages" now has a subheading per mechanism, AdminRoles and the rule of Internet Information Services, instead of two paragraphs that opened with "The first mechanism" and "The second mechanism".

— Claude for Gaël Fraiteur (@gfraiteur)

Comment thread docs/configuration.md Outdated
| `/version` | Which build is deployed, and which version of the licensing library it uses to parse license keys. |

The server serves these three endpoints without authentication, as it serves `Lease.ashx`. A load
balancer and a monitoring agent have no Windows credentials, and a probe that receives 401 reports

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't mention a load balancer. It's useless since this app typically receives max one request per day and per user. also an outage of a few hours should be with minimal consequences since the clients cache their lease for days. so operating requirements are minimal.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ab6f85d. No document mentions a load balancer any more. The Monitoring section states the operating requirements instead: one developer sends about one request per day, a client that cannot reach the server keeps the lease it holds, and monitoring exists to learn that the server needs attention rather than to fail over.

The Concurrency section was rewritten again in 97cc811, when the lock moved into the database. Several worker processes on one database are now serialized against each other, so the section no longer asks anybody to run one process.

— Claude for Gaël Fraiteur (@gfraiteur)

Comment thread docs/configuration.md Outdated
The operating requirements of this server are low. One developer sends about one request per day,
because the client stores its lease and renews it after `NewLeaseDays` minus `MinLeaseDays` days. A
client that cannot reach the server keeps the lease it holds, so an interruption of a few hours
affects nobody. Monitor the server to learn that it needs attention, and not to fail over.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

affects only new usersor devices who don't have a lease yet.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c1413f7. The sentence now says that an interruption affects only a user or a machine that holds no lease yet, which means a new user, a new machine, or one whose lease has expired.

— Claude for Gaël Fraiteur (@gfraiteur)

Comment thread docs/configuration.md
| `LicenseServer:SeedTestLicenses` | `false` | Whether the server issues to itself the license keys it serves. |
| `LicenseServer:TestLicensingAuthorities` | empty | The licensing authorities whose license keys a development server accepts, in addition to the production authority. |

The server refuses to start when one of the last two settings is set outside the Development

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is a development environment? how is it detected?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c1413f7. The Testing section now says that the environment is the one of ASP.NET Core: it is read from ASPNETCORE_ENVIRONMENT, the server runs as Production when that variable is not set, and Development is set by Properties/launchSettings.json when the project is started from an editor or with dotnet run, and by nothing else. A published server therefore runs as Production unless an administrator sets the variable.

— Claude for Gaël Fraiteur (@gfraiteur)

Comment thread docs/configuration.md

## Testing

| Setting | Default | Meaning |

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there should be an introduction saying that testing is for the developers of this product, not for customers. and explain what a development environment is.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c1413f7. The Testing section now opens by saying that its settings exist for the developers of the license server itself and for an evaluation of it, and that a customer who serves license keys needs none of them. The explanation of the Development environment follows the table.

— Claude for Gaël Fraiteur (@gfraiteur)

/// <c>DOMAIN\PostSharp Administrators</c>. When the value is empty, the administrative pages are
/// not restricted, which is the behaviour of the legacy <c>Web.config</c>.
/// </summary>
public string[] AdminRoles { get; set; } = [];

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's actually never used.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was used once, by the startup warning, while the authorization policy read the configuration section a second time, key by key. That is what made the property look unused.

c1413f7 makes the property the single source: the section is bound once, before the policies are built, and both the administrative policy and the lease policy read that instance. RequireAuthenticatedLeaseRequests was read the same way and now comes from the same instance.

— Claude for Gaël Fraiteur (@gfraiteur)

if ( parsedLicense.MinPostSharpVersion > this.serverVersion.LicensingLibraryVersion )
{
errors[license.LicenseId] = string.Format(
"The license #{0} requires a higher version of the licensing library on the License Server. Please upgrade the License Server to >= {1}.{2}.{3}",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewrite to interpolated string everywhere.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c1413f7. The six calls of string.Format in LeaseService are interpolated strings. The text of every message is unchanged, including the d format of the dates, because the tests and the deployed clients read these messages.

— Claude for Gaël Fraiteur (@gfraiteur)

<p>
The seat is released immediately. The lease itself is kept in the audit log.
</p>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mention that this does not cancel the lease on the developer machine, which continues to hold it under expiration. The feature only allows to free the lease on the server.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c1413f7. The page now says that the seat is released on this server immediately, and that the machine of the user keeps the lease it already holds until that lease expires, because a client is never told that a lease was cancelled. Cancelling frees the seat for somebody else; it does not stop the product on that machine.

— Claude for Gaël Fraiteur (@gfraiteur)

</div>

<p>
The export is a text file with one line per lease. User and machine names appear only as hashes,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no longer hashes

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c1413f7. The page now says that the file has one line per lease, with the user name and the machine name as they were recorded, that it contains personal data, and that it is meant for the administrator of this server.

— Claude for Gaël Fraiteur (@gfraiteur)

private readonly DateTimeOffset origin;
private readonly double acceleration;

public AcceleratedTimeProvider( TimeProvider inner, double acceleration )

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the abstraction and the implementation used by SharpCrafters.Backstage

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked at what Backstage offers, and I do not think this class can use it as it stands.

The abstraction is IDateTimeProvider, in SharpCrafters.Backstage.Infrastructure. Its implementation in the product, CurrentDateTimeProvider, reads the real clock. The accelerated implementation, AcceleratedDateTimeProvider, is in SharpCrafters.Backstage.Testing, and it is the client side of this protocol: it calls GET /GetTime.ashx on a license server and then follows the clock of that server. This server is the clock it follows, so it cannot follow itself.

The class here is the source of that reading. It is a TimeProvider of the base class library, which is what lets the tests use FakeTimeProvider, and what the repository, the services and the pages take.

There is also no inconsistency to fix today: the parser uses LicenseKeyData and its extensions, which read no clock, so no Backstage code validates a date behind the back of this provider.

What would you like here? Registering an IDateTimeProvider backed by this provider is possible, if you intend Backstage code that reads a clock to be called from the server later.

— Claude for Gaël Fraiteur (@gfraiteur)

The lock that serializes the lease requests is no longer held in the process. On SQL Server the
request calls sp_getapplock with the owner Session and releases it at the end; on SQLite it opens
its transaction with BEGIN IMMEDIATE, which takes the write lock of the file at once. The engine
chooses the mechanism, so LeaseLockMode is gone, together with InProcessLeaseLock and NullLeaseLock,
and a deployment of several worker processes on one database is serialized. MutexTimeout stays: a
timeout is configuration.

The test suite runs a second time against SQL Server when LICENSESERVER_TEST_SQLSERVER holds a
connection string. No test is duplicated, because the engine is a property of the run. Each test
receives a database of its own, created from CreateTables.sql, which also proves that the script and
the model agree, and the databases are pooled because creating one costs about half a second.

Two tests covering the lock hold a request at a named synchronization point, through
ITestSynchronizationProvider, which the tests register and production does not have. The first test
asserts that the second request waits and then sees the lease of the first; the second asserts the
status 503 when the wait exceeds the timeout.

The SQL Server run found two defects of its own. SeedTestLicensesTests sorted the column LicenseKey
in the database, which Transact-SQL refuses for the type text, so the keys are sorted after they are
read. The health check test that drops both tables returned its database to the pool, which left the
next test without a schema, so a test that modifies the schema now calls DoNotReuse and the pool
drops that database. The pool also reseeds the identity of the leases only when the table has
received a row: on a table that has received none, DBCC CHECKIDENT makes the next row take the value
itself, which is zero.

The build now runs in a container, as the builds of the other products of the family do. That is
what lets the build definition generate the second image, a Linux one carrying SQL Server 2022,
which the configuration Tests on SQL Server builds and runs eng/TestSqlServer.ps1 in. The script
also serves a developer machine: it starts the database service of docker-compose.yml when it finds
no other server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PostgreSQL joins SQL Server as an engine supported in production, and SQLite stays the engine of the
development loop. Set LicenseServer:DatabaseProvider to PostgreSql and run
Database/CreateTables.PostgreSql.sql, which is the script an administrator runs, as CreateTables.sql
is on SQL Server. The server still never creates the schema and never modifies it.

The lock that serializes the lease requests is an advisory lock. The request calls pg_advisory_lock
and releases it with pg_advisory_unlock, both of which belong to the session, which is the connection
of the request. The wait is bounded by lock_timeout, taken from MutexTimeout. PostgreSQL then cancels
the statement with the code 55P03, and the request is answered with the status 503, as it is on SQL
Server.

Two differences of the engine were found by running the suite rather than by reading. Npgsql refuses
a DateTime whose kind is Unspecified, which is what a date built from a year and a month is, and the
export builds one from its query string; the value converter of the context now sets the kind in both
directions, which writes the same value SQL Server and SQLite have always stored. PostgreSQL folds an
identifier that is not quoted to lower case, so the two tests that modify the schema quote the names
of the tables.

The comparison of a user name and of a machine name ignores the case, as it does on SQL Server, whose
default collation ignores it. PostgreSQL offers no such collation, so the schema creates one from the
International Components for Unicode, and the model names it. Without it, a user who signed in under
two spellings would hold two sets of machines.

The test suite runs against PostgreSQL when LICENSESERVER_TEST_POSTGRESQL holds a connection string,
the way it already runs against SQL Server. Each test receives a database of its own, created from
the script of the engine, and the databases are pooled. TRUNCATE takes the place of the DELETE and
the reseed that SQL Server needs.

eng/TestDatabase.ps1 replaces eng/TestSqlServer.ps1 and serves both engines. It starts the server the
run needs: the one installed in the image, when the continuous integration build runs it, and a
container of its own on a developer machine. The container of a test run carries no volume and
publishes a port beside the default one, so a license server deployed on the same machine with
docker-compose.yml is left alone. On a machine whose Docker engine runs inside the Windows Subsystem
for Linux, the script reaches Docker through wsl.

The build definition generates a second Linux image, carrying PostgreSQL 17 from the repository of
the PostgreSQL project, and a second configuration, Tests on PostgreSQL, beside the one for SQL
Server.

Verified: 278 tests pass on each of the three engines, and the two images build and run their server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The audit log names the user and the machine. It used to write a hash of each name, which nothing
could read, and the file is exported to be read: the administrator of the server uses it to learn
which user holds a seat on which machine. The exported file therefore contains personal data, and
the export page, the protocol document and the upgrade notes say so. StringHash had no other caller
and is removed.

A Metalama license names the lowest version of Metalama that can read it, in MinMetalamaVersion,
which the signature algorithm of the key decides. That minimum is independent of the minimum
PostSharp version, and a Metalama version number is lower than the PostSharp version number of the
same year, so comparing a Metalama client against the PostSharp minimum refused leases it should
grant. The minimum that applies is now the one of the family of the licensed product, the message
names that family, and two tests cover the rule.

ILeaseSerializer is removed. It had one implementation, no test replaced it, and the endpoint takes
the class. ILicenseParser stays: CachingLicenseParser decorates it and every test that hosts the
application replaces it with FakeLicenseParser.

LicenseServerOptions is now the one place that reads the administrative roles. The policy read the
configuration section a second time, key by key, so the property looked unused. The section is bound
once, before the policies are built, and the two policies read that instance.

The messages of LeaseService are interpolated strings. Their text is unchanged.

The documents answer the rest of the review. The configuration document opens with the purpose of
the server: compliance with the license agreement is the responsibility of the customer, the server
measures usage inside the network of the customer, it is independent of the license audit, and a
customer who uses it is eligible for a waiver of that audit. It also says that only the described
deployments are supported, that an interruption affects a user who holds no lease yet, that the
settings of the Testing section are for the developers of this product, and what the Development
environment is and how it is detected. The container document says that its deployment is for
development and testing, because the database it starts is Developer Edition. The protocol document
says that clients of several versions use one server at the same time, with compatibility down to
the versions released before PostSharp 5, and that the audit log has no auditor as its reader. The
cancellation page says that the machine of the user keeps its lease until that lease expires.

eng/.gitignore is removed. It held one entry, for a file the build system no longer writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A class no longer uses a primary constructor. Thirty of them did, in the two projects of the product,
in the tests and in the build definition. Each one now declares its fields, assigns them in a
constructor, and reads them as this.field. A record keeps its primary constructor.

eng/style carries the shared code style of PostSharp.Engineering: .editorconfig,
CommonStyle.DotSettings for the JetBrains tools, and stylecop.json. The .editorconfig at the root of
the repository is a symbolic link to eng/style/.editorconfig, which is what `Build.ps1 codestyle
pull` creates, so a clone needs core.symlinks. README.md says this, and says which step of Rider no
script performs.

The style raised six warnings, and the build has none again. LeaseSerializer holds no state and
becomes a static class, so the endpoint calls it directly and the container registers nothing; that
is the end of the interface the review asked to remove. Two members of the tests become static, one
call names its StringComparison, one names its IFormatProvider, and the two database pools carry a
justification for CA1001: a pool lives in a static dictionary as long as the run, and its semaphores
are released with the process.

TestDatabase.ps1 chooses the Docker daemon by the operating system it reports, and no longer by
whether a client answers. Docker Desktop in the Windows container mode answers and then refuses a
Linux image, with "no matching manifest for windows", which is what happened on the machine this was
written on.

Verified: 280 tests pass on SQLite, on SQL Server and on PostgreSQL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
// characters with which they could forge a line in a plain-text log.
logger.LogWarning(
"The lease request for {User} on {Machine} took {Elapsed}.",
Sanitize( userName ),
logger.LogWarning(
"The lease request for {User} on {Machine} took {Elapsed}.",
Sanitize( userName ),
Sanitize( machine ),
The JetBrains tools reformatted every file against eng/style/CommonStyle.DotSettings, the profile
named Custom. They wrote the copyright header of the company, replaced an explicit type with var,
removed the newline at the end of a file, reflowed the Razor markup, and spaced the nested
parentheses. Nothing else changed: the build has no warning, and 280 tests pass on SQLite, on SQL
Server and on PostgreSQL.

The header the tools write names LICENSE.md, and this repository had no license file, although the
README states that the server is published under the MIT License. LICENSE.md now carries that
license, in the form the other repositories of the company use, and the README links to it.

SharpCrafters.Backstage.LicenseServer.slnx.DotSettings carries the team-shared settings layer of the
solution, which names eng/style/CommonStyle.DotSettings. Rider reads it, so the manual step of
"Manage Layers" is no longer needed.

`Build.ps1 codestyle format` does not work here, and the README says so and gives the command that
does. That command relies on the settings layer of the solution to find the cleanup profile, and the
JetBrains tools do not read that layer for a solution in the slnx format: they answer "Unable to find
the code cleanup profile with 'Custom' name", with either name for the settings file and with or
without the absolute path in it. Passing --settings=eng/style/CommonStyle.DotSettings works, with the
same layers disabled as the build system disables, so the result is the one the command would have
produced.

dotnet-tools.json is ignored. The format command writes it to pin the version of the JetBrains tools
on the machine that runs them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants