Add a tailnet demo of the signup and billing flow with simulated Keymail and Stripe
TestBillingFlowDemo (skipped unless CARLOS_BILLING_DEMO_ADDR, _ORIGIN and _STOP are set) serves the real console handlers and store on a listener, with Keymail's passkey ceremony and Stripe's hosted checkout and portal replaced by labelled simulations on the same host. The console's own Stripe client and its hosted-URL host check are untouched: the demo rewrites checkout.stripe.com and billing.stripe.com in the Location header outside the console. Payment delivers the signed webhooks a real payment would. Verified by driving sign-in, step-up, checkout, payment, the active plan, the portal and a failed renewal with curl.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+451
−0
internal/console/billingflow_demo_test.go+451 −0
CI passed — run details
diff --git a/internal/console/billingflow_demo_test.go b/internal/console/billingflow_demo_test.go| new file mode 100644 |
| index 000000000..f24cc9fc2 |
| --- /dev/null |
| +++ b/internal/console/billingflow_demo_test.go |
| @@ -0,0 +1,451 @@ |
| +package console |
| + |
| +import ( |
| + "crypto/hmac" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "fmt" |
| + "html" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "os" |
| + "path" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/billingstripe" |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +// TestBillingFlowDemo serves a review copy of the signup and billing flow on |
| +// a real listener: the real console handlers and store, with Keymail and |
| +// Stripe replaced by clearly labelled simulations that live on the same |
| +// listener. Nothing external is called and no real account, card or charge |
| +// exists anywhere. It is a review aid, skipped unless three variables are |
| +// set: |
| +// |
| +// CARLOS_BILLING_DEMO_ADDR listen address, e.g. 0.0.0.0:41881 |
| +// CARLOS_BILLING_DEMO_ORIGIN the URL the reviewer's browser uses, |
| +// e.g. http://100.119.180.57:41881 |
| +// CARLOS_BILLING_DEMO_STOP a path; the demo ends when the file exists |
| +// |
| +// The console's own Stripe client refuses any hosted URL that is not on |
| +// checkout.stripe.com or billing.stripe.com, and that check is left alone: |
| +// the demo rewrites those two hosts to its simulated pages in the response |
| +// Location header, outside the console. |
| +func TestBillingFlowDemo(t *testing.T) { |
| + addr, origin, stop := os.Getenv("CARLOS_BILLING_DEMO_ADDR"), os.Getenv("CARLOS_BILLING_DEMO_ORIGIN"), os.Getenv("CARLOS_BILLING_DEMO_STOP") |
| + if addr == "" || origin == "" || stop == "" { |
| + t.Skip("set CARLOS_BILLING_DEMO_ADDR, CARLOS_BILLING_DEMO_ORIGIN and CARLOS_BILLING_DEMO_STOP to serve the demo") |
| + } |
| + origin = strings.TrimRight(origin, "/") |
| + regPath, relStore, priv, pub := buildFixtures(t) |
| + dir := t.TempDir() |
| + st, err := Open(filepath.Join(dir, "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer st.Close() |
| + |
| + fk := newFakeKeymail(t, "paul@keymail.dev") |
| + fk.mu.Lock() |
| + fk.authTime = time.Now() |
| + fk.mu.Unlock() |
| + stripe := newDemoStripe(origin) |
| + stripeAPI := httptest.NewServer(http.HandlerFunc(stripe.serveAPI)) |
| + defer stripeAPI.Close() |
| + |
| + srv, err := NewServer(Config{ |
| + Origin: origin, Keymail: origin + "/__keymail", Registry: regPath, |
| + Releases: relStore, ReleasePub: pub, Key: priv, AppScheme: "http", |
| + }, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv.instances.timeout = 100 * time.Millisecond |
| + client, err := billingstripe.NewClient("rk_test_demo", billingstripe.WithBaseURL(stripeAPI.URL)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + prices := map[string]string{} |
| + for _, plan := range billingPaidPlans { |
| + for _, band := range []string{plans.BandA, plans.BandB, plans.BandC, plans.BandD} { |
| + prices[billingPriceKey(plans.CurrentTermsVersion, plan, band)] = "price_" + plan + "_" + band |
| + } |
| + } |
| + srv.billing = &billingRuntime{locks: newStoreWalkLocks(), client: client, cfg: BillingConfig{ |
| + MerchantAccount: "acct_demo", PortalConfiguration: "bpc_demo", |
| + TaxCodes: map[string]string{plans.PlanPersonal: "txcd_10102001", plans.PlanCommunity: "txcd_10102001", plans.PlanBusiness: "txcd_10102000"}, |
| + CountryBands: plans.CountryBands(), Prices: prices, WebhookSecrets: []string{demoWebhookSecret}, |
| + }} |
| + stripe.console = srv |
| + |
| + mux := http.NewServeMux() |
| + // Simulated Keymail, mounted where the console's Keymail base points. |
| + km := http.NewServeMux() |
| + km.HandleFunc("GET /.well-known/keymail", fk.handleWellKnown) |
| + km.HandleFunc("POST /api/oauth/token", fk.handleToken) |
| + km.HandleFunc("POST /api/oauth/revoke", fk.handleRevoke) |
| + km.HandleFunc("GET /api/federation/lookup", fk.handleLookup) |
| + km.HandleFunc("POST /api/app/send", fk.handleSend) |
| + km.HandleFunc("GET /oauth/authorize", func(w http.ResponseWriter, r *http.Request) { demoKeymailAuthorize(w, r, fk) }) |
| + mux.Handle("/__keymail/", http.StripPrefix("/__keymail", km)) |
| + mux.HandleFunc("/__stripe/", stripe.servePages) |
| + mux.Handle("/", demoRewriteHostedURLs(origin, srv)) |
| + |
| + ln, err := net.Listen("tcp", addr) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + hs := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} |
| + go hs.Serve(ln) //nolint:errcheck |
| + t.Logf("billing flow demo serving on %s as %s", addr, origin) |
| + deadline := time.Now().Add(14 * time.Hour) |
| + for time.Now().Before(deadline) { |
| + if _, err := os.Stat(stop); err == nil { |
| + break |
| + } |
| + time.Sleep(5 * time.Second) |
| + } |
| + hs.Close() |
| +} |
| + |
| +const demoWebhookSecret = "whsec_demo_not_a_secret" |
| + |
| +// demoKeymailAuthorize is the simulated Keymail approval page. It stands in |
| +// for keymail.dev's passkey ceremony: the reviewer types an address and |
| +// approves, and the fake mints a single-use code for the console to |
| +// exchange. It serves both the sign-in and the step-up redirect. |
| +func demoKeymailAuthorize(w http.ResponseWriter, r *http.Request, fk *fakeKeymail) { |
| + q := r.URL.Query() |
| + if r.URL.Query().Get("approve") == "1" { |
| + address := strings.TrimSpace(q.Get("address")) |
| + if address == "" || !strings.Contains(address, "@") { |
| + address = "paul@keymail.dev" |
| + } |
| + var b [12]byte |
| + rand.Read(b[:]) //nolint:errcheck |
| + code := "demo-" + hex.EncodeToString(b[:]) |
| + fk.mu.Lock() |
| + fk.address = address |
| + fk.challenge = q.Get("code_challenge") |
| + fk.expectCode = code |
| + fk.authTime = time.Now() |
| + fk.mu.Unlock() |
| + back, err := url.Parse(q.Get("redirect_uri")) |
| + if err != nil || back.Path == "" { |
| + http.Error(w, "bad redirect_uri", http.StatusBadRequest) |
| + return |
| + } |
| + bq := back.Query() |
| + bq.Set("code", code) |
| + bq.Set("state", q.Get("state")) |
| + back.RawQuery = bq.Encode() |
| + http.Redirect(w, r, back.String(), http.StatusSeeOther) |
| + return |
| + } |
| + step := "Sign in" |
| + if strings.HasSuffix(q.Get("redirect_uri"), "/auth/stepup/callback") { |
| + step = "Confirm it is you" |
| + } |
| + fmt.Fprintf(w, demoPage, "Simulated Keymail", `<p class="sim">SIMULATION. This stands in for the passkey sign-in at keymail.dev. No real Keymail account is touched. Type any address; it becomes the console user.</p> |
| +<h1>`+step+` with Keymail</h1> |
| +<form method="get" action="`+html.EscapeString(r.URL.Path)+`"> |
| +`+demoHidden(q, "client_id", "redirect_uri", "state", "code_challenge", "code_challenge_method", "scope", "response_type")+` |
| +<input type="hidden" name="approve" value="1"> |
| +<label>Address <input type="email" name="address" value="paul@keymail.dev"></label> |
| +<button type="submit">Approve</button> |
| +</form>`) |
| +} |
| + |
| +func demoHidden(q url.Values, keys ...string) string { |
| + var b strings.Builder |
| + for _, k := range keys { |
| + if v := q.Get(k); v != "" { |
| + fmt.Fprintf(&b, `<input type="hidden" name="%s" value="%s">`+"\n", k, html.EscapeString(v)) |
| + } |
| + } |
| + return b.String() |
| +} |
| + |
| +// demoRewriteHostedURLs sends the browser to the simulated Stripe pages |
| +// where the real console would send it to checkout.stripe.com or |
| +// billing.stripe.com. The console's own host validation is untouched. |
| +func demoRewriteHostedURLs(origin string, next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + next.ServeHTTP(&demoLocationWriter{ResponseWriter: w, origin: origin}, r) |
| + }) |
| +} |
| + |
| +type demoLocationWriter struct { |
| + http.ResponseWriter |
| + origin string |
| +} |
| + |
| +func (d *demoLocationWriter) WriteHeader(code int) { |
| + if loc := d.Header().Get("Location"); loc != "" { |
| + for host, page := range map[string]string{"https://checkout.stripe.com/": "/__stripe/checkout/", "https://billing.stripe.com/": "/__stripe/portal/"} { |
| + if strings.HasPrefix(loc, host) { |
| + d.Header().Set("Location", d.origin+page+strings.TrimPrefix(loc, host)) |
| + } |
| + } |
| + } |
| + d.ResponseWriter.WriteHeader(code) |
| +} |
| + |
| +// demoStripe is a simulated Stripe: the API the console calls (server side) |
| +// and the hosted pages the browser sees (checkout, portal), both fake, both |
| +// labelled. Sessions complete only when the reviewer presses Pay. |
| +type demoStripe struct { |
| + origin string |
| + console http.Handler |
| + mu sync.Mutex |
| + seq int |
| + sessions map[string]*demoSession |
| + customerCountry string |
| + customerSqid string |
| + subscription *demoSubscription |
| +} |
| + |
| +type demoSession struct { |
| + ID, Status, Payment, SuccessURL, CancelURL, Price, Sqid string |
| + Quantity int64 |
| + Meta map[string]string |
| + Expires int64 |
| +} |
| + |
| +type demoSubscription struct { |
| + ID, Status, Price string |
| + Quantity int64 |
| + Meta map[string]string |
| + Start, End int64 |
| +} |
| + |
| +func newDemoStripe(origin string) *demoStripe { |
| + return &demoStripe{origin: origin, sessions: map[string]*demoSession{}, customerCountry: "IE"} |
| +} |
| + |
| +func demoPlanBand(priceID string) (plan, band string) { |
| + parts := strings.Split(strings.TrimPrefix(priceID, "price_"), "_") |
| + if len(parts) == 2 { |
| + return parts[0], parts[1] |
| + } |
| + return "", "" |
| +} |
| + |
| +func (d *demoStripe) serveAPI(w http.ResponseWriter, r *http.Request) { |
| + d.mu.Lock() |
| + defer d.mu.Unlock() |
| + w.Header().Set("Content-Type", "application/json") |
| + now := time.Now() |
| + var out any |
| + p := r.URL.Path |
| + switch { |
| + case p == "/v1/account": |
| + out = map[string]string{"id": "acct_demo"} |
| + case strings.HasPrefix(p, "/v1/products/prod_"): |
| + plan := strings.TrimPrefix(p, "/v1/products/prod_") |
| + code := "txcd_10102001" |
| + if plan == plans.PlanBusiness { |
| + code = "txcd_10102000" |
| + } |
| + out = map[string]any{"id": "prod_" + plan, "active": true, "livemode": false, "tax_code": code} |
| + case strings.HasPrefix(p, "/v1/prices/price_"): |
| + plan, band := demoPlanBand(strings.TrimPrefix(p, "/v1/prices/")) |
| + def, err := plans.Lookup(plans.CurrentTermsVersion, plan) |
| + if err != nil { |
| + w.WriteHeader(http.StatusNotFound) |
| + return |
| + } |
| + q, err := plans.Quote(plans.CurrentTermsVersion, plan, band, def.MinQuantity) |
| + if err != nil { |
| + w.WriteHeader(http.StatusNotFound) |
| + return |
| + } |
| + out = map[string]any{"id": "price_" + plan + "_" + band, "product": "prod_" + plan, "active": true, "livemode": false, "currency": "eur", "unit_amount": q.AnnualPriceMicroEUR / q.Quantity / 10000, "tax_behavior": "exclusive", "billing_scheme": "per_unit", "metadata": map[string]string{"terms_version": plans.CurrentTermsVersion, "plan": plan, "band": band}, "recurring": map[string]any{"interval": "year", "interval_count": 1, "usage_type": "licensed"}} |
| + case p == "/v1/billing_portal/configurations/bpc_demo": |
| + out = map[string]any{"id": "bpc_demo", "active": true, "livemode": false, "features": map[string]any{"customer_update": map[string]any{"enabled": false}, "subscription_update": map[string]any{"enabled": false}, "subscription_cancel": map[string]any{"enabled": true, "mode": "at_period_end"}, "payment_method_update": map[string]bool{"enabled": true}, "invoice_history": map[string]bool{"enabled": true}}} |
| + case p == "/v1/billing_portal/sessions" && r.Method == http.MethodPost: |
| + out = map[string]any{"id": "bps_demo", "url": "https://billing.stripe.com/p/session/demo", "livemode": false} |
| + case p == "/v1/tax/registrations": |
| + rows := []map[string]any{} |
| + for _, kind := range []string{"standard", "oss_union"} { |
| + rows = append(rows, map[string]any{"id": "taxreg_demo_" + kind, "country": "IE", "status": "active", "active_from": now.Add(-time.Hour).Unix(), "country_options": map[string]any{"ie": map[string]string{"type": kind}}}) |
| + } |
| + out = map[string]any{"data": rows, "has_more": false} |
| + case p == "/v1/customers" || strings.HasPrefix(p, "/v1/customers/"): |
| + if r.Method == http.MethodPost { |
| + _ = r.ParseForm() |
| + if c := r.PostForm.Get("address[country]"); c != "" { |
| + d.customerCountry = c |
| + } |
| + if sq := r.PostForm.Get("metadata[account_sqid]"); sq != "" { |
| + d.customerSqid = sq |
| + } |
| + } |
| + out = map[string]any{"id": "cus_demo", "livemode": false, "address": map[string]string{"country": d.customerCountry}, "shipping": nil, "metadata": map[string]string{"account_sqid": d.customerSqid}, "tax": map[string]any{"automatic_tax": "supported", "location": map[string]string{"country": d.customerCountry}}} |
| + case p == "/v1/checkout/sessions" && r.Method == http.MethodPost: |
| + _ = r.ParseForm() |
| + d.seq++ |
| + s := &demoSession{ID: fmt.Sprintf("cs_demo_%d", d.seq), Status: "open", Payment: "unpaid", SuccessURL: r.PostForm.Get("success_url"), CancelURL: r.PostForm.Get("cancel_url"), Price: r.PostForm.Get("line_items[0][price]"), Sqid: r.PostForm.Get("client_reference_id"), Meta: map[string]string{}, Expires: now.Add(24 * time.Hour).Unix()} |
| + s.Quantity, _ = parseInt64(r.PostForm.Get("line_items[0][quantity]")) |
| + for _, key := range []string{"account_sqid", "terms_version", "plan", "band", "checkout_intent"} { |
| + s.Meta[key] = r.PostForm.Get("subscription_data[metadata][" + key + "]") |
| + } |
| + d.sessions[s.ID] = s |
| + out = d.sessionJSON(s) |
| + case p == "/v1/checkout/sessions" && r.Method == http.MethodGet: |
| + rows := []any{} |
| + for _, s := range d.sessions { |
| + rows = append(rows, d.sessionJSON(s)) |
| + } |
| + out = map[string]any{"data": rows, "has_more": false} |
| + case strings.HasPrefix(p, "/v1/checkout/sessions/"): |
| + id := strings.TrimPrefix(p, "/v1/checkout/sessions/") |
| + if strings.HasSuffix(id, "/expire") { |
| + id = strings.TrimSuffix(id, "/expire") |
| + if s := d.sessions[id]; s != nil && s.Status == "open" { |
| + s.Status = "expired" |
| + } |
| + } |
| + s := d.sessions[id] |
| + if s == nil { |
| + w.WriteHeader(http.StatusNotFound) |
| + return |
| + } |
| + out = d.sessionJSON(s) |
| + case p == "/v1/subscriptions/sub_demo": |
| + if d.subscription == nil { |
| + w.WriteHeader(http.StatusNotFound) |
| + return |
| + } |
| + s := d.subscription |
| + out = map[string]any{"id": s.ID, "customer": "cus_demo", "status": s.Status, "livemode": false, "created": s.Start, "current_period_start": s.Start, "current_period_end": s.End, "metadata": s.Meta, "items": map[string]any{"data": []map[string]any{{"id": "si_demo", "price": map[string]string{"id": s.Price}, "quantity": s.Quantity}}, "has_more": false}} |
| + default: |
| + w.WriteHeader(http.StatusNotFound) |
| + return |
| + } |
| + _ = json.NewEncoder(w).Encode(out) |
| +} |
| + |
| +func parseInt64(s string) (int64, error) { |
| + var n int64 |
| + _, err := fmt.Sscan(s, &n) |
| + return n, err |
| +} |
| + |
| +func (d *demoStripe) sessionJSON(s *demoSession) map[string]any { |
| + sub := "" |
| + if s.Status == "complete" { |
| + sub = "sub_demo" |
| + } |
| + return map[string]any{"id": s.ID, "url": "https://checkout.stripe.com/c/" + s.ID, "status": s.Status, "expires_at": s.Expires, "livemode": false, "customer": "cus_demo", "subscription": sub, "payment_status": s.Payment, "client_reference_id": s.Sqid, "metadata": s.Meta, "customer_details": map[string]any{"address": map[string]string{"country": d.customerCountry}}} |
| +} |
| + |
| +// servePages is the browser-facing half: the simulated checkout and portal. |
| +func (d *demoStripe) servePages(w http.ResponseWriter, r *http.Request) { |
| + switch { |
| + case strings.HasPrefix(r.URL.Path, "/__stripe/checkout/"): |
| + // Stripe's hosted URL is /c/<session>; the rewrite keeps the path. |
| + id := path.Base(r.URL.Path) |
| + d.mu.Lock() |
| + s := d.sessions[id] |
| + d.mu.Unlock() |
| + if s == nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if r.Method == http.MethodPost { |
| + _ = r.ParseForm() |
| + switch r.PostForm.Get("outcome") { |
| + case "pay": |
| + d.pay(s) |
| + http.Redirect(w, r, s.SuccessURL, http.StatusSeeOther) |
| + default: |
| + http.Redirect(w, r, s.CancelURL, http.StatusSeeOther) |
| + } |
| + return |
| + } |
| + plan, band := demoPlanBand(s.Price) |
| + q, _ := plans.Quote(plans.CurrentTermsVersion, plan, band, s.Quantity) |
| + price := "?" |
| + if q != nil { |
| + price = formatEuroMicro(q.AnnualPriceMicroEUR) |
| + } |
| + fmt.Fprintf(w, demoPage, "Simulated Stripe Checkout", fmt.Sprintf(`<p class="sim">SIMULATION. This page stands in for Stripe's hosted checkout. There is no card form, no payment and no real Stripe object. Pay marks the session complete and delivers the webhooks a real payment would; Cancel returns you to the console.</p> |
| +<h1>Carloku %s</h1> |
| +<p>%d member(s), %s a year before VAT, session <code>%s</code>, account <code>%s</code>.</p> |
| +<form method="post"><button name="outcome" value="pay">Pay (simulated)</button> <button name="outcome" value="cancel" class="quiet">Cancel</button></form>`, html.EscapeString(billingPlanNames[plan]), s.Quantity, price, s.ID, html.EscapeString(s.Sqid))) |
| + case strings.HasPrefix(r.URL.Path, "/__stripe/portal/"): |
| + if r.Method == http.MethodPost { |
| + _ = r.ParseForm() |
| + d.mu.Lock() |
| + if d.subscription != nil { |
| + switch r.PostForm.Get("outcome") { |
| + case "fail": |
| + d.subscription.Status = "past_due" |
| + case "recover": |
| + d.subscription.Status = "active" |
| + case "cancel": |
| + d.subscription.Status = "canceled" |
| + } |
| + } |
| + d.mu.Unlock() |
| + d.webhook("customer.subscription.updated", "sub_demo") |
| + http.Redirect(w, r, r.URL.Path, http.StatusSeeOther) |
| + return |
| + } |
| + status := "none" |
| + d.mu.Lock() |
| + if d.subscription != nil { |
| + status = d.subscription.Status |
| + } |
| + d.mu.Unlock() |
| + back := d.origin + "/" |
| + fmt.Fprintf(w, demoPage, "Simulated Stripe portal", fmt.Sprintf(`<p class="sim">SIMULATION. This stands in for Stripe's customer portal. Each button changes the fake subscription and delivers the webhook a real change would.</p> |
| +<h1>Your subscription: %s</h1> |
| +<form method="post"> |
| +<button name="outcome" value="fail">Simulate a failed renewal payment</button> |
| +<button name="outcome" value="recover">Simulate a recovered payment</button> |
| +<button name="outcome" value="cancel">Cancel the subscription</button> |
| +</form> |
| +<p><a href="%s">Back to the console</a></p>`, html.EscapeString(status), html.EscapeString(back))) |
| + default: |
| + http.NotFound(w, r) |
| + } |
| +} |
| + |
| +func (d *demoStripe) pay(s *demoSession) { |
| + d.mu.Lock() |
| + now := time.Now() |
| + s.Status, s.Payment = "complete", "paid" |
| + d.subscription = &demoSubscription{ID: "sub_demo", Status: "active", Price: s.Price, Quantity: s.Quantity, Meta: s.Meta, Start: now.Unix(), End: now.AddDate(1, 0, 0).Unix()} |
| + d.mu.Unlock() |
| + d.webhook("checkout.session.completed", s.ID) |
| + d.webhook("customer.subscription.created", "sub_demo") |
| +} |
| + |
| +// webhook delivers a signed event to the console exactly as Stripe would, |
| +// straight into the handler. |
| +func (d *demoStripe) webhook(typ, objectID string) { |
| + var b [8]byte |
| + rand.Read(b[:]) //nolint:errcheck |
| + payload := fmt.Sprintf(`{"id":"evt_demo_%s","type":%q,"created":%d,"livemode":false,"data":{"object":{"id":%q}}}`, hex.EncodeToString(b[:]), typ, time.Now().Unix(), objectID) |
| + ts := time.Now().Unix() |
| + mac := hmac.New(sha256.New, []byte(demoWebhookSecret)) |
| + fmt.Fprintf(mac, "%d.%s", ts, payload) |
| + r := httptest.NewRequest(http.MethodPost, "/billing/webhook", strings.NewReader(payload)) |
| + r.Header.Set("Stripe-Signature", fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil)))) |
| + w := httptest.NewRecorder() |
| + d.console.ServeHTTP(w, r) |
| +} |
| + |
| +const demoPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>%s</title> |
| +<style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:3rem auto;padding:0 1rem;color:#1d1927}.sim{background:#fff3c4;border:1px solid #e0b400;padding:.75rem 1rem;border-radius:.5rem;font-size:.9rem}button{font:inherit;padding:.6rem 1.1rem;border-radius:.5rem;border:1px solid #5b3e96;background:#5b3e96;color:#fff;margin:.25rem .25rem .25rem 0}button.quiet{background:#fff;color:#1d1927;border-color:#999}label{display:block;margin:1rem 0}input{font:inherit;padding:.4rem}code{background:#eee;padding:.1rem .3rem}</style></head><body>%s</body></html>` |