Derive Stripe mode from API key and refine disabled controls

Derive Stripe test/live mode from the API key prefix at boot, failing on
unrecognized prefixes, and drop the separate `stripe-mode` config key.

Refine disabled controls to render through the shared `disabledControl`
part with the not-allowed cursor, and add a lint rule refusing
hand-rolled disabled buttons.

Adjust plan cards to offer no purchase control on free rungs, fix bound
checkbox Bool handling, and rename "Public/Private" to "Listed/Unlisted"
with enhanced readiness verdicts.
This commit is contained in:
2026-09-13 16:59:12 -05:00
parent 412b7f67d8
commit 782ca8f326
73 changed files with 1840 additions and 250 deletions
+3
View File
@@ -60,3 +60,6 @@ test/screens/
# screen-texts/): evidence nothing cites, reproducible from the app at that
# commit; the walk's authored files (missions, reviews, findings) stay tracked
status/explorations/*/screen-texts/
# tools/legacy-backfill build artifact (never-merge branch); 40 MB binary
/legacy-backfill
+21 -6
View File
@@ -116,6 +116,16 @@ var startCmd = &cobra.Command{
os.Exit(1)
}
// The Stripe mode is a property of the API key, derived here beside
// the other configuration checks so a key with an unrecognized
// prefix fails boot rather than mislabeling live money as test
// data downstream (design D7).
stripeMode, err := stripeintegration.ModeForKey(viper.GetString("stripe-api-key"))
if err != nil {
logger.Error("invalid configuration", slog.Any("error", err))
os.Exit(1)
}
// Database Setup
dbDSN := viper.GetString("db-dsn")
dbConfig := db.DefaultDBConfig(dbDSN)
@@ -345,11 +355,15 @@ var startCmd = &cobra.Command{
}
// Create server config. stripeDashboardURL is Stripe-specific plumbing
// (mapping stripe-mode to a dashboard base URL) owned by the Stripe
// integration and only consumed downstream by the payments-seam
// operator billing UI (internal/server/operator_billing.go,
// operator_partials.go), which stays in core per design.md Decision 6.
stripeDashboardURL := stripeintegration.DashboardURL(viper.GetString("stripe-mode"))
// (the derived mode mapped to a dashboard base URL) owned by the
// Stripe integration and only consumed
// downstream by the payments-seam operator billing UI (internal/
// server/operator_billing.go, operator_partials.go), which stays in
// core per design.md Decision 6 (design D7).
stripeDashboardURL := stripeintegration.DashboardURL(stripeMode)
if stripeMode != "" {
logger.Info("stripe mode derived from the API key", slog.String("mode", stripeMode))
}
serverConfig := server.Config{
Port: port,
@@ -366,6 +380,7 @@ var startCmd = &cobra.Command{
StripeWebhookSecret: viper.GetString("stripe-webhook-secret"),
StripeAPIKey: viper.GetString("stripe-api-key"),
StripeDashboardURL: stripeDashboardURL,
StripeMode: stripeMode,
BaseURL: viper.GetString("base-url"),
AskFallbackURL: viper.GetString("domains-ask-fallback-url"),
DomainsPolicy: domainsPolicy,
@@ -472,7 +487,7 @@ func init() {
viper.SetDefault("domains-initiation-budget", domains.DefaultInitiationBudget)
viper.SetDefault("domains-expiry-sweep-interval", wfDomains.DefaultExpirySweepInterval)
viper.SetDefault("webhook-partition-ensure-interval", wfMaintenance.DefaultWebhookPartitionEnsureInterval)
// fedwiki-site-scheme and stripe-mode defaults are set by
// fedwiki-site-scheme and the other integration defaults are set by
// registerIntegrationConfigFlags above, from each integration's
// declared ConfigSpec.
+3 -3
View File
@@ -558,7 +558,7 @@ generically (`cmd/start.go`'s `registerIntegrationConfigFlags`, looping
`config.ValidateStart` validates every declared `RequiredGroup` the same
way — there is no hardcoded per-integration conditional in
`internal/config/validate.go` to extend. Keys with no `RequiredGroup` and no
`Secret` (e.g. `stripe-mode`) need nothing beyond the `ConfigSpec` entry
`Secret` (e.g. `fedwiki-site-scheme`) need nothing beyond the `ConfigSpec` entry
itself.
**Boolean keys**: `ConfigKey.Default`'s flag constructors cover `string` and
@@ -598,8 +598,8 @@ overrides up unmodified. Two consequences to design for:
- **Changes apply on restart.** The overlay runs once at boot (your config
reads happen at registration time anyway); the settings page says so and
badges keys whose stored override differs from the boot-effective value.
- **Declare `Enum` for closed-set keys** (`stripe-mode` test|live,
`fedwiki-site-scheme` http|https). Enum keys render as a select and are
- **Declare `Enum` for closed-set keys** (`fedwiki-site-scheme` http|https,
`discourse-linkage-mode` email|oidc|discourseconnect). Enum keys render as a select and are
validated at save time and again by the overlay at boot. For the
string-typed boolean workaround above, `Enum: []string{"false","true"}`
gives the same closed UI (`discourse-auto-create-users` is the worked
+4 -1
View File
@@ -282,7 +282,10 @@ the DOM next to the control, referenced by the button's
`aria-describedby`, so a screen reader announces it regardless of
presentation; which presentation is the surface's density choice: a
tooltip on a focusable wrapper (operator, maintainer decision 2026-08-23)
or ordinary `<small>` text (member).
or ordinary `<small>` text (member). Both variants wrap the button in
`span.disabled-control`, which carries the not-allowed cursor: a disabled
button receives no pointer events of its own and so cannot show a cursor
by itself.
---
-1
View File
@@ -127,7 +127,6 @@ deployment-wide.
| `stripe-api-key-file` | string | `MC_STRIPE_API_KEY_FILE` | File path holding `stripe-api-key`. | none | Optional |
| `stripe-webhook-secret` (secret) | string | `MC_STRIPE_WEBHOOK_SECRET` | Stripe webhook endpoint signing secret. | none | Required together with `stripe-api-key` |
| `stripe-webhook-secret-file` | string | `MC_STRIPE_WEBHOOK_SECRET_FILE` | File path holding `stripe-webhook-secret`. | none | Optional |
| `stripe-mode` | test\|live | `MC_STRIPE_MODE` | `test` or `live`. Controls the operator panel's "view in Stripe" dashboard link URLs. | `test` | Optional |
See [stripe.md](stripe.md) for the webhook endpoint, subscribed events, and catalog sync.
+10 -1
View File
@@ -22,10 +22,19 @@ This guide covers connecting the member console to Stripe for payment processing
| `--stripe-api-key-file` | Path to file containing the API key |
| `--stripe-webhook-secret` | Webhook endpoint signing secret |
| `--stripe-webhook-secret-file` | Path to file containing the webhook secret |
| `--stripe-mode` | `test` or `live` (controls dashboard link URLs) |
All flags support `_FILE` variants for secret injection from mounted files.
There is no mode setting. The console derives test or live from the API
key's prefix at boot (`sk_live_` and `rk_live_` are live, `sk_test_` and
`rk_test_` are test); any other prefix fails boot. "View in Stripe" links
carry the mode and nothing else: Stripe opens them in the account and
context your dashboard session is in, and offers its own switch when the
object lives elsewhere. The console never reads or stores the account id:
reading it needs the Connect "Accounts Read" permission on a restricted
key, and a restricted key with only the permissions the console uses is
the recommended deployment.
## Webhook endpoint
Register a webhook in the Stripe Dashboard pointing to:
@@ -0,0 +1,15 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- The stripe-mode setting is retired: the console derives test or live
-- from the API key's prefix at boot, so a stored override could only
-- disagree with the key in force. The boot overlay skips an override for a
-- key no integration declares, so the row is already inert; it is deleted
-- so the settings store holds no value that nothing reads.
DELETE FROM core.integration_config_overrides WHERE key = 'stripe-mode';
-- +goose Down
-- No down: the deleted row said what the API key already says, and
-- restoring it would reintroduce a value nothing reads.
SELECT 1;
+8
View File
@@ -607,6 +607,14 @@ summary.app-section-header {
cursor: pointer;
}
/* Disabled controls (design-system.md §3, design D6; spec form-conventions
* "Disabled controls carry their reason in the DOM"): the disabledControl
* part wraps its button in this span because a disabled button receives no
* pointer events of its own and so cannot show a cursor by itself. */
.disabled-control {
cursor: not-allowed;
}
/* Row primaries (chrome-conventions "Row navigation is the primary
column's link"): the identifier cell is a th[scope=row]. Semibold sits
@@ -67,6 +67,7 @@
{{ end }}
</ul>
{{ if ne .Relation "current" }}
{{ if .MoveKind }}
{{ if .MoveEnabled }}
{{/* chrome-conventions D2 (one dominant primary action per screen):
every purchasable tier across every ladder is a parallel,
@@ -99,6 +100,7 @@
{{ template "disabledControl" .DisabledControl }}
{{ end }}
{{ end }}
{{ end }}
</div>
</div>
</div>
@@ -11,7 +11,8 @@
CSP-safe. Built from the page-anatomy parts: sectionHeader outside
the card; check states (met, missing, sync pending, sync failed)
render through statusBadge; annotations (Purchasable, Incomplete,
Shown, Not shown, Not applicable) render as text per design D3. */}}
Shown, Not shown, Not applicable, Unlisted, No rules) render as text
per design D3. */}}
<div id="product-readiness"
{{ if .Readiness.SyncPending }}hx-get="{{ routeURL "/partials/operator/products/{productID}/readiness" .Product.ProductID }}"
hx-trigger="every 3s" hx-swap="outerHTML"{{ end }}>
@@ -23,8 +24,8 @@
2026-08-31). product-management "The readiness panel prints its
verdict": the template always prints .Readiness.Verdict, the
string the evaluation computes ("Purchasable", "Purchasable; not
shown in the member catalog", "Ready to grant" for a private
product, "Incomplete; missing: ..."), and never a literal (such
shown in the member catalog", "Ready to grant" for an unlisted
product, "Inactive", "Incomplete; missing: ..."), and never a literal (such
as "Purchasable.") derived from the Purchasable boolean in its
place. */}}
{{ if .Readiness.Purchasable }}
@@ -40,6 +41,10 @@
<span>{{ .Label }}</span>
{{ if eq .State "n/a" }}
<span class="text-body-secondary small">Not applicable</span>
{{ else if eq .State "unlisted" }}
<span class="text-body-secondary small">Unlisted; issued as grants</span>
{{ else if eq .State "no_rules" }}
<span class="text-warning-emphasis small">No rules</span>
{{ else if eq .State "shown" }}
<span class="text-body-secondary small">Shown</span>
{{ else if eq .State "hidden" }}
@@ -48,7 +53,7 @@
{{ template "statusBadge" .StateBadge }}
{{ end }}
</div>
{{ if and (ne .State "met") .Detail }}
{{ if and (or .DetailAlways (ne .State "met")) .Detail }}
<small class="text-muted d-block mt-1">{{ .Detail }}{{ if .Href }} <a href="{{ .Href }}">Settings</a>{{ end }}</small>
{{ end }}
</li>
@@ -93,7 +93,7 @@
would read as the same not-applicable marker even
though Visibility is always a definite state, so the
cell states which one instead of Yes/No. */}}
<td>{{ if .IsPublic }}Public{{ else }}Private{{ end }}</td>
<td>{{ if .IsPublic }}Listed{{ else }}Unlisted{{ end }}</td>
<td class="text-nowrap">{{ .CreatedAt }}</td>
</tr>
{{ end }}
@@ -11,13 +11,16 @@
2026-08-23); the Visible variant renders the same reason as ordinary
<small> text instead (member surface density), still referenced by
aria-describedby, so a screen reader announces it either way. No page
hand-rolls a `<span title="...">` around a disabled button. */}}
hand-rolls a `<span title="...">` around a disabled button. Both
variants wrap the button in span.disabled-control, which carries the
not-allowed cursor (app.css): a disabled button gets no pointer
events of its own and cannot show a cursor by itself (design D6). */}}
{{ define "disabledControl" }}
{{ if .Visible }}
<button type="button" class="{{ .Classes }}" disabled aria-describedby="{{ .ID }}">{{ .Label }}</button>
<span class="disabled-control d-inline-block"><button type="button" class="{{ .Classes }}" disabled aria-describedby="{{ .ID }}">{{ .Label }}</button></span>
<small id="{{ .ID }}" class="text-muted d-block mt-1">{{ .Reason }}</small>
{{ else }}
<span class="d-inline-block" tabindex="0" data-bs-toggle="tooltip" title="{{ .Reason }}"><button type="button" class="{{ .Classes }}" disabled aria-describedby="{{ .ID }}">{{ .Label }}</button></span>
<span class="disabled-control d-inline-block" tabindex="0" data-bs-toggle="tooltip" title="{{ .Reason }}"><button type="button" class="{{ .Classes }}" disabled aria-describedby="{{ .ID }}">{{ .Label }}</button></span>
<span id="{{ .ID }}" class="visually-hidden">{{ .Reason }}</span>
{{ end }}
{{ end }}
+4 -1
View File
@@ -64,15 +64,18 @@ func (v *Values) Set(name, raw string) {
// SetBool records a checkbox's state. A ticked box carries the field's
// submitted value; an unticked one carries nothing, which is how the
// browser submits it.
// browser submits it. Either way, the typed bool is recorded too, so a
// bound checkbox answers Bool the same way a parsed one does (design D1).
func (v *Values) SetBool(f Field, on bool) {
v.init()
if on {
v.Set(f.Name, f.CheckboxValue())
v.setTyped(f.Name, true)
return
}
v.raw[f.Name] = ""
v.present[f.Name] = false
v.setTyped(f.Name, false)
}
// Raw returns the submitted (or bound) string, trimmed as Parse trimmed it.
+24
View File
@@ -274,6 +274,30 @@ func TestParseCheckboxAbsenceIsFalse(t *testing.T) {
}
}
// A checkbox bound to a value set (SetBool, the record-bind and re-render
// path) answers Bool the same way a parsed one does: the typed half is
// recorded beside the raw value and presence, not left to Parse alone
// (design D1, the defect where a bound checkbox read false while its raw
// value read true).
func TestSetBoolAnswersBool(t *testing.T) {
f := testSpec().Fields[5] // "public", the Checkbox field
if f.Name != "public" {
t.Fatalf("test fixture drifted: field 5 is %q, want %q", f.Name, "public")
}
values := NewValues()
values.SetBool(f, true)
if !values.Bool("public") {
t.Error("SetBool(f, true) must answer Bool true")
}
values = NewValues()
values.SetBool(f, false)
if values.Bool("public") {
t.Error("SetBool(f, false) must answer Bool false")
}
}
// Repeated names on a scalar field take the first value, as Go's own form
// parsing does; a multi-valued control does not exist in this library.
func TestParseRepeatedNameTakesTheFirst(t *testing.T) {
@@ -40,15 +40,19 @@
carry a call-to-action, so Create Site stays outline-styled rather
than filled. -->
<div class="d-flex justify-content-between align-items-center mb-3">
<small class="text-muted">{{ if .HasEntitlement }}{{ .CurrentCount }} of {{ .Quota }} active sites used{{ end }}</small>
<small class="text-muted">{{ if .HasEntitlement }}{{ if gt .CurrentCount .Quota }}{{ .CurrentCount }} active sites, {{ .Quota }} allowed.{{ else }}{{ .CurrentCount }} of {{ .Quota }} active sites used{{ end }}{{ end }}</small>
{{ if .CanCreate }}
<button class="btn btn-outline-primary btn-sm"
{{ if .CanCreate }}data-bs-toggle="modal" data-bs-target="#createSiteModal"
hx-get="/partials/fedwiki/create-form" hx-target="#createSiteModalBody" hx-swap="innerHTML"{{ else }}disabled title="{{ if .HasEntitlement }}Site limit reached{{ else }}Site creation is unavailable right now{{ end }}"{{ end }}>
data-bs-toggle="modal" data-bs-target="#createSiteModal"
hx-get="/partials/fedwiki/create-form" hx-target="#createSiteModalBody" hx-swap="innerHTML">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2"/>
</svg>
Create site
</button>
{{ else }}
{{ template "disabledControl" .CreateControl }}
{{ end }}
</div>
{{ if .HasReadonly }}
@@ -125,9 +129,7 @@
<td>
{{ if .IsReadonly }}
{{ if $.CooldownMsg }}
<button class="btn btn-outline-primary btn-sm me-1" disabled title="{{ $.CooldownMsg }}">
Keep active
</button>
{{ template "disabledControl" (.KeepActiveControl $.CooldownMsg) }}
{{ else }}
<button class="btn btn-outline-primary btn-sm me-1"
hx-post="{{ routeURL "/partials/fedwiki/sites/{domain}/keep-active" .Domain }}"
@@ -170,9 +172,7 @@
<td class="text-muted text-nowrap">{{ .Domain }}</td>
<td class="text-end">
{{ if and .IsForceReduced $.CooldownMsg }}
<button class="btn btn-outline-primary btn-sm me-1" disabled title="{{ $.CooldownMsg }}">
Restore
</button>
{{ template "disabledControl" (.RestoreControl $.CooldownMsg) }}
{{ else }}
<button class="btn btn-outline-primary btn-sm me-1"
hx-post="{{ routeURL "/partials/fedwiki/sites/{domain}/restore" .Domain }}"
@@ -214,6 +214,52 @@ type CustomDomainClaimViewModel struct {
CreatedAt string
}
// CreateControl builds the shared disabledControl part's data (design D6,
// spec form-conventions "Disabled controls carry their reason in the DOM")
// for the Create site button when CanCreate is false: the member surface's
// visible-reason variant (AsVisible), the same idiom member_products.go
// uses. The reason names the limit when the workspace holds an entitlement
// at or over it; otherwise the allowance itself could not be read.
func (d SitesData) CreateControl() server.DisabledControl {
reason := "Site creation is unavailable right now"
if d.HasEntitlement {
reason = "Site limit reached"
}
return server.NewDisabledControl(
"fedwiki-create-site-reason",
"Create site",
"btn btn-outline-primary btn-sm",
reason,
).AsVisible()
}
// KeepActiveControl builds the shared disabledControl part's data for a
// read-only site's Keep active button during the swap cooldown (design D6):
// the tooltip-on-wrapper variant, since the row it sits in has no room to
// stack a visible reason beside each action. The id is unique per site so
// several rows on the same page never collide.
func (s SiteViewModel) KeepActiveControl(reason string) server.DisabledControl {
return server.NewDisabledControl(
"fedwiki-site-keep-active-reason-"+s.ID,
"Keep active",
"btn btn-outline-primary btn-sm me-1",
reason,
)
}
// RestoreControl builds the shared disabledControl part's data for a
// force-reduced archived site's Restore button during the swap cooldown
// (design D6), the same tooltip variant KeepActiveControl uses and for the
// same reason.
func (s SiteViewModel) RestoreControl(reason string) server.DisabledControl {
return server.NewDisabledControl(
"fedwiki-site-restore-reason-"+s.ID,
"Restore",
"btn btn-outline-primary btn-sm me-1",
reason,
)
}
// GetSites handles GET /partials/fedwiki/sites
func (h *FedWikiPartialsHandler) GetSites(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
@@ -120,6 +120,108 @@ func TestSitesPartialEmbedsDomainsSection(t *testing.T) {
}
}
// Scenario (design D6, spec fedwiki-sites "UI displays quota usage"): at
// the site limit the Create control renders through the shared
// disabledControl part, with its reason in the DOM and referenced by
// aria-describedby rather than a bare disabled+title button, and the usage
// line still reads the at-or-under-limit sentence. Over the limit (a
// downgrade before its force-reduce sweep runs) the usage line switches to
// its own over-limit sentence with the exact count and allowance.
func TestSitesCardCreateControlAtAndOverLimit(t *testing.T) {
h, err := NewFedWikiPartialsHandler(FedWikiPartialsConfig{
Logger: slog.Default(),
TemplatesFS: os.DirFS("../templates"),
})
if err != nil {
t.Fatalf("construct handler: %v", err)
}
render := func(data SitesData) string {
t.Helper()
rec := httptest.NewRecorder()
h.Templates.Render(rec, "fedwiki_sites.html", data)
if rec.Code != 200 {
t.Fatalf("render = %d, body: %s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
atLimit := render(SitesData{HasEntitlement: true, CanCreate: false, CurrentCount: 1, Quota: 1})
if !strings.Contains(atLimit, `aria-describedby="fedwiki-create-site-reason"`) {
t.Errorf("at the limit: Create control missing aria-describedby through the part: %s", atLimit)
}
if !strings.Contains(atLimit, "Site limit reached") {
t.Errorf("at the limit: Create control missing its reason: %s", atLimit)
}
if strings.Contains(atLimit, "disabled title=") {
t.Errorf("at the limit: Create control must not hand-roll disabled+title: %s", atLimit)
}
if !strings.Contains(atLimit, "1 of 1 active sites used") {
t.Errorf("at the limit: usage line does not read the at-or-under-limit sentence: %s", atLimit)
}
overLimit := render(SitesData{HasEntitlement: true, CanCreate: false, CurrentCount: 12, Quota: 1})
if !strings.Contains(overLimit, "12 active sites, 1 allowed.") {
t.Errorf("over the limit: usage line does not read the over-limit sentence: %s", overLimit)
}
if strings.Contains(overLimit, "of 1 active sites used") {
t.Errorf("over the limit: usage line must not also read the at-or-under-limit sentence: %s", overLimit)
}
unavailable := render(SitesData{
HasEntitlement: false,
CanCreate: false,
Sites: []SiteViewModel{{ID: "site-1", Domain: "wiki.example.test", Status: "active"}},
})
if !strings.Contains(unavailable, "Site creation is unavailable right now") {
t.Errorf("no entitlement: Create control keeps its existing reason: %s", unavailable)
}
}
// Scenario (design D6): the swap-cooldown Keep active and Restore buttons
// were the other two hand-rolled disabled+title buttons the
// disabled-outside-part lint rule would otherwise refuse; both now render
// through the shared part with their cooldown reason in the DOM.
func TestSitesCardCooldownButtonsUseThePart(t *testing.T) {
h, err := NewFedWikiPartialsHandler(FedWikiPartialsConfig{
Logger: slog.Default(),
TemplatesFS: os.DirFS("../templates"),
})
if err != nil {
t.Fatalf("construct handler: %v", err)
}
rec := httptest.NewRecorder()
h.Templates.Render(rec, "fedwiki_sites.html", SitesData{
HasEntitlement: true, CanCreate: true, Quota: 1, CurrentCount: 1,
CooldownMsg: "Wait 2 hours before swapping another site.",
Sites: []SiteViewModel{{
ID: "site-1", Domain: "wiki.example.test", Status: "readonly", IsReadonly: true,
}},
ArchivedSites: []SiteViewModel{{
ID: "site-2", Domain: "archived.example.test", IsForceReduced: true,
}},
})
if rec.Code != 200 {
t.Fatalf("render = %d, body: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, "disabled title=") {
t.Errorf("cooldown buttons must not hand-roll disabled+title: %s", body)
}
for _, want := range []string{
`aria-describedby="fedwiki-site-keep-active-reason-site-1"`,
`aria-describedby="fedwiki-site-restore-reason-site-2"`,
} {
if !strings.Contains(body, want) {
t.Errorf("cooldown button missing %q: %s", want, body)
}
}
if strings.Count(body, "Wait 2 hours before swapping another site.") < 2 {
t.Errorf("both cooldown buttons should carry the cooldown reason: %s", body)
}
}
// Scenario (ux-first-run task 3.6): the sites list's blocked empty state
// names which blocker actually applies instead of one generic message, and
// its copy must differ from both the other blocker's copy and the
+1 -1
View File
@@ -39,7 +39,7 @@ No Stripe IDs appear on core billing tables (Decision 113). The mapping tables a
## Provider config
Stripe declares its configuration via `ConfigSpec()` (`stripe-api-key`, `stripe-webhook-secret`, `stripe-mode`); credentials live in environment variables, never in the database. Non-secret keys are operator-overridable app-wide via `core.integration_config_overrides` (see `docs/building-an-integration.md` §6). The former `stripe.provider_configs` singleton was dropped in migration 00002 — it was never read or written by any code.
Stripe declares its configuration via `ConfigSpec()` (`stripe-api-key`, `stripe-webhook-secret`; the test-or-live mode is derived from the key's prefix, not declared); credentials live in environment variables, never in the database. Non-secret keys are operator-overridable app-wide via `core.integration_config_overrides` (see `docs/building-an-integration.md` §6). The former `stripe.provider_configs` singleton was dropped in migration 00002 — it was never read or written by any code.
## Temporal workflows
+53 -20
View File
@@ -58,6 +58,7 @@ import (
"errors"
"log/slog"
"net/http"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
@@ -110,15 +111,18 @@ func (Adapter) MigrationSource() db.MigrationSource {
// stripe-webhook-secret are secrets that must be set together (or neither,
// to leave billing disabled) — RequiredGroup ties them together for
// ValidateStart's generic check, replacing the hardcoded "Conditional:
// Stripe" block that used to live in internal/config/validate.go.
// stripe-mode isn't a secret and has no group. (A stripe-account-id flag
// existed with no reader anywhere; retired with the provider_configs
// table — the account is identified by the API key itself.)
// Stripe" block that used to live in internal/config/validate.go. The mode
// is not declared: ModeForKey derives it from the API key's prefix (the
// retired stripe-mode key could disagree with the key in force). No
// account id is declared or read: reading it (GET /v1/account) needs
// Stripe's Connect "Accounts Read" permission on a restricted key, which
// is more than the console should hold, and a declared id could disagree
// with the key with nothing to check it against. Dashboard links stay
// unscoped and resolve in the operator's own dashboard session.
func (Adapter) ConfigSpec() []config.ConfigKey {
return []config.ConfigKey{
{Name: "stripe-api-key", Secret: true, RequiredGroup: "Stripe", Usage: "Stripe API key"},
{Name: "stripe-webhook-secret", Secret: true, RequiredGroup: "Stripe", Usage: "Stripe webhook signing secret"},
{Name: "stripe-mode", Default: "test", Enum: []string{"test", "live"}, Usage: "Stripe mode (test or live)"},
}
}
@@ -216,22 +220,51 @@ func (Adapter) Startup(ctx context.Context, c client.Client, taskQueue string, d
return nil
}
// DashboardURL maps the stripe-mode config value to the Stripe dashboard
// base URL used for operator deep links. This is Stripe-specific plumbing
// that used to be inlined in cmd/start.go; it is exported here — rather
// than folded into ConfigSpec, which only declares static key metadata, not
// derived values — because its only consumers (internal/server/
// operator_billing.go and operator_partials.go, building "view in Stripe"
// links) are payments-seam files that stay in core per design.md Decision 6.
// cmd/start.go calls this directly (a composition root importing an
// integration package by name, same as its stripemod.New(database) call for
// StripeQ) rather than routing it through a capability hook: the value is
// needed once, synchronously, to populate server.Config — there is no
// generic "derived config value" hook in the Integration interface, and
// adding one for a single string would be more machinery than the problem
// warrants.
// ModeTest and ModeLive are the two modes a Stripe API key can be in;
// the empty string is the third state, an unconfigured deployment.
const (
ModeTest = "test"
ModeLive = "live"
)
// ModeForKey derives the mode from the API key's prefix: `sk_live_` and
// `rk_live_` (restricted live) are live, `sk_test_` and `rk_test_` are
// test, an empty key is unset, and anything else is a misconfiguration
// that fails boot. The mode is a property of the key — a deployment
// pointed at a sandbox account cannot be "in live mode" — so it is read
// here rather than declared as a setting that could disagree with it.
func ModeForKey(apiKey string) (string, error) {
key := strings.TrimSpace(apiKey)
switch {
case key == "":
return "", nil
case strings.HasPrefix(key, "sk_live_"), strings.HasPrefix(key, "rk_live_"):
return ModeLive, nil
case strings.HasPrefix(key, "sk_test_"), strings.HasPrefix(key, "rk_test_"):
return ModeTest, nil
}
return "", errors.New("stripe-api-key must begin with sk_live_, rk_live_, sk_test_, or rk_test_")
}
// DashboardURL builds the Stripe dashboard base URL operator deep links
// hang off, from the derived mode: `https://dashboard.stripe.com/test`
// in test mode and `https://dashboard.stripe.com` in live. The links are
// not account-scoped (see ConfigSpec): Stripe resolves an object id in
// the account and context the operator's dashboard session is in, and
// offers its own "Did you mean live mode?" switch on a mismatch.
//
// This is Stripe-specific plumbing that used to be inlined in
// cmd/start.go; it is exported here — rather than folded into ConfigSpec,
// which only declares static key metadata, not derived values — because
// its only consumers (internal/server/operator_billing.go and
// operator_partials.go, building "view in Stripe" links) are payments-seam
// files that stay in core per design.md Decision 6. cmd/start.go calls
// this directly (a composition root importing an integration package by
// name, same as its stripemod.New(database) call for StripeQ) rather than
// routing it through a capability hook: the value is needed once,
// synchronously, to populate server.Config.
func DashboardURL(mode string) string {
if mode == "test" {
if mode == ModeTest {
return "https://dashboard.stripe.com/test"
}
return "https://dashboard.stripe.com"
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package stripe
import (
"strings"
"testing"
)
// TestModeForKey covers stripe-integration-infrastructure ("Stripe mode is
// derived from the API key"): the four accepted prefixes, the unset key,
// and a key whose prefix is none of them, which must fail boot with the
// accepted prefixes named.
func TestModeForKey(t *testing.T) {
for _, tc := range []struct {
name string
key string
want string
wantErr bool
}{
{"live secret key", "sk_live_abc123", ModeLive, false},
{"live restricted key", "rk_live_abc123", ModeLive, false},
{"test secret key", "sk_test_abc123", ModeTest, false},
{"test restricted key", "rk_test_abc123", ModeTest, false},
{"unset", "", "", false},
{"whitespace only", " ", "", false},
{"publishable key", "pk_live_abc123", "", true},
{"junk", "not-a-key", "", true},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := ModeForKey(tc.key)
if tc.wantErr {
if err == nil {
t.Fatalf("ModeForKey(%q) = %q, want an error", tc.key, got)
}
for _, prefix := range []string{"sk_live_", "rk_live_", "sk_test_", "rk_test_"} {
if !strings.Contains(err.Error(), prefix) {
t.Errorf("error must name %s, got: %v", prefix, err)
}
}
return
}
if err != nil {
t.Fatalf("ModeForKey(%q): %v", tc.key, err)
}
if got != tc.want {
t.Errorf("ModeForKey(%q) = %q, want %q", tc.key, got, tc.want)
}
})
}
}
// TestDashboardURL: the base follows the derived mode and is never
// account-scoped.
func TestDashboardURL(t *testing.T) {
for _, tc := range []struct{ mode, want string }{
{ModeTest, "https://dashboard.stripe.com/test"},
{ModeLive, "https://dashboard.stripe.com"},
{"", "https://dashboard.stripe.com"},
} {
if got := DashboardURL(tc.mode); got != tc.want {
t.Errorf("DashboardURL(%q) = %q, want %q", tc.mode, got, tc.want)
}
}
}
// TestConfigSpecHasNoModeKey guards the retirement of stripe-mode: the
// settings page lists exactly the keys ConfigSpec declares, so a mode key
// here is a mode control there (integration-settings, removed requirement
// "Switching Stripe to live mode carries a consequence warning").
func TestConfigSpecHasNoModeKey(t *testing.T) {
for _, key := range (Adapter{}).ConfigSpec() {
if key.Name == "stripe-mode" {
t.Fatal("stripe-mode is retired; the mode is derived from the API key")
}
}
}
+48 -12
View File
@@ -30,13 +30,14 @@ import (
// only shrinks, and is empty once every page is (forms-library, 2026-09).
const (
partPageHeader = "ui_page_header.html"
partSectionHeader = "ui_section_header.html"
partBadge = "ui_badge.html"
partEmptyState = "ui_empty_state.html"
partForm = "ui_form.html"
partFormField = "ui_form_field.html"
listScaffold = "operator_list_controls.html"
partPageHeader = "ui_page_header.html"
partSectionHeader = "ui_section_header.html"
partBadge = "ui_badge.html"
partEmptyState = "ui_empty_state.html"
partForm = "ui_form.html"
partFormField = "ui_form_field.html"
partDisabledControl = "ui_disabled_control.html"
listScaffold = "operator_list_controls.html"
// confirmModal is the shell's dialog title, chrome like the parts: the
// one <h2> outside ui_section_header.html the anatomy allows.
confirmModal = "shell_confirm_modal.html"
@@ -62,11 +63,23 @@ var (
// anatomy allowlist is empty").
rawTableRe = regexp.MustCompile(`<table[\s>]`)
listControlsCallRe = regexp.MustCompile(`template\s+"(listControls|listPager)"`)
hxConfirmRe = regexp.MustCompile(`\bhx-confirm=`)
hxDisinheritRe = regexp.MustCompile(`\bhx-disinherit=`)
htmx2CompatRe = regexp.MustCompile(`htmx-2-compat`)
htmxOldEventRe = regexp.MustCompile(`htmx:[a-z]+[A-Z][a-zA-Z]*`)
loadFetchRe = regexp.MustCompile(`hx-trigger="[^"]*\bload\b`)
// buttonTagRe and disabledTokenRe implement disabled-outside-part (spec
// ui-quality-gate "Lint refuses a hand-rolled disabled control", design
// D6): the button tag itself may span several source lines (a template
// action choosing the disabled branch on its own line, as
// fedwiki_sites.html did), so it is matched across the whole source
// rather than per line; [^>] already spans newlines with no flag needed.
// disabledTokenRe then looks for "disabled" as its own token — bounded
// by whitespace, quotes, `=`, `>`, or a template action's `{{`/`}}`,
// never by a hyphen — so an hx-disabled-elt attribute (a real htmx
// attribute that happens to contain the substring) does not false-fire.
buttonTagRe = regexp.MustCompile(`<button\b[^>]*>`)
disabledTokenRe = regexp.MustCompile(`(^|[^\w-])disabled($|[^\w-])`)
hxConfirmRe = regexp.MustCompile(`\bhx-confirm=`)
hxDisinheritRe = regexp.MustCompile(`\bhx-disinherit=`)
htmx2CompatRe = regexp.MustCompile(`htmx-2-compat`)
htmxOldEventRe = regexp.MustCompile(`htmx:[a-z]+[A-Z][a-zA-Z]*`)
loadFetchRe = regexp.MustCompile(`hx-trigger="[^"]*\bload\b`)
// (?s) so a multi-line {{/* comment */}} or {{ if ... }} block strips as
// one unit: html.Tokenizer has no notion of Go template syntax, so a
// comment spanning several source lines is otherwise left partly in the
@@ -258,6 +271,29 @@ func anatomyViolations(path, src string, isPageBody bool) []Violation {
hasListScaleExempt := strings.Contains(src, "list-scale exempt:")
lines := strings.Split(src, "\n")
// disabled-outside-part (spec ui-quality-gate "Lint refuses a hand-rolled
// disabled control", design D6): a disabled <button> outside the part has
// no reason in the DOM, no aria-describedby and no cursor of its own — a
// disabled element receives no pointer events, so neither a tooltip nor a
// cursor can be hung off it directly. Only the part supplies all three.
// The escape is a marker on the line immediately before the tag,
// reviewed beside the one occurrence it exempts, which is why this rule
// keeps anatomy_allowlist.txt empty the same way table-without-list-
// controls does (ui-quality-gate "The anatomy allowlist is empty").
if base != partDisabledControl {
for _, loc := range buttonTagRe.FindAllStringIndex(src, -1) {
tag := src[loc[0]:loc[1]]
if !disabledTokenRe.MatchString(tag) {
continue
}
startLine := 1 + strings.Count(src[:loc[0]], "\n")
if startLine >= 2 && strings.Contains(lines[startLine-2], "disabled-control exempt:") {
continue
}
add(startLine, "disabled-outside-part", `renders a disabled <button> outside the disabledControl part; build a server.DisabledControl (NewDisabledControl, or .AsVisible() for the member surface) and {{ template "disabledControl" ... }}, or mark it {{/* disabled-control exempt: <reason> */}} on the line before the tag (spec ui-quality-gate, design D6)`)
}
}
for i, line := range lines {
n := i + 1
if !isFormPart && rawFormTagRe.MatchString(line) {
+56
View File
@@ -411,6 +411,62 @@ func TestTableWithoutListControlsFires(t *testing.T) {
}
}
// TestDisabledOutsidePartFires pins ui-quality-gate "Lint refuses a
// hand-rolled disabled control" (design D6): a bare disabled <button>
// fails outside the part (including one whose disabled attribute sits on
// its own line inside a template action, the fedwiki_sites.html shape),
// the marker on the line before the tag exempts one occurrence, an
// hx-disabled-elt attribute and an <option disabled> never trigger it, and
// the part itself is clean.
func TestDisabledOutsidePartFires(t *testing.T) {
dir := t.TempDir()
bare := filepath.Join(dir, "partials", "operator_bare_disabled.html")
writeFile(t, bare, `<button class="btn btn-outline-primary btn-sm" disabled title="Nothing to do">Extend</button>`)
marked := filepath.Join(dir, "partials", "operator_marked_disabled.html")
writeFile(t, marked, `{{/* disabled-control exempt: legacy row action pending rebuild */}}
<button class="btn btn-outline-primary btn-sm" disabled title="Nothing to do">Extend</button>`)
multiline := filepath.Join(dir, "partials", "operator_multiline_disabled.html")
writeFile(t, multiline, `<button class="btn btn-outline-primary btn-sm"
{{ if .CanCreate }}hx-get="/x"{{ else }}disabled title="No"{{ end }}>
Create
</button>`)
clean := filepath.Join(dir, "partials", "operator_clean_disabled.html")
writeFile(t, clean, `<button class="btn btn-outline-primary btn-sm" hx-disabled-elt="this" hx-post="/x">Save</button>
<select><option value="a" disabled>A</option></select>`)
part := filepath.Join(dir, "partials", "ui_disabled_control.html")
writeFile(t, part, `{{ define "disabledControl" }}<button type="button" class="{{ .Classes }}" disabled aria-describedby="{{ .ID }}">{{ .Label }}</button>{{ end }}`)
vs, _ := ruleAnatomy([]string{dir}, "", nil)
fired := map[string]int{}
for _, v := range vs {
if v.Rule == "disabled-outside-part" {
fired[filepath.Base(v.File)] = v.Line
}
}
if line, ok := fired[filepath.Base(bare)]; !ok {
t.Errorf("expected disabled-outside-part to fire for %s, got rules %v", bare, rulesOf(vs))
} else if line != 1 {
t.Errorf("expected the violation at the <button> line (1), got line %d", line)
}
if _, ok := fired[filepath.Base(marked)]; ok {
t.Errorf("the disabled-control exempt marker should have exempted %s", marked)
}
if _, ok := fired[filepath.Base(multiline)]; !ok {
t.Errorf("expected disabled-outside-part to fire for the multi-line button in %s, got rules %v", multiline, rulesOf(vs))
}
if _, ok := fired[filepath.Base(clean)]; ok {
t.Errorf("hx-disabled-elt and <option disabled> must not trigger disabled-outside-part, got rules %v", rulesOf(vs))
}
if _, ok := fired[filepath.Base(part)]; ok {
t.Errorf("the disabledControl part itself must be exempt, got rules %v", rulesOf(vs))
}
}
// TestPageWithoutHeader pins page-anatomy "One title size and one header
// layout": a template a handler names as a page body must render the
// pageHeader part; a fragment need not.
+3 -2
View File
@@ -25,7 +25,7 @@ func TestDisabledControlRender(t *testing.T) {
t.Fatal(err)
}
button := `<button type="button" class="btn btn-outline-primary btn-sm" disabled aria-describedby="extend-tier-reason">Extend tier</button>`
wrapper := `<span class="d-inline-block" tabindex="0" data-bs-toggle="tooltip" title="Nothing to extend: this delivery is not grant-backed.">` + button + `</span>`
wrapper := `<span class="disabled-control d-inline-block" tabindex="0" data-bs-toggle="tooltip" title="Nothing to extend: this delivery is not grant-backed.">` + button + `</span>`
reason := `<span id="extend-tier-reason" class="visually-hidden">Nothing to extend: this delivery is not grant-backed.</span>`
for _, want := range []string{wrapper, reason} {
if strings.Count(out, want) != 1 {
@@ -45,8 +45,9 @@ func TestDisabledControlRender(t *testing.T) {
if err != nil {
t.Fatal(err)
}
visibleWrapper := `<span class="disabled-control d-inline-block">` + button + `</span>`
visibleReason := `<small id="extend-tier-reason" class="text-muted d-block mt-1">Nothing to extend: this delivery is not grant-backed.</small>`
for _, want := range []string{button, visibleReason} {
for _, want := range []string{visibleWrapper, visibleReason} {
if strings.Count(out, want) != 1 {
t.Errorf("disabledControl (visible variant) missing %q in:\n%s", want, out)
}
+43 -32
View File
@@ -640,41 +640,52 @@ func (h *MemberProductsHandler) buildPlansData(ctx context.Context, orgID string
if tier.Relation != "current" {
tier.PriceID, tier.Purchasable, tier.PriceText, tier.NoPriceTier = h.resolveTierPrice(ctx, t.ProductID)
tier.MoveLadderID = ladder.PlanLadderID
switch tier.Relation {
case "available":
tier.MoveLabel = "Subscribe"
case "upgrade":
tier.MoveLabel = "Upgrade"
case "downgrade":
tier.MoveLabel = "Downgrade"
}
// Route each move to its built mechanism (plan-switch-mechanics):
// free/default → first paid : Checkout (creates the subscription)
// paid → another paid rung : in-place switch (Stripe proration)
// paid → free/default rung : cancel (rank-0 downgrade)
// Commitment policy (block/fee) is enforced by the endpoints; the
// all-evergreen deployment exercises only the immediate path.
switch {
case tier.Relation == "available" || (tier.Relation == "upgrade" && currentRank == 0):
tier.MoveKind = "checkout"
if tier.Purchasable {
tier.MoveEnabled = true
} else {
tier.DisabledReason = "Not available for purchase yet"
// design D5: a free rank-0 tier is conferred, never bought. When
// the org holds nothing on this ladder (Relation == "available")
// it gets no move control at all — no MoveKind, no MoveLabel, no
// DisabledReason, MoveEnabled stays false. An org on a paid rung
// falls through to the default case below, which still routes the
// rank-0 tier to today's cancel control (Relation == "downgrade").
freeRungNotHeld := tier.Rank == 0 && tier.NoPriceTier && tier.Relation == "available"
if !freeRungNotHeld {
tier.MoveLadderID = ladder.PlanLadderID
switch tier.Relation {
case "available":
tier.MoveLabel = "Subscribe"
case "upgrade":
tier.MoveLabel = "Upgrade"
case "downgrade":
tier.MoveLabel = "Downgrade"
}
case tier.Rank == 0:
// Leaving the paid ladder for its free/default rung.
tier.MoveKind = "cancel"
tier.MoveEnabled = true
default:
// Paid → another paid rung.
tier.MoveKind = "switch"
if tier.Purchasable {
// Route each move to its built mechanism (plan-switch-mechanics):
// free/default → first paid : Checkout (creates the subscription)
// paid → another paid rung : in-place switch (Stripe proration)
// paid → free/default rung : cancel (rank-0 downgrade)
// Commitment policy (block/fee) is enforced by the endpoints; the
// all-evergreen deployment exercises only the immediate path.
switch {
case tier.Relation == "available" || (tier.Relation == "upgrade" && currentRank == 0):
tier.MoveKind = "checkout"
if tier.Purchasable {
tier.MoveEnabled = true
} else {
tier.DisabledReason = "Not available for purchase yet"
}
case tier.Rank == 0:
// Leaving the paid ladder for its free/default rung.
tier.MoveKind = "cancel"
tier.MoveEnabled = true
} else {
tier.DisabledReason = "Not available for purchase yet"
default:
// Paid → another paid rung.
tier.MoveKind = "switch"
if tier.Purchasable {
tier.MoveEnabled = true
} else {
tier.DisabledReason = "Not available for purchase yet"
}
}
}
}
+70 -4
View File
@@ -132,8 +132,10 @@ func TestMemberPlansPriceOnEveryTier(t *testing.T) {
{Name: "Public", Relation: "current"},
// Priced and synced: shows its own price and interval.
{Name: "Standard", Relation: "upgrade", PriceID: "price_std", Purchasable: true, PriceText: "$10.00/month", MoveKind: "checkout", MoveEnabled: true, MoveLabel: "Upgrade"},
// No price at all: the free rung reads "Included".
{Name: "Free", Relation: "available", Rank: 0, NoPriceTier: true, MoveKind: "checkout", MoveEnabled: false, MoveLabel: "Subscribe", DisabledReason: "Not available for purchase yet"},
// No price at all and the org holds nothing on this ladder:
// the free rung reads "Included" and carries no move control
// (member-product-discovery: "A free rung offers no purchase").
{Name: "Free", Relation: "available", Rank: 0, NoPriceTier: true},
// Priced but not yet synced: no price line, disabled control keeps its reason.
{Name: "Locked", Relation: "upgrade", MoveKind: "switch", MoveEnabled: false, MoveLabel: "Upgrade", DisabledReason: "Not available for purchase yet"},
},
@@ -156,8 +158,72 @@ func TestMemberPlansPriceOnEveryTier(t *testing.T) {
}
// "Locked" carries neither PriceText nor NoPriceTier: no price line for
// it, only the disabledControl part's visible reason (design D10, ACC-8).
if got := strings.Count(body, "Not available for purchase yet"); got != 2 {
t.Errorf("expected the disabled reason to render once for each of the two unpurchasable tiers, got %d", got)
// "Free" carries no MoveKind at all, so it contributes no occurrence.
if got := strings.Count(body, "Not available for purchase yet"); got != 1 {
t.Errorf("expected the disabled reason to render once, for the unsynced priced tier only, got %d", got)
}
}
// TestMemberPlansFreeRungOffersNoPurchase covers the member-product-discovery
// ADDED requirement "A free rung offers no purchase" (design D5): a rank-0
// tier with no price is conferred, never bought, so it carries a move
// control only when the org already holds a paid rung to move down from.
func TestMemberPlansFreeRungOffersNoPurchase(t *testing.T) {
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{Logger: discardLogger()})
if err != nil {
t.Fatalf("new handler: %v", err)
}
// Scenario: not enrolled sees no control on the free rung.
notEnrolled := server.PlansData{
Ladders: []server.LadderViewModel{
{
Name: "Hosting",
Tiers: []server.TierViewModel{
{Name: "Free", Relation: "available", Rank: 0, NoPriceTier: true},
},
},
},
}
rec := httptest.NewRecorder()
h.Templates.Render(rec, "member_plans.html", notEnrolled)
if rec.Code != 200 {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Included") {
t.Error("expected the free rung to read 'Included'")
}
if strings.Contains(body, "<button") {
t.Errorf("expected no button on a free rung the org holds nothing on, got:\n%s", body)
}
if strings.Contains(body, "Not available for purchase yet") {
t.Error("expected no 'Not available' line on a free rung the org holds nothing on")
}
// Scenario: a paid holder sees the cancel control on the free rung.
paidHolder := server.PlansData{
Ladders: []server.LadderViewModel{
{
Name: "Hosting", Enrolled: true,
Tiers: []server.TierViewModel{
{Name: "Standard", Relation: "current"},
{Name: "Free", Relation: "downgrade", Rank: 0, NoPriceTier: true, MoveLadderID: "ladder_1", MoveKind: "cancel", MoveEnabled: true, MoveLabel: "Downgrade"},
},
},
},
}
rec = httptest.NewRecorder()
h.Templates.Render(rec, "member_plans.html", paidHolder)
if rec.Code != 200 {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
body = rec.Body.String()
if !strings.Contains(body, `data-action-url="/partials/member/plans/cancel"`) {
t.Error("expected the cancel control on the free rung for an org on a paid rung")
}
if !strings.Contains(body, ">Downgrade<") {
t.Errorf("expected the cancel control labeled 'Downgrade', got:\n%s", body)
}
}
+6
View File
@@ -59,6 +59,10 @@ type OperatorHandler struct {
// degrades to "never dismissed" rather than panicking (tests that build
// an OperatorHandler by hand, as most do here, don't need to wire it).
InstanceSettings *instance.Store
// StripeMode is the mode derived from the Stripe API key at boot
// ("test", "live", or "" when no key is configured); the overview's
// Stripe row labels it (see Config.StripeMode).
StripeMode string
}
// OperatorHandlerConfig holds configuration for the operator handler
@@ -71,6 +75,7 @@ type OperatorHandlerConfig struct {
OrgQ organization.Querier
IdentityQ identity.Querier
IntegrationConfigs []IntegrationConfigInfo
StripeMode string
}
// NewOperatorHandler creates a new OperatorHandler
@@ -134,6 +139,7 @@ func NewOperatorHandler(cfg OperatorHandlerConfig) (*OperatorHandler, error) {
IntegrationConfigs: cfg.IntegrationConfigs,
StripeQ: stripedb.New(cfg.Database),
InstanceSettings: instance.NewStore(cfg.Database),
StripeMode: cfg.StripeMode,
}, nil
}
+1 -1
View File
@@ -330,7 +330,7 @@ func TestPagesUseTheParts(t *testing.T) {
{ProductID: "p1", Name: "Pro", EntitlementSetID: "s1", EntitlementSetName: "Base", DisplayCategory: "addon", IsActive: true, IsPublic: true, LifecycleStatus: "published", CreatedAt: "Jan 1, 2026", VerdictState: "purchasable"},
{ProductID: "p2", Name: "Old", IsActive: false, LifecycleStatus: "retired", CreatedAt: "Jan 1, 2026", VerdictState: "retired", Lifecycle: "Retired"},
}, EntitlementSets: []EntitlementSetOption{{SetID: "s1", Name: "Base"}}, Nav: ListNav{Total: 2}},
want: []string{`<h1 class="h2 mb-0">Products</h1>`, `>2 products</span>`, `<h2 class="h5 mb-0">Catalog overview</h2>`, `<h2 class="h5 mb-0">Products</h2>`, `<a href="/operator/products/new" class="btn btn-sm btn-primary">New product</a>`, `<th scope="row"><a href="/operator/products/p1">Pro</a></th>`, `<a href="/operator/entitlement-sets/s1">Base</a>`, `<span class="badge text-bg-success">Purchasable</span>`, `<span class="badge text-bg-secondary">Retired</span>`, `<span class="badge text-bg-light border">addon</span>`, `nav-pills`, `<td>Public</td>`, `<td>Private</td>`},
want: []string{`<h1 class="h2 mb-0">Products</h1>`, `>2 products</span>`, `<h2 class="h5 mb-0">Catalog overview</h2>`, `<h2 class="h5 mb-0">Products</h2>`, `<a href="/operator/products/new" class="btn btn-sm btn-primary">New product</a>`, `<th scope="row"><a href="/operator/products/p1">Pro</a></th>`, `<a href="/operator/entitlement-sets/s1">Base</a>`, `<span class="badge text-bg-success">Purchasable</span>`, `<span class="badge text-bg-secondary">Retired</span>`, `<span class="badge text-bg-light border">addon</span>`, `nav-pills`, `<td>Listed</td>`, `<td>Unlisted</td>`},
// design D20 (round 4): creation has a page of its own, so the
// list holds no create panel and no create form.
banned: []string{`>Manage<`, `card-title`, `Total: `, `Create Product`, `Entitlement Set<`, `btn-outline-secondary btn-sm">Entitlement sets`, `Internal wrap`, `>Internal<`, `id="productCreatePanel"`, `id="createProductForm"`},
@@ -5,13 +5,19 @@ package server
import (
"bytes"
"context"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http/httptest"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"github.com/alexedwards/scs/v2"
)
// billingTestTemplates parses the operator partial set the way
@@ -140,7 +146,7 @@ func TestOperatorBillingPillNav(t *testing.T) {
// below the title so the page's own heading always leads): the wrapper
// renders one alert-warning banner with the sentence and the Stripe
// settings link, whenever the caller sets TestMode (renderBillingPage
// derives it from stripe-mode), on both a plain view and an instance page
// derives it from the mode the console derives from the API key), on both a plain view and an instance page
// (the invoice detail), and renders nothing when TestMode is unset (live
// mode).
func TestOperatorBillingTestModeBanner(t *testing.T) {
@@ -187,6 +193,45 @@ func TestOperatorBillingTestModeBanner(t *testing.T) {
}
}
// TestOperatorBillingBannerFollowsDerivedMode covers operator-billing-views
// ("Billing views mark Stripe test mode") at the seam the requirement
// names: the banner follows the mode derived from the API key at boot
// (OperatorPartialsHandler.StripeMode), not a setting, so a live-keyed
// deployment can never show it.
func TestOperatorBillingBannerFollowsDerivedMode(t *testing.T) {
database := newRollbackTestDB(t)
for _, tc := range []struct {
mode string
wantBanner bool
}{
{"test", true},
{"live", false},
{"", false},
} {
sm := scs.New()
h, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
Database: database,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
AuthConfig: &auth.Config{SessionManager: sm},
StripeMode: tc.mode,
})
if err != nil {
t.Fatalf("NewOperatorPartialsHandler: %v", err)
}
ctx, err := sm.Load(context.Background(), "")
if err != nil {
t.Fatalf("session Load: %v", err)
}
r := httptest.NewRequestWithContext(ctx, "GET", "/operator/billing/invoices", nil)
w := httptest.NewRecorder()
h.renderBillingPage(w, r, "invoices", "operator_invoices.html", "billing-invoices", InvoicesData{})
got := strings.Contains(w.Body.String(), "Stripe is in test mode.")
if got != tc.wantBanner {
t.Errorf("mode %q: banner rendered = %v, want %v", tc.mode, got, tc.wantBanner)
}
}
}
// TestOperatorBillingRecencyStamp covers the operator-billing-views delta's
// projection-recency requirement (design.md Decision 3; task 4.2) as
// amended in the anatomy-sweep maintainer round (2026-08-30): a billing
@@ -175,7 +175,7 @@ func TestProductNewPartial_Form(t *testing.T) {
`Create product`,
`<a href="/operator/products" class="btn btn-outline-secondary">Cancel</a>`,
`type="checkbox" id="form-operator.product-visibility-control" name="visibility" value="public"`,
`aria-label="Help: Public"`,
`aria-label="Help: Listed"`,
} {
if !strings.Contains(out, want) {
t.Errorf("product create page missing %q, got:\n%s", want, out)
@@ -346,6 +346,87 @@ func TestCreateEntitlementSetRuleKindDerived(t *testing.T) {
}
}
// A ticked Per unit box stores resource_per_unit = true (design D1): the
// defect was forms.Values.SetBool filling only the raw half of a bound
// value set while CreateEntitlementSetRule read the typed half, which only
// Parse filled, so every rule was stored with resource_per_unit = false
// regardless of what the operator ticked. This exercises the handler with
// both a ticked and an unticked submission and reads the stored row back.
func TestCreateEntitlementSetRuleResourcePerUnitStored(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
eq := entitlements.New(tx)
h := newRulesHandler(t, eq)
postRule := func(setID string, form url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/partials/operator/entitlement-sets/"+setID+"/rules", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("setID", setID)
rec := httptest.NewRecorder()
h.CreateEntitlementSetRule(rec, req)
return rec
}
tickedSet, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "Per Unit Ticked Test Set",
IsActive: true,
})
if err != nil {
t.Fatalf("create ticked set: %v", err)
}
rec := postRule(tickedSet.SetID, url.Values{
"resource_key": {"fedwiki_sites"},
"resource_value": {"5"},
"stacking_policy": {"additive"},
"resource_per_unit": {"true"},
})
if rec.Code != 200 {
t.Fatalf("ticked create = %d, body: %s", rec.Code, rec.Body.String())
}
tickedRules, err := eq.GetActiveRulesBySetID(ctx, tickedSet.SetID)
if err != nil {
t.Fatalf("list ticked rules: %v", err)
}
if len(tickedRules) != 1 {
t.Fatalf("ticked rules = %d, want 1", len(tickedRules))
}
if !tickedRules[0].ResourcePerUnit.Valid || !tickedRules[0].ResourcePerUnit.Bool {
t.Errorf("ticked Per unit stored ResourcePerUnit = %+v, want {Bool: true, Valid: true}", tickedRules[0].ResourcePerUnit)
}
untickedSet, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "Per Unit Unticked Test Set",
IsActive: true,
})
if err != nil {
t.Fatalf("create unticked set: %v", err)
}
rec = postRule(untickedSet.SetID, url.Values{
"resource_key": {"fedwiki_sites"},
"resource_value": {"5"},
"stacking_policy": {"additive"},
})
if rec.Code != 200 {
t.Fatalf("unticked create = %d, body: %s", rec.Code, rec.Body.String())
}
untickedRules, err := eq.GetActiveRulesBySetID(ctx, untickedSet.SetID)
if err != nil {
t.Fatalf("list unticked rules: %v", err)
}
if len(untickedRules) != 1 {
t.Fatalf("unticked rules = %d, want 1", len(untickedRules))
}
if !untickedRules[0].ResourcePerUnit.Valid || untickedRules[0].ResourcePerUnit.Bool {
t.Errorf("unticked Per unit stored ResourcePerUnit = %+v, want {Bool: false, Valid: true}", untickedRules[0].ResourcePerUnit)
}
}
// Rule authoring is additive-only as of 2026-08-22 (maintainer decision,
// design D9): the form no longer submits a stacking_policy field, so a
// normal create defaults to "additive"; a non-additive value smuggled past
@@ -59,9 +59,10 @@ type SettingRow struct {
SecretSet bool
// Warning is optional consequence copy rendered adjacent to the
// control, before submission, for a setting whose value change has a
// real-world effect the operator should see up front (today: only
// stripe-mode, set by name in GetIntegrationSettingsPage). Empty for
// every routine key.
// real-world effect beyond this deployment's own state. No installed
// integration declares such a key today (the Stripe mode, which used
// to, is derived from the API key and is no longer a setting). Empty
// for every routine key.
Warning string
}
@@ -207,14 +208,6 @@ func (h *OperatorPartialsHandler) loadSettingRows(r *http.Request, info Integrat
row.PendingRestart = eff.Source == config.SourceOverride
}
}
if key.Name == "stripe-mode" {
// The one setting on this generic page whose value has a
// real-world consequence beyond this deployment's own state:
// switching to live processes real charges. Disclosed here,
// adjacent to the control, before the operator ever submits —
// see the integration-settings spec's stripe-mode requirement.
row.Warning = "Selecting live moves this deployment onto real payment processing: charges become real money, not test transactions."
}
rows = append(rows, row)
}
return rows, errMsg
@@ -147,8 +147,8 @@ func settingsField(row SettingRow) forms.Field {
}
// settingsHint is the key's declared usage, and, for the rare key whose
// value has a real-world consequence beyond this deployment's own state
// (stripe-mode), that consequence too. The effective value is a column now,
// value has a real-world consequence beyond this deployment's own state,
// that consequence too. The effective value is a column now,
// and the pending-restart state is a badge in it, so neither is repeated
// here (design D21: "the Effective column carries the fact").
func settingsHint(row SettingRow) string {
@@ -12,7 +12,6 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"github.com/spf13/viper"
)
// The Stripe provider page (design D24; integration-settings ADDED
@@ -30,7 +29,8 @@ type StripeIntegrationData struct {
// table use, so all three surfaces read the same fact.
Configured bool
Missing []string
// ModeLabel is "Test mode" or "Live mode", from viper key stripe-mode
// ModeLabel is "Test mode" or "Live mode", from the mode derived from
// the Stripe API key at boot
// — mirrors OverviewStripeFacts.ModeLabel (operator_overview.go) so the
// overview's Stripe row and this page never disagree.
ModeLabel string
@@ -224,14 +224,22 @@ func loadDeadLetterEntries(ctx context.Context, db *sql.DB, logger *slog.Logger)
return entries
}
// stripeModeLabel names the mode derived from the Stripe API key at boot
// (server.Config.StripeMode). A deployment with no key configured reads
// "Test mode": nothing it shows is live money, and the page states
// separately that Stripe is unconfigured.
func stripeModeLabel(mode string) string {
if mode == "live" {
return "Live mode"
}
return "Test mode"
}
// GetStripeIntegrationPage handles GET /operator/integrations/stripe.
func (h *OperatorPartialsHandler) GetStripeIntegrationPage(w http.ResponseWriter, r *http.Request) {
configured, missing := configurationReadiness(h.IntegrationConfigs, "stripe")
modeLabel := "Test mode"
if viper.GetString("stripe-mode") == "live" {
modeLabel = "Live mode"
}
modeLabel := stripeModeLabel(h.StripeMode)
bodyData := StripeIntegrationData{
Configured: configured,
@@ -185,7 +185,7 @@ func TestStripeIntegrationNoAlarmWithoutDeadLetter(t *testing.T) {
// that GetStripeIntegrationPage wires the shared outbox (core.outbox) into
// the Delivery queue section: a seeded dead-lettered row surfaces in the
// counts, the alarm styling, and the operation-identifier table, and the
// mode label follows the stripe-mode viper key.
// mode label follows the mode derived from the API key.
func TestGetStripeIntegrationPageDeliveryQueue(t *testing.T) {
database := newRollbackTestDB(t)
if _, err := database.ExecContext(context.Background(),
@@ -204,12 +204,12 @@ func TestGetStripeIntegrationPageDeliveryQueue(t *testing.T) {
}
viper.Reset()
viper.Set("stripe-mode", "live")
t.Cleanup(viper.Reset)
sm := scs.New()
h, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
Database: database,
StripeMode: "live",
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
AuthConfig: &auth.Config{SessionManager: sm},
IntegrationConfigs: stripeConfigs,
@@ -234,7 +234,7 @@ func TestGetStripeIntegrationPageDeliveryQueue(t *testing.T) {
"card declined",
"text-danger",
"needs an operator",
"Live mode", // stripe-mode=live
"Live mode", // the key is a live key
"Not configured", // stripeConfigs' required key is unresolved
// The inbound mirror: the dead-lettered webhook event, by type.
"Inbound events",
@@ -249,7 +249,7 @@ func TestGetStripeIntegrationPageDeliveryQueue(t *testing.T) {
}
// TestGetStripeIntegrationPageConfigured covers the Configured state end to
// end, with the mode defaulting to "Test mode" when stripe-mode is unset.
// end, with the mode reading "Test mode" for a test key.
func TestGetStripeIntegrationPageConfigured(t *testing.T) {
viper.Reset()
viper.Set("stripe-api-key", "sk_test_x")
@@ -257,6 +257,7 @@ func TestGetStripeIntegrationPageConfigured(t *testing.T) {
sm := scs.New()
h, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
StripeMode: "test",
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
AuthConfig: &auth.Config{SessionManager: sm},
IntegrationConfigs: stripeConfigs,
@@ -279,7 +280,7 @@ func TestGetStripeIntegrationPageConfigured(t *testing.T) {
t.Errorf("configured Stripe must read Configured, got:\n%s", out)
}
if !strings.Contains(out, "Test mode") {
t.Errorf("unset stripe-mode must default to Test mode, got:\n%s", out)
t.Errorf("a test key must read Test mode, got:\n%s", out)
}
if !strings.Contains(out, "Queue health is unavailable") {
t.Errorf("a nil database must degrade the delivery queue to unavailable, got:\n%s", out)
+2 -6
View File
@@ -13,7 +13,6 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
"github.com/google/uuid"
"github.com/spf13/viper"
)
// This file backs the "At a glance" and "System" regions of the operator
@@ -70,7 +69,7 @@ type OverviewStat struct {
// work — pending/retrying are normal draining states, not something an
// operator needs to act on.
type OverviewStripeFacts struct {
ModeLabel string // "Test mode" | "Live mode", from viper key stripe-mode
ModeLabel string // "Test mode" | "Live mode", from the mode derived from the API key
Queued int64
Attention int64
Available bool // whether the outbox probe succeeded
@@ -402,10 +401,7 @@ func (h *OperatorHandler) loadOverviewIntegrationsCard(ctx context.Context) Over
// directions: outbox entries and inbound webhook events, read the way the
// provider page reads them.
func (h *OperatorHandler) loadOverviewStripeFacts(ctx context.Context) OverviewStripeFacts {
mode := "Test mode"
if viper.GetString("stripe-mode") == "live" {
mode = "Live mode"
}
mode := stripeModeLabel(h.StripeMode)
queue := loadDeliveryQueue(ctx, h.IntegrationQ, h.Logger)
inbound := loadInboundEvents(ctx, h.Database, h.Logger)
return OverviewStripeFacts{
+3 -4
View File
@@ -15,7 +15,6 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
"github.com/spf13/viper"
)
// buildOperatorPageData populates the auth/CSRF/keycloak fields every
@@ -441,8 +440,8 @@ type OperatorBillingWrapperData struct {
// in the trail (Billing / Invoices / <number>), so the wrapper renders
// no pills there (maintainer, 2026-08-31).
Detail bool
// TestMode is true when the configured Stripe mode (viper key
// stripe-mode) is "test": every billing view and the invoice detail
// TestMode is true when the Stripe mode derived from the API key is
// "test": every billing view and the invoice detail
// render a banner above the page header (TestModeBanner) so no billing
// figure can be read as live money by mistake (design D14;
// operator-billing-views spec "Billing views mark Stripe test mode").
@@ -476,7 +475,7 @@ func (h *OperatorPartialsHandler) renderBillingPage(w http.ResponseWriter, r *ht
ActiveSection: activeSection,
InnerTemplate: innerTemplate,
InnerData: innerData,
TestMode: viper.GetString("stripe-mode") == "test",
TestMode: h.StripeMode == "test",
}
if processedAt, ok := h.latestProcessedWebhookEventAt(r.Context()); ok {
wrapper.DataAsOf = processedAt.Format("Jan 2, 2006 15:04 MST")
+9 -2
View File
@@ -107,8 +107,13 @@ type OperatorPartialsHandler struct {
AuthConfig *auth.Config
Templates *SafeTemplates
StripeDashboardURL string
StripeConfigured bool
TemporalClient client.Client
// StripeMode is the mode derived from the Stripe API key at boot
// ("test", "live", or "" when no key is configured): the provider
// page's mode label and the billing views' test-mode banner read it
// (see Config.StripeMode).
StripeMode string
StripeConfigured bool
TemporalClient client.Client
// Registry is the domains allocation API, for the operator Domains
// surface (operator_domains.go). Nil disables that page's data rather
// than the page: it renders the "registry unavailable" banner, the same
@@ -130,6 +135,7 @@ type OperatorPartialsConfig struct {
Logger *slog.Logger
AuthConfig *auth.Config
StripeDashboardURL string
StripeMode string
StripeConfigured bool
TemporalClient client.Client
Registry *domains.Registry
@@ -225,6 +231,7 @@ func NewOperatorPartialsHandler(cfg OperatorPartialsConfig) (*OperatorPartialsHa
Templates: NewSafeTemplates(tmpl, cfg.Logger),
IntegrationConfigs: cfg.IntegrationConfigs,
StripeDashboardURL: cfg.StripeDashboardURL,
StripeMode: cfg.StripeMode,
StripeConfigured: cfg.StripeConfigured,
TemporalClient: cfg.TemporalClient,
Registry: cfg.Registry,
+5 -3
View File
@@ -83,13 +83,15 @@ var productForm = forms.Register(forms.FormSpec{
},
{
Name: "visibility",
Label: "Public",
Label: "Listed",
Control: forms.Checkbox,
Value: "public",
// One checkbox, not a pair of radios and not two switches:
// the model records only is_public, and a private product is
// the model records only is_public, and an unlisted product is
// the one way to grant a set (Doc 41; walk run 2, ACC-12).
Help: forms.Help("Public", "A public product is sold in the catalog. A private product is only issued by an operator as a grant."),
// The field name and its value stay "visibility"/"public";
// only the operator-facing words change (design D4).
Help: forms.Help("Listed", "Shown in the member catalog."),
},
// The three edit-only fields: the create page publishes the
// product in one step, so its write-up, its feature list and its
@@ -91,9 +91,9 @@ func TestProductEditRendersPurchasabilityPanel(t *testing.T) {
// <ul> or a stray second {{ range }}) that text-presence checks miss: such a
// corruption can still emit the right words but the wrong DOM, making row
// badges misrender. These counts pin the structure down.
t.Run("row structure: one list, six rows, badges track per-row state", func(t *testing.T) {
// One unpriced, off-ladder public product: 3 met preconditions
// (published, public&active, entitlement set present), 2 unmet (active
t.Run("row structure: one list, seven rows, badges track per-row state", func(t *testing.T) {
// One unpriced, off-ladder listed product: 4 met preconditions
// (entitlement set, published, active, listed), 2 unmet (active
// price, payment processing), plus the member-catalog-visibility
// diagnostic row (hidden — off-ladder, no addon category) that is
// neither met nor missing.
@@ -103,16 +103,16 @@ func TestProductEditRendersPurchasabilityPanel(t *testing.T) {
if n := strings.Count(out, "list-group-flush"); n != 1 {
t.Errorf("expected exactly one readiness <ul>, found %d (duplicate list = corrupted template)", n)
}
if n := strings.Count(out, `<li class="list-group-item`); n != 6 {
t.Errorf("expected exactly 6 rows (5 preconditions + catalog visibility), found %d", n)
if n := strings.Count(out, `<li class="list-group-item`); n != 7 {
t.Errorf("expected exactly 7 rows (6 preconditions + catalog visibility), found %d", n)
}
// 3 met rows render the success "Met" badge; 2 unmet render the
// 4 met rows render the success "Met" badge; 2 unmet render the
// secondary "Missing" badge; the catalog-visibility row renders its own
// "Not shown" badge, counted separately below. (The verdict badge is "⚠
// Incomplete", not ">Met<"/">Missing<", so these row-badge counts are
// unambiguous.)
if n := strings.Count(out, ">Met</span>"); n != 3 {
t.Errorf("expected 3 Met row badges, found %d", n)
if n := strings.Count(out, ">Met</span>"); n != 4 {
t.Errorf("expected 4 Met row badges, found %d", n)
}
if n := strings.Count(out, ">Missing</span>"); n != 2 {
t.Errorf("expected 2 Missing row badges, found %d", n)
@@ -129,20 +129,20 @@ func TestProductEditRendersPurchasabilityPanel(t *testing.T) {
if n := strings.Count(out, ">Sync pending</span>"); n != 1 {
t.Errorf("expected exactly 1 'Sync pending' row badge, found %d", n)
}
if n := strings.Count(out, ">Met</span>"); n != 4 {
t.Errorf("expected 4 Met row badges (all but stripe-mapped), found %d", n)
if n := strings.Count(out, ">Met</span>"); n != 5 {
t.Errorf("expected 5 Met row badges (all but stripe-mapped), found %d", n)
}
})
t.Run("fully purchasable but off-ladder: five met badges, zero missing, qualifier shown", func(t *testing.T) {
t.Run("fully purchasable but off-ladder: six met badges, zero missing, qualifier shown", func(t *testing.T) {
// Off-ladder (ladder_count=0, no addon category), so purchasable but not
// findable: the verdict badge must carry the catalog-visibility qualifier
// alongside ">Purchasable" (UX-8).
vm := buildVM(product("published", true, true), shapeFor(true, "recurring"),
PriceReadiness{HasActivePrice: true, PriceID: "x", StripeMapped: true}, false, "", nil)
out := renderProductEdit(t, OperatorProductEditData{Product: base, Readiness: vm})
if n := strings.Count(out, ">Met</span>"); n != 5 {
t.Errorf("expected 5 Met row badges, found %d", n)
if n := strings.Count(out, ">Met</span>"); n != 6 {
t.Errorf("expected 6 Met row badges, found %d", n)
}
if n := strings.Count(out, ">Missing</span>"); n != 0 {
t.Errorf("expected 0 Missing row badges, found %d", n)
+24 -16
View File
@@ -237,7 +237,7 @@ func (h *OperatorPartialsHandler) CreateProduct(w http.ResponseWriter, r *http.R
// design D6 (round 4; maintainer 2026-09-03: "make it a checkbox with a
// label with a tooltip"): Visibility is one checkbox named "visibility"
// with value "public". An unticked checkbox submits nothing, so absence
// means Private, which is the wrap-product path: publish immediately,
// means Unlisted, which is the wrap-product path: publish immediately,
// never public, no price required.
isPublic := values.Bool("visibility")
@@ -358,7 +358,7 @@ func (h *OperatorPartialsHandler) UpdateProduct(w http.ResponseWriter, r *http.R
isActive := values.Bool("is_active")
// design D6 (round 4): the edit form presents the same Visibility
// checkbox as the create page. An unticked checkbox submits nothing, so
// absence is Private.
// absence is Unlisted.
isPublic := values.Bool("visibility")
// Build metadata from features input
@@ -666,10 +666,12 @@ func (h *OperatorPartialsHandler) buildProductListViewModels(ctx context.Context
providerRows = resolveProviderReadinessRows(rulesBySet[esID], keyByResource, h.IntegrationConfigs)
}
vm := buildProductReadinessVM(readinessInputs{
product: product,
shape: shapeByProduct[p.ProductID],
price: pr,
providers: providerRows,
product: product,
shape: shapeByProduct[p.ProductID],
price: pr,
providers: providerRows,
activeRules: len(rulesBySet[esID]),
stripeMode: h.StripeMode,
}, false, "")
state, note := productListStatus(p.LifecycleStatus, p.IsPublic, vm)
@@ -806,11 +808,14 @@ func (h *OperatorPartialsHandler) loadProductEditDataResult(r *http.Request, pro
if err != nil {
h.Logger.Error("failed to load product shape", slog.Any("error", err), slog.String("product_id", productID))
}
providerRows, activeRules := h.loadProviderReadinessRows(r.Context(), esID)
data.Readiness = buildProductReadinessVM(readinessInputs{
product: product,
shape: shape,
price: pr,
providers: h.loadProviderReadinessRows(r.Context(), esID),
product: product,
shape: shape,
price: pr,
providers: providerRows,
activeRules: activeRules,
stripeMode: h.StripeMode,
}, syncFailed, syncErr)
// The edit form is the product declaration bound to this record: the
@@ -853,26 +858,29 @@ func (h *OperatorPartialsHandler) loadEntitlementSetOptionsFor(r *http.Request,
// configuration must be resolved. setID == "" (no entitlement set assigned)
// yields no rows — the entitlement-set-present row already reports that
// precondition. Best-effort: a query failure logs and yields no rows rather
// than blocking the rest of the readiness panel.
func (h *OperatorPartialsHandler) loadProviderReadinessRows(ctx context.Context, setID string) []ProductReadinessRow {
// than blocking the rest of the readiness panel. It also returns how many
// active rules the set holds, the count the Entitlement set row needs to
// tell a rule-less set from a populated one (design D2), read from the
// same query rather than a second one.
func (h *OperatorPartialsHandler) loadProviderReadinessRows(ctx context.Context, setID string) ([]ProductReadinessRow, int) {
if setID == "" {
return nil
return nil, 0
}
rules, err := h.EntitlementsQ.GetActiveRulesBySetID(ctx, setID)
if err != nil {
h.Logger.Error("failed to load entitlement set rules for readiness", slog.Any("error", err), slog.String("set_id", setID))
return nil
return nil, 0
}
keys, err := h.EntitlementsQ.ListResourceKeys(ctx)
if err != nil {
h.Logger.Error("failed to load resource keys for readiness", slog.Any("error", err))
return nil
return nil, len(rules)
}
byKey := make(map[string]entitlements.ResourceKey, len(keys))
for _, k := range keys {
byKey[k.ResourceKey] = k
}
return resolveProviderReadinessRows(rules, byKey, h.IntegrationConfigs)
return resolveProviderReadinessRows(rules, byKey, h.IntegrationConfigs), len(rules)
}
// GetProductDetailPage handles GET /operator/products/{productID} — the
+38 -3
View File
@@ -99,6 +99,27 @@ func (e *productListEnv) seedEntitlementSet(name string) string {
return id
}
// seedSetRule gives a set one active rule on a platform-owned (no provider)
// resource key. A set with no active rule confers nothing and the readiness
// verdict refuses to call such a product ready (design D2), so every set a
// fixture means as complete needs one.
func (e *productListEnv) seedSetRule(setID, marker string) {
e.t.Helper()
key := "pl_" + marker
if _, err := e.database.ExecContext(context.Background(),
`INSERT INTO core.resource_keys (resource_key, display_name, unit) VALUES ($1, $1, 'count')
ON CONFLICT (resource_key) DO NOTHING`, key,
); err != nil {
e.t.Fatalf("fixture resource key: %v", err)
}
if _, err := e.database.ExecContext(context.Background(),
`INSERT INTO core.entitlement_set_rules (set_id, rule_type, resource_key, resource_value)
VALUES ($1, 'limit', $2, 1)`, setID, key,
); err != nil {
e.t.Fatalf("fixture set rule: %v", err)
}
}
// seedProduct inserts a product row directly (raw SQL, mirroring
// operator_organizations_list_test.go's seedOrg): the loader under test
// must never be exercised to build its own fixtures. setID == "" leaves
@@ -165,7 +186,7 @@ func productRowSegment(t *testing.T, body, name string) string {
// TestOperatorProductsPageVerdictPerRow pins product-management "The Status
// column is the verdict" (design D1) end to end: a set-less published
// public product reads Incomplete with "Missing: Entitlement set" (the
// public product reads Incomplete with "Missing: entitlement set" (the
// acceptance example, D6), a complete private product reads Ready to
// grant, a fully wired public product (entitlement set, active default
// price, Stripe mapping) reads Purchasable, and draft/retired read their
@@ -178,10 +199,12 @@ func TestOperatorProductsPageVerdictPerRow(t *testing.T) {
env.seedProduct(setless, "published", true, "")
readySetID := env.seedEntitlementSet("Verdict Ready Set " + mk)
env.seedSetRule(readySetID, mk+"a")
readyToGrant := fmt.Sprintf("Verdict ReadyToGrant %s", mk)
env.seedProduct(readyToGrant, "published", false, readySetID)
purchasableSetID := env.seedEntitlementSet("Verdict Purchasable Set " + mk)
env.seedSetRule(purchasableSetID, mk+"b")
purchasableName := fmt.Sprintf("Verdict Purchasable %s", mk)
purchasableID := env.seedProduct(purchasableName, "published", true, purchasableSetID)
priceID := env.seedActivePrice(purchasableID, true)
@@ -202,8 +225,8 @@ func TestOperatorProductsPageVerdictPerRow(t *testing.T) {
if !strings.Contains(seg, `<span class="badge text-bg-warning">Incomplete</span>`) {
t.Errorf("set-less product row missing the Incomplete badge, got: %s", seg)
}
if !strings.Contains(seg, "Missing: Entitlement set") {
t.Errorf("set-less product row missing the 'Missing: Entitlement set' note, got: %s", seg)
if !strings.Contains(seg, "Missing: entitlement set") {
t.Errorf("set-less product row missing the 'Missing: entitlement set' note, got: %s", seg)
}
seg = productRowSegment(t, body, readyToGrant)
@@ -211,10 +234,22 @@ func TestOperatorProductsPageVerdictPerRow(t *testing.T) {
t.Errorf("complete private product row missing the Ready to grant badge, got: %s", seg)
}
// product-management "The products list shows Visibility": the cell
// states Listed or Unlisted, never Public or Private (design D4).
if !strings.Contains(seg, "<td>Unlisted</td>") {
t.Errorf("unlisted product row's Visibility cell does not read Unlisted, got: %s", seg)
}
seg = productRowSegment(t, body, purchasableName)
if !strings.Contains(seg, `<span class="badge text-bg-success">Purchasable</span>`) {
t.Errorf("fully wired public product row missing the Purchasable badge, got: %s", seg)
}
if !strings.Contains(seg, "<td>Listed</td>") {
t.Errorf("listed product row's Visibility cell does not read Listed, got: %s", seg)
}
if strings.Contains(body, "<td>Public</td>") || strings.Contains(body, "<td>Private</td>") {
t.Error("the products list still reads Public or Private for the visibility flag")
}
seg = productRowSegment(t, body, draftName)
if !strings.Contains(seg, `<span class="badge text-bg-secondary">Draft</span>`) {
+113 -32
View File
@@ -130,6 +130,17 @@ type ProductReadinessRow struct {
// configuration"), which links to the unconfigured provider's settings
// page.
Href string
// MissingName, when set, is how this row names itself in the verdict's
// "Incomplete; missing: ..." list. It differs from Label where the
// label is a noun for the row and the verdict wants the thing that is
// absent ("entitlement set", "rules", "published"). Empty means the
// verdict uses Label.
MissingName string
// DetailAlways renders Detail even when State is "met". Set only by
// the Payment processing row, whose met detail names the Stripe mode
// the sync reaches ("Synced, live" / "Synced, test"); every other
// row's detail is remediation and has nothing to say once met.
DetailAlways bool
}
// providerDisplayName resolves a provider key to its registered display
@@ -198,9 +209,10 @@ func resolveProviderReadinessRows(rules []entitlements.EntitlementSetRule, resou
// ProductReadinessVM is the view model for the operator product purchasability
// readiness panel. It reads core.product_shape (set presence + billing shape)
// composed with the shared price+mapping gate (PriceReadiness). Readiness is
// set_present AND (is_public ⇒ the product is priced and Stripe-mapped); an
// internal wrap product (is_public=false) needs neither price nor visibility,
// so those rows report "n/a" (Doc 41 §5.4).
// set_present with at least one active rule AND (is_public ⇒ the product is
// priced and Stripe-mapped), and is_active either way; an unlisted wrap
// product (is_public=false) needs neither price nor listing, so those rows
// report "n/a" or "unlisted" (Doc 41 §5.4, design D2/D3).
type ProductReadinessVM struct {
Purchasable bool
Verdict string
@@ -248,8 +260,8 @@ func (g memberGate) OK() bool {
// evaluateMemberGate computes the product-level member gate. This is the
// single definition shared by the member catalog paths (checkout's product
// load, the current-rung carve-out in buildPlansData) and the operator
// readiness panel (whose "Published" and "Public & active" rows are this
// gate's Published and Active+Public legs), so the surfaces can never
// readiness panel (whose "Published", "Active" and "Listed" rows are this
// gate's three legs), so the surfaces can never
// disagree about what "publishable for members" means.
func evaluateMemberGate(product billing.Product) memberGate {
return memberGate{
@@ -270,6 +282,31 @@ type readinessInputs struct {
shape billing.CoreProductShape
price PriceReadiness
providers []ProductReadinessRow
// activeRules is how many active rules the product's entitlement set
// holds. product_shape reports only that a set is attached, and a set
// with no active rule confers nothing, so the count is the second half
// of the entitlement-set precondition (design D2). Zero with a set
// present is "No rules"; it is not read at all when no set is present.
activeRules int
// stripeMode is the deployment's Stripe mode, "test", "live" or "" when
// unknown, as a plain value: the readiness computation reads no
// configuration of its own, so the detail page and the list can both
// name the account the sync reaches without this function knowing where
// the mode comes from.
stripeMode string
}
// syncedPaymentDetail names the Stripe account a synced price lives in, so
// the surface where syncing is triggered says which mode it reached
// (design D3). An unknown mode names nothing rather than guessing.
func syncedPaymentDetail(stripeMode string) string {
switch stripeMode {
case "live":
return "Synced, live"
case "test":
return "Synced, test"
}
return "Synced"
}
// buildProductReadinessVM assembles the readiness panel for one product from
@@ -281,8 +318,8 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
gate := evaluateMemberGate(product)
published := gate.Published
isPublic := product.IsPublic
visible := gate.Active && gate.Public
setPresent, _ := shape.SetPresent.(bool)
hasRules := in.activeRules > 0
priced := shape.BillingShape != "unpriced"
var vm ProductReadinessVM
@@ -295,39 +332,59 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
}
publishedRow := ProductReadinessRow{
Label: "Published",
State: state(published),
Detail: "Publish this product so it can be sold.",
Label: "Published",
State: state(published),
MissingName: "published",
Detail: "Publish this product so it can be sold.",
}
// Visibility applies only to public products; a wrap product is
// intentionally never public.
visRow := ProductReadinessRow{Label: "Public & active"}
if isPublic {
visRow.State = state(visible)
visRow.Detail = "Mark the product Active and Public so it appears in the member catalog."
} else {
visRow.State = "n/a"
visRow.Detail = "Private product; not shown in the member catalog."
// is_active applies to every product: the grant form offers only active
// products and the catalog shows only active products, so an inactive
// product is offered nowhere whether it is listed or not (design D3).
activeRow := ProductReadinessRow{
Label: "Active",
State: state(gate.Active),
MissingName: "active",
Detail: "An inactive product is offered by neither the member catalog nor the grant form.",
}
// Listed is reported for every product, but an unlisted product is not
// unmet: unlisted is a working state, not a gap (design D3/D4).
listedRow := ProductReadinessRow{Label: "Listed"}
if isPublic {
listedRow.State = "met"
} else {
listedRow.State = "unlisted"
}
// ACC-29: the row is labeled "Entitlement set", not "Entitlement set
// present" — the old label read as an assertion, so the verdict's
// "missing: Entitlement set present" mis-stated what was missing (an
// entitlement set, not the fact of its presence).
setRow := ProductReadinessRow{
Label: "Entitlement set",
State: state(setPresent),
Detail: "Assign an entitlement set; a product with none confers nothing.",
// entitlement set, not the fact of its presence). Design D2 splits the
// precondition in two: a set must be attached AND hold an active rule,
// since a rule-less set confers nothing.
setRow := ProductReadinessRow{Label: "Entitlement set"}
switch {
case !setPresent:
setRow.State = "unmet"
setRow.MissingName = "entitlement set"
setRow.Detail = "Assign an entitlement set; a product with none confers nothing."
case !hasRules:
setRow.State = "no_rules"
setRow.MissingName = "rules"
setRow.Detail = "Add a rule to the entitlement set; a set with none confers nothing."
default:
setRow.State = "met"
}
// Price + payment processing apply only to public products.
// Price + payment processing apply only to listed products.
priceRow := ProductReadinessRow{Label: "Active price"}
stripeRow := ProductReadinessRow{Label: "Payment processing"}
if !isPublic {
priceRow.State = "n/a"
priceRow.Detail = "Private product; no price required."
priceRow.Detail = "Unlisted product; no price required."
stripeRow.State = "n/a"
stripeRow.Detail = "Private product; not sold, so no payment processing."
stripeRow.Detail = "Unlisted product; not sold, so no payment processing."
} else {
priceRow.State = state(priced && pr.HasActivePrice)
priceRow.Detail = "Add an active price on the Prices view."
@@ -335,6 +392,8 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
switch {
case pr.StripeMapped:
stripeRow.State = "met"
stripeRow.Detail = syncedPaymentDetail(in.stripeMode)
stripeRow.DetailAlways = true
case syncFailed:
stripeRow.State = "failed"
stripeRow.Detail = "The last Stripe sync failed"
@@ -361,7 +420,7 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
// Causal order (maintainer, 2026-08-31): what the product confers, its
// lifecycle, whether it is offered, then the commerce chain, then the
// provider(s) that deliver it, then the catalog-visibility outcome.
vm.Rows = append(vm.Rows, setRow, publishedRow, visRow, priceRow, stripeRow)
vm.Rows = append(vm.Rows, setRow, publishedRow, activeRow, listedRow, priceRow, stripeRow)
vm.Rows = append(vm.Rows, providerRows...)
vm.StripeConfigured = pr.StripeConfigured
@@ -372,10 +431,17 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
// the member-catalog-visibility row below is appended: that row is a
// reported diagnostic, never a purchasability precondition (Decision 6),
// so it must never contribute to "missing" or to the Purchasable verdict.
// "unlisted" joins "met" and "n/a" as a state that is not a gap: an
// unlisted product is deliberately out of the catalog (design D3).
for _, row := range vm.Rows {
if row.State != "met" && row.State != "n/a" {
vm.Missing = append(vm.Missing, row.Label)
if row.State == "met" || row.State == "n/a" || row.State == "unlisted" {
continue
}
name := row.MissingName
if name == "" {
name = row.Label
}
vm.Missing = append(vm.Missing, name)
}
// product-management "Readiness includes the delivering provider's
@@ -391,9 +457,9 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
}
if isPublic {
vm.Purchasable = published && visible && setPresent && priced && pr.StripeMapped && providersOK
vm.Purchasable = published && gate.Active && setPresent && hasRules && priced && pr.StripeMapped && providersOK
} else {
vm.Purchasable = published && setPresent && providersOK
vm.Purchasable = published && gate.Active && setPresent && hasRules && providersOK
}
// Member catalog visibility: reported for is_public products only; the
@@ -408,7 +474,7 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
switch {
case !isPublic:
catalogRow.State = "n/a"
catalogRow.Detail = "Private product; never shown in the member catalog."
catalogRow.Detail = "Unlisted product; never shown in the member catalog."
case findable:
catalogRow.State = "shown"
catalogRow.Detail = "This product is a plan-ladder tier or is tagged display_category=addon, so the member catalog renders it."
@@ -418,7 +484,18 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
}
vm.Rows = append(vm.Rows, catalogRow)
// Verdict precedence (design D3): what the product confers and whether
// it is published come first, because neither "Inactive" nor
// "Purchasable" says anything true about a product that confers
// nothing. Then is_active, which is a deliberate state rather than a
// gap: an inactive product is offered by neither the catalog nor the
// grant form, and "Purchasable" or "Ready to grant" would both be
// false of it.
switch {
case !setPresent || !hasRules || !published:
vm.Verdict = "Incomplete; missing: " + strings.Join(vm.Missing, ", ")
case !gate.Active:
vm.Verdict = "Inactive"
case vm.Purchasable && isPublic:
vm.Verdict = "Purchasable"
if !findable {
@@ -447,6 +524,10 @@ func productListStatus(lifecycleStatus string, isPublic bool, vm ProductReadines
return lifecycleStatus, ""
}
switch {
// The list states the panel's verdict, so an inactive product reads
// Inactive here too rather than "Incomplete; missing: Active".
case vm.Verdict == "Inactive":
return "inactive", ""
case vm.Purchasable && isPublic:
return "purchasable", vm.CatalogQualifier
case vm.Purchasable:
+101 -2
View File
@@ -53,8 +53,17 @@ func shapeForLadder(setPresent bool, billingShape string, ladderCount int64) bil
// and syncError stay separate, detail-page-only args). Keeping the call
// sites' argument order and the verdict-text assertions themselves pinned
// unchanged is the point of this indirection.
// An attached set holds one active rule unless a case says otherwise
// (buildVMRules): a rule-less set is its own state (design D2) and every
// case written before it existed meant a populated set.
func buildVM(product billing.Product, shape billing.CoreProductShape, pr PriceReadiness, syncFailed bool, syncError string, providerRows []ProductReadinessRow) ProductReadinessVM {
return buildProductReadinessVM(readinessInputs{product: product, shape: shape, price: pr, providers: providerRows}, syncFailed, syncError)
return buildProductReadinessVM(readinessInputs{product: product, shape: shape, price: pr, providers: providerRows, activeRules: 1}, syncFailed, syncError)
}
// buildVMRules is buildVM with the set's active-rule count and the Stripe
// mode spelled out: the two inputs design D2 and D3 added.
func buildVMRules(product billing.Product, shape billing.CoreProductShape, pr PriceReadiness, activeRules int, stripeMode string) ProductReadinessVM {
return buildProductReadinessVM(readinessInputs{product: product, shape: shape, price: pr, activeRules: activeRules, stripeMode: stripeMode}, false, "")
}
// rowState returns the State of the named precondition row, or "" if absent.
@@ -115,7 +124,7 @@ func TestBuildProductReadinessVM(t *testing.T) {
rowState: "unmet",
},
{
name: "private product needs no price and is ready to grant",
name: "unlisted product needs no price and is ready to grant",
product: product("published", true, false),
shape: shapeFor(true, "unpriced"),
pr: PriceReadiness{},
@@ -224,6 +233,96 @@ func TestBuildProductReadinessVM_MemberCatalogVisibility(t *testing.T) {
})
}
// TestBuildProductReadinessVM_RuleLessSet pins design D2: an entitlement set
// with no active rule confers nothing, so the Entitlement set row reads "No
// rules" and the verdict names rules as what is missing — never "Ready to
// grant" or "Purchasable".
func TestBuildProductReadinessVM_RuleLessSet(t *testing.T) {
// Unlisted so the price and payment-processing rows are not applicable
// and the missing list is the one thing under test.
vm := buildVMRules(product("published", true, false), shapeFor(true, "unpriced"), PriceReadiness{}, 0, "")
if got := rowState(vm, "Entitlement set"); got != "no_rules" {
t.Errorf("Entitlement set row state = %q, want no_rules", got)
}
if vm.Verdict != "Incomplete; missing: rules" {
t.Errorf("Verdict = %q, want %q", vm.Verdict, "Incomplete; missing: rules")
}
if vm.Purchasable {
t.Error("a product whose set holds no active rule must never be Purchasable")
}
// A listed, fully priced and mapped product is held back the same way.
listed := buildVMRules(product("published", true, true), shapeForLadder(true, "recurring", 1),
PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true, StripeConfigured: true}, 0, "live")
if listed.Verdict != "Incomplete; missing: rules" {
t.Errorf("listed product Verdict = %q, want %q", listed.Verdict, "Incomplete; missing: rules")
}
}
// TestBuildProductReadinessVM_InactiveAndListed pins design D3's verdict
// precedence and the Listed vocabulary: is_active is a precondition for every
// product, an inactive one reads "Inactive" rather than being called ready,
// and an unlisted product's Listed row is a state, not a gap.
func TestBuildProductReadinessVM_InactiveAndListed(t *testing.T) {
mapped := PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true, StripeConfigured: true}
t.Run("inactive unlisted product reads Inactive", func(t *testing.T) {
vm := buildVMRules(product("published", false, false), shapeFor(true, "unpriced"), PriceReadiness{}, 1, "")
if vm.Verdict != "Inactive" {
t.Errorf("Verdict = %q, want %q", vm.Verdict, "Inactive")
}
if strings.Contains(vm.Verdict, "Ready to grant") || strings.Contains(vm.Verdict, "Purchasable") {
t.Errorf("Verdict = %q must not call an inactive product ready", vm.Verdict)
}
if vm.Purchasable {
t.Error("an inactive product is offered nowhere and must not be Purchasable")
}
if got := rowState(vm, "Active"); got != "unmet" {
t.Errorf("Active row state = %q, want unmet", got)
}
// The grant form offers only products ListActiveProducts returns
// (is_active = TRUE), so this product is absent from it; that filter
// lives in SQL and has no DB-free builder to assert on here.
})
t.Run("inactive listed product reads Inactive", func(t *testing.T) {
vm := buildVMRules(product("published", false, true), shapeForLadder(true, "recurring", 1), mapped, 1, "live")
if vm.Verdict != "Inactive" {
t.Errorf("Verdict = %q, want %q", vm.Verdict, "Inactive")
}
if vm.Purchasable {
t.Error("an inactive product must not be Purchasable")
}
})
t.Run("active unlisted product with a rule reads Ready to grant", func(t *testing.T) {
vm := buildVMRules(product("published", true, false), shapeFor(true, "unpriced"), PriceReadiness{}, 1, "")
if vm.Verdict != "Ready to grant" {
t.Errorf("Verdict = %q, want %q", vm.Verdict, "Ready to grant")
}
if strings.Contains(vm.Verdict, "Purchasable") {
t.Errorf("Verdict = %q must not read Purchasable for an unlisted product", vm.Verdict)
}
if got := rowState(vm, "Listed"); got != "unlisted" {
t.Errorf("Listed row state = %q, want unlisted (an unlisted product is not unmet)", got)
}
for _, m := range vm.Missing {
if m == "Listed" {
t.Error("Listed must never appear in Missing for an unlisted product")
}
}
})
t.Run("Payment processing detail names the Stripe mode", func(t *testing.T) {
for mode, want := range map[string]string{"live": "Synced, live", "test": "Synced, test", "": "Synced"} {
vm := buildVMRules(product("published", true, true), shapeForLadder(true, "recurring", 1), mapped, 1, mode)
if got := rowDetail(vm, "Payment processing"); got != want {
t.Errorf("stripe-mode %q: Payment processing detail = %q, want %q", mode, got, want)
}
}
})
}
// rowDetail returns the Detail of the named precondition row, or "" if absent.
func rowDetail(vm ProductReadinessVM, label string) string {
for _, r := range vm.Rows {
+9 -1
View File
@@ -49,7 +49,13 @@ type Config struct {
StripeWebhookSecret string // Stripe webhook signing secret
StripeAPIKey string // Stripe API secret key
StripeDashboardURL string // Stripe dashboard base URL for deep links
BaseURL string // Application base URL for redirects
// StripeMode is "test" or "live", derived at boot from the API key's
// prefix ("" when no key is configured). Every surface that shows or
// acts on the mode reads it from here, so none can disagree with the
// key in force (stripe-integration-infrastructure, "Stripe mode is
// derived from the API key").
StripeMode string
BaseURL string // Application base URL for redirects
// AskFallbackURL is the optional legacy on-demand-TLS answerer
// (domains-ask-fallback-url; empty disables it). Registry misses — and
// only misses — are forwarded there, so a deployment can move off a
@@ -419,6 +425,7 @@ func Start(ctx context.Context, cfg Config) error {
OrgQ: cfg.OrgQ,
IdentityQ: cfg.IdentityQ,
IntegrationConfigs: cfg.IntegrationConfigs,
StripeMode: cfg.StripeMode,
})
if err != nil {
cfg.Logger.Error("failed to set up Operator handler", slog.Any("error", err))
@@ -437,6 +444,7 @@ func Start(ctx context.Context, cfg Config) error {
Logger: cfg.Logger,
AuthConfig: authConfig,
StripeDashboardURL: cfg.StripeDashboardURL,
StripeMode: cfg.StripeMode,
StripeConfigured: cfg.StripeAPIKey != "" && cfg.StripeWebhookSecret != "",
TemporalClient: cfg.TemporalClient,
// The operator Domains surface reads and moderates claims through
+4 -4
View File
@@ -48,15 +48,15 @@ func TestFormPartCheckboxLabelSitsAfterTheBox(t *testing.T) {
Name: "test.checkbox", Kind: forms.KindEdit, Family: forms.Stacked,
Method: "PUT", Path: "/partials/test", Commit: "Save",
Fields: []forms.Field{{
Name: "visibility", Label: "Public", Control: forms.Checkbox, Value: "public",
Help: forms.Help("Public", "A public product is sold in the catalog."),
Name: "visibility", Label: "Listed", Control: forms.Checkbox, Value: "public",
Help: forms.Help("Listed", "Shown in the member catalog."),
}},
}
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound}))
box := strings.Index(out, `type="checkbox"`)
label := strings.Index(out, `class="form-check-label"`)
help := strings.Index(out, `aria-label="Help: Public"`)
help := strings.Index(out, `aria-label="Help: Listed"`)
if box < 0 || label < 0 || help < 0 {
t.Fatalf("checkbox, label or help icon missing:\n%s", out)
}
@@ -67,7 +67,7 @@ func TestFormPartCheckboxLabelSitsAfterTheBox(t *testing.T) {
t.Errorf("expected Bootstrap's form-check wrapper:\n%s", out)
}
// The help icon is the label's sibling: no button inside the label.
if strings.Contains(out, `<label class="form-check-label" for="form-test.checkbox-visibility-control">Public <button`) {
if strings.Contains(out, `<label class="form-check-label" for="form-test.checkbox-visibility-control">Listed <button`) {
t.Errorf("the help icon must not sit inside the <label>:\n%s", out)
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-13
@@ -0,0 +1,57 @@
## Context
Six defects from the 2026-09-12 production authoring session, all recorded in `status/issues.md` on that date with their evidence. Two are behavioral: the add-rule form drops the Per unit tick (`forms.Values.SetBool` fills the raw half of a bound value set while the handler reads the typed half that only `Parse` fills), and the readiness panel calls a product ready while its entitlement set has no rules, which is the state the org-type default change conferred on 2026-09-12 (the rule arrived a minute later and, by the `entitlement-set-management` spec, "rule changes apply to a pool at its next conferral event"; making rule changes reach live pools is the separate change `entitlement-set-changes`, with a preview-and-commit form and a change history, on the maintainer's call that set changes are mission-critical and must be documented). Four are honesty defects: the readiness verdict ignores `is_active` for private products; the member catalog routes a free rung to a disabled checkout; the member Create site button hand-rolls `disabled title="…"` on a button Bootstrap gives `pointer-events: none`; and Stripe's test-or-live mode is the `stripe-mode` setting, so the console labeled a product synced to the live account as test until the setting was changed. The contrast defect from the same walk was fixed separately and is already committed (412b7f6).
The maintainer's decisions on 2026-09-12: fix all six now; give rule changes their own change with a commit form and a history; derive the Stripe mode from the key and drop the setting; rename the visibility flag to Listed / Unlisted while the readiness rows are rewritten.
## Goals / Non-Goals
Goals:
- A ticked Per unit box stores `resource_per_unit = true`; the library invariant makes any future bound checkbox behave the same.
- A product whose entitlement set has no active rule is never called ready.
- The readiness panel tells the truth about inactive products and names the Stripe mode where syncing happens.
- The visibility flag's copy says what it does: Listed / Unlisted.
- A free rung never offers a purchase.
- Every disabled control goes through the shared part, carries its reason, and shows the deny cursor; the lint refuses the hand-rolled shape; the sites card states an over-limit count in its own sentence.
- The Stripe mode is a fact read from the key; dashboard links land in the account the key belongs to.
Non-Goals:
- Rule changes reaching conferred pools, the preview-and-commit form for them, and a change history on the set: `entitlement-set-changes`.
- Editing a rule in place (the surface has add and delete only; the immutability question stays parked in `status/issues.md`).
- Warning in the default-change and tier previews when the incoming product's set has no rules (logged; the readiness verdict now refuses to call such a product ready, and `entitlement-set-changes` closes the window itself).
- Renaming the `is_public` column or the `visibility` form field.
- The "internal" badge on the Issue grant product select (unchanged; vocabulary entry).
- Any change to the delegated-subtree question, the per-unit-on-product thought, or the resources-by-holder issue.
## Decisions
- **D1. `SetBool` records the typed bool.** `Values.SetBool(f, on)` sets `typed[f.Name] = on` beside the raw value and presence it already records. A bound checkbox then answers `Bool` the way a parsed one does. The alternative, reading the raw string in the rule handler, fixes one call site and leaves the trap for the next. The handler test posts `resource_per_unit=true` on a numeric key and asserts `{Bool: true, Valid: true}`; a second case posts nothing and asserts `{Bool: false, Valid: true}`.
- **D2. A rule-less set is not ready.** The readiness panel's Entitlement set row distinguishes absent ("Missing") from present with no active rule ("No rules"); either makes the verdict "Incomplete; missing: entitlement set" or "Incomplete; missing: rules". The grant form keeps offering the product (a set can be filled after the grant, and `entitlement-set-changes` will make that reach the pool), but nothing calls it ready.
- **D3. Readiness rows and verdicts.** Rows become: Entitlement set (every product; "No rules" when present but empty, per D2), Published (every product), Active (every product), Listed (every product; an unlisted product reads "Unlisted; issued as grants"), Active price (listed only), Payment processing (listed only; the detail names the mode: "Synced, live", "Synced, test", "Sync pending", "Sync failed", "No active price"), Member catalog visibility (listed only). Verdict precedence: a missing set, a set with no active rule, or unpublished status is "Incomplete; missing: …" as today; otherwise an inactive product reads "Inactive"; otherwise a listed product reads "Purchasable" (with the catalog qualifier as today) and an unlisted one "Ready to grant". "Inactive" is its own verdict rather than a missing item because the state is deliberate: the grant form does not offer the product and the catalog does not show it, which is what the panel must say.
- **D4. Listed / Unlisted.** The product form's checkbox label reads "Listed" with help "Shown in the member catalog."; the products list's Visibility column reads "Listed" or "Unlisted"; the readiness row is "Listed"; the unlisted state's readiness row reads "Unlisted; issued as grants" with no second line (a help sentence repeating it would break the copy rule). Nothing on an operator surface reads "Public" or "Private" for this flag. The `is_public` column, the `visibility` form field and its values stay, since renaming them is spec and migration churn with no behavior change.
- **D5. A free rung offers no purchase.** In `member_products.go`, a tier at rank 0 with no price and `Relation != current` gets no `MoveKind`, no `MoveLabel`, no `DisabledReason`, and `MoveEnabled = false` unless the org holds a paid rung on the ladder, in which case it keeps today's cancel control. The card's cost line stays "Included". The "Not available for purchase yet" line is reserved for priced tiers whose price is not yet synced, which is what it was written for.
- **D6. Disabled controls: the part, the cursor, the lint.** `ui_disabled_control.html` wraps the button in a `span.disabled-control` in both variants (the tooltip variant already has the wrapper; it gains the class); `app.css` gives `.disabled-control { cursor: not-allowed; }`. The member sites card renders its Create control through the part's Visible variant with the reason "Site limit reached" when the workspace is entitled and at or over its limit, and the existing "Site creation is unavailable right now" when the allowance could not be read; the usage line reads "N of M active sites used" at or under the limit and "N active sites, M allowed." over it. The anatomy lint gains `disabled-outside-part`: a `<button` tag in a template other than the part whose attributes contain the word `disabled`, literal or inside a template action, is refused; the exemption marker is `{{/* disabled-control exempt: <reason> */}}`; the allowlist stays empty. `docs/design-system.md` §3 gains one sentence on the cursor.
- **D7. Stripe mode from the key.** At boot the Stripe integration reads the API key's prefix: `sk_live_` or `rk_live_` is live, `sk_test_` or `rk_test_` is test, an empty key is unset, anything else fails boot with a message naming the accepted prefixes. The mode lives on the integration's runtime state, and `ModeLabel`, `TestModeBanner` and `DashboardURL` read it there instead of viper. `DashboardURL` stays unscoped: `https://dashboard.stripe.com/test` for test and `https://dashboard.stripe.com` for live. Two designs were tried and dropped on 2026-09-13: reading the account at boot (`GET /v1/account`), because production runs a restricted key (`rk_live_`, Stripe's least-privilege recommendation) and that call needs the Connect "Accounts Read" permission, observed as a 403 on the test stack; and a declared `stripe-account-id` setting, because the mode is a fact derived from the key while the id would be a claim the console cannot check against it, so a pasted live id under a sandbox key would send every link to the wrong place. Stripe resolves an unscoped link in the operator's own dashboard session and offers its "Did you mean live mode?" switch on a mismatch; that is enough. The `stripe-mode` key leaves the config registry, the settings page, the docs and the parity test; a migration deletes any stored `stripe-mode` override row. The "Switching Stripe to live mode carries a consequence warning" requirement goes with the setting: the consequence now lives where the key is set, in the deployment's secrets.
- **D8. Scope boundary with the contrast fix.** The `text-warning-emphasis` change is committed outside this change; nothing here touches it.
## Risks / Trade-offs
- Boot now fails on a malformed Stripe key prefix. Restricted keys (`rk_`) are accepted; any other prefix is a misconfiguration and failing early is the console's existing stance on configuration.
- Dashboard links are not deterministic across several accounts or sandboxes in one operator's browser: an unscoped link opens in the session's current context and Stripe offers a switch. Accepted; the alternatives (an API read needing a Connect permission, or an unverifiable declared id) were worse.
- The lint rule may catch templates that disable a button for a non-precondition reason (an in-flight commit). Those use `hx-disabled-elt`, not a `disabled` attribute, so the rule should find only the two known hand-rolled cases; any other hit is either a defect or an exemption with a reason.
## Migration Plan
- One migration: `DELETE FROM core.integration_config_overrides WHERE key = 'stripe-mode'` (or the table's actual key column). No data changes otherwise.
- Deployment: build, push, redeploy `member-console-next`; the boot log shows the derived Stripe mode and the account id (or the fallback WARN).
## Open Questions
- None blocking. The immutability question ("Should entitlement sets be immutable?") and the preview warnings for rule-less sets stay in `status/issues.md`.
@@ -0,0 +1,37 @@
## Why
The 2026-09-12 production authoring session on console.wiki.cafe surfaced six defects, all logged in `status/issues.md` under "Operator panel — UX, IA & accessibility" on that date. Two of them caused or amplified a production incident the same day: the add-rule form stores every rule with `per_unit = false` whatever the operator ticked, and the readiness panel called a product with a rule-less entitlement set ready, which is how an org-type default change left every personal org with a materialized site limit of 0 for an hour (the rule arrived a minute after the conferral, and rule changes never reach conferred pools; that second half is the separate change `entitlement-set-changes`). The other four are honesty and vocabulary defects the maintainer walked into while authoring the catalog: the readiness panel says "Ready to grant" for an inactive private product the grant form refuses to offer; the member catalog offers "Subscribe" on a free rung and then explains it is "Not available for purchase yet"; the member Create site button hand-rolls its disabled state so nothing says it is disabled or why; and the Stripe test-or-live mode is a setting the operator declares rather than a fact derived from the key, which mislabeled a live product as test for twenty minutes. The maintainer chose to fix all six now, before gates G6 to G8 of the cutover, and to rename the product visibility flag from Public to Listed while the readiness rows are rewritten.
## What Changes
- **Per unit stored as ticked.** `forms.Values.SetBool` records the typed bool as well as the raw value, so a bound checkbox answers `Bool` the way a parsed one does; the rule handler's test asserts the stored flag for a numeric key.
- **A set with no rules is not ready.** The readiness panel's Entitlement set row reads "No rules" when the set has no active rule, and the verdict is "Incomplete; missing: rules", so a product cannot read "Ready to grant" or "Purchasable" while conferring nothing. (Re-materializing live pools on rule changes, with a preview-and-commit form and a change history on the set, is its own change: `entitlement-set-changes`.)
- **Readiness treats Active as its own precondition for every product.** A separate Active row; an inactive product's verdict reads "Inactive" instead of "Purchasable" or "Ready to grant"; the visibility row is renamed with the flag.
- **Visibility flag reads Listed / Unlisted.** The product form's label, the products list's Visibility column, the readiness row and the help line say what the flag does: shown in the member catalog, or issued as grants only. The column `is_public` and the form field keep their names.
- **A free rung never offers a purchase.** A rank-0 tier with no price renders no purchase control and no "Not available for purchase yet" line; it carries the cancel control only when the org holds a paid rung on that ladder.
- **Disabled controls through the part, with a deny cursor.** The member Create site button renders through the shared `disabledControl` part with its reason; the part's wrapper carries `cursor: not-allowed` in both variants; the anatomy lint refuses a `disabled` button outside the part; the sites card states an over-limit count in its own sentence.
- **Stripe mode from the key.** The console derives test or live from the API key's prefix at boot, keeps dashboard links unscoped (no account read, which a restricted key cannot do without a Connect permission, and no declared id, which nothing could verify), and drops the `stripe-mode` setting and its live-switch warning. The product readiness Payment processing row names the mode.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `entitlement-set-management`: the add-rule form stores Per unit as submitted.
- `product-management`: Active is a readiness precondition for every product; a rule-less set is not ready; Listed / Unlisted vocabulary; Payment processing row names the Stripe mode.
- `member-product-discovery`: a free rung offers no purchase control.
- `form-conventions`: the disabled-control part's wrapper carries the deny cursor.
- `fedwiki-sites`: the sites card's Create control renders through the part and the over-limit state has its own sentence.
- `ui-quality-gate`: lint rule against a hand-rolled disabled control.
- `operator-billing-views`: the test-mode banner follows the key, not a setting; the live-switch warning requirement is removed.
- `integration-settings`: the `stripe-mode` setting requirement is removed.
- `stripe-integration-infrastructure`: mode derived from the key; dashboard links carry the mode only.
## Impact
- Code: `internal/forms/parse.go`; `internal/server/product_readiness.go`, `operator_product_forms.go`, `operator_products.go` and their templates; `internal/server/member_products.go` and `member_plans.html`; `internal/embeds/templates/partials/ui_disabled_control.html`, `internal/embeds/static/app.css`, `internal/lint/anatomy.go`, `internal/integrations/fedwiki/templates/fedwiki_sites.html` and `web/partials.go`; `internal/integrations/stripe/stripe.go` (config registry, mode, dashboard URL), the boot wiring that reads the mode, `operator_pages.go`, `operator_integrations_stripe.go`; one migration removing stored `stripe-mode` overrides.
- Copy: readiness verdict "Inactive" and "Incomplete; missing: rules"; "Listed" / "Unlisted"; "Site limit reached"; the over-limit sentence.
- Docs: `docs/environment-reference.md` and `docs/stripe.md` lose `stripe-mode`; `docs/design-system.md` §3 gains the cursor sentence.
@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: The add-rule form stores the Per unit choice as submitted
The add-rule form SHALL store `resource_per_unit` exactly as the operator submitted it: a ticked Per unit box stores true, an unticked box stores false, and a boolean resource key stores NULL. A checkbox bound to a value set SHALL answer the typed `Bool` accessor the same way a parsed checkbox does, so no handler can read a bound checkbox as false while the raw value reads true.
#### Scenario: Ticked Per unit is stored
- **WHEN** an operator adds a limit rule for a numeric key with Per unit ticked
- **THEN** the stored rule carries `resource_per_unit = true`
#### Scenario: Unticked Per unit is stored
- **WHEN** an operator adds a limit rule for a numeric key with Per unit unticked
- **THEN** the stored rule carries `resource_per_unit = false`
@@ -0,0 +1,21 @@
## MODIFIED Requirements
### Requirement: UI displays quota usage
The system SHALL display the user's current site usage and limit in the sites list view. The current count SHALL be the number of the workspace's `fedwiki.sites` rows whose status is `active`, never the reservation counter in `numeric_entitlement_usage`; the limit SHALL be the materialized `resource_limit`. At or under the limit the display SHALL read "N of M active sites used"; over the limit, where the active row count exceeds `resource_limit` (a tier downgrade before its force-reduce sweep runs, or a default change to a smaller plan), the display SHALL state it in its own sentence with the count and the limit: "N active sites, M allowed." A disabled Create affordance SHALL render through the shared disabled-control part with its reason in the DOM ("Site limit reached" when the workspace is entitled and at or over its limit), never as a bare `disabled` attribute with a `title`. The Create affordance and the JSON usage endpoint SHALL derive their "at the limit" state from the same row count.
#### Scenario: Sites list shows quota
- **WHEN** a user views their sites list
- **THEN** the page SHALL display the current site count and the entitlement limit
#### Scenario: Over the limit reads as a sentence
- **WHEN** a workspace holds 12 active site rows and a materialized limit of 1
- **THEN** the sites card reads "12 active sites, 1 allowed." and the Create control renders through the disabled-control part with the reason "Site limit reached"
#### Scenario: Rows written outside the create workflow are counted
- **WHEN** a workspace holds 64 active site rows inserted by an import or by farm sync while its reservation counter reads 1
- **THEN** the sites list SHALL display 64 as the current count
- **AND** with a limit of 64 the Create affordance SHALL be disabled and the JSON usage endpoint SHALL report `canCreate = false`
@@ -0,0 +1,20 @@
## MODIFIED Requirements
### Requirement: Disabled controls carry their reason in the DOM
A control rendered disabled because a precondition is unmet SHALL carry its reason in the document and reference it with `aria-describedby`, on both surfaces, through one shared part. Whether the reason is visible text (the member surface) or a tooltip on a focusable wrapper (the operator surface, where dense action rows do not stack notes between buttons) is the surface's density choice; the keyboard path and the described-by relation exist in both cases. Pages SHALL NOT hand-roll a `title` on a wrapper as the only reason. This is the one place a hover tooltip remains in the console; help uses popovers. In both variants the control SHALL sit in a wrapper that carries the not-allowed cursor, because a disabled button receives no pointer events of its own and cannot show a cursor or a tooltip by itself.
#### Scenario: A keyboard user reaches the reason
- **WHEN** a disabled control's wrapper receives focus on the operator surface
- **THEN** the reason is announced through `aria-describedby` and shown as the tooltip
#### Scenario: A pointer over a disabled control sees the deny cursor
- **WHEN** a pointer rests on a disabled control rendered through the part, on either surface
- **THEN** the cursor is the not-allowed cursor
#### Scenario: Both surfaces use the part
- **WHEN** a disabled control with a reason renders on either surface
- **THEN** it renders through the shared part, and the reason is present in the DOM whether or not it is visible
@@ -0,0 +1,6 @@
## REMOVED Requirements
### Requirement: Switching Stripe to live mode carries a consequence warning
**Reason**: the `stripe-mode` setting is retired; the mode is derived from the API key (stripe-integration-infrastructure "Stripe mode is derived from the API key"), so there is no control to warn beside. The consequence lives where the key is set, in the deployment's secrets.
**Migration**: remove the key from the config registry, the settings page and the docs; a migration deletes stored overrides.
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: A free rung offers no purchase
A rank-0 tier with no price SHALL render no purchase control and no "Not available for purchase yet" line, because it is conferred and never bought: when the member's organization holds nothing on the ladder the card shows its cost line and features only; when the organization holds a paid rung on the ladder the card carries the cancel control as the paid-to-free move; when the organization holds the free rung the card reads as the current plan.
#### Scenario: Not enrolled sees no control on the free rung
- **WHEN** a member whose organization holds no rung on a ladder views that ladder's rank-0 tier, which carries no price
- **THEN** the card reads "Included", lists the tier's features, and renders no button and no "Not available" line
#### Scenario: A paid holder sees the cancel control on the free rung
- **WHEN** a member whose organization holds a paid rung views the same ladder's free rank-0 tier
- **THEN** the card offers the cancel control labeled "Downgrade"
#### Scenario: An unsynced priced tier keeps its reason
- **WHEN** a member views a priced tier whose price is not synced
- **THEN** the card's move control is disabled with "Not available for purchase yet"
@@ -0,0 +1,15 @@
## MODIFIED Requirements
### Requirement: Billing views mark Stripe test mode
When the configured Stripe API key is a test key (prefix `sk_test_` or `rk_test_`), every operator billing view (accounts, subscriptions, invoices, payments) and the invoice detail SHALL render one banner under the page header, above the billing pills, reading "Stripe is in test mode. Figures on these pages are test data." with a "Stripe settings" link to `/operator/integrations/stripe/settings`, so no billing figure can be read as live money by mistake; in live mode no banner renders. The banner derives from the mode the console derives from the key at boot, the same fact behind its dashboard links, never from a setting or a stored flag, and renders through one shared element so the five pages cannot differ.
#### Scenario: Test mode is visible on every billing page
- **WHEN** the Stripe API key is a test key and an operator opens any billing view or an invoice detail
- **THEN** the banner renders under the page header, above the billing pills, with the sentence and the settings link
#### Scenario: Live mode shows nothing
- **WHEN** the Stripe API key is a live key
- **THEN** no banner renders
@@ -0,0 +1,253 @@
## ADDED Requirements
### Requirement: The visibility flag reads Listed
Every operator surface that names the `is_public` flag SHALL call its states "Listed" and "Unlisted": the product form's checkbox label reads "Listed" with the help "Shown in the member catalog."; the readiness row is "Listed"; the unlisted state's readiness row reads "Unlisted; issued as grants" and carries no second line. No operator surface SHALL read "Public" or "Private" for this flag. The column `is_public` and the form field keep their names.
#### Scenario: The product form names the flag
- **WHEN** an operator opens the product create or edit form
- **THEN** the flag's checkbox is labeled "Listed" and its help reads "Shown in the member catalog."
#### Scenario: No surface says Public
- **WHEN** an operator views the products list, a product page or its readiness panel
- **THEN** the flag's states read "Listed" or "Unlisted" and the words "Public" and "Private" do not appear for it
## MODIFIED Requirements
### Requirement: Operator product edit page surfaces purchasability readiness
The operator product edit page (`/operator/products/{id}`) SHALL display a
**purchasability readiness panel** that evaluates every precondition standing between
the product and a member being able to purchase it, and renders a single verdict —
**purchasable**, **ready to grant**, **inactive** or **incomplete** — with the specific unmet
preconditions named.
The panel SHALL evaluate and display the live state of each of these preconditions,
read from `billing.product_shape`:
- **Published**`lifecycle_status = 'published'`.
- **Active**`is_active = TRUE`, for **every** product. An inactive product is
offered by neither the member catalog nor the grant form, so the panel SHALL
report the row as unmet and the verdict SHALL read **Inactive** (never
**Purchasable** or **Ready to grant**) whenever the set and published
preconditions are met.
- **Listed**`is_public = TRUE`. For an unlisted product (`is_public = FALSE`;
Requirement: Wrap-product creation) the panel SHALL report the row as
"Unlisted; issued as grants" rather than **unmet** — unlisted products are never
meant to be in the catalog, and only the entitlement-set half of the gate applies
to them.
- **Entitlement set present, with rules**`product_shape.set_present`
(`entitlement_set_id IS NOT NULL`) and the set holds at least one active rule;
a present set with no active rule reads "No rules" and is unmet ("Incomplete;
missing: rules"). Required for **every** product regardless of
`is_public` — a published product with no entitlement set confers nothing,
whether it is a storefront product or an internal wrap product (Requirement:
Wrap-product creation).
- **Has an active price** — required **only for `is_public` products**:
`product_shape.billing_shape <> 'unpriced'` (at least one `billing.prices` row
with `is_active = TRUE`). Products with `is_public = FALSE` (wrap products) are
**exempt** from this precondition — they are minted priceless by design, and the
panel SHALL NOT report a missing price against them.
- **Payment processing** (the Stripe-mapped price precondition, labeled in
operator-facing, implementation-neutral terms) — evaluated only where a price is
required (`is_public` products); for `is_public = FALSE` products the panel SHALL
report this precondition as **not applicable** rather than unmet. Where it does
apply, the active price has a `stripe.price_mappings` entry, distinguishing
**synced**, **sync pending** (a sync was enqueued and the mapping has not landed
yet), and **sync failed** (the enqueued sync has terminally failed). The row's
detail SHALL name the Stripe mode the console derives from its API key ("Synced,
live" / "Synced, test"), so the surface where syncing is triggered says which
account it reaches.
The panel SHALL additionally report, for `is_public` products, a **member catalog
visibility** line: whether the member catalog will actually render this product —
true when the product is a tier on a plan ladder or carries
`display_category = 'addon'` (the two sections the member catalog renders). This
line SHALL NOT change the purchasable verdict — off-ladder purchasability is by
design — but when the product is not rendered by the member catalog, the verdict
line SHALL carry an explicit qualifier (e.g. "Purchasable — not shown in the
member catalog"), stating in plain terms that members have no catalog path to it,
so **Purchasable** can never be read as **findable**. For `is_public = FALSE`
products the line reports **not applicable**.
The panel SHALL additionally report, for **diagnostics only and never as a
pass/fail precondition**, the product's shape from `billing.product_shape`:
`ladder_count`, `billing_shape`, and `consumption_shape`. These per-dimension
values are reported side by side and are never resolved into a single "kind"; a
product legitimately reporting, for example, `ladder_count = 0` alongside
`billing_shape = 'mixed'` is an ordinary, fully describable shape, not an
ambiguous or error state. A published product that is on no ladder and carries no
`display_category` is likewise an ordinary shape — off-ladder is not a limbo
state — but the panel SHALL say plainly, via the member-catalog-visibility line,
that the member catalog does not show it.
The readiness verdict SHALL be derived from the **same shared computation** the
member catalog uses to decide purchasability, so the operator panel and the member
catalog cannot disagree about whether a product is purchasable. This gate remains
**application-level**, not schema/CHECK-enforced.
"Stripe configured for the deployment" SHALL be determined by the deployment's Stripe
credentials being present (the Stripe API key and webhook secret are both set), not
merely by a Stripe querier being constructed. When Stripe is **not** configured, the
panel SHALL render an explanatory "Stripe is not configured" state for the
Stripe-mapped precondition rather than a bare "missing", and SHALL NOT offer the sync
action.
The Stripe-mapped precondition SHALL be **actionable**, not merely a marker:
- When Stripe is configured, the product has an active price, and that price is
neither mapped nor a sync is already pending, the panel SHALL offer an explicit
**"Sync to Stripe"** action that enqueues the Stripe product and price sync.
- When a previously-enqueued sync has **terminally failed** (dead-lettered), the panel
SHALL show the Stripe-mapped precondition as **sync failed** — surfacing the failure
(with the recorded error) rather than leaving it pinned at "sync pending" — and
SHALL offer a **Retry** action.
While a sync is **pending**, the panel SHALL live-update without a manual page
refresh: it polls its own readiness partial and stops polling once the state is
terminal (synced or failed), so the operator watches "sync pending" resolve in
place.
The panel MUST NOT change member-facing catalog behavior; it is an operator-side
legibility surface only, and the sync-failed state is derived without altering the
shared purchasability gate.
#### Scenario: A rule-less set is not ready
- **WHEN** an operator views the readiness panel of a published, active product whose entitlement set has no active rule
- **THEN** the Entitlement set row reads "No rules" and the verdict reads "Incomplete; missing: rules"
#### Scenario: Fully configured plan shows purchasable
- **WHEN** an operator views the edit page for a published, public, active plan
product that is a tier on a ladder, has an active price, and whose price is
Stripe-mapped
- **THEN** the readiness panel shows every precondition met and a verdict of
**Purchasable**
- **AND** the member-catalog-visibility line reports the product as shown in the
member catalog
#### Scenario: Plan missing a price shows incomplete with guidance
- **WHEN** an operator views a published, on-ladder plan product that has no active
`billing.prices` row
- **THEN** the readiness panel shows a verdict of **Incomplete**, names the missing
"active price" precondition, and offers inline guidance to add a price
#### Scenario: Unmapped price (Stripe configured) offers the Sync to Stripe action
- **WHEN** an operator views a product with an active price that has no Stripe
mapping, and Stripe is configured for the deployment
- **THEN** the readiness panel shows the Stripe-mapped precondition as unmet **and**
offers a "Sync to Stripe" action for it
#### Scenario: Price present but Stripe mapping pending
- **WHEN** an operator views a product that has an active price for which a Stripe
sync has been enqueued (a `stripe.price_mappings` row with `sync_status = 'pending'`)
and the enqueued outbox actions have not terminally failed, but no `stripe_price_id`
has landed yet
- **THEN** the readiness panel shows the Stripe-mapping precondition as **sync
pending** (not synced), does not offer the Sync action again, and the overall
verdict as **Incomplete**
#### Scenario: Pending sync resolves in place without a refresh
- **WHEN** an operator triggers Sync to Stripe and stays on the product page
- **THEN** the readiness panel SHALL poll while the sync is pending and update to
the terminal state (synced or failed) automatically, ceasing to poll thereafter
#### Scenario: Terminally-failed sync shows Sync failed with a Retry action
- **WHEN** an operator views a product whose enqueued Stripe sync has dead-lettered
(the `create_stripe_product` / `create_stripe_price` outbox entry reached
`dead_letter`) while the mapping is still unmapped
- **THEN** the readiness panel shows the Stripe-mapped precondition as **sync failed**
with the recorded error, and offers a **Retry** action rather than showing an
indefinite "sync pending"
#### Scenario: Stripe not configured shows an explanatory state, not a dead end
- **WHEN** an operator views a product with an active price and the deployment's
Stripe credentials are not set
- **THEN** the readiness panel shows the Stripe-mapped precondition with an
explanatory "Stripe not configured for this deployment" note and SHALL NOT offer
the "Sync to Stripe" action
#### Scenario: Published, off-ladder, untyped product is an ordinary shape, not limbo
- **WHEN** an operator views a published, `is_public` product with `display_category`
blank, `ladder_count = 0`, an entitlement set, and an active, Stripe-mapped price
- **THEN** the readiness panel reports `ladder_count = 0` as diagnostic shape
information alongside `billing_shape` and `consumption_shape`, not as a violation
- **AND** the panel does NOT flag the product as being in a limbo or ambiguous state
- **AND**, having an entitlement set and an active mapped price, the verdict is
**Purchasable**
- **AND** the member-catalog-visibility line reports the product as not shown in the
member catalog, with the verdict carrying the "not shown in the member catalog"
qualifier
#### Scenario: Off-ladder public product with price and mapping is purchasable
- **WHEN** an operator views a published, public, active product that is on no
ladder, carries an entitlement set, and has an active, Stripe-mapped price
- **THEN** the readiness panel shows the entitlement-set and price preconditions met
(ladder membership is not required for purchasability) and a verdict of
**Purchasable**
- **AND** the verdict carries the "not shown in the member catalog" qualifier when
the product also lacks `display_category = 'addon'`
#### Scenario: Tier and addon products report as findable
- **WHEN** an operator views a published, public product that is a ladder tier, or
one carrying `display_category = 'addon'`
- **THEN** the member-catalog-visibility line reports the product as shown in the
member catalog
- **AND** no visibility qualifier is attached to the verdict
#### Scenario: Wrap product is exempt from the price precondition
- **WHEN** an operator views the edit page for an internal wrap product
(`is_public = FALSE`, `lifecycle_status = 'published'`, carrying an entitlement
set, and no `billing.prices` rows)
- **THEN** the readiness panel reports the "Entitlement set present" precondition
as met
- **AND** the panel SHALL NOT report the "Has an active price" or "Payment
processing" preconditions as unmet — they are reported as not applicable,
because `is_public = FALSE` products are exempt from the price half of the gate
#### Scenario: Wrap product's Visible precondition is not applicable, not unmet
- **WHEN** an operator views the edit page for an internal wrap product
(`is_public = FALSE`, `lifecycle_status = 'published'`, carrying an entitlement
set, and no `billing.prices` rows)
- **THEN** the readiness panel reports the **Visible** precondition as **not
applicable**
- **AND** the panel SHALL NOT report **Visible** as unmet or count it against the
product's readiness verdict — wrap products are never meant to be visible, and
only the entitlement-set half of the gate applies to them
### Requirement: The readiness panel prints its verdict
The readiness panel SHALL render the verdict string the readiness evaluation computes ("Purchasable", "Purchasable; not shown in the member catalog", "Ready to grant" for an unlisted product, "Inactive" for a product whose set and published preconditions are met but whose `is_active` is false, "Incomplete; missing: …") and SHALL NOT render any literal derived from a single boolean in its place.
#### Scenario: An inactive product reads Inactive
- **WHEN** an operator views the readiness panel of a product with an entitlement set, published, and `is_active = FALSE`, listed or not
- **THEN** the verdict reads "Inactive" and neither "Purchasable" nor "Ready to grant" appears
#### Scenario: An unlisted product reads Ready to grant
- **WHEN** an operator views the readiness panel of an active unlisted product whose entitlement set is present
- **THEN** the panel's verdict reads "Ready to grant" and the word "Purchasable" does not appear
### Requirement: The products list shows Visibility
The products list SHALL carry a "Visibility" column reading "Listed" or "Unlisted" per row, in place of any boolean-named column.
#### Scenario: The column names the state
- **WHEN** an operator views the products list
- **THEN** each row's Visibility cell reads Listed or Unlisted and no column is headed "Public"
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: Stripe mode is derived from the API key
The console SHALL derive its Stripe mode from the configured API key's prefix at boot: `sk_live_` or `rk_live_` is live, `sk_test_` or `rk_test_` is test, an empty key is unset, and any other prefix SHALL fail boot with a message naming the accepted prefixes. No setting SHALL declare the mode; the `stripe-mode` configuration key is retired and a stored override for it is removed by migration. Every surface that shows or acts on the mode (the test-mode banner, the mode label on the overview and the Stripe provider page, the readiness panel's Payment processing detail, dashboard links) SHALL read the derived value. Dashboard links SHALL carry the mode and no account: the console SHALL NOT read the account from the API (a restricted key would need Stripe's Connect "Accounts Read" permission) nor accept a declared account id (nothing could check it against the key).
#### Scenario: A live key is live
- **WHEN** the console boots with an API key beginning `sk_live_`
- **THEN** the mode is live, no test banner renders, and the mode label reads "Live mode"
#### Scenario: A sandbox key is test
- **WHEN** the console boots with an API key beginning `sk_test_`
- **THEN** the mode is test and the test banner renders on billing views
#### Scenario: A malformed key refuses boot
- **WHEN** the console boots with an API key whose prefix is none of the accepted four
- **THEN** boot fails and the message names the accepted prefixes
@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: Lint refuses a hand-rolled disabled control
The anatomy lint SHALL refuse, under the rule `disabled-outside-part`, any `<button` tag in a template other than the disabled-control part whose attributes contain the word `disabled`, whether literal or inside a template action, because a disabled control's reason, described-by relation and cursor exist only through the part. A template MAY exempt one occurrence with the marker `{{/* disabled-control exempt: <reason> */}}` on the line before the tag; the allowlist for this rule stays empty.
#### Scenario: A hand-rolled disabled button fails lint
- **WHEN** a template renders `<button class="btn" disabled title="…">` outside the part
- **THEN** `make lint` fails naming the template and the rule
#### Scenario: The part passes lint
- **WHEN** a template renders its disabled control through `{{ template "disabledControl" … }}`
- **THEN** the rule reports nothing
@@ -0,0 +1,40 @@
## 1. Per unit stored as ticked
- [x] 1.1 `internal/forms/parse.go`: `SetBool` records `typed[f.Name] = on` beside the raw value and presence; unit test in the forms package that a bound checkbox answers `Bool` both ways (design D1).
- [x] 1.2 `internal/server/operator_entitlement_set_rules_test.go`: post `resource_per_unit=true` on a numeric key and assert the stored rule is `{Bool: true, Valid: true}`; post nothing and assert `{Bool: false, Valid: true}` (entitlement-set-management delta).
## 2. A rule-less set is not ready
- [x] 2.1 `internal/server/product_readiness.go`: the Entitlement set row reads "No rules" when the set exists with no active rule; verdict "Incomplete; missing: rules" (design D2); test with a set that has no rule.
## 3. Readiness and the Listed vocabulary
- [x] 3.1 `internal/server/product_readiness.go`: rows per design D3 (Entitlement set, Published, Active, Listed, Active price, Payment processing with the mode in its detail, Member catalog visibility); verdict precedence Incomplete, Inactive, Purchasable / Ready to grant.
- [x] 3.2 `operator_product_forms.go`: checkbox label "Listed", help "Shown in the member catalog."; readiness and products-list copy "Listed" / "Unlisted", "Unlisted products are issued as grants." (`operator_product_readiness.html`, `operator_products.html`, `operator_product_detail.html` and any template printing Public / Private for the flag; design D4).
- [x] 3.3 Tests: inactive unlisted product reads "Inactive" and the grant form's product options exclude it; inactive listed product reads "Inactive"; active unlisted product reads "Ready to grant"; products list column reads Listed / Unlisted; Payment processing detail names the mode (product-management delta scenarios). Update the existing readiness and product tests that pin "Public" / "Private".
## 4. A free rung offers no purchase
- [x] 4.1 `internal/server/member_products.go`: a rank-0 tier with no price and `Relation != current` gets no move control and no disabled reason unless the org holds a paid rung on the ladder (design D5).
- [x] 4.2 Tests: not enrolled → free rung card has no button and no "Not available" line; on a paid rung → free rung card offers the cancel control; a priced unsynced tier still reads "Not available for purchase yet" (member-product-discovery delta scenarios).
## 5. Disabled controls through the part
- [x] 5.1 `ui_disabled_control.html`: both variants wrap the button in `span.disabled-control`; `app.css`: `.disabled-control { cursor: not-allowed; }`; `docs/design-system.md` §3 gains the cursor sentence (design D6).
- [x] 5.2 `internal/integrations/fedwiki/templates/fedwiki_sites.html` and `web/partials.go`: the Create control renders through the part's Visible variant with "Site limit reached" or the existing unavailable copy; the usage line reads "N of M active sites used" at or under the limit and "N active sites, M allowed." over it.
- [x] 5.3 `internal/lint/anatomy.go`: rule `disabled-outside-part` with marker `{{/* disabled-control exempt: <reason> */}}`; allowlist stays empty; `make lint` green after 5.2 with no marker added (ui-quality-gate delta).
- [x] 5.4 Tests: sites card at the limit renders the part with `aria-describedby` and the reason; over the limit renders the sentence; the lint rule refuses a fixture template with a hand-rolled disabled button and accepts the part (fedwiki-sites and ui-quality-gate delta scenarios).
## 6. Stripe mode from the key
- [x] 6.1 `internal/integrations/stripe/stripe.go`: derive the mode from the key prefix at boot (live, test, unset; any other prefix fails boot with the accepted prefixes named); remove the `stripe-mode` config key; `DashboardURL` takes the derived mode and the account id (design D7).
- [x] 6.2 Dashboard links stay unscoped (`DashboardURL(mode)`); no account read and no account id setting (both tried and dropped 2026-09-13, design D7).
- [x] 6.3 Replace every `viper.GetString("stripe-mode")` read (`operator_overview.go`, `operator_integrations_stripe.go`, `operator_pages.go` banner, `operator_integration_settings.go` special-casing) with the derived mode; remove the settings-page live-switch consequence copy (`operator_integration_settings_forms.go`).
- [x] 6.4 Migration deleting stored `stripe-mode` overrides; `cmd/integration_config_parity_test.go` and `docs/environment-reference.md`, `docs/stripe.md` updated.
- [x] 6.5 Tests: prefix table (sk_live_, rk_live_, sk_test_, rk_test_, empty, junk); banner renders for a test key and not for a live key; dashboard URL per mode; settings page no longer lists `stripe-mode` (stripe-integration-infrastructure, operator-billing-views, integration-settings deltas).
## 7. Verification and release
- [x] 7.1 `make lint`, `make test` green; `make screens` run with the test app; sheet reviewed for the product page, products list, entitlement set page, member plans, member dashboard sites card, billing views.
- [x] 7.2 Maintainer review; then `openspec archive` in the implementation commit on the maintainer's word (reviewed and archived 2026-09-13).
- [x] 7.3 Build and push the image, redeploy `member-console-next`, confirm the boot log's derived Stripe mode, record the tag in the cutover ledger (image 2026-09-13T21-36Z deployed 21:47 UTC; log: migrated to version 17, mode live).
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-13
@@ -0,0 +1,30 @@
## Why
An entitlement set defines what every product built on it delivers, and today a rule added or deleted on a set reaches nothing that is already conferred: the `entitlement-set-management` spec says rule changes "apply to a pool at its next conferral event", and the rules panel discloses it. On 2026-09-12 that gap turned an org-type default change into an hour of zero site limits for every personal org on console.wiki.cafe: the default was conferred a minute before the set got its rule, and nothing ever recomputed the pools. The maintainer's position the same night: set changes are mission-critical, must not happen "willy-nilly", and must be documented. So the fix is not a silent re-materialization bolted onto the add-rule handler. It is the same commit shape the console uses for every consequential operator act (the org-type default change and the tier removal both preview their effect per pool and commit with a stated disposition), plus a history on the set so anyone can read what changed, when, by whom, and what it did to which pools.
## What Changes
- **Rule changes are previewed and committed.** Adding or deleting a rule opens the shared preview-and-commit form: the preview names every pool whose active provisions carry the set and, per resource key, the limit before and after (up, down, unchanged) and whether the pool ends up over its usage; the commit applies the rule and re-materializes those pools in one transaction.
- **The set carries a change history.** Every committed change writes one ledger row (actor, time, the rule before and after, the pools recomputed with their before and after limits); the set page shows the history newest first, paged like every operator list.
- **The "next conferral" disclosure is retired.** Its copy leaves the rules panel; the preview replaces it. The live-backing consequence copy near Delete says what the preview will show.
- **Conferral against a rule-less set is refused.** The org-type default change and the tier add previews refuse a product whose set has no active rule, naming the set; `slice3-followup-fixes` already makes readiness call such a product incomplete.
## Capabilities
### New Capabilities
- `entitlement-set-history`: the change ledger on a set and its page section.
### Modified Capabilities
- `entitlement-set-management`: rule add and delete become preview-and-commit flows; the next-conferral disclosure requirement is removed; the delete consequence copy changes meaning.
- `entitlements`: a query listing the pools carrying a set; re-materialization as part of the rule commit.
- `plan-enrollment-administration` and `plan-ladder-management`: default-change and tier-add previews refuse a rule-less set.
- `form-conventions`: the preview-and-commit idiom gains a third user (if the shared part needs any extension for per-pool rows).
## Impact
- Schema: one new table for the ledger (set id, rule snapshot before and after, actor, reason, recomputed pools as JSON, created_at) and a query listing pools by set.
- Code: `operator_entitlement_sets.go` rule handlers become preview and commit endpoints; a materializer helper that returns before-and-after limits per pool; the set page gains a History section.
- Scale: the preview and commit touch every pool carrying the set synchronously; at wiki.cafe that is one pool per personal org. A workflow-backed commit is the escape hatch if a deployment outgrows it; the ledger row is written either way.
- Design first: this proposal records the position. The design must settle the ledger's shape (per rule change or per commit), how the preview computes limits without writing, and whether the history also records conferral-time materializations, before tasks are written.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-13
@@ -0,0 +1,34 @@
## Why
The console remembers a synced Stripe object as an id and a status and nothing else: `stripe.product_mappings`, `price_mappings`, `customer_mappings` and `subscription_mappings` carry no record of the environment the id was created in, and a sync is one create call at authoring time that nothing ever reads back. An id resolves only in the environment that created it (live, the legacy test mode, or one sandbox). So the moment the API key moves to another environment, every mapping becomes a dangling pointer the console still calls synced. The maintainer named the case on 2026-09-13: products authored in a sandbox, then the key set to the live account; and the reverse, a live start walked back to a sandbox. In both directions today the readiness row reads "Synced, live" or "Synced, test" from the current key rather than from where the object is; the Sync control refuses with "This price is already synced to Stripe."; a member checkout sends the old price id under the new key and Stripe answers `resource_missing`, which the member sees as "failed to start checkout"; the fulfillment reconcile fails the same way; and the billing banner's "Figures on these pages are test data" describes the key while the figures are ledger rows written under whatever key was configured when the webhooks arrived. `slice3-followup-fixes` derived the mode from the key, which is right, and left the mappings as they were. console.wiki.cafe is not exposed: its objects were created under the live key from the start.
## What Changes
- **Every mapping records its environment.** The outbox executors write the `livemode` flag from the object Stripe returns onto the mapping row (products, prices, customers, subscriptions, subscription items); the projection-side mappings (invoices, payments, payment methods) take it from the webhook event's `livemode`. Rows that predate the column hold unknown until the read-back settles them; the migration guesses nothing.
- **A disagreeing mapping is not synced.** Where the recorded mode differs from the key's mode: the readiness Payment processing row names the recorded mode and the disagreement instead of "Synced, live"; the Sync control creates the object again in the current environment instead of refusing; the member checkout refuses before calling Stripe; the reconcile skips the subscription and logs it.
- **A read-back verifies ids in the current environment.** Under the current key the console reads each synced product and price and marks a `resource_missing` mapping stale; this is the only way to detect a move between two sandboxes, which share `livemode` false. Run from the Stripe provider page as an operator action and at boot when the key differs from the one the last verification ran under; the design settles the trigger and the boot cost.
- **Webhook events that disagree with the key are refused.** An event whose `livemode` differs from the key's mode is captured, not processed, and surfaced on the Stripe provider page, so a signing secret left behind from the other environment is visible rather than silent.
- **The banner's provenance sentence is earned by data.** "Figures on these pages are test data" renders only when no stored event carries `livemode` true; when the ledger mixes environments the banner says so. The design settles the copy.
## Capabilities
### New Capabilities
- None. The environment stamp and the read-back extend the existing Stripe capabilities.
### Modified Capabilities
- `stripe-integration-infrastructure`: mapping rows record `livemode`; the read-back and its stale marking; webhook events that disagree with the key's mode are refused and surfaced.
- `stripe-product-catalog-sync`: the product and price executors stamp the mapping; the Sync control creates again when the mapping disagrees with the key.
- `stripe-customer-sync`: the customer executor stamps the mapping; a disagreeing customer mapping is created again at checkout.
- `stripe-subscription-creation`: checkout refuses a price whose mapping disagrees with the key's mode, before any Stripe call.
- `product-management`: the readiness Payment processing detail reads the recorded mode and names a disagreement; the sync-from-readiness requirement's "already synced" condition becomes "synced in this environment".
- `operator-billing-views`: the test-mode banner's provenance sentence follows stored events, not the key alone.
## Impact
- Schema: one nullable `livemode` column on the eight `stripe.*_mappings` tables (unknown until verified), a `verified_at` on the sync-managed ones, and a single-row record of the key fingerprint the last verification ran under (design decides whether that is a config override or a table); sqlc regeneration for the stripe store.
- Code: `workflows/outbox.go` (five executors), the webhook projections that upsert mappings, `SyncProductToStripe` and the readiness inputs in `internal/server`, `billing.go` checkout, `fulfillment/reconcile.go`, the Stripe provider page (verify action, refused-event count), the billing banner.
- Docs: `docs/stripe.md` gains a section on moving a deployment between Stripe environments (what to swap together: key, webhook secret; what the console does with the old mappings).
- Production: the migration leaves the existing live rows at unknown; the first verification under the live key marks them verified. No behavior changes for wiki.cafe until the key changes.
- Design first: the copy for a disagreeing row, the verification trigger and its cost, and the banner's mixed-ledger wording are settled in the design before specs and tasks.
@@ -195,3 +195,17 @@ The rules table on a set's page SHALL NOT render an "Active" column (no control
- **WHEN** an operator opens the Add rule panel
- **THEN** its fields render in one dense row with small controls, the filled commit at the row's end, and an outline-secondary "Cancel" that closes the panel
### Requirement: The add-rule form stores the Per unit choice as submitted
The add-rule form SHALL store `resource_per_unit` exactly as the operator submitted it: a ticked Per unit box stores true, an unticked box stores false, and a boolean resource key stores NULL. A checkbox bound to a value set SHALL answer the typed `Bool` accessor the same way a parsed checkbox does, so no handler can read a bound checkbox as false while the raw value reads true.
#### Scenario: Ticked Per unit is stored
- **WHEN** an operator adds a limit rule for a numeric key with Per unit ticked
- **THEN** the stored rule carries `resource_per_unit = true`
#### Scenario: Unticked Per unit is stored
- **WHEN** an operator adds a limit rule for a numeric key with Per unit unticked
- **THEN** the stored rule carries `resource_per_unit = false`
+6 -1
View File
@@ -91,13 +91,18 @@ A member "delete" action SHALL move a site to the `archived` (Trash) state rathe
### Requirement: UI displays quota usage
The system SHALL display the user's current site usage and limit in the sites list view. The current count SHALL be the number of the workspace's `fedwiki.sites` rows whose status is `active`, never the reservation counter in `numeric_entitlement_usage`; the limit SHALL be the materialized `resource_limit`. The display SHALL show the current count and maximum (e.g., "Your Sites (2 of 5)"), including the over-limit case where the active row count exceeds `resource_limit` (e.g., "3 of 1 sites used") produced by a tier downgrade before its force-reduce sweep runs. The Create affordance and the JSON usage endpoint SHALL derive their "at the limit" state from the same row count.
The system SHALL display the user's current site usage and limit in the sites list view. The current count SHALL be the number of the workspace's `fedwiki.sites` rows whose status is `active`, never the reservation counter in `numeric_entitlement_usage`; the limit SHALL be the materialized `resource_limit`. At or under the limit the display SHALL read "N of M active sites used"; over the limit, where the active row count exceeds `resource_limit` (a tier downgrade before its force-reduce sweep runs, or a default change to a smaller plan), the display SHALL state it in its own sentence with the count and the limit: "N active sites, M allowed." A disabled Create affordance SHALL render through the shared disabled-control part with its reason in the DOM ("Site limit reached" when the workspace is entitled and at or over its limit), never as a bare `disabled` attribute with a `title`. The Create affordance and the JSON usage endpoint SHALL derive their "at the limit" state from the same row count.
#### Scenario: Sites list shows quota
- **WHEN** a user views their sites list
- **THEN** the page SHALL display the current site count and the entitlement limit
#### Scenario: Over the limit reads as a sentence
- **WHEN** a workspace holds 12 active site rows and a materialized limit of 1
- **THEN** the sites card reads "12 active sites, 1 allowed." and the Create control renders through the disabled-control part with the reason "Site limit reached"
#### Scenario: Rows written outside the create workflow are counted
- **WHEN** a workspace holds 64 active site rows inserted by an import or by farm sync while its reservation counter reads 1
+6 -1
View File
@@ -111,13 +111,18 @@ Each category of form SHALL have a recorded decision on what happens without Jav
### Requirement: Disabled controls carry their reason in the DOM
A control rendered disabled because a precondition is unmet SHALL carry its reason in the document and reference it with `aria-describedby`, on both surfaces, through one shared part. Whether the reason is visible text (the member surface) or a tooltip on a focusable wrapper (the operator surface, where dense action rows do not stack notes between buttons) is the surface's density choice; the keyboard path and the described-by relation exist in both cases. Pages SHALL NOT hand-roll a `title` on a wrapper as the only reason. This is the one place a hover tooltip remains in the console; help uses popovers.
A control rendered disabled because a precondition is unmet SHALL carry its reason in the document and reference it with `aria-describedby`, on both surfaces, through one shared part. Whether the reason is visible text (the member surface) or a tooltip on a focusable wrapper (the operator surface, where dense action rows do not stack notes between buttons) is the surface's density choice; the keyboard path and the described-by relation exist in both cases. Pages SHALL NOT hand-roll a `title` on a wrapper as the only reason. This is the one place a hover tooltip remains in the console; help uses popovers. In both variants the control SHALL sit in a wrapper that carries the not-allowed cursor, because a disabled button receives no pointer events of its own and cannot show a cursor or a tooltip by itself.
#### Scenario: A keyboard user reaches the reason
- **WHEN** a disabled control's wrapper receives focus on the operator surface
- **THEN** the reason is announced through `aria-describedby` and shown as the tooltip
#### Scenario: A pointer over a disabled control sees the deny cursor
- **WHEN** a pointer rests on a disabled control rendered through the part, on either surface
- **THEN** the cursor is the not-allowed cursor
#### Scenario: Both surfaces use the part
- **WHEN** a disabled control with a reason renders on either surface
@@ -295,16 +295,6 @@ Settings surfaces SHALL render required secret keys with a truthful presence ind
- **THEN** the secret's row states that no value is set
- **AND** a present secret renders as set-and-masked, visually distinct from the absent state
### Requirement: Switching Stripe to live mode carries a consequence warning
The `stripe-mode` setting SHALL disclose, adjacent to the control and before submission, that selecting `live` moves the deployment onto real payment processing with real charges, distinguishing this control from routine settings on the same page.
#### Scenario: Live mode selection is warned before save
- **WHEN** an operator selects `live` for `stripe-mode`
- **THEN** consequence copy adjacent to the control states that real charges result
- **AND** the copy renders before the operator submits the change
### Requirement: The Stripe provider page carries its status and the delivery-queue report
Stripe SHALL have a provider page at `/operator/integrations/stripe`, the destination of its name on the Integrations table and on the overview's Integrations card, with the trail "Operator / Integrations / Stripe". The page SHALL state whether Stripe is configured (from the required-key resolution), its mode ("Test mode" or "Live mode" from `stripe-mode`), and carry a "Delivery queue" section reporting the integration outbox split into pending, retrying, and dead-letter buckets, a caption naming which integrations' queued work the outbox covers (so an all-quiet outbox cannot imply health for integrations that dispatch outside it), and the dead-letter table with its operation identifiers rendered as `<code>`; only the dead-letter bucket SHALL receive alarm styling. The page SHALL also carry an "Inbound events" section reporting Stripe's dead-lettered webhook events: their count and a table with the event type as `<code>`, the error, the attempts and the last attempt, alarm-styled only when there are any. The page SHALL link to the Stripe settings page, which SHALL carry configuration only and link back; the overview's Integrations card summarizes the queue in Stripe's row, counting dead-lettered work in both directions, and SHALL NOT carry the report.
@@ -265,3 +265,22 @@ The confirmation a member sees before a plan move, the panel that carries the pr
- **THEN** the response is 422 with the preview re-rendered in place and the reason in the form-level slot
- **AND** the enablement of the move's control is unchanged, as "Plan-move controls reflect built billing mechanics" states
### Requirement: A free rung offers no purchase
A rank-0 tier with no price SHALL render no purchase control and no "Not available for purchase yet" line, because it is conferred and never bought: when the member's organization holds nothing on the ladder the card shows its cost line and features only; when the organization holds a paid rung on the ladder the card carries the cancel control as the paid-to-free move; when the organization holds the free rung the card reads as the current plan.
#### Scenario: Not enrolled sees no control on the free rung
- **WHEN** a member whose organization holds no rung on a ladder views that ladder's rank-0 tier, which carries no price
- **THEN** the card reads "Included", lists the tier's features, and renders no button and no "Not available" line
#### Scenario: A paid holder sees the cancel control on the free rung
- **WHEN** a member whose organization holds a paid rung views the same ladder's free rank-0 tier
- **THEN** the card offers the cancel control labeled "Downgrade"
#### Scenario: An unsynced priced tier keeps its reason
- **WHEN** a member views a priced tier whose price is not synced
- **THEN** the card's move control is disabled with "Not available for purchase yet"
@@ -230,15 +230,15 @@ An invoice with stored status `open` whose due date is in the past SHALL present
### Requirement: Billing views mark Stripe test mode
When `stripe-mode` is test, every operator billing view (accounts, subscriptions, invoices, payments) and the invoice detail SHALL render one banner under the page header, above the billing pills, reading "Stripe is in test mode. Figures on these pages are test data." with a "Stripe settings" link to `/operator/integrations/stripe/settings`, so no billing figure can be read as live money by mistake; in live mode no banner renders. The banner derives from the configured mode the console already reads for Stripe dashboard links, never from a stored flag, and renders through one shared element so the five pages cannot differ.
When the configured Stripe API key is a test key (prefix `sk_test_` or `rk_test_`), every operator billing view (accounts, subscriptions, invoices, payments) and the invoice detail SHALL render one banner under the page header, above the billing pills, reading "Stripe is in test mode. Figures on these pages are test data." with a "Stripe settings" link to `/operator/integrations/stripe/settings`, so no billing figure can be read as live money by mistake; in live mode no banner renders. The banner derives from the mode the console derives from the key at boot, the same fact behind its dashboard links, never from a setting or a stored flag, and renders through one shared element so the five pages cannot differ.
#### Scenario: Test mode is visible on every billing page
- **WHEN** `stripe-mode` is test and an operator opens any billing view or an invoice detail
- **WHEN** the Stripe API key is a test key and an operator opens any billing view or an invoice detail
- **THEN** the banner renders under the page header, above the billing pills, with the sentence and the settings link
#### Scenario: Live mode shows nothing
- **WHEN** `stripe-mode` is live
- **WHEN** the Stripe API key is a live key
- **THEN** no banner renders
+49 -15
View File
@@ -214,20 +214,27 @@ All row actions submit via HTMX to operator partial routes and re-render the pro
The operator product edit page (`/operator/products/{id}`) SHALL display a
**purchasability readiness panel** that evaluates every precondition standing between
the product and a member being able to purchase it, and renders a single verdict —
**purchasable** or **incomplete** — with the specific unmet preconditions named.
**purchasable**, **ready to grant**, **inactive** or **incomplete** — with the specific unmet
preconditions named.
The panel SHALL evaluate and display the live state of each of these preconditions,
read from `billing.product_shape`:
- **Published**`lifecycle_status = 'published'`.
- **Visible**`is_public = TRUE` and `is_active = TRUE`. This precondition applies
only to products meant to be seen and purchased by members. For internal wrap
products (`is_public = FALSE`; Requirement: Wrap-product creation), the panel
SHALL report **Visible** as **not applicable** rather than **unmet** — wrap
products are never meant to be visible, and only the entitlement-set half of the
gate applies to them.
- **Entitlement set present**`product_shape.set_present`
(`entitlement_set_id IS NOT NULL`). Required for **every** product regardless of
- **Active**`is_active = TRUE`, for **every** product. An inactive product is
offered by neither the member catalog nor the grant form, so the panel SHALL
report the row as unmet and the verdict SHALL read **Inactive** (never
**Purchasable** or **Ready to grant**) whenever the set and published
preconditions are met.
- **Listed**`is_public = TRUE`. For an unlisted product (`is_public = FALSE`;
Requirement: Wrap-product creation) the panel SHALL report the row as
"Unlisted; issued as grants" rather than **unmet** — unlisted products are never
meant to be in the catalog, and only the entitlement-set half of the gate applies
to them.
- **Entitlement set present, with rules**`product_shape.set_present`
(`entitlement_set_id IS NOT NULL`) and the set holds at least one active rule;
a present set with no active rule reads "No rules" and is unmet ("Incomplete;
missing: rules"). Required for **every** product regardless of
`is_public` — a published product with no entitlement set confers nothing,
whether it is a storefront product or an internal wrap product (Requirement:
Wrap-product creation).
@@ -242,7 +249,10 @@ read from `billing.product_shape`:
report this precondition as **not applicable** rather than unmet. Where it does
apply, the active price has a `stripe.price_mappings` entry, distinguishing
**synced**, **sync pending** (a sync was enqueued and the mapping has not landed
yet), and **sync failed** (the enqueued sync has terminally failed).
yet), and **sync failed** (the enqueued sync has terminally failed). The row's
detail SHALL name the Stripe mode the console derives from its API key ("Synced,
live" / "Synced, test"), so the surface where syncing is triggered says which
account it reaches.
The panel SHALL additionally report, for `is_public` products, a **member catalog
visibility** line: whether the member catalog will actually render this product —
@@ -297,6 +307,11 @@ The panel MUST NOT change member-facing catalog behavior; it is an operator-side
legibility surface only, and the sync-failed state is derived without altering the
shared purchasability gate.
#### Scenario: A rule-less set is not ready
- **WHEN** an operator views the readiness panel of a published, active product whose entitlement set has no active rule
- **THEN** the Entitlement set row reads "No rules" and the verdict reads "Incomplete; missing: rules"
#### Scenario: Fully configured plan shows purchasable
- **WHEN** an operator views the edit page for a published, public, active plan
@@ -532,21 +547,26 @@ The product readiness evaluation SHALL carry a provider leg: for every resource
### Requirement: The readiness panel prints its verdict
The readiness panel SHALL render the verdict string the readiness evaluation computes ("Purchasable", "Purchasable; not shown in the member catalog", "Ready to grant" for a private product, "Incomplete; missing: …") and SHALL NOT render any literal derived from a single boolean in its place.
The readiness panel SHALL render the verdict string the readiness evaluation computes ("Purchasable", "Purchasable; not shown in the member catalog", "Ready to grant" for an unlisted product, "Inactive" for a product whose set and published preconditions are met but whose `is_active` is false, "Incomplete; missing: …") and SHALL NOT render any literal derived from a single boolean in its place.
#### Scenario: A private product reads Ready to grant
#### Scenario: An inactive product reads Inactive
- **WHEN** an operator views the readiness panel of a private product whose entitlement set is present
- **WHEN** an operator views the readiness panel of a product with an entitlement set, published, and `is_active = FALSE`, listed or not
- **THEN** the verdict reads "Inactive" and neither "Purchasable" nor "Ready to grant" appears
#### Scenario: An unlisted product reads Ready to grant
- **WHEN** an operator views the readiness panel of an active unlisted product whose entitlement set is present
- **THEN** the panel's verdict reads "Ready to grant" and the word "Purchasable" does not appear
### Requirement: The products list shows Visibility
The products list SHALL carry a "Visibility" column reading "Public" or "Private" per row, in place of any boolean-named column.
The products list SHALL carry a "Visibility" column reading "Listed" or "Unlisted" per row, in place of any boolean-named column.
#### Scenario: The column names the state
- **WHEN** an operator views the products list
- **THEN** each row's Visibility cell reads Public or Private and no column is headed "Public"
- **THEN** each row's Visibility cell reads Listed or Unlisted and no column is headed "Public"
### Requirement: An off-ladder product's ladders section is one line
@@ -601,3 +621,17 @@ For a published product the Products list's Status cell SHALL render the readine
- **WHEN** the list renders a page of fifty products
- **THEN** the verdicts come from the batch reads for that page, not from fifty per-product readiness computations
### Requirement: The visibility flag reads Listed
Every operator surface that names the `is_public` flag SHALL call its states "Listed" and "Unlisted": the product form's checkbox label reads "Listed" with the help "Shown in the member catalog."; the readiness row is "Listed"; the unlisted state's readiness row reads "Unlisted; issued as grants" and carries no second line. No operator surface SHALL read "Public" or "Private" for this flag. The column `is_public` and the form field keep their names.
#### Scenario: The product form names the flag
- **WHEN** an operator opens the product create or edit form
- **THEN** the flag's checkbox is labeled "Listed" and its help reads "Shown in the member catalog."
#### Scenario: No surface says Public
- **WHEN** an operator views the products list, a product page or its readiness panel
- **THEN** the flag's states read "Listed" or "Unlisted" and the words "Public" and "Private" do not appear for it
@@ -134,3 +134,22 @@ The product, price and customer handlers SHALL NOT apply an event when a complet
- **WHEN** an event recorded before the provider time existed is processed
- **THEN** it applies as before
### Requirement: Stripe mode is derived from the API key
The console SHALL derive its Stripe mode from the configured API key's prefix at boot: `sk_live_` or `rk_live_` is live, `sk_test_` or `rk_test_` is test, an empty key is unset, and any other prefix SHALL fail boot with a message naming the accepted prefixes. No setting SHALL declare the mode; the `stripe-mode` configuration key is retired and a stored override for it is removed by migration. Every surface that shows or acts on the mode (the test-mode banner, the mode label on the overview and the Stripe provider page, the readiness panel's Payment processing detail, dashboard links) SHALL read the derived value. Dashboard links SHALL carry the mode and no account: the console SHALL NOT read the account from the API (a restricted key would need Stripe's Connect "Accounts Read" permission) nor accept a declared account id (nothing could check it against the key).
#### Scenario: A live key is live
- **WHEN** the console boots with an API key beginning `sk_live_`
- **THEN** the mode is live, no test banner renders, and the mode label reads "Live mode"
#### Scenario: A sandbox key is test
- **WHEN** the console boots with an API key beginning `sk_test_`
- **THEN** the mode is test and the test banner renders on billing views
#### Scenario: A malformed key refuses boot
- **WHEN** the console boots with an API key whose prefix is none of the accepted four
- **THEN** boot fails and the message names the accepted prefixes
+14 -1
View File
@@ -68,7 +68,6 @@ TBD - created by archiving change page-anatomy. Update Purpose after archive.
- **WHEN** a partial that no handler names as a body template renders a `<table`
- **THEN** the rule does not fire
### Requirement: The anatomy allowlist is empty
After the sweep, `internal/lint/anatomy_allowlist.txt` SHALL list no templates, and `member-console lint` SHALL therefore hold every page on both surfaces to the page-anatomy rules with no exemptions. A template MAY be added to the allowlist only by a change whose proposal records the maintainer's decision and the entry's removal plan; the stale check stands, so an entry that stops violating fails the lint until it is removed.
@@ -202,3 +201,17 @@ The capture utility SHALL record the `data-form` id of every form it rendered op
- **WHEN** a change registers a form and no capture shows it opened or refused
- **THEN** the coverage test fails naming the form, until the form is reachable from a manifest screen or declared walkthrough-verified
### Requirement: Lint refuses a hand-rolled disabled control
The anatomy lint SHALL refuse, under the rule `disabled-outside-part`, any `<button` tag in a template other than the disabled-control part whose attributes contain the word `disabled`, whether literal or inside a template action, because a disabled control's reason, described-by relation and cursor exist only through the part. A template MAY exempt one occurrence with the marker `{{/* disabled-control exempt: <reason> */}}` on the line before the tag; the allowlist for this rule stays empty.
#### Scenario: A hand-rolled disabled button fails lint
- **WHEN** a template renders `<button class="btn" disabled title="…">` outside the part
- **THEN** `make lint` fails naming the template and the rule
#### Scenario: The part passes lint
- **WHEN** a template renders its disabled control through `{{ template "disabledControl" … }}`
- **THEN** the rule reports nothing
@@ -30,17 +30,17 @@ therefore gone; rollback now means restoring from the backups in
| 4 | Deploy the stack with sync disabled | G1: boot log clean, operator login works, `/operator/setup` reachable, operator root ensured | Passed after the Temporal bind fix (host reboot at 16:02 UTC left Temporal's frontend bound to one overlay only; `BIND_ON_IP=0.0.0.0` added to `/srv/temporal/compose.yaml`, uncommitted there). Boot log carries four ERROR lines: three "schedule with this ID is already registered" (the known redeploy issue in issues.md) and one describe timeout on the leftover `fedwiki-site-sync` schedule. |
| 5 | Author the catalog via `/operator/setup` | G2: setup checklist fully green; products visible in live Stripe | Catalog exists (14 active grants conferred against it). Stripe wired 2026-09-12: the two docker secrets exist, `compose.yaml` references them, the boot log shows the Stripe outbox poller starting, and `/webhooks/stripe` answers an unsigned POST with 400. Passed 2026-09-12: all six checklist steps read Done at `/operator/setup` (verified in the browser); Wiki Cafe Standard and its $8/month price synced to live Stripe at 18:16 UTC (outbox rows completed, live-mode webhook events received); ladder Wiki Cafe Plans holds Wiki Cafe Public at rank 0 and Wiki Cafe Standard at rank 1; Personal org type defaults to that ladder. |
| 6 | `legacy-backfill --dry-run --report` | G3: report counts reconcile (153 dirs = classified entries, 0 unmatched subjects) | No report file found on the host or in the repo. Unrecorded. |
| 7 | `legacy-backfill --yes`, restart | G4: reconciliation log counts; `psql` counts (persons ~26, orgs, sites, claims); `/domains/ask` probes | Ran: 14 persons (plan expected ~26), 13 personal orgs, 101 active sites, 45 member claims + 3 external + 1 operator root, 99 placements, 14 active grants. Two sites left unplaced by boot adoption: `triage_mh.wiki.cafe` (underscore fails `name_rules`) and `robert.podding.wiki.cafe` (sits inside another workspace's `podding.wiki.cafe` claim). Read 2026-09-12: `triage_mh` is mike_hales' three-page site from 2023-11-05, and `triagemh.wiki.cafe` (same owner, same day) holds ten pages including larger versions of the same three, so the underscore site is the abandoned first cut; `robert.podding` is one example page from 2026-04, rob's; deleted 2026-09-12 on the maintainer's word the way the console's delete activity does it (site directory removed on the farm, `fedwiki.sites` row deleted; no placement to release), with the directory backed up first under `~christian/backups/10d-slice3-2026-09-11/deleted-sites/`. Caddy still holds its certificate until expiry. The delegated-subtree case is logged in issues.md. `triage_mh` awaits the maintainer's word. The persons gap and the two strays need the maintainer's read. Partly verified. |
| 7 | `legacy-backfill --yes`, restart | G4: reconciliation log counts; `psql` counts (persons ~26, orgs, sites, claims); `/domains/ask` probes | Ran: 14 persons (plan expected ~26), 13 personal orgs, 101 active sites, 45 member claims + 3 external + 1 operator root, 99 placements, 14 active grants. Two sites left unplaced by boot adoption: `triage_mh.wiki.cafe` (underscore fails `name_rules`) and `robert.podding.wiki.cafe` (sits inside another workspace's `podding.wiki.cafe` claim). Read 2026-09-12: `triage_mh` is mike_hales' three-page site from 2023-11-05, and `triagemh.wiki.cafe` (same owner, same day) holds ten pages including larger versions of the same three, so the underscore site is the abandoned first cut; `robert.podding` is one example page from 2026-04, rob's; deleted 2026-09-12 on the maintainer's word the way the console's delete activity does it (site directory removed on the farm, `fedwiki.sites` row deleted; no placement to release), with the directory backed up first under `~christian/backups/10d-slice3-2026-09-11/deleted-sites/`. Caddy still holds its certificate until expiry. The delegated-subtree case is logged in issues.md. `triage_mh` awaits the maintainer's word. The persons gap and the two strays need the maintainer's read. Partly verified. Persons gap read 2026-09-12: the Wiki-Cafe realm holds 31 human users (plus 3 service accounts); the console creates a person at first sign-in and the backfill created one per legacy grant holder, so the 13 persons are exactly the 13 legacy orgs' owners and the other 18 realm users (several of them test accounts) get a person when they first sign in. One anomaly: farm owner `rob2` (rob2.wiki.cafe, test.wiki.oddly-influenced.dev) has no realm user. The two strays are resolved (row 8). Naming: the backfill auto-provisioned each person with the username as display name (Keycloak holds first and last names for all but `grist`), and the personal org's name was derived from it ("marick's Organization" where Keycloak says Brian Marick); sign-in refreshes the person's display name from the token but never the org name. Logged in issues.md; one-off rename done 2026-09-13 by the maintainer's hand (twelve persons and their personal orgs renamed from the realm's first and last names in one transaction, matched through `core.users.oidc_subject`; two UPDATE 12). |
| 8 | Maintainer UI walkthrough: People, per-org sites, domains, grants, grandfathered quantities, claims ledger | G5: maintainer sign-off recorded | In progress. The 2026-09-11 walk produced the ten findings logged in `status/issues.md` ("2026-09-11 10d Slice 3 production walk"). The four fix-now findings shipped 2026-09-12 as `slice3-walk-fixes` (archived); deployed 2026-09-12 as image `2026-09-12T06-19Z`; its first boot repaired all 13 site-usage counters (log: candidates 13, repaired 13; zero pools mismatched afterwards). Not signed off. |
| 9 | Enable `MC_FEDWIKI_SYNC_ENABLED=true` and trigger | G6: sync log shows updates only, zero added, infra names placed as operator placements, no workspace rewrites | Sync still disabled. Open. |
| 10 | Cutover: Caddy label on the final domain, redirect URIs, `MC_BASE_URL`, `MC_DOMAINS_ASK_FALLBACK_URL` to the incumbent answerer, farm Caddy ask pointed at `https://console.wiki.cafe/domains/ask` | G7: ask answers match the pre-cutover snapshot for all 153 names; member login works | Label and base URL done. The Caddy ask still targets the filesystem answerer (`caddy.on_demand_tls.ask=http://caddy_ask:3000/` in `/srv/caddy/compose.ask-filesystem.yml`); no fallback URL set on the console. No pre-cutover snapshot found. Open. |
| 9 | Enable `MC_FEDWIKI_SYNC_ENABLED=true` and trigger | G6: sync log shows updates only, zero added, infra names placed as operator placements, no workspace rewrites | Sync still disabled. Maintainer said go on 2026-09-12, but the pre-check found 49 farm directories the console lacks (open question 3), so "zero added" cannot hold until the drop list is decided; waiting on that decision. |
| 10 | Cutover: Caddy label on the final domain, redirect URIs, `MC_BASE_URL`, `MC_DOMAINS_ASK_FALLBACK_URL` to the incumbent answerer, farm Caddy ask pointed at `https://console.wiki.cafe/domains/ask` | G7: ask answers match the pre-cutover snapshot for all 153 names; member login works | Label and base URL done. 2026-09-13 03:56 UTC: the console joined the `caddy_backend` network and got `MC_DOMAINS_ASK_FALLBACK_URL=http://caddy_ask:3000/` (maintainer ran the deploy); verified from outside: `/domains/ask` answers 200 for a placed name, 200 for a farm-only name through the fallback (test.wiki.cafe, console.wiki.cafe, forum.wiki.cafe, admin1.wiki.cafe), 404 for an unknown name. Pre-cutover comparison done by data rather than probes: 99 servable placements plus 49 farm directories the registry lacks, all of which the fallback answers. Left: the Caddy label flip to `https://console.wiki.cafe/domains/ask` in `/srv/caddy/compose.ask-filesystem.yml` (a recipe file; make the URL a variable with the current value as default) and the stack redeploy, both the maintainer's (raw `docker stack deploy`, not abra). Fact recorded 2026-09-13: every app in the swarm uses `caddy.tls.on_demand`, so Caddy consults the ask for service hostnames too (console, forum, matrix, meet, registry, temporal…); their farm directories exist only so the filesystem answerer says yes. Until the console carries those names as operator placements, the fallback is load-bearing and the filesystem answerer cannot be retired. |
| 11 | Retire the mkdir flow; after soak remove the old stack, archive its volume | G8: after a soak period | Old stack already removed 2026-09-11; `member-console_data` volume and `member-console_*` secrets retained. Soak not started. Mkdir flow retirement not communicated. |
## Open maintainer questions carried from the plan
1. Census-vs-DB ownership conflicts: which classification wins where they disagree.
2. The 128-site legacy quota: intentional grandfathering or reduce. Answered 2026-09-12: grandfathered at 64 sites via the 64-pack grant (13 orgs).
3. The drop list (about thirty test names): delete after cutover or leave unplaced.
3. The drop list (about thirty test names): delete after cutover or leave unplaced. Sized 2026-09-12 before G6: the farm holds 49 site directories the console does not know (151 farm entries, 99 console rows), all of which an enabled sync would import as system-tenant sites with operator placements (test.wiki.oddly-influenced.dev without a placement). 33 of them hold zero pages (service hostnames such as auth, console, forum, matrix, meet, registry, temporal, woodpecker, which are NOT strays: the maintainer created them so the filesystem ask answers yes for the swarm's on-demand TLS, see step 10; plus the test names admin1/2/3, claimme, draft, rrr, tester, testrob, testy, testy2, tesy2, try123, test2, xn--53h, robb, and admin's admin.wiki.wiki.cafe, hello.admin, test.admin, testy.admin, rob's try.rob); 16 hold pages: admin.wiki.cafe (5), mh (3), benny (2), robert (2), test.wiki.cafe (3wordchant, 2), and one page each for cgalo, galo.wiki.wiki.cafe, literallyanything, quizlet, rob2.wiki.cafe (rob2), test.wiki.oddly-influenced.dev (rob2), wiki.cafe (apex), wiki.wiki.cafe (admin). Position 2026-09-13: keep the service hostnames (an enabled sync imports them as system-tenant sites with operator placements, which is what lets the console answer for them after the fallback goes); delete the zero-page test directories (admin1, admin2, admin3, claimme, draft, rrr, tester, testrob, testy, testy2, tesy2, try123, test2, robb, try.rob, hello.admin, test.admin, testy.admin, admin.wiki.wiki.cafe; xn--53h is open question 4); keep the unowned directories with pages for review after import. Maintainer said go 2026-09-13; the 19 zero-page test directories were backed up under `~christian/backups/10d-slice3-2026-09-11/deleted-sites/` and removed from the farm (each verified at zero pages first); xn--53h left for question 4. Their 19 reserved name rules remain in the registry (harmless; nobody can claim them). Farm entries now 133 plus three config files.
4. The IDN name `xn--53h.wiki.cafe`: reserve or allow.
5. Which users hold `operator-member`; dedicated Temporal OAuth client or the shared one.
@@ -48,6 +48,8 @@ therefore gone; rollback now means restoring from the backups in
- Stripe webhook endpoint must be created at API version `2025-02-24.acacia` (the version `stripe-go/v81` pins); Basil and later drop the invoice fields the projection reads. Documented in `docs/stripe.md` on 2026-09-11.
- `/srv/temporal/compose.yaml` change (`BIND_ON_IP=0.0.0.0`) is uncommitted in that host-side repo.
- A 40 MB `legacy-backfill` build artifact sits untracked at the repository root; it must not be committed.
- A 40 MB `legacy-backfill` build artifact sits untracked at the repository root; it must not be committed. It slipped into the 2026-09-12 deployment-record commit and was removed from history on 2026-09-13 before the branch was ever pushed (the two commits were rewritten as 6091899 and 412b7f6, same content otherwise); `/legacy-backfill` is now in `.gitignore`.
- The `legacy-backfill` branch declares itself never-merge; the milestones table did not record that decision until 2026-09-11.
- Production incident 2026-09-12 18:51 UTC: the Personal org type's default ladder change (migrate) ended the 13 Transitional provisional grant provisions and conferred Wiki Cafe Public before its set had a rule, leaving every personal org with a materialized `fedwiki_sites` limit of 0 (site creation blocked). Repair path and gaps in `status/issues.md` ("Org-type default change against a rule-less set..."). Repaired 19:59 UTC by removing and re-setting the default (limit 1 everywhere; four orgs over it). Open question 2 answered 2026-09-12: the maintainer chose to grandfather; "Legacy Grant - FedWiki 64 pack" (reason legacy) issued to all 13 legacy orgs through the Issue grant form, every pool re-materialized to 65.
- `slice3-followup-fixes` (Per unit checkbox, rule-less set readiness, Inactive verdict, Listed/Unlisted, free rung, disabled-control part and lint, over-limit sentence, Stripe mode from the key with the `stripe-mode` override migration) reviewed, screens accepted and archived 2026-09-13; specs synced (5 added, 6 modified, 1 removed). Image `git.coopcloud.tech/wiki-cafe/member-console:2026-09-13T21-36Z` pushed by the maintainer 21:36 UTC (pulled back and checked: the binary carries the new readiness detail, the `stripe-mode` override migration and the lint rule, and no account id string); deployed by the maintainer 21:47 UTC: the new task runs the tag, the boot log reads "OK 00017_drop_stripe_mode_override.sql", "successfully migrated database to version: 17" and "stripe mode derived from the API key" with mode live, and `core.integration_config_overrides` holds no `stripe-mode` row.
- Two changes opened as proposals only, design pending: `entitlement-set-changes` (rule add/delete become preview-and-commit with a change history on the set; from the 2026-09-12 incident) and `stripe-environment-stamp` (mappings record the Stripe environment they were created in; from the maintainer's 2026-09-13 key-move question, logged in `status/issues.md`). Neither is needed for the remaining gates.
+13
View File
@@ -251,6 +251,11 @@ Labels: `bug`, `frontend`, `correctness`
Labels: `design`, `billing`, `operations`
**Logged 2026-09-12** while wiring live Stripe on production. `stripe-mode` is a config key (default `test`) read once at boot; it drives the billing-view banner, the dashboard link, and the mode label on the Stripe integration page, and nothing else. The mode that matters is decided by the API key (`sk_test_`/`rk_test_` versus live) and by which dashboard the webhook endpoint was created in; the setting can disagree with both, and today it did (live key, setting still `test` until a restart). The maintainer's ask: make the effective mode visible in more places where it is relevant, and analyze where. Position for the exploration: (1) derive the mode from the key prefix at boot and turn the setting into a validation-only declaration (a mismatch refuses to boot, the way other config validation does), or retire it; (2) surface the derived mode where money or the Stripe mirror is touched: the purchasability panel's "Payment processing" row ("Registered in Stripe, test mode"), the Sync to Stripe control and its confirmation, the Prices view, the setup checklist's Stripe item, the member checkout affordance in test mode, and the existing billing banner; (3) a test-mode deployment should also be recognisable from the webhook events it stores (`livemode` is in every payload). Design exploration first, then one change. Vocabulary and links: `DashboardURL` returns `https://dashboard.stripe.com/test` for the test setting, Stripe's legacy test-mode URL; Stripe now isolates test data in Sandboxes, each with its own account id and `sk_test_` key, and a sandbox's dashboard lives under its account id, so the link is wrong for a sandbox account even when the mode is right. Deriving the mode from the key prefix (`sk_live_` / `sk_test_`) and the dashboard base from the account the key belongs to (`GET /v1/account`) covers both. Confirmed 2026-09-12: the maintainer's sandbox holds no product; the product the console synced at 18:16 UTC was created in the live account under the test label, and the console's `/test/products/prod_…` link, opened in the sandbox, shows Stripe's "Did you mean live mode? We couldn't find what you were looking for in test mode, but we found it in live mode." That interstitial is what made the product look test-side before the restart.
### The console does not record which Stripe environment a synced object lives in; a key change leaves every mapping pointing at the old one while the pages describe the new
Labels: `design`, `billing`, `operations`
**Logged 2026-09-13** from the maintainer's question about the billing banner ("You create products in a sandbox and then once you are ready, you set the settings to the live account... the old products synced would remain in the member-console... I imagine the opposite is also true"). Confirmed. `stripe.product_mappings`, `price_mappings`, `customer_mappings` and `subscription_mappings` store the Stripe id and a `sync_status` and nothing about the environment the id was created in (`internal/integrations/stripe/store/migrations/00001_init.sql`; `provider_configs`, which carried an account id and a mode, was dropped unused in 00002). A sync is one `product.New` or `price.New` at creation (`workflows/outbox.go`) and nothing ever reads the id back, so a mapping is a pointer, and an id resolves only in the environment that created it (live, the legacy test mode, or one sandbox). After the key moves to another environment, in either direction: the readiness row reads "Synced, live" or "Synced, test" from the current key (`syncedPaymentDetail`), not from where the object is; the Sync control refuses with "This price is already synced to Stripe." because the mapping holds an id; a member checkout sends the old price id under the new key, Stripe answers `resource_missing`, and the member sees "failed to start checkout" (`internal/server/billing.go`); the fulfillment reconcile's `subscription.Get` fails the same way; and the billing banner's sentence "Figures on these pages are test data" describes the key, while the figures are ledger rows that webhooks wrote under whatever key was configured when they arrived, so after a live-to-sandbox move the sentence is false. Webhooks do not cross-talk (a signing secret belongs to one endpoint in one environment), but nothing compares an event's `livemode` with the key's mode, so a secret left behind fails signatures silently. Fix, in order of cost: (1) stamp `livemode` from the returned object on every mapping row at creation and treat a row whose flag disagrees with the key's mode as not synced: the readiness detail names the recorded mode, the Sync control creates again instead of refusing, checkout refuses before calling Stripe; this catches sandbox and live in both directions at no cost. (2) A read-back (`product.Get` under the current key; a restricted key that writes products can read them) that marks a `resource_missing` mapping stale, run from the Stripe integration page or at boot when the key changed; this is the only way to catch a sandbox-to-sandbox move. (3) The webhook handler refuses an event whose `livemode` disagrees with the key's mode. (4) The banner's provenance sentence goes unless stored events carry `livemode` and can vouch for it. Not in `slice3-followup-fixes`, which ships the derived mode and the "Synced, live" detail as they are; its own change.
### The add-rule form's Per unit checkbox is ignored; every rule authored through the form stores per_unit = false
**Logged 2026-09-12** from the maintainer's production walk ("'Per Unit' being checked (or unchecked) in the add rule form creates an entitlement set with Per unit = No... Basically there is no way of setting it to yes"). Reproduced with a handler test: a POST carrying `resource_per_unit=true` for `fedwiki_sites` stores `{Bool:false Valid:true}`. Every limit rule in production carries false (Wiki Cafe Public 1, Wiki Cafe Standard 16, Legacy Grant 64 pack 64, Transitional 64), all authored through the form. Cause: `entitlementSetRuleFormValues` (`internal/server/operator_entitlement_set_forms.go`) binds the checkbox with `Values.SetBool`, which records the raw string and presence only, and `CreateEntitlementSetRule` (`operator_entitlement_sets.go`) reads `values.Bool("resource_per_unit")`, which reads the typed map that only `Parse` fills; the two halves of `forms.Values` disagree. Present since `entitlement-rule-authoring` (2026-07-21). The existing test posted the checkbox only on the boolean key, where the handler ignores it by design, so it never asserted the stored flag. Sibling audit: every other typed read in `internal/server` (`Bool`, `Int`, `Time`, `UUID`; four call sites in the entitlement set, product and grant handlers) reads values returned by `ParseSide`/`ParseWith`; this is the only handler reading typed values off a bound `Values`. Fix: `SetBool` records the typed bool too (a library invariant: a bound checkbox answers `Bool` the way a parsed one does), plus a handler test that asserts the stored flag for a numeric key. Impact today is nil because every live grant has quantity 1; `materialize.go` multiplies by quantity only when the stored flag is true, so a quantity-2 grant of any of these sets would deliver the base value. Fix-now, in the Slice 3 follow-up change with the contrast and readiness fixes.
@@ -279,6 +284,14 @@ Labels: `design`, `billing`, `operations`
**Logged 2026-09-12** from the Slice 3 cutover. `podding.wiki.cafe` is an active member claim held by admin's organization; `robert.podding.wiki.cafe` is a real farm site whose `fedwiki.sites` row belongs to rob's organization. The registry places no site inside another workspace's `member` or `external` claim (`fedwiki-sites` "the sync SHALL NOT create any placement" inside such a claim), so the site has no placement, and after the G7 cutover the console's ask refuses it unless the legacy fallback answers. The maintainer: "sometimes people want to delegate subdomains to others... Yes, you own the tree, but you can delegate ownership... this is yet another issue we have to resolve and probably will require some proper design work." What exists: claims carved from operator roots (`ClaimCarved`, `GrandfatherCarvedClaim` in `internal/domains/registry.go`); what does not: a delegation act by which the holder of a claim carves a child claim for another workspace and keeps the rest of the tree. Design needed before code: the consenting act and its revocation, what the delegate may do under it (sites, deeper delegation, external DNS), how the ask and the sync treat a delegated child, and how the org composite and the domains page show it. Interim: set `domains-ask-fallback-url` to the legacy answerer at G7 as the runbook already planned, so a registry miss keeps serving (`robert.podding.wiki.cafe` stays served and stays listed in rob's dashboard through its `fedwiki.sites` row); a name the FQDN shape check refuses (an underscore, as in `triage_mh.wiki.cafe`) never reaches the fallback.
### The backfill named persons by username and froze that into their organizations' names
**Logged 2026-09-13.** The legacy backfill auto-provisioned each legacy owner through the same path first sign-in uses, passing the Keycloak username as the display name, so twelve of the thirteen persons read as their username ("3wordchant", "marick", "mike_hales") while the realm holds first and last names for every one of them; the one person who signed in reads "Christian Galo" because sign-in writes the token's name. The personal organization's name is derived from the display name at creation ("marick's Organization") and nothing ever rewrites it, so those twelve organizations stay named after usernames after their owners sign in. Two things: a one-off rename of the twelve persons and their organizations from the realm's names (proposed to the maintainer 2026-09-13), and a design question for the org model: whether a personal organization's name should follow its owner's display name (a derived name, not a stored one) until the owner renames it. The backfill tool itself is never-merge; the fix for future imports is to pass the realm's name.
### Service hostnames live on the farm only so the on-demand TLS ask says yes
**Logged 2026-09-13** (maintainer: "they merely exist so that Caddy allows them to be routed"). Every app in the swarm carries `caddy.tls.on_demand`, so Caddy consults the ask endpoint for console.wiki.cafe, forum.wiki.cafe, matrix, meet, registry, temporal and the rest, and the filesystem answerer says yes because a farm directory of that name exists. When the console becomes the answerer (G7) those names must be servable placements in the registry; the only path today is the FedWiki sync importing them as system-tenant sites with operator placements, which records a service hostname as a wiki site. The registry needs a first-class operator placement for a name that is not a site (provider other than `fedwiki`, no `fedwiki.sites` row), authored on the Domains page, so the farm directories and the filesystem answerer can go. Until then the ask fallback is load-bearing.
### Resources by holder: the organization composite shows no domains or sites, and resource pages cannot be asked "which belong to this organization"
Labels: `ia`, `frontend`, `design`