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:
- Register a normal account through
/Identity/Account/Registerusing the email you intend to be the platform owner. - Set
Portal:BootstrapOwnerEmailto that same email (environment variablePortal__BootstrapOwnerEmail, or the equivalent config key for your deployment). - Restart the app.
BootstrapOwnerRole.EnsureAsyncruns on every startup, finds the user by email, creates thePlatformOwnerrole 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. - 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—/adminrequires both thePlatformOwnerrole 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:
- Activation (
POST /api/v1/activations, and/portal/activationsfor Owner/Admin/Developer): a fuzzy 3-of-5 fingerprint match (MachineFingerprint.Matches) against any live activation sharing the licence'sLicenseIdreuses/renews that activation without consuming a seat; a genuinely new fingerprint is blocked with 409 once live activations reach the row'sMaxInstances. Deactivation counts towardMaxRehostsPerYearthe same way, across sibling rows. GET /api/v1/licenses/currenttakesAuthorization: License <key>, returns status (Active/Lapsed/Revoked), whether the presented key is still the current one for thatLicenseId(a re-issue mints a new key on the sameLicenseIdand revokes the old row), edition, seats used/total and the feature list.- Self-service licence management (
/portal/licenses) — generate (evaluation or from-subscription), re-issue, revoke — is restricted to Owner/Admin only (Pages/Portal/Licenses/Index.cshtml.cs'sManageRoles).docs/portal.md§4 describes a Developer self-generate path ("Developer may generate one for themself if seats are free"); the code does not implement it — seedocs/portal.md§9. - One evaluation licence per organisation, ever.
LicenseService.HasEvaluationLicenseAsyncchecksEdition == Evaluationfor the org regardless of status, so even a revoked evaluation licence permanently blocks issuing another one for that org. - Manual admin issuance (
/admin/licenses/issue,PlatformOwner+ mandatory 2FA) bypasses every business rule enforced elsewhere inLicenseService— not just the seat cap — per that service's own doc comment: it is the intended override path. - Automatic issuance from Stripe subscriptions.
SubscriptionLicenseIssuer(Features/Licensing/SubscriptionLicenseIssuer.cs) is registered as anISubscriptionLifecycleHandleralongside any other handler and runs on every subscription change the billing webhook processes (see Billing below):- The first time an org's subscription becomes
activeortrialingon a licensable tier (Starter/Pro/Enterprise — Community carries no entitlement), it issues a licence via the sameLicenseService.IssueManualAsyncpath the admin form uses, with seats fromSubscription.Seatsand expiry atSubscription.CurrentPeriodEnd.trialingis treated identically toactive— a licence is issued for the full length of a Stripe trial, not only once the first payment succeeds. - A later
active/trialingchange (tier/seat/period-end moved) updates that same licence row in place — no key rotation, since an already-installed key must keep resolving — and never issues a second licence for the same subscription. canceledlets the existing licence lapse at the subscription's already-known period end (setsExpiresAt) rather than revoking it immediately.past_dueand any other status this handler doesn't recognise: no change — a dunning subscription never revokes access.- If
Organisation.IssuancePausedis set (via a Stripe dispute — see Billing), issuance/updates are skipped entirely and an audit entry (licensing.issuance_skipped) is written instead.
- The first time an org's subscription becomes
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:
- Download (
GET /api/v1/releases/{version}/{kind}/{rid}/download) accepts a Bearer token with thedownloadscope or an authenticated portal session with active org membership — every download is recorded as aDownloadEventeither way, so the endpoint always needs an identity. When the active store supports presigning (Store=S3), the response is a redirect to a presigned URL;FileSystemArtifactStorealways returnsnullfromTryGetPresignedUrl, so onStore=FileSystemthe endpoint streams the file directly with HTTP range support instead. - Publish (
POST /api/v1/releases, multipart) requires a Bearer token with thereleases:publishscope — deliberately no session/PlatformOwnerfallback, because minimal-API routes sit outsideUseAntiforgery(). The Admin UI (/admin/releases/create) instead callsIReleaseServicedirectly and always creates the release unpublished (IsPublic=false);ReleaseServicehas separatePublishAsync/YankAsyncmethods for the explicit state change. The public listing (GET /api/v1/releases, anonymous) only returns releases withIsPublic=true && !IsYanked, with an optional?channel=filter (stableorpreview). - API token scopes in use (
ApiTokenScopes.Known):download,activate,releases:publish,admin,licenses:read. - Token management (
/portal/tokens) is Owner/Admin only; every org role can view existing tokens (prefix/last-four only) and download releases.
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:
- One live subscription per organisation (
Subscription.OrgIdis a unique index).StartCheckoutAsyncopens a new Stripe Checkout session only when the org has no subscription in a live status (active/trialing/past_due/unpaid); otherwise a tier change is applied as a price change on the existing subscription (ProrationBehavior=create_prorations), not a new subscription. customer.updatedkeepsOrganisation.BillingEmailin sync from Stripe.charge.dispute.createdsetsOrganisation.IssuancePaused = true— this only pauses future licence issuance/updates (see Licensing above); nothing already granted is revoked./portal/billing: Owner/Admin/Billing roles can start checkout, open the Stripe billing portal, cancel or resume; every org role can view the current plan./admin/stripe-events(PlatformOwner, read-only) lists the raw ledger, capped at the 200 most recent rows byReceivedAt— useful for diagnosing a stuck or failed webhook without DB access.
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:
- The admin image is not built by CI.
.gitlab-ci.ymlbuilds only the customer image (the Dockerfile's last stage,portal) through the shared templateci/build-image.ymlof DynDyn.Servers, which has no--targetsupport, so theadminimage (--target admin) is not published and the back office is not deployed to p360. The fix is an optionalTARGETvariable in that template (docker build --target "$TARGET"), owned by the DynDyn.Servers repo; once it exists, add a secondextends: .p360_build_imagejob withTARGET: adminandIMAGE_NAME: admin. - No team members at all. The portal is single-seat: an organisation has exactly one member,
its Owner, for its whole life.
IOrganisationServicehas no add/change-role/remove/transfer operation any more —CreatePersonalOrganisationAsync(run once, at registration) is the only code path that ever writes aMembershiprow (docs/portal.md §9). This isn't a gap to fill later; it's a deliberate product decision. - Most default Identity UI pages are unstyled.
AddDefaultUI()supplies the packaged Razor Class Library pages for login/2FA/manage-account under/Identity/Account/.... No Bootstrap or jQuery assets are bundled, so those pages render with browser-default styling rather than the site's own design system (wwwroot/css/site.css). The one exception is/Identity/Account/Register, which is overridden in-project (Areas/Identity/Pages/Account/Register.cshtml) purely so a new account gets a personal organisation and an Owner membership (IOrganisationService.CreatePersonalOrganisationAsync) — it picks up the site layout as a side effect of that override, not as a deliberate styling pass; every other packaged page (login, 2FA setup, manage account, forgot password) is unstyled. - No email sender.
NoOpEmailSender(Services/NoOpEmailSender.cs) satisfies the packaged Identity UI pages'IEmailSenderdependency by logging instead of sending. Password reset and email-confirmation links are never actually delivered. - The KMS signer is a stub.
Licensing:Signer=Kmsboots the app but throwsNotSupportedExceptionon every signing operation — see Licensing above. Only the PEM signer is usable today. - No impersonation, no SSO. Both remain out of scope per
docs/portal.md§9.PlatformOwneradmins debug customer issues by querying the DB directly or reading/admin/audit; there is no "log in as this org" feature.Organisation.SsoConfigexists as an unused, reserved column. - The Postgres migration is generated but has limited real-database exercise.
Migrations/InitialCreate(and any migrations layered on it since) is generated withdotnet ef migrations addagainst the Npgsql design-time factory (Data/PortalDbContextFactory.cs) and runs viaDatabase.Migrate()at startup whenConnectionStrings:Portalis set. Local/dev/test all default to SQLiteEnsureCreated()instead, which does not exercise the migration path at all —docker/docker-compose.portal.yml(Postgres 17) is the primary way to exercise it before a real deployment. - No
.gitlab-ci.ymlstage runsdotnet ef database update(or any equivalent) against a real Postgres.build:imagebuilds the customer image fromdocker/portal.Dockerfile(and pushes it fromv*tags), but nothing in CI exercisesDatabase.Migrate()against Postgres before a deployment does —docker/docker-compose.portal.yml(Postgres 17) is the primary way to exercise that path today, and only locally.