← All docs

Portal operations

Operational reference for src/DynDyn.Portal (the customer site) and src/DynDyn.AdminPortal (the back office) — two applications sharing the src/DynDyn.Portal.Shared Razor class library, one database, one artifacts volume and one licence signing key. See docs/portal.md for the build spec this implements. As of 2026-09-02 (branch feat/portal) the portal has organisations/roles, licensing and activations, release distribution (downloads + API tokens), an admin back office, and Stripe billing wired in — see the Licensing, Distribution and Billing sections below. Everything in this document applies to both applications unless a section says otherwise; "The back office is a separate application" below covers what differs between them.

Running locally

dotnet run --project src/DynDyn.Portal

With no ConnectionStrings:Portal configured, the app creates and uses a SQLite database at portal.db in the project's content root (Database.EnsureCreated() on startup — there is no SQLite migration history, only the Npgsql one). This is the default for local development and is what tests/DynDyn.Tests.Portal uses as well, via an in-memory SQLite connection.

EnsureCreated() creates the schema once and never migrates it, so a portal.db left over from before an entity change fails at startup with a bare SQLite error naming the missing column (for example SQLite Error 1: 'table organisations has no column named billing_email'). The local file holds nothing worth keeping — delete src/DynDyn.Portal/portal.db* and start again. Postgres deployments are unaffected: they run the migrations.

Running src/DynDyn.AdminPortal the same way (dotnet run --project src/DynDyn.AdminPortal) falls back to its own portal.db, next to its own project file, and the two applications would then disagree about every user and organisation in the system — the back office ships Portal:InitialiseDatabase=false, so it never even creates that schema itself, and starting it alone against an empty file fails every query. Point it at the customer app's database instead with Portal:SqliteFile (below), or use pwsh scripts/start-dev.ps1 -AdminPort <port>, which does this for you — see "Default development administrator".

Default development administrator

pwsh scripts/start-dev.ps1

starts src/DynDyn.Portal (the customer site) on http://localhost:5100 by default (-Port or -Urls to change it), waits for its /healthz to answer, then opens a browser tab. Sign in at /Identity/Account/Login — the "Email or username" field accepts either — with username admin and password admin: a seeded Identity user with the PlatformOwner role and a personal organisation, its password hash written directly so Identity's normal password policy is bypassed for the seed. Two-factor enforcement (Portal:EnforceTwoFactor, below) is off for this run only, so admin can reach /portal without first enrolling an authenticator.

Pass -AdminPort <port> to also start src/DynDyn.AdminPortal (the back office) as a second process, once the customer site's /healthz answers, against that same SQLite database (via Portal:SqliteFile, below) — see "The back office is a separate application" for why they need to share one. The two applications are genuinely separate: signing in as admin/admin on the customer site does nothing for http://localhost:<AdminPort>/admin — sign in there again with the same credentials, exactly as in production. -Watch runs whichever processes are started under dotnet watch instead of dotnet run; -NoBrowser skips opening the browser tab. The script stops any process left listening on the target port(s) from a previous run before starting, and stops everything it started on Ctrl+C.

Under the hood, for the customer site the script runs dotnet run --project src/DynDyn.Portal -c <Configuration> --urls <Urls> -- --environment Development --Portal:DevAdmin:Enabled true --Portal:EnforceTwoFactor false, and, when -AdminPort is set, for the back office dotnet run --project src/DynDyn.AdminPortal -c <Configuration> --urls http://localhost:<AdminPort> -- --environment Development --Portal:EnforceTwoFactor false --Portal:InitialiseDatabase false --Portal:SqliteFile <customer site's portal.db> --Portal:CustomerBaseUrl <customer site's base URL>. Portal:DevAdmin:Enabled and Portal:EnforceTwoFactor=false are Development-only: InitialisePortalDatabaseAsync (src/DynDyn.Portal.Shared/Hosting/PortalHostExtensions.cs), which both hosts call at startup, throws if either reaches any environment other than Development, so neither can reach a real deployment — see "Configuration keys" below.

Configuration keys

All keys below live under the Portal: section unless noted otherwise.

Key Default Purpose
ConnectionStrings:Portal (unset) Npgsql connection string. When set, the app targets Postgres schema app and runs Database.Migrate() at startup. When unset, it falls back to a SQLite file (portal.db) with EnsureCreated().
Portal:DatabasePasswordFile (unset) Path of a file holding only the database password. When set and ConnectionStrings:Portal is non-empty, the file is read, trailing whitespace/newlines are trimmed, and the result is set as the connection string's Password (overriding any Password= in the string; merged with NpgsqlConnectionStringBuilder). A path that does not exist, or an empty file, fails startup with an exception naming the path (same semantics as Licensing:SigningKeyPemFile); the value is never logged. Unset: the connection string is used as written. Exists because on p360 the shared PostgreSQL's per-app password is a Podman secret holding only the password, mounted as /run/secrets/db_pw (see deploy/compose.yaml). Ignored when no connection string is configured (SQLite fallback).
Portal:DataProtectionKeysPath (unset) Directory the ASP.NET Core Data Protection key ring is persisted to (PersistKeysToFileSystem; the per-host SetApplicationName is kept). Unset: the default key location, which in a container is lost with the container, so every restart invalidates every session and antiforgery token. Set it to a path on a volume (/data/dataprotection in deploy/compose.yaml). Each host needs its own directory/volume: the two hosts use different application names and must keep separate key rings.
Portal:InitialiseDatabase true Whether this process brings the schema up at startup (Database.Migrate() on Postgres, EnsureCreated() on SQLite) and runs the PlatformOwner bootstrap and the Development seed. Both applications share one schema, so when they run against the same database exactly one of them must own bring-up — the customer host does, as it always has; src/DynDyn.AdminPortal's shipped appsettings.json sets this to false so the two processes never race a migration.
Portal:SqliteFile (unset — defaults to portal.db in the content root) Overrides the location of the SQLite dev-fallback file used when ConnectionStrings:Portal is unset (relative paths resolve against the content root). Exists so the customer site and the back office can share one SQLite database when run side by side locally — scripts/start-dev.ps1 -AdminPort sets it on the back office process to point at the customer site's portal.db. Irrelevant in production, where ConnectionStrings:Portal selects Postgres instead.
Portal:PublicBaseUrl https://localhost:5001 Externally reachable base URL of this deployment. Every absolute link the app builds is derived from this — nothing is hard-coded. Each application has its own value: the two are different hosts with different hostnames.
Portal:ProductName DynDyn.Pdf Product name shown in the header, page titles and (future) emails.
Portal:DemoUrl https://localhost:5001/demo URL of the hosted bring-your-own-PDF demo, linked from the nav and marketing pages.
Portal:ContactEmail sales@example.com Contact address shown on /contact and used as the mailto: CTA target.
Portal:BootstrapOwnerEmail (unset) Email of the user promoted to the PlatformOwner Identity role at startup, once that user exists. Empty/unset disables the bootstrap — see below.
Portal:ArtifactRoot artifacts Filesystem root FileSystemArtifactStore serves release artifacts from (relative to the content root unless rooted).
Portal:DocsRoot (unset — defaults to ../../docs relative to the content root) Folder the /docs/{slug} pages render markdown from. In a normal repo checkout this resolves to the repo's own docs/ folder. docker/portal.Dockerfile sets this explicitly to /app/docs (a copy of the repo's docs/ baked into the image).
Portal:LegalRoot (unset — defaults to the repo root, ../.. relative to the content root) Folder /legal/licence and /legal/third-party-notices read LICENSE.txt / THIRD-PARTY-NOTICES.txt from. docker/portal.Dockerfile sets this explicitly to /app/legal (just those two files copied in, not the whole repo).
Portal:AuthRateLimitPerMinute 10 Requests per minute the "auth" rate-limit policy allows per remote IP, applied to login/register. It is a fixed-window limiter, and the limit is read from IConfiguration fresh for each new partition (i.e. each new client IP) at the moment that partition is created — not cached once at startup — so changing it takes effect for IPs not yet seen without restarting the app. Tests raise this value to avoid tripping the limiter during a run.
Portal:EnforceTwoFactor true Whether an Owner/Admin without two-factor authentication is blocked from /portal (other than /portal/security) and /admin. Setting this to false is Development-only — both hosts' startup throws if it reaches any other environment; see "Default development administrator" above.
Portal:DevAdmin:Enabled false Seeds a fixed-password administrator (admin/admin by default) idempotently at startup, with the PlatformOwner role and a personal organisation. Development-only — both hosts' startup throws if this is true in any other environment; see "Default development administrator" above.
Portal:DevAdmin:UserName admin Login username for the seeded administrator. Only read when Portal:DevAdmin:Enabled is true, which is itself Development-only.
Portal:DevAdmin:Password admin Fixed password for the seeded administrator. Identity's normal password policy (12-character minimum) is bypassed for the seed — the hash is written directly. Only read when Portal:DevAdmin:Enabled is true, which is itself Development-only.
Portal:DevAdmin:Email admin@localhost Email recorded for the seeded administrator. Only read when Portal:DevAdmin:Enabled is true, which is itself Development-only.
Portal:CustomerBaseUrl (unset — falls back to Portal:PublicBaseUrl) Back-office only: absolute origin of the customer site, used by the handful of admin-surface links (the header's "Portal" button, the footer's legal links) that cross back to it — the back office maps no /portal routes of its own for a same-origin link to reach. Ignored entirely by the customer host, where those links stay relative.

The back office is a separate application

/admin is not a mode of src/DynDyn.Portal — it is src/DynDyn.AdminPortal, a second ASP.NET Core host, its own process and its own deployable, built from the same src/DynDyn.Portal.Shared Razor class library (Data, Migrations, Features, Services, Security, Authorization, Options, Hosting, the shared _Layout and wwwroot, served at /_content/DynDyn.Portal.Shared/...) but with its own composition root. The customer host serves the marketing site, /portal and /api; the back office serves /admin and nothing else — no /portal, no /api, no /docs, no /legal, and no /Identity/Account/Register (its route selectors are cleared at startup, since nobody self-registers into the back office — platform-owner accounts are created out of band, see "First PlatformOwner bootstrap" below). / on the back office redirects to /admin; both hosts answer /healthz.

Each application signs in independently: its own login page, its own authentication cookie (portal.auth on the customer host, admin.auth on the back office), its own antiforgery cookie (portal.antiforgery / admin.antiforgery), and its own ASP.NET Core Data Protection application name (SetApplicationName("DynDyn.Portal") / SetApplicationName("DynDyn.AdminPortal")), so neither host can decrypt the other's session cookie. A session stolen from the customer site — the surface open to the internet at large — is worthless against the back office; a compromised customer process has no /admin routes mapped in it to reach at all. This is a blast-radius boundary on top of, not instead of, the existing authorization: /admin still requires the PlatformOwner role and mandatory two-factor (TwoFactorEnforcementPageFilter) exactly as before — an anonymous caller still gets a 302 to /Identity/Account/Login, not a free pass. The split narrows which process /admin runs in; it does not replace who may use it.

What the two processes still share: one Postgres/SQLite database and migration history (Portal:InitialiseDatabase above says which process owns bring-up), one artifacts volume (Portal:ArtifactRoot — read-write from the back office, which uploads release artifacts at /admin/releases/create, and read-only from the customer host, which serves them), and one licence signing key (Licensing:SigningKeyPemFile).

Deployment. docker/portal.Dockerfile builds both applications from one Dockerfile (--target portal / --target admin), sharing the restore and build layers so the two images only differ in their last stage. docker/docker-compose.portal.yml runs them as the portal and admin services alongside postgres; admin waits on portal's health check so schema bring-up only ever happens once, before the back office starts querying it. docker/admin.env, loaded by the admin service only and layered after docker/portal.env, carries the handful of settings that must differ between the two hosts — Portal:PublicBaseUrl above all, since the back office has its own hostname — see docker/portal.env.example for the full list. docker/.env's PORTAL_PUBLIC_BASE_URL feeds the customer site's origin to the admin service as Portal:CustomerBaseUrl. Neither service publishes a host port by default; the repo's real deployment sits behind Traefik, and the compose file's commented-out labels: block gives the back office its own router, on its own hostname, behind an IP allow-list — publishing it on the same open listener the split exists to get it off of would undo the whole point.

Locally, pwsh scripts/start-dev.ps1 -AdminPort <port> starts both processes against one SQLite database — see "Default development administrator" above.

First PlatformOwner bootstrap

There is no admin UI to grant the PlatformOwner role, by design (no impersonation, no privilege escalation surface in phase 1). Instead:

  1. Register a normal account through /Identity/Account/Register using the email you intend to be the platform owner.
  2. Set Portal:BootstrapOwnerEmail to that same email (environment variable Portal__BootstrapOwnerEmail, or the equivalent config key for your deployment).
  3. Restart the app. BootstrapOwnerRole.EnsureAsync runs on every startup, finds the user by email, creates the PlatformOwner role if it doesn't exist, and adds the user to it if not already a member. It is idempotent and safe to leave configured permanently.
  4. Sign out and back in (or wait for the next login) so the new role claim lands in a fresh authentication cookie, then enable two-factor authentication at /portal/security — /admin requires both the PlatformOwner role and 2FA.

If Portal:BootstrapOwnerEmail is unset, no user is ever promoted and /admin is unreachable by anyone. That is the expected state for a fresh deployment until an operator configures it.

Licensing

Config keys live under Licensing: (Features/Licensing/LicensingOptions.cs):

Key Default Purpose
Licensing:Signer Pem Pem or Kms (case-insensitive). Selects the IPayloadSigner implementation; exactly one is ever constructed, at startup.
Licensing:SigningKeyPem (unset) PEM-encoded ECDSA P-256 private key for PemPayloadSigner. One of this and Licensing:SigningKeyPemFile is required under ASPNETCORE_ENVIRONMENT=Production: PemPayloadSigner's constructor throws if neither is set there, so the app fails at boot (see Licensing:AllowPemSignerInProduction below for why that is a startup failure and not a first-request one). Outside Production, unset/blank falls back to an ephemeral in-memory key generated at startup and logs a warning — fine for a dev run, useless anywhere durable, because revocation lists and issued licences stop verifying across a restart.
Licensing:SigningKeyPemFile (unset) Path to a file holding that PEM, read at startup when Licensing:SigningKeyPem is empty. This is the route for containers: a PEM is multi-line, and Docker's --env-file and Compose's env_file: reject multi-line values outright, so the env-var form only works through docker run -e "Licensing__SigningKeyPem=$PEM" from a shell that preserves the newlines. It also keeps the key out of the process environment. docker-compose.portal.yml sets this to /data/keys/signing.pem and mounts the portal-keys volume there read-only. A path that does not exist is a startup failure naming the path, not a silent fallback.
Licensing:SigningKeyId portal-v1 The KeyId stamped on every licence and revocation-list payload this instance signs.
Licensing:AllowPemSignerInProduction false Must be explicitly true to run Signer=Pem under ASPNETCORE_ENVIRONMENT=Production. Otherwise PemPayloadSigner's constructor throws — and because LicensingStartupValidator (an IHostedService) force-resolves IPayloadSigner during host startup, this fails the app at boot rather than on the first licence request.
Licensing:MaxRehostsPerYear 3 Cap on deactivations per licence over a rolling 365-day window, counted across every re-issued row that shares the same LicenseId. Exceeding it returns 403.
Licensing:ActivationCodeLifetimeDays 90 Activation code lifetime, capped at the licence's own ExpiresAt if that comes sooner.
Licensing:RevocationCacheSeconds 60 How long the signed revocation-list token (GET /api/v1/revocations) is cached before being recomputed.
Licensing:GraceDays 30 Informational only, per its own doc comment — not read by any policy check today.
Licensing:TrustedKeys (empty) {KeyId: publicKeyPem} trust table for verifying licences/revocation lists signed under a rotated key.

The KMS signer is a stub, not a missing feature: setting Licensing:Signer=Kms selects KmsPayloadSigner, whose KeyId, PublicKeyPem and Sign members all throw NotSupportedException. The app boots fine either way — the exception only fires the first time something actually needs a signature — but no licence, activation code or revocation list can be issued/signed while Signer=Kms until a real KMS-backed implementation replaces the stub.

Behaviour, verified against Features/Licensing/*.cs:

Distribution

Config keys live under Distribution: (Features/Distribution/DistributionOptions.cs):

Key Default Purpose
Distribution:Store FileSystem FileSystem or S3 (case-insensitive). Selects the IArtifactStore implementation.
Distribution:S3Bucket (unset) Required when Store=S3 — S3ArtifactStore's constructor throws if blank.
Distribution:S3Region (unset) AWS region name, e.g. eu-central-1.
Distribution:S3ServiceUrl (unset) Set for an S3-compatible endpoint (e.g. MinIO); also switches the client to path-style addressing.
Distribution:S3AccessKey / S3SecretKey (unset) Static credentials. If either is blank the client falls back to the default AWS credential chain.
Distribution:S3PresignLifetimeMinutes 5 Lifetime of a presigned download URL.
Distribution:ApiTokenPrefix ddp_ Prepended to a generated API token's random secret. Note: the token's stored Prefix display column is the first 8 characters of the full token (prefix + secret), not this setting's value alone.
Distribution:MaxArtifactSizeBytes 536870912 (512 MiB) Enforced on the upload endpoint (bumps the request's MaxRequestBodySize and rejects with 413 if exceeded) and on the Admin upload form, which additionally has a hard [RequestSizeLimit(2_000_000_000)] (~2 GB) ceiling regardless of this setting.

Behaviour, verified against Features/Distribution/*.cs:

Billing

Config keys live under Stripe: (Features/Billing/StripeOptions.cs):

Key Default Purpose
Stripe:SecretKey (unset) Stripe API secret key. Blank keeps IBillingGateway on NullBillingGateway; setting it swaps in StripeBillingGateway and registers the Stripe client (BillingFeature.AddBillingFeature). StripeOptions.IsConfigured is exactly "this is non-blank".
Stripe:WebhookSecret (unset) Required to verify Stripe-Signature on inbound webhooks — the webhook endpoint returns 503 while this is unset.
Stripe:PublishableKey (unset) Used by the client-side Stripe.js on the billing/checkout pages.
Stripe:Prices:Starter, Stripe:Prices:Pro (unset) Stripe Price IDs for the two self-serve tiers (dictionary, case-insensitive keys). Community needs no Stripe price (it's free); Enterprise deliberately has none either — it's sales-led, per StripeOptions' own doc comment.
Stripe:TaxEnabled false Enables Stripe Tax on Checkout sessions.
Stripe:TrialDays 0 Trial length applied to new Checkout sessions.

Webhook endpoint: POST /api/v1/billing/webhook (anonymous, antiforgery disabled, raw body + Stripe-Signature header). Idempotency is a StripeEvent ledger row keyed by Stripe's EventId: a delivery whose row already has ProcessedAt != null && Error == null is short-circuited with a plain 200 and nothing is reprocessed.

What happens when something throws, from StripeWebhookHandler.cs:

Event application (the subscription upsert plus the audit-log write) runs inside a database transaction. If that work — or the following save/commit — throws, the transaction is rolled back (none of that event's DB writes persist), StripeEvent.Error is set to the exception message with ProcessedAt left/reset to null, and the handler returns 500. Because the idempotency check requires both ProcessedAt != null and Error == null, Stripe's automatic retry of that same event delivery does not short-circuit — it re-enters the handler and reprocesses the event from scratch (the upsert logic is written to be safe to repeat).

Registered ISubscriptionLifecycleHandlers (e.g. SubscriptionLicenseIssuer, see Licensing above) run only after that transaction has already committed, so a handler throwing does not roll back the Subscription/StripeEvent write — it stays committed with ProcessedAt already non-null. A handler failure instead sets just StripeEvent.Error (leaving the already-committed ProcessedAt alone) and the endpoint still returns 500. Since Error is now non-null, the same idempotency check still fails on retry — so a retry after a handler failure also does not short-circuit: it re-runs the whole event application again (a harmless repeat of an idempotent upsert) and re-invokes every registered lifecycle handler, including ones that already succeeded on the first attempt.

Other behaviour:

Release publishing from CI

Implemented via the Distribution feature above: a CI job (or any automation) publishes a release by calling POST /api/v1/releases with a Bearer token carrying the releases:publish scope — the same mechanism a human uses via /portal/tokens to create that token. There is no separate "CI publishing" code path; it is the same endpoint DistributionAuthFilter gates for any caller. A release created this way starts unpublished (IsPublic=false); making it visible on the public GET /api/v1/releases listing is a separate PublishAsync step (currently exposed only through IReleaseService, i.e. from the Admin pages or by extending them — there is no POST /api/v1/releases/{version}/publish endpoint yet).

Key rotation

Licence/revocation-list signing key custody is Licensing:SigningKeyPem / Licensing:SigningKeyId / Licensing:TrustedKeys (see Licensing above), not a portal-specific mechanism. To rotate: generate a new key, deploy it as Licensing:SigningKeyPem with a new Licensing:SigningKeyId, and add the old KeyId → its public key to Licensing:TrustedKeys so licences already issued under the old key keep verifying. There is no in-app rotation UI or automation — this is an operator procedure against the config values above. The Licensing:Signer=Kms path (intended to source the key from a real KMS instead of a config value) is a stub — see Licensing above — so today every deployment necessarily uses the PEM signer.

Health check

GET /healthz (AddHealthChecks() / MapHealthChecks("/healthz") in Program.cs) — no checks are registered against it yet, so it currently reports healthy whenever the process is up and answering HTTP requests. docker/portal.Dockerfile adds a container HEALTHCHECK that curls this endpoint every 30s.

Known gaps

Deliberate scope cuts and known limitations, tracked here per the "every gap needs a line here" rule rather than as inline TODOs: