← All docs

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

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:

4. Portal pages (/portal)

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

/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.