Skip to content

The customer portal

A tenant buys seats, tries a module and changes plan at the operator’s customer portal, not in the product. Box Office gives the product one thing for it: a signed link that opens the portal for the right tenant, so the portal does not have to ask who is calling and the tenant cannot be changed by editing the address.

The portal is optional. A product that sets neither option has no customer portal: the frontend does not offer to buy, and asking for a link fails with CustomerPortalNotConfigured.

OptionDefaultMeaning
CustomerPortalUrlnoneThe address of the operator’s customer portal.
CustomerPortalSigningKeynoneThe key the product and the operator share. Keep it in a secret store, never in source.
CustomerPortalLinkLifetime15 minutesHow long a link works. More than zero and at most a day.
builder.AddBoxOffice(boxOffice =>
{
boxOffice.CustomerPortalUrl = new("https://portal.example.com/box-office");
boxOffice.CustomerPortalSigningKey = builder.Configuration["BoxOffice:PortalSigningKey"];
});

The host fails to start when only one of the address and the key is set, so a half-configured portal cannot sign links nobody can verify.

PortalLink.LinkToPortal is a query the frontend calls through LinkToPortal in @cratis/showrunner.boxoffice, with what the user wants to do and, when they looked at one, the offer. Any signed-in user may ask for a link. The link is the portal’s address with these parameters:

ParameterMeaning
tenantThe tenant, which is the name of its Chronicle namespace.
intentbuy, try or changeplan.
offerThe offer the user looked at; left out when there was none.
personThe signed-in user as provider:subject, so the portal can say who is buying; left out when the user is not known.
expiresWhen the link stops working, in seconds since 1970-01-01 UTC.
signatureAn HMAC-SHA256 over the other parameters, as base64url without padding.

The tenant comes from the application’s event store, never from the request, so a user cannot ask for a link for another tenant.

The portal makes the same signature with the shared key and refuses the link when it differs, or when expires has passed. The text that is signed is the five values joined with a line feed, in this order, empty when a value is left out:

tenant
intent
offer
person
expires

SignedCustomerPortal.Canonical builds that text, for a portal written in .NET. Compare signatures in constant time. A link is a bearer credential for its lifetime, so keep the lifetime short and do not log full links.

Which tenant, what the user intends and who they are is all the link says. What the portal then sells and how it takes payment is the operator’s business; the result reaches the product as the operator’s decisions in Contracts, and the frontend refreshes what the tenant may do when it next asks.