Files
cgalo5758 782ca8f326 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.
2026-09-13 16:59:12 -05:00

23 KiB

integration-settings

Purpose

Defines runtime-managed integration configuration: a single core-owned store of operator-set, non-secret overrides layered over the environment (override → environment → default), discovered and validated from each integration's declared configuration spec, applied by a boot-time overlay (changes take effect on restart), and surfaced through a generic per-integration operator settings page. Secrets and bootstrap remain environment-only; this capability never stores or displays a secret.

Requirements

Requirement: Runtime overrides are stored app-level and override-only

The system SHALL store operator-set integration configuration overrides in a single core-owned table, core.integration_config_overrides, keyed by the flat configuration key name. A row SHALL exist only while an override is set; value encoding SHALL be the same string form the environment transport uses. Each row SHALL record who set it and when. No per-integration configuration tables SHALL be introduced.

Scenario: Absent row falls through

  • WHEN no override row exists for a configuration key
  • THEN the key SHALL resolve from environment/flag/config-file, then its declared default

Scenario: Clearing an override removes the row

  • WHEN an operator clears an override
  • THEN the row SHALL be deleted, restoring resolution from environment/default at next effect

Requirement: Configuration resolves runtime override first

Every declared integration configuration key SHALL resolve in the order: runtime override → environment/flag/config-file → declared default. The order SHALL be uniform across keys and integrations.

Scenario: Override wins over environment

  • WHEN a key is set in the environment and an override row exists
  • THEN the effective value SHALL be the override

Scenario: Environment wins over default

  • WHEN a key has no override row and is set in the environment
  • THEN the effective value SHALL be the environment value, not the declared default

Requirement: Overrides take effect at boot

The system SHALL load overrides once at startup — after migrations run, before any route or worker registration — and apply them ahead of all integration configuration reads. Saving an override SHALL NOT alter the running process's effective configuration. The settings surface SHALL state that changes apply on restart and SHALL indicate per key when a stored override differs from the boot-effective value.

Scenario: Save does not change the running process

  • WHEN an operator saves an override while the system is running
  • THEN behavior governed by that key SHALL be unchanged until restart
  • AND the settings surface SHALL mark the key as pending restart

Scenario: Restart applies the override

  • WHEN the system restarts with an override row present
  • THEN the override SHALL be the effective value everywhere the key is read

Requirement: Secret keys are never runtime-managed

Keys declared secret SHALL NOT be storable as overrides, SHALL NOT be editable from the settings surface, and their values SHALL never be displayed. The settings surface SHALL list secret keys by name with an environment-managed marker only.

Scenario: Secret key rendering

  • WHEN the settings page renders an integration whose spec declares a secret key
  • THEN the key SHALL appear with its usage text and an environment-managed marker, with no value and no input

Scenario: Secret override write is rejected

  • WHEN a write names a secret key as an override target
  • THEN the system SHALL reject it and store nothing

Requirement: Override values are validated and typed

Override writes SHALL be parsed at save time by the key's declared value type through the one parser the config contract names (integration-config-declaration): an enum value SHALL be a member, a URL absolute with an http or https scheme and a host, a duration a Go duration string that is not negative, an int an integer, a bool true or false, a list comma-separated. A value the parser refuses SHALL answer 422 with the parser's own sentence under the control, and no row SHALL be written. At boot, the overlay SHALL parse each stored value the same way, so typed reads behave identically to environment-sourced values, and a stored value that fails to parse SHALL fail startup with an error naming the key and the remediation. A settings text control SHALL carry its type's format example as its placeholder and nothing else.

Scenario: List-typed override reads as a list

  • WHEN a key declared with a list default has override value "a.example,b.example"
  • THEN typed reads of that key SHALL return two elements after restart

Scenario: Invalid enum value rejected at save

  • WHEN an operator submits a value outside a key's declared enumeration
  • THEN the save SHALL be rejected and no row written

Scenario: A word is not a duration

  • WHEN an operator types hello into fedwiki-sync-interval and presses Save
  • THEN the page answers 422, the control shows "value "hello" is not a duration; for example 30m or 1h30m", every other row keeps what was typed, and no row is written

Scenario: A word is not a URL

  • WHEN an operator types hello into fedwiki-farm-api-url and presses Save
  • THEN the page answers 422 with "value "hello" is not an absolute http or https URL" under the control, and no row is written

Scenario: The empty control shows the format

  • WHEN an operator clears a duration key's control
  • THEN the empty control shows 1h30m as its placeholder, and saving it empty is refused as an emptied value (forms-library D21), not stored

Scenario: Corrupt stored value fails boot actionably

  • WHEN the overrides table contains a value that fails to parse for its key (e.g. written by hand, or saved before values were typed)
  • THEN startup SHALL fail with an error naming the key, the parser's sentence, and the remediation: member-console config clear <key> first, and the operator panel as a fix that works before restarting; no SQL statement

Requirement: Bootstrap remains environment-only

Boot-time required-configuration validation SHALL continue to run before the database is available and SHALL be satisfiable from the environment alone. Overrides SHALL layer on top of a valid environment configuration and SHALL NOT substitute for required keys at validation time.

Scenario: Required key present only as an override

  • WHEN a required key is unset in the environment but has an override row
  • THEN boot validation SHALL fail exactly as if the key were unset

Requirement: Operator settings surface is generic and declaration-driven

The operator panel SHALL provide a per-integration settings page at /operator/integrations/{integrationKey}/settings, rendered by one core handler and template for all integrations from the integration's declared configuration spec. The settings form SHALL be a declared form of the settings kind, rendered through the form part in the table family (form-library, design D21): one table, one commit, every declared key a row in declared order. Each row SHALL carry the key and its usage text in the label column, the effective value and the winning source (override, environment, or default) in their own columns, and its control in the control column. Every control on that table SHALL set a value and SHALL do nothing else: a key declaring an enumeration SHALL render as a closed select offering its declared members and no empty option, a key whose declared default is a boolean SHALL render as a checkbox, and every other key SHALL render as a text control. A row whose stored state has not taken effect SHALL carry a pending-restart marker in its effective-value cell. A secret key SHALL be a row of the same table stating where its value is set, with no control at all and nothing submitted for it, and the page SHALL NOT carry a second table for the secret keys. Writes SHALL follow the operator panel's HTMX + CSRF-header pattern and require the operator role. The integration a write addresses SHALL be read from the route's {integrationKey} segment alone; the form SHALL NOT carry a hidden field naming the integration, and the handler SHALL NOT read one (finding FA-10). Registering an integration with declared configuration SHALL surface its settings page with no per-integration template or handler edits. Override rows whose key matches no installed integration's declaration SHALL be listed with a removal affordance.

Each row's control SHALL be bound to the value in force: the stored override when one exists, the effective value otherwise. A checkbox SHALL be ticked when that value is true and SHALL read as false when the submission carries no value for it. Because every row arrives on every save, the handler SHALL compare each submitted value with the value that row's control was rendered with and treat an equal value as no change; any other value SHALL be validated the way the boot overlay validates it and written. A save that changes nothing SHALL still navigate to the page with its toast.

A text or list key that comes back empty against a value that was there SHALL be refused with 422 and the message "Enter a value, or clear the override." when the row has a stored override, or "Enter a value." when it does not; it SHALL NOT be read as a request to remove the override, and no override SHALL be written or removed by that submission.

A row with a stored override SHALL carry a control that removes it, in its source cell, addressing DELETE /operator/integrations/{integrationKey}/settings/{key}. No confirm modal SHALL guard it: the same override can be set again from the same row. A row with no stored override SHALL NOT carry it. A request naming an integration this console does not run, a key that integration does not declare, a secret key, or a key with no stored override SHALL answer 404. A successful removal SHALL navigate the way a successful save does, to the settings page with the flash query parameter the landing render turns into its own toast.

A refused write, for any reason, SHALL answer 422 with the settings form re-rendered in place: the reason under the control it belongs to when it belongs to one, otherwise in the form-level slot the part always renders, with every submitted value carried back. A refusal SHALL NOT answer 200, SHALL NOT answer with an HX-Redirect, and SHALL NOT carry its reason in a query string (finding FA-24). A successful save or clear SHALL keep its navigation, through the one helper every navigating success uses: an htmx submit gets HX-Redirect to the settings page with the flash query parameter that the landing render turns into a success toast, and a native submit gets the 303 to the same destination (design D9 as corrected in round 4).

The page SHALL carry one line of prose, "Saved changes apply when the application restarts.", a side effect no row can state. It SHALL NOT say that secret keys are managed via the environment and never shown: each secret row says so itself.

Scenario: Settings page renders from declarations

  • WHEN an operator opens an integration's settings page
  • THEN every declared key SHALL appear as a row of one table with its usage text, effective value, and source, non-secret keys with a control bound to the value in force and secret keys with an environment-managed row and no control

Scenario: Saving an untouched page writes nothing

  • WHEN an operator presses Save without editing any row
  • THEN no override SHALL be written or cleared, and the page SHALL land with its saved toast

Scenario: New integration needs no surface work

  • WHEN a new integration declaring configuration is registered
  • THEN its settings page SHALL be reachable and complete with no operator template or handler edits

Scenario: Setting and clearing an override

  • WHEN an operator saves a valid value for a non-secret key and later presses that row's Clear
  • THEN the save SHALL upsert the override row and Clear SHALL delete it, each reflected on the page

Scenario: Emptying a control removes nothing

  • WHEN an operator empties a text control on a row and saves
  • THEN the response is 422 with the reason under that control, naming Clear as the way to remove an override when the row has one
  • AND the stored override, if any, is still there

Scenario: A boolean key is a checkbox

  • WHEN a key declares a boolean default
  • THEN its row renders a checkbox, ticked when the value in force is true, and a save that leaves it alone writes nothing

Scenario: A refused value stays on the form

  • WHEN an operator saves a value the declaration cannot coerce to the key's type
  • THEN the response is 422 with the form re-rendered in place, the value still in its control, and the reason under that control
  • AND no redirect occurs and no reason travels in a query string

Scenario: A refusal that belongs to no field has a home

  • WHEN a write is refused because the key is unknown to this integration or the save itself fails
  • THEN the response is 422 and the form-level slot carries the reason

Scenario: The route names the integration

  • WHEN an operator saves an override on an integration's settings page
  • THEN the integration is resolved from the route path
  • AND the submitted body carries no field naming the integration

Scenario: Orphan overrides are visible

  • WHEN an override row's key matches no installed integration's declaration
  • THEN the operator surface SHALL list it as unrecognized with an affordance to delete it

Requirement: Integration status presentation distinguishes registry lifecycle from configuration readiness

Operator surfaces that present per-integration state SHALL show one Status per integration with exactly one of three honest values, derived in this order: Disabled when the registry lifecycle value (the operator-managed status, defaulting to active and never changed by any health check) is not active; Not configured when any required key resolves to no value, from the same required-key resolution the settings pages perform; Configured otherwise. The registry lifecycle value SHALL NOT render as a second column or badge beside the configuration state (the two-column design let a green "Active" sit beside "Not configured" and was read as working, walk run 2, ACC-6), and no surface SHALL present any of these values under wording that implies connectivity or live health ("connected", "read live"). A settings page MAY additionally show the registry lifecycle value where the operator changes it.

Scenario: Unconfigured integration is visibly unconfigured on the list

  • WHEN an operator views the integrations list while an integration's required key resolves to no value
  • THEN that integration's row shows the single Status "Not configured"
  • AND no "Active" registry badge renders beside it
  • AND the page does not describe the integration as connected

Scenario: A configured integration reads Configured

  • WHEN every required key of an active integration resolves
  • THEN its Status reads "Configured" (Stripe included; no empty-value marker stands in for a state)

Scenario: A disabled integration reads Disabled

  • WHEN an integration's registry lifecycle value is not active
  • THEN its Status reads "Disabled" regardless of its configuration

Requirement: Secret settings distinguish set from unset

Settings surfaces SHALL render required secret keys with a truthful presence indicator — set (masked) versus not set — rather than one identical masked treatment for both states, so an operator can tell whether a required secret exists without consulting the environment.

Scenario: Missing secret renders as not set

  • WHEN an operator views the settings page for an integration whose required secret is absent from the environment
  • 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: 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.

Scenario: The provider page is Stripe's home

  • WHEN an operator follows "Stripe" from the Integrations table or the overview's Integrations card
  • THEN /operator/integrations/stripe renders with the configured state, the mode, the delivery-queue section, the inbound-events section, and a link to the settings page

Scenario: Outbox caption names its coverage

  • WHEN an operator views the provider page's delivery-queue section
  • THEN the caption names which integrations' queued work the outbox covers
  • THEN an empty outbox does not present itself as evidence that uncovered integrations are healthy

Scenario: Only dead-letter outbox work raises alarm styling

  • WHEN the integration outbox contains pending and retrying entries but no dead-letter entries
  • THEN the section renders without alarm styling
  • WHEN the integration outbox contains at least one dead-letter entry
  • THEN the dead-letter bucket and the table render with alarm styling, and the overview's Integrations card says work needs attention in Stripe's row

Scenario: Dead-lettered inbound events raise the same alarm

  • WHEN at least one Stripe webhook event is at dead_letter
  • THEN the inbound-events section renders the count and the table with alarm styling, and the overview's Integrations card says work needs attention in Stripe's row
  • WHEN none is
  • THEN the section says inbound events are processing normally, without alarm styling

Scenario: The settings page is configuration only

  • WHEN an operator views /operator/integrations/stripe/settings
  • THEN it renders the configuration keys and a link to the provider page, and no delivery-queue section

Requirement: Unset keys read "Not set"

On every integration settings page an unset key SHALL read "Not set", whether the key is a secret or a plain value; a set secret SHALL read "Set" and a set plain key SHALL show its value. The empty-value marker SHALL NOT stand in for "Not set" on one kind of key while the other kind spells it out.

Scenario: One fact, one rendering

  • WHEN an operator views a settings page where one secret key and one plain key are both unset
  • THEN both read "Not set"

Requirement: The settings row keeps to its subject

A settings row's Effective cell SHALL hold the value in force at boot (or Not set) and nothing else, and its Source cell SHALL hold that value's source badge and nothing else. Everything that describes the stored override rather than the running value SHALL render in the Override cell under the control, on one line: the Clear action when a stored override exists, and the Pending restart badge when the stored state differs from the running one. No cell SHALL stack a badge on a value or a button under a badge.

Scenario: A row with no override

  • WHEN a key has no stored override
  • THEN its Effective cell shows the value, its Source cell shows Environment or Default, and its Override cell shows the control alone

Scenario: An override saved and waiting for a restart

  • WHEN an operator has saved an override for a key whose running value came from the environment
  • THEN the Effective cell still shows the running value, the Source cell still says Environment, and the Override cell shows the control bound to the saved value with Clear and Pending restart on one line under it

Scenario: An override cleared and still running

  • WHEN an operator has cleared an override that is still the running value
  • THEN the Effective cell shows that value, the Source cell says Override, and the Override cell shows the control with Pending restart under it and no Clear

Requirement: Overrides are managed by the panel or the CLI, never by hand

The console SHALL offer a command-line way to do what the settings page does and nothing more: member-console config list [integration] SHALL show, per declared key, the value the environment or default gives, the stored override if any, and which wins at the next boot, with secret values masked and unrecognized rows listed as such; member-console config set <key> <value> SHALL parse the value through the same parser the page uses, refuse with the same sentence, refuse a secret key, and write the override; member-console config clear <key> SHALL delete the override row, an unrecognized row included. The commands and the page's handlers SHALL call one settings service, so their behaviour cannot differ. The CLI SHALL NOT claim what it cannot know from a separate process: it never states the value in force in the running app or a pending restart; it states that the running app applies changes on restart. No user-facing text (the boot refusal, the pre-flight refusal, the documentation) SHALL name the overrides table or a SQL statement as a remediation; the boot and pre-flight messages for an unparsable stored override SHALL name member-console config clear <key> first and the operator panel as a fix that works before restarting.

Scenario: A word is refused at the terminal the same way

  • WHEN an operator runs member-console config set fedwiki-sync-interval hello
  • THEN the command prints "value "hello" is not a duration; for example 30m or 1h30m", writes nothing, and exits 1

Scenario: The boot refusal names the command

  • WHEN the console refuses to boot on a stored override that no longer parses
  • THEN the message names the key, the parser's sentence, and member-console config clear <key>, and mentions the operator panel only as a fix that works before restarting

Scenario: The listing says what it knows

  • WHEN an operator runs member-console config list fedwiki after saving an override the running app has not applied
  • THEN the row shows the environment value, the stored override, and Override as the next-boot winner, secret keys show a masked value, and the output ends with one line saying the running app applies changes on restart