DynDyn.Pdf website + client portal — build spec
Status: implementation spec, 2026-09-02. Realises Product B of dyndyn-net-plan.md (§5 portal, §7 pricing, §8 legal pages, §3 key format) plus the device-activation flow agreed on 2026-09-02. Where this document and the plan disagree, this document wins; deviations are listed in §9.
1. Shape
Two ASP.NET Core processes (net10.0, Razor Pages), sharing the src/DynDyn.Portal.Shared Razor
class library (Data, Migrations, Features, Services, Security, Authorization, Options, Hosting, the
shared _Layout and wwwroot), serve four things between them:
| Surface | Path | Auth | Served by |
|---|---|---|---|
| Marketing site + legal + docs | /, /features, /pricing, /security, /trust, /docs/{slug}, /legal/{page}, /contact |
anonymous | src/DynDyn.Portal |
| Client portal | /portal/... |
ASP.NET Core Identity, org-scoped roles | src/DynDyn.Portal |
| JSON API | /api/v1/... |
bearer api_token, or licence key for activation | src/DynDyn.Portal |
| Admin back-office | /admin/... |
Identity role PlatformOwner + mandatory 2FA |
src/DynDyn.AdminPortal |
The two are separate applications, not one process split by path: separate deployables, separate
sign-in cookies (portal.auth / admin.auth) and separate ASP.NET Core Data Protection key rings,
so a session from one is useless against the other — see §5 and docs/portal-ops.md "The back
office is a separate application" for why. What they still share is one database, one artifacts
volume and one licence signing key.
src/DynDyn.Licensing (net10.0, BCL only, no package references) holds the key format,
signer, verifier, fingerprint and activation codes. Both hosts reference it to issue; the
product will reference it later to verify (Product A). It must therefore never depend on
ASP.NET, EF or either host.
Hostnames are provisional (see plan amendment): everything public is built from
Portal:PublicBaseUrl. Nothing hard-codes dyndyn.net.
Stack: Razor Pages, ASP.NET Core Identity (+ default Identity UI for register/login/2FA), EF Core with Npgsql in production and SQLite for tests/dev-without-Postgres, Markdig for docs, Stripe.net for billing, AWSSDK.S3 for pre-signed artifact downloads. No SPA, no Blazor, no JS framework; only small vanilla JS where a page needs it (copy-to-clipboard, theme toggle).
2. Licensing library (src/DynDyn.Licensing)
2.1 Payload
LicensePayload
Version int 1
Kind "license" | "activation" | "revocations"
LicenseId Guid
Customer string org display name
Product "DynDyn.Pdf"
Edition Community | Evaluation | Starter | Pro | Enterprise
Features string[] e.g. "web-api", "native-engine", "batch"
IssuedAt DateTimeOffset
ExpiresAt DateTimeOffset? null = perpetual
MinVersion string? semver, inclusive
MaxVersion string? semver, inclusive; null = free upgrades
MaxInstances int deployments allowed (activations)
KeyId string which signing key; product ships a trust table
Fingerprint string[]? activation codes only: component hashes (see 2.4)
RevokedIds Guid[]? revocation lists only
Wire format (both licence keys and activation codes):
base64url(utf8 json) + "." + base64url(signature). Signature is ECDSA P-256 / SHA-256,
IeeeP1363FixedFieldConcatenation, 64 bytes, over the exact UTF-8 bytes of the first part.
JSON is camelCase, no indentation, unknown members ignored on read.
2.2 API surface
LicenseSigner— wraps anECDsaprivate key +KeyId.string Sign(LicensePayload).static LicenseSigner CreateEphemeral(string keyId)for tests;static FromPem(string pem, keyId).TrustedKeys—KeyId → ECDsa public key;FromPem(IDictionary<string,string>).LicenseVerifier(TrustedKeys)—VerificationResult Verify(string token)withStatus ∈ {Valid, Malformed, UnknownKey, BadSignature}and the parsedPayloadwhen the signature is valid. Signature validity only — policy is separate.LicensePolicy.Evaluate(payload, now, productVersion, fingerprint?, revocations?)→LicenseState ∈ {Licensed, Grace, Community}plus aReasonenum{None, Expired, ExpiredBeyondGrace, Revoked, VersionBelowMin, VersionAboveMax, FingerprintMismatch, WrongProduct}. Grace = expired ≤ 30 days, or fingerprint mismatch (rehost window) → still full features. Version compare is semver-aware and ignores prerelease/build metadata for the window check.MachineFingerprint—Collect(FingerprintOptions)returns 5 components, eachSHA-256(component-name + ":" + value)base64url: machine name, OS machine id (/etc/machine-id,IOPlatformUUIDon macOS,HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuidon Windows), first non-loopback physical MAC,osArch + processorCount, and an install id read fromFingerprintOptions.InstallIdPath(created with a random Guid on first use whenCreateInstallIdIfMissing).static bool Matches(IReadOnlyList<string> a, IReadOnlyList<string> b, int minimum = 3). Raw values never leave the machine; only hashes do.ActivationRequest—LicenseId,Fingerprint(5 hashes),ProductVersion,Nonce(8 bytes).string Encode()→"DDR-" + base32grouped in blocks of 5 (uppercase, Crockford alphabet, no padding) so it can be typed from an air-gapped console;static ActivationRequest Decode(string). Compact binary layout, not JSON: version byte, 16-byte id, 5 × 32-byte hashes, version string (length-prefixed UTF-8), nonce. Round-trips exactly.RevocationList— a signed payload ofKind = "revocations";LicensePolicytakes the parsedRevokedIds.
Tests: tests/DynDyn.Tests.Licensing (net10.0, xunit 2.9.2 like the other test projects):
round-trip, tampered payload → BadSignature, unknown key, malformed token, each policy reason,
grace boundaries (30 days inclusive), semver window incl. prerelease, fingerprint 3-of-5 rule,
request-code round-trip and rejection of a corrupted character, revocation list.
3. Data model (portal, schema app)
Provider-agnostic columns only (must work on SQLite for tests): no jsonb, no text[]. Arrays
are stored as a \n-joined string through a value converter; JSON blobs as text.
| Table | Columns (beyond id/timestamps) |
|---|---|
organisations |
name, slug (unique), vat_id?, country?, stripe_customer_id?, sso_config? (text, unused) |
memberships |
org_id, user_id, role ∈ Owner/Admin/Developer/Billing/ReadOnly; unique (org, user); exactly one Owner per org |
subscriptions |
org_id, tier, status, stripe_subscription_id?, current_period_end, cancel_at_period_end |
licenses |
org_id, license_id (Guid, the payload id), edition, features, issued_at, expires_at?, max_instances, min_version?, max_version?, key_id, status ∈ Active/Revoked/Lapsed, token_hash (SHA-256 of the full key; the key itself is never stored), revoked_at?, revoked_by?, revoke_reason?, issued_by?, label |
activations |
license_id (FK), fingerprint (5 hashes), machine_label?, request_code_hash, activation_token_hash, activated_at, deactivated_at?, deactivated_reason?, product_version |
api_tokens |
org_id, name, scopes (download, activate, releases:publish, admin), token_hash, prefix (first 8 chars for display), created_by, last_used_at?, expires_at?, revoked_at? |
releases |
version (unique), published_at, notes?, is_public |
artifacts |
release_id, rid, file_name, size_bytes, sha256, storage_key |
download_events |
org_id, user_id?, api_token_id?, artifact_id, at, ip, user_agent |
audit_log |
org_id?, actor_user_id?, actor_token_id?, action, target_type, target_id, details (text json), at, ip — append-only; no update/delete code path anywhere |
stripe_events |
event_id (unique), type, received_at, processed_at?, error? |
Seat rules:
- Generate licence: blocked when
count(licenses where org & Active) ≥ tier.Deployments(Starter 1, Pro 3, Enterprise unlimited, Evaluation 1). Manual admin issue bypasses. - Activate: blocked when
count(activations where license & not deactivated) ≥ MaxInstances. - Rehost (deactivate + activate a different fingerprint): at most 3 per licence per rolling 365 days via self-service; admin bypasses.
- Evaluation: one per organisation, ever; 14 days; MaxInstances 1; Pro features; the 25-conversion cap is a product-side counter and is not modelled here.
4. Portal pages (/portal)
Dashboard— org name, tier, seats used/available, active activations, latest release, trial CTA if eligible.Organisation— rename, VAT id, and a read-only line naming the Owner. Single-seat (§9): an organisation has exactly one member, its Owner, for its whole life, so there is no member/role/transfer UI. Admin+ (in practice Owner-only, since no other role can ever exist — see §9).Licences— list; Generate (label, seat check; full key rendered exactly once with copy button, then only prefix + hash), Re-issue (new key, same seat: old row → Revoked reasonreissued, new row), Revoke (Admin+, reason required, step-up 2FA if any activation in last 24 h). Generate/re-issue/revoke are Owner/Admin only in the implementation — there is no Developer self-generate path (see §9).Activations— per licence: list, Manual activation (pasteDDR-…request code → seat and rehost checks → activation code shown once with copy), deactivate with reason. Open to Owner/Admin/Developer.Downloads— releases and artifacts, grouped by delivery format (Docker image / CLI / DLL — seeArtifact.Kind); each link points atGET /api/v1/releases/{version}/{kind}/{rid}/download(§6) →download_eventsrow → 302 to a pre-signed URL (S3 store) or a streamed file (filesystem store). The implementation does not gate this on an active licence/seat — any authenticated org member (any role), or a token with thedownloadscope, can download any published artifact (see §9). Includes thedotnet nuget add sourcesnippet with the org's deploy token placeholder from config.Tokens— api tokens with scopes; shown once; revoke.Billing— current subscription; Subscribe buttons for Starter/Pro → Stripe Checkout (hosted, annual); Manage → Stripe Billing Portal; Enterprise → contact form. Billing role or Owner.Security— 2FA status. Owner and Admin cannot use any/portalpage other than this one until an authenticator is enrolled (enforced by a page filter, not by convention).
Every mutating action writes audit_log. Every page is org-scoped through the current membership;
a user with memberships in several orgs picks the active one (cookie), and no query anywhere
touches another org's rows — tests assert this by trying.
5. Admin (/admin)
PlatformOwner role, 2FA mandatory. Customer search; org drill-down (members, licences,
activations, downloads, audit); manual licence issue (bypasses seat check, reason required);
trial extension; global download log; audit log viewer (read-only, filter by org/action/date);
releases: publish a release manifest by hand. Impersonation is not in v1 (see §9).
A separate application, not a network mode. /admin is src/DynDyn.AdminPortal, its own
process and its own deployable — not a path served by src/DynDyn.Portal behind a second listening
port. This is a blast-radius boundary independent of, not instead of, the PlatformOwner + 2FA
authorization above: the two are layered, and a compromise of one gives no purchase on the other,
because a compromised customer process has no /admin routes mapped in it to reach at all. See
docs/portal-ops.md "The back office is a separate application" for the full shape — separate
sign-in cookies, separate Data Protection key rings, and what the two processes still share (one
database, one artifacts volume, one licence signing key).
6. API (/api/v1)
Bearer Authorization: Bearer <api_token> unless stated. JSON problem-details on errors. Rate
limited per token/IP. Never echo tokens or keys back except in the issuing response.
| Method + path | Auth | Purpose |
|---|---|---|
POST /activations |
body { licenseKey, requestCode, machineLabel? } — the licence key is the credential |
online activation: verify key signature + Active status, decode request, seat + rehost checks, sign activation code, return { activationCode, expiresAt, instancesUsed, maxInstances } |
POST /activations/deactivate |
body { licenseKey, requestCode } |
frees the seat for that fingerprint |
GET /licenses/current |
licence key in body? no — Authorization: License <key> header |
status: Active/Revoked/Lapsed + seats, so the product's optional online check has an endpoint |
GET /revocations |
anonymous | the current signed revocation list (Kind = "revocations") |
GET /releases |
anonymous | list (only IsPublic && !IsYanked releases; optional ?channel= filter) |
GET /releases/{version}/{kind}/{rid}/download |
download scope, or an authenticated portal session (any org role) |
audit (download_events row), 302 pre-signed / stream — no active-licence/seat check (§9) |
POST /releases |
releases:publish scope |
CI publishes multipart version/channel/kind/rid/notes/file fields (kind defaults to cli, channel to stable) |
POST /billing/webhook |
Stripe signature | see §7 |
7. Billing
Stripe Checkout + Billing Portal + Stripe Tax, annual. Price ids and the webhook secret come
from configuration; when Stripe:SecretKey is absent the Billing page shows "billing not
configured" and webhooks return 503 — the portal must run without Stripe.
Webhook handling exactly as plan §7: persist stripe_events.event_id with a unique constraint
before acting (duplicate → 200, no-op); checkout.session.completed → subscription row +
first issuance; invoice.paid → re-issue with current_period_end + 3 days; payment_failed
→ mark dunning, never revoke; subscription.deleted → licence lapses at expiry, nothing revoked;
charge.dispute.created → org flag issuance_paused. Issuance is a pure function of
(org, tier, expiry) so a redelivered event re-derives the same licence rather than a second one.
8. Signing key custody
IPayloadSigner abstraction. v1 ships PemPayloadSigner (private key PEM from configuration or
a file path, dev/staging) and the TrustedKeys table from Licensing:TrustedKeys:{keyId}=<pem>.
A KMS-backed signer (Azure Key Vault / Scaleway) is the production target and is an explicit
extension point with its interface in place; wiring it needs an account and is out of scope here.
Startup refuses to run in Production with PemPayloadSigner unless
Licensing:AllowPemSignerInProduction=true is set, so the gap cannot be forgotten.
9. Deviations from the plan
- Single-seat, not multi-member orgs (plan §5, this doc §3–§4 as originally written): an
organisation now has exactly one member, its Owner, for its whole life.
IOrganisationService.CreatePersonalOrganisationAsync(run once, at registration) is the only code path anywhere that creates aMembershiprow;AddMemberAsync,ChangeRoleAsync,RemoveMemberAsyncandTransferOwnershipAsync— and themembership.add,membership.change_role,membership.removeandorganisation.transfer_ownershipaudit actions they wrote — have been deleted outright, not merely hidden behind a flag. Themembershipstable keeps itsrolecolumn andOrgRolekeepsAdmin/Developer/Billing/ReadOnlyalongsideOwner(§3) purely so the authorization policies and pre-single-seat audit rows keep their meaning; no code path assigns any role butOwnerany more. - Portal is net10.0 (plan: 8): the host and the platform run .NET 10; the product library is also net10.0 now that the whole DynDyn.Pdf graph targets .NET 10. Nothing in the portal is consumed by the product.
- Activation / device binding is new (agreed 2026-09-02);
MaxInstancesbecomes enforceable. - No Postgres RLS in v1: org scoping is enforced in the data layer (every query goes through an org-scoped repository, asserted by tests). RLS can be added as defence in depth with session variables later.
- No impersonation in v1; the admin drill-down covers the debugging need without the standing risk. Revisit when a real support case needs it.
- KMS signer is an interface, not an implementation (§8):
KmsPayloadSignerexists and is selectable viaLicensing:Signer=Kms, but every member (KeyId,PublicKeyPem,Sign) throwsNotSupportedException. The app boots fine withSigner=Kmsset — only an actual signing operation (issuing/verifying a licence, revocation list or activation code) fails. - SSO stays deferred as in the plan; the
sso_configcolumn exists and the settings page is greyed out. - Docs are rendered from this repo's
docs/*.mdvia Markdig (allow-listed slugs only) instead of a separatedocs.host. - Postgres 17, not 16 (plan §4):
docker/docker-compose.portal.ymlrunspostgres:17. Deliberate — tracks the newest stable release available when the compose file was written; nothing in the schema or EF migrations depends on 16-specific behaviour. - Licence self-service is Owner/Admin only, not the plan's Developer self-issue (plan §5.1):
Pages/Portal/Licenses/Index.cshtml.csrestricts generate/re-issue/revoke to Owner/Admin. A Developer can still self-serve activations (§4 Activations) — just not licence issuance. - Downloads carry no active-licence/seat check (§4, §6, contrary to the plan's §5.3
"checks the org has an active licence seat"): the implementation gates the download endpoint on
identity only — an authenticated org member of any role, or a token with the
downloadscope — and always writes adownload_eventsrow, but never verifies the org holds an active licence before serving the file.GET /api/v1/releases(the listing) is anonymous.
10. Legal pages
/legal/licence (LICENSE.txt), /legal/third-party-notices (THIRD-PARTY-NOTICES.txt),
/legal/terms, /legal/privacy, /legal/dpa — the last three are drafts clearly banner-marked
"not yet reviewed by counsel", per plan §8. /trust lists sub-processors, data flow, key custody
summary and the honest "SOC 2 / ISO 27001: not yet" statement, with links to docs/security.md.
11. Security requirements (tested)
CSP (default-src 'self', no inline script except nonce'd theme toggle), HSTS, X-Content-Type-Options,
Referrer-Policy, frame-ancestors 'none'; antiforgery on every form; secure/HttpOnly/SameSite=Lax
cookies; rate limiting on login, register (Portal:AuthRateLimitPerMinute, default 10/min per
remote IP, fixed window), /api/v1/activations, webhook; passwords per Identity
defaults with breached-password-length minimum 12; tokens and keys stored as SHA-256 only;
logs never contain a key, token, request code or Stripe secret (a test asserts the log sink after
an issuance); audit log append-only; org isolation tests; Stripe signature verification tests;
download URL expiry.
12. Tests
Three suites, one per deployable: tests/DynDyn.Tests.Licensing for the signing/verification
library, and one WebApplicationFactory<Program> suite per host. The two host suites are separate
projects rather than one because each factory boots a different composition root — they cannot share
a Program — and because a failure should name the application it belongs to.
tests/DynDyn.Tests.Portal (net10.0): the customer host on SQLite in-memory (one open connection
per test), ephemeral signing key, fake artifact store, fake Stripe secret. Coverage listed in §11
plus: registration creates org + Owner membership; seat rules; re-issue and revoke; activation
end-to-end (request code → activation code → LicenseVerifier + LicensePolicy say Licensed for the
same fingerprint, Grace for a 2-of-5 drift); rehost cap; evaluation once-per-org; download event +
302; API token scopes; webhook idempotency and each event type; and that this host serves no
/admin at all.
tests/DynDyn.Tests.AdminPortal (net10.0): the back office on the same SQLite arrangement. Accounts
are created through UserManager + IOrganisationService rather than a registration page, because
this host does not route one — which is also how a platform owner comes into being in a real
deployment. Coverage: the door (anonymous, a normal user, and a PlatformOwner without 2FA are all
refused; a PlatformOwner with 2FA is admitted); the absent surfaces (Register, /portal and
every /api/v1/* path 404); / redirects to /admin; the admin licensing and distribution pages;
the Development seed; and the cookie namespacing that keeps the two sign-ins apart
(admin.auth/admin.antiforgery here, portal.* there — mirrored by a matching test on the
customer side).
13. Ops
docker/portal.Dockerfile (sdk:10.0 build → aspnet:10.0 runtime, non-root; one shared build stage
publishes both hosts, --target portal / --target admin picks which runtime image results, both
listening on 8080 inside their own container — see §5), docker/docker-compose.portal.yml
(postgres + portal + admin services sharing a Postgres database, an artifacts volume and the
licence signing key, admin waiting on portal's health check so schema bring-up only ever happens
once, plus a commented-out loopback publish / second Traefik router + IP allow-list for the admin
service), .gitlab-ci.yml jobs test:portal, test:adminportal and test:licensing plus
build:image (builds the customer image; pushes from v* tags), README section for running locally
(dotnet run --project src/DynDyn.Portal with SQLite when ConnectionStrings:Portal is unset, or
pwsh scripts/start-dev.ps1 -AdminPort <port> to also run src/DynDyn.AdminPortal against the same
database), and docs/portal-ops.md for configuration keys, first PlatformOwner bootstrap
(Portal:BootstrapOwnerEmail), the back office as a separate application, release publishing from
CI, and key rotation.