openvibers/openvibe.billing Commits

Card payments: the Stripe keys and the live switch are owner settings (billing.cards) (#205)

OP OpenVibers committed 6 days ago · 2a47b0b823 · 16 files, +538 −43

* Card payments: the Stripe keys and the live switch are owner settings (billing.cards)

The Stripe secret key, publishable key and webhook signing secret are secret-class
fields of a new billing.cards namespace (sealed, write-only), with the live switch as
an x-danger boolean. Each carries x-owner-input (group money) so the admin panel's
Setup tab lists it with where to get it. The Stripe adapter reads the keys at use
time: the panel's value first, STRIPE_* from the environment as the fallback, so
nothing changes until the owner enters a key. With the live switch set, a key of the
other mode leaves card payments off and says why.

POST /internal/config/billing.cards/test/:key (billing.config.manage) checks the
secret key with Stripe's account and the signing secret against Stripe's webhook
endpoints, read-only, and answers in one line with the key scrubbed.

Every field of billing.credits and billing.vibes now has a plain title and a
description of 30+ characters; the hold lease has x-unit seconds and the fee bps.

* Merge remote-tracking branch 'origin/main' into feat/owner-inputs

* Pin openvibe-contracts v0.311.0

* Follow the released billing.config.manage test route and words

Changes

ddiff --git a/.env.example b/.env.example
iindex 4968d14..9680e83 100644
---- a/.env.example
++++ b/.env.example
@@@ -96,8 +96,10 @@ POWERCHAT_SITE_USERNAME=
# Credit isTest deliveries (flagged test). Development/sandbox only.
#POWERCHAT_ALLOW_TEST_FULFILLMENT=false
--# Stripe (off in production)
++# Stripe (off in production). The owner can enter these in the admin panel's Setup tab instead (billing.cards,
++# sealed); a value entered there wins, these stay the fallback.
#STRIPE_SECRET_KEY=
++#STRIPE_PUBLISHABLE_KEY=
#STRIPE_WEBHOOK_SECRET=
# PayPal (off in production)
#PAYPAL_CLIENT_ID=
ddiff --git a/README.md b/README.md
iindex 24c6167..000f4d8 100644
---- a/README.md
++++ b/README.md
@@@ -66,7 +66,7 @@ promo write changes both in one transaction.
- OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import)
- OpenVibe.Events (the SDK outbox relay, when `EVENTS_URL` and the OAuth client secret are set)
- the payment providers whose secrets are configured (none in production today)
--- `openvibe-contracts` v0.297.0, `openvibe-sdk` v0.93.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`, `openvibe-sdk/vibes`), `openvibe-shared` v4.124.0, pinned by release tarball
++- `openvibe-contracts` v0.311.0, `openvibe-sdk` v0.93.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`, `openvibe-sdk/vibes`), `openvibe-shared` v4.124.0, pinned by release tarball
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
`openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)
@@@ -513,7 +513,7 @@ PowerChat purchase settling end to end after the cutover.
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.297.0
++- openvibe-contracts: v0.311.0
- openvibe-sdk: v0.93.0
- openvibe-shared: v4.124.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 2a19a30..7b60755 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"authoritative": true,
"deployed": true,
"deployment": "The money ledger since the cutover of 2026-10-17 04:50-04:56 UTC (BILLING_AUTHORITY=billing in billing.env and live.env): deployed with `sudo ovhost deploy billing`, /opt/openvibe.billing, unit openvibe-billing, API on 127.0.0.1:4600 (loopback only; /api/v1 is denied on the public host). Public at https://billing.openvibe.network: the staff console (Network SSO + PKCE, BILLING_STAFF_SUBJECTS = the owner only), the billing policy (/policy), a person's own Vibes (/api/me/vibes, CORS for OpenVibe's sites), /api/health, /api/ready, /release.json and /webhooks/*. PowerChat deliveries reach /webhooks/powerchat through openvibe.live's /api/powerchat/webhook (proxied by Live's vhost, OpenVibe.Live#172).",
-- "contracts": "openvibe-contracts v0.297.0",
++ "contracts": "openvibe-contracts v0.311.0",
"sdk": "openvibe-sdk v0.93.0",
"shared": "openvibe-shared v4.124.0",
"notes": "Runtime, journal, adapters, import, reconciliation and the staff console (cashout approve/deny, review queue, reconciliation, freeze, audit log) are built, tested and deployed, with /metrics (loopback only), hourly scheduled reconciliation, EXTERNAL PowerChat receipts (billing.receipt.external, announced for OpenVibe.Tips), overflow guards and serializable settlement (PostgreSQL, ADR-035). The cutover (README runbook, docs/live-cutover.md) ran on 2026-10-17: final import 0 new rows, 0 holds, 0 anomalies; reconciliation OK before and after; Live's money tables are read-only behind a database tripwire. OpenVibe.Tips and OpenVibe.VIP launched right after (VIP checkout open). Still due: a small real PowerChat purchase settling end to end after the cutover. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.",
ddiff --git a/docs/capabilities-proposal/billing.config.manage.json b/docs/capabilities-proposal/billing.config.manage.json
iindex 100c264..183fdcd 100644
---- a/docs/capabilities-proposal/billing.config.manage.json
++++ b/docs/capabilities-proposal/billing.config.manage.json
@@@ -4,7 +4,7 @@
"owner": "billing",
"status": "active",
"visibility": "internal",
-- "description": "Manage the billing.credits namespace of OpenVibe.Billing through openvibe-shared/config (D11): GET /internal/config lists the namespace summaries, GET /internal/config/:namespace returns one as common.config-namespace@1, POST applies common.config-change@1, and PUT /internal/config/:namespace/secrets/:key sets a secret with common.config-secret-put@1 (write-only; the answer is the marker) while DELETE removes it. Only Network's admin proxy, acting for the owner, holds this capability; it is never grantable to apps. Each request carries the owner as actor, and the service audits every write with that actor and key, never a secret value.",
++ "description": "Manage the billing.credits namespace of OpenVibe.Billing through openvibe-shared/config (D11): GET /internal/config lists the namespace summaries, GET /internal/config/:namespace returns one as common.config-namespace@1, POST applies common.config-change@1, and PUT /internal/config/:namespace/secrets/:key sets a secret with common.config-secret-put@1 (write-only; the answer is the marker) while DELETE removes it. POST /internal/config/:namespace/test/:key asks the service's tester to try a stored value with the provider on the server, answering { ok, message } in one line that never carries a secret (the owner's Setup tab). Only Network's admin proxy, acting for the owner, holds this capability; it is never grantable to apps. Each request carries the owner as actor, and the service audits every write with that actor and key, never a secret value.",
"permissions": [
"config:manage"
],
@@@ -16,7 +16,8 @@
"implementedBy": [
"GET /internal/config",
"GET|POST /internal/config/:namespace",
-- "PUT|DELETE /internal/config/:namespace/secrets/:key"
++ "PUT|DELETE /internal/config/:namespace/secrets/:key",
++ "POST /internal/config/:namespace/test/:key"
],
"inputSchema": "common.config-change@1",
"outputSchema": "common.config-namespace@1"
ddiff --git a/package-lock.json b/package-lock.json
iindex 778acf4..4ed3736 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0"
@@@ -1103,9 +1103,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.297.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
-- "integrity": "sha512-FycRElRNzeFLEfzVUKIyE3ieKphUrQbAbsprnvs0Uu7ZdlBKJG6wQV/9gD77Neu1Etcsyezf9Boyq+MsjuraVA==",
++ "version": "0.311.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",
++ "integrity": "sha512-s0CubY2CA281mADLTwfnHSlpcC+fXbdmTEHFd0gExXrE6P2DGZlf+l+p6GyyOTF0k4CJZeCutDzwSjfSU/ZJRg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 461d064..fc1a1f5 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0"
ddiff --git a/server/app.js b/server/app.js
iindex d85b436..152fb8a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -26,7 +26,7 @@ const { createKeyProvider, createIdentity, createProjects, createApps } = requir
const { createAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { internalRouter } = require('./api/internal');
--const { createBillingConfig, createVibesConfig, applyValues, applyVibesValues } = require('./config-store');
++const { createBillingConfig, createVibesConfig, createCardsConfig, stripeSettings, applyValues, applyVibesValues } = require('./config-store');
const { internalConfigRouter } = require('./internal-config');
const { createVibesWebhooks } = require('./ops/vibes-webhooks');
const { createActorLimits } = require('./api/actor-limits');
@@@ -57,7 +57,7 @@ async function createApp(opts = {}) {
});
// ctx.env is the raw environment the owner-settings store reads its x-env pins from; the service reads the
// settings themselves through ctx.configStore (D11). Tests pass their own env through here.
-- const ctx = { db, config, env: opts.env || process.env, rates: createRates(config.rates), now: opts.now || (() => Date.now()), network: identity, log, outbox };
++ const ctx = { db, config, env: opts.env || process.env, rates: createRates(config.rates), now: opts.now || (() => Date.now()), network: identity, log, outbox, fetchImpl };
// D11 owner settings: `billing.credits` is the source of the Balance switches, the top-up terms and the
// hold lease; `billing.vibes` holds the Vibes Pay limits and the Get Vibes with Balance switch. The env pins
// keep today's variables winning while they are set; a change applies live.
@@@ -65,6 +65,10 @@ async function createApp(opts = {}) {
applyValues(config, ctx.configStore.get());
ctx.vibesConfigStore = await createVibesConfig({ db, env: ctx.env, log, onActivate: (values) => applyVibesValues(config, values) });
applyVibesValues(config, ctx.vibesConfigStore.get());
++ // The card processor's keys and live switch (`billing.cards`, owner-setup design): the Stripe adapter reads them at
++ // use time, the panel's sealed value first and the environment's STRIPE_* as the fallback.
++ ctx.cardsConfigStore = await createCardsConfig({ db, env: ctx.env, log });
++ config.providers.stripe = stripeSettings(ctx.cardsConfigStore, config.providers.stripe);
// Network's project-membership reads (D12): the projects a person administers and one role check.
ctx.projects = opts.projects || createProjects(config, { fetchImpl });
// Network's app registry (D12 Vibes Pay): the app name/icon on the pay sheet. Best effort; Network's own
ddiff --git a/server/config-store.js b/server/config-store.js
iindex 51d0455..8c97185 100644
---- a/server/config-store.js
++++ b/server/config-store.js
@@@ -12,8 +12,14 @@
* BILLING_INFERENCE_LEASE_SECONDS as its pin; when neither is set the loader's 300 s default applies.
*
* `credits`, `topup` and the two top-up terms are danger keys (they open or move money). There are no secret-class
-- * keys in this namespace: Billing's provider credentials stay in the service's environment, so the
-- * write-only `/secrets/:key` route refuses every key here with 422 config.not_secret.
++ * keys in this namespace, so the write-only `/secrets/:key` route refuses every key here with 422 config.not_secret.
++ *
++ * `billing.cards` (the owner-setup design of 2026-11-07) holds the card processor: the Stripe secret key, the
++ * publishable key and the webhook signing secret (secret class: write-only, sealed with the service's KEK, never
++ * shown again) and the live switch (x-danger). Its keys carry `x-owner-input` so the admin panel's Setup tab lists them
++ * under Money. They have no x-env pin: the value entered in the panel wins when it is set, and STRIPE_SECRET_KEY,
++ * STRIPE_PUBLISHABLE_KEY and STRIPE_WEBHOOK_SECRET from the environment stay the fallback, read at use time
++ * (stripeSettings below), so nothing changes until the owner enters a key.
*/
const config = require('openvibe-shared/config');
const { MAX_RECEIPT_CENTS } = require('./ops/common');
@@@ -24,44 +30,47 @@ const NAMESPACE = 'billing.credits';
const GROUP = 'Balance';
const VIBES_NAMESPACE = 'billing.vibes';
const VIBES_GROUP = 'Vibes';
++const CARDS_NAMESPACE = 'billing.cards';
++const CARDS_GROUP = 'Card payments';
const SCHEMA = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
-- title: 'Billing Balance',
-- description: 'Balance ledger and the owner terms of the checkout top-up.',
++ title: 'Balance',
++ description: 'The Balance people pay for AI and apps with, and the terms on which they can top it up by card. Change these when you open Balance, start offering top-ups, or change the amounts and the fee.',
type: 'object',
additionalProperties: false,
properties: {
credits: {
type: 'boolean', default: false,
-- title: 'Balance ledger', description: 'Open the Balance ledger: the internal hold/settle API and the person\'s Balance routes.',
++ title: 'Open Balance', description: 'When on, people have a Balance: services can hold and spend it, and people see it on their account. Off (the default) answers that Balance is closed. Turning it off again stops new holds; nothing already spent is undone.',
'x-group': GROUP, 'x-env': 'BILLING_CREDITS', 'x-danger': true,
},
sandbox: {
type: 'boolean', default: false,
-- title: 'Sandbox credit grants', description: 'Allow synthetic sandbox credit grants, for development and integration tests only.',
++ title: 'Free test Balance', description: 'When on, test accounts can be given made-up Balance to try paid features without paying. For development and integration tests only; off (the default) in production.',
'x-group': GROUP, 'x-env': 'BILLING_CREDITS_SANDBOX',
},
topup: {
type: 'boolean', default: false,
-- title: 'Balance top-up', description: 'Offer checkout top-ups of Balance through the payment providers.',
++ title: 'Let people top up by card', description: 'When on, people can add to their Balance through the card processor, at the amounts and fee below. Off (the default) hides top-ups. Turning it off stops new top-ups; paid ones stay paid.',
'x-group': GROUP, 'x-env': 'BILLING_CREDITS_TOPUP', 'x-danger': true,
},
topup_amounts_cents: {
type: 'array', items: { type: 'integer', minimum: 1, maximum: MAX_RECEIPT_CENTS }, minItems: 1, uniqueItems: true,
-- title: 'Top-up amounts (cents)', description: 'The only amounts a person may top up, in whole USD cents.',
++ title: 'Top-up amounts (cents)', description: 'The only amounts a person may pick when topping up, in US cents: [500, 1000, 2500] offers $5, $10 and $25. Unset, top-ups say they are not set up yet.',
++ 'x-example': '500, 1000, 2500',
'x-group': GROUP, 'x-env': 'BILLING_CREDITS_TOPUP_AMOUNTS_CENTS', 'x-danger': true,
},
topup_fee_bps: {
type: 'integer', minimum: 0, maximum: 10_000,
-- title: 'Top-up fee (bps)', description: 'Fee in basis points charged on top of the top-up at checkout; 0 is a real, configured fee.',
++ title: 'Top-up fee', 'x-unit': 'bps', 'x-example': 300,
++ description: 'The fee added on top of a top-up at checkout, in hundredths of a percent: 300 is 3%, so a $10 top-up costs $10.30. 0 is a real setting (no fee); unset, top-ups say they are not set up yet.',
'x-group': GROUP, 'x-env': 'BILLING_CREDITS_TOPUP_FEE_BPS', 'x-danger': true,
},
-- // The unit is seconds; Shared 4.110.0's x-unit only accepts nano_usd, ms and bps, so it is in the title
-- // rather than an x-unit that would make createConfigStore throw.
++ // Shared 4.122.0's x-unit accepts seconds.
hold_lease_seconds: {
-- type: 'integer', minimum: 1, maximum: 86_400, default: 300,
-- title: 'Hold lease (seconds)', description: 'How long a Balance hold lasts before it expires.',
++ type: 'integer', minimum: 1, maximum: 86_400, default: 300, 'x-unit': 'seconds',
++ title: 'How long a hold lasts', description: 'Seconds a service may keep Balance on hold before spending it (default 300, five minutes). Longer lets slow AI requests finish; shorter gives unspent Balance back sooner.',
'x-group': GROUP, 'x-env': 'BILLING_INFERENCE_LEASE_SECONDS',
},
},
@@@ -69,34 +78,139 @@ const SCHEMA = {
const VIBES_SCHEMA = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
-- title: 'Billing Vibes',
-- description: 'Vibes Pay limits and the switch that lets a person get Vibes with Balance.',
++ title: 'Vibes',
++ description: 'Vibes are the coins people spend inside apps. These settings decide whether apps may charge Vibes, how much, and whether people can buy Vibes with their Balance.',
type: 'object',
additionalProperties: false,
properties: {
vibes_pay: {
type: 'boolean', default: false,
-- title: 'Vibes Pay', description: 'Let apps charge Vibes on the pay sheet. Turning it on enables spending real Vibes.',
++ title: 'Let apps charge Vibes', description: 'When on, apps can ask a person to pay Vibes on the pay sheet, and real Vibes are spent. Off (the default) refuses every app charge. Turning it off again stops new charges at once.',
'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_PAY', 'x-danger': true,
},
max_charge_vibes: {
type: 'integer', minimum: 0, maximum: 10_000_000, default: 0,
-- title: 'Largest single charge (Vibes)', description: 'The most Vibes one app charge may ask for; 0 closes app charges.',
++ title: 'Largest single charge (Vibes)', 'x-example': 500,
++ description: 'The most Vibes one app charge may ask for (default 0, which refuses every charge). Higher allows bigger purchases in apps; lower limits what one mistake can cost.',
'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_MAX_CHARGE',
},
daily_app_spend_vibes_per_person: {
type: 'integer', minimum: 0, maximum: 10_000_000, default: 0,
-- title: 'Daily app spend per person (Vibes)', description: 'The most Vibes one person may spend in apps in a day; 0 closes app spending.',
++ title: 'Most a person may spend in apps a day (Vibes)', 'x-example': 2000,
++ description: 'The most Vibes one person may spend in all apps together in one UTC day (default 0, which refuses all app spending). Lower protects people from spending too much.',
'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_DAILY_APP_SPEND',
},
from_balance: {
type: 'boolean', default: false,
-- title: 'Get Vibes with Balance', description: 'Let a person buy Vibes with their Balance, at the pack value rate.',
++ title: 'Let people buy Vibes with Balance', description: 'When on, a person can turn Balance into Vibes at the same rate as a Vibes pack. Off (the default) offers Vibes packs only. Turning it off stops new purchases; Vibes already bought stay.',
'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_FROM_BALANCE', 'x-danger': true,
},
},
};
++/**
++ * The card processor (Stripe). The three keys are secret class: write-only through /secrets/:key, sealed with the
++ * KEK, shown only as the last four characters. No x-env pin: the panel's value wins and the environment is the
++ * fallback, read at use time by stripeSettings().
++ */
++const CARDS_SCHEMA = {
++ $schema: 'https://json-schema.org/draft/2020-12/schema',
++ title: 'Card payments',
++ description: 'The card processor that takes payments for Balance top-ups, purchases and subscriptions (Stripe). Change these when you connect your Stripe account, rotate a key, or are ready to take real payments.',
++ type: 'object',
++ additionalProperties: false,
++ properties: {
++ 'stripe.secret_key': {
++ type: 'string', minLength: 8, maxLength: 512, pattern: '^(sk|rk)_(test|live)_[A-Za-z0-9]+$',
++ title: 'Stripe secret key', 'x-group': CARDS_GROUP, 'x-example': 'sk_live_…',
++ description: 'The key Billing uses to open Stripe checkouts and cancel subscriptions. Without it (and the webhook signing secret) card payments stay off. A test key (sk_test_…) charges no real card.',
++ 'x-owner-input': {
++ group: 'money',
++ unlocks: 'Lets people pay by card: Balance top-ups, purchases and subscriptions open a Stripe checkout. Without it card payments stay off.',
++ get_it_at: 'https://dashboard.stripe.com/apikeys',
++ get_it_how: 'Copy the Secret key (sk_live_… for real payments, sk_test_… to try it), or create a restricted key that may write Checkout Sessions and Subscriptions.',
++ test: true,
++ },
++ 'x-required-for': 'Card payments',
++ },
++ 'stripe.publishable_key': {
++ type: 'string', minLength: 8, maxLength: 512, pattern: '^pk_(test|live)_[A-Za-z0-9]+$',
++ title: 'Stripe publishable key', 'x-group': CARDS_GROUP, 'x-example': 'pk_live_…',
++ description: 'The public key a page needs to show Stripe\'s card form inside OpenVibe. Billing\'s checkout sends people to Stripe\'s own page today, so nothing needs it yet; it is kept for an in-page form.',
++ 'x-owner-input': {
++ group: 'money',
++ unlocks: 'Lets a page show Stripe\'s card form inside OpenVibe instead of sending people to Stripe\'s checkout page. Optional: today\'s checkout works without it.',
++ get_it_at: 'https://dashboard.stripe.com/apikeys',
++ get_it_how: 'Copy the Publishable key from the same page as the secret key, in the same mode (pk_live_… with sk_live_…).',
++ test: false,
++ },
++ },
++ 'stripe.webhook_secret': {
++ type: 'string', minLength: 8, maxLength: 512, pattern: '^whsec_[A-Za-z0-9+/=_-]+$',
++ title: 'Stripe webhook signing secret', 'x-group': CARDS_GROUP, 'x-example': 'whsec_…',
++ description: 'The secret Stripe signs its payment notices with, so Billing only believes notices that really come from Stripe. Without it card payments stay off, because a paid checkout could never be confirmed.',
++ 'x-owner-input': {
++ group: 'money',
++ unlocks: 'Lets Billing confirm card payments: Stripe tells Billing when a checkout is paid, refunded or disputed. Without it card payments stay off.',
++ get_it_at: 'https://dashboard.stripe.com/webhooks',
++ get_it_how: 'Add an endpoint for https://billing.openvibe.network/webhooks/stripe with the checkout, invoice, charge and subscription events, then reveal its Signing secret (whsec_…).',
++ test: true,
++ },
++ 'x-required-for': 'Card payments',
++ },
++ 'stripe.live': {
++ type: 'boolean', title: 'Take real card payments', 'x-group': CARDS_GROUP, 'x-danger': true,
++ description: 'On: only a live key (sk_live_…) is used and real cards are charged. Off: only a test key (sk_test_…) is used and no real card is charged. Not set (the default): the key decides, as before this switch. Turning it off stops real charges at once.',
++ 'x-owner-input': {
++ group: 'money',
++ unlocks: 'Decides whether card payments charge real cards. Turn it on once a test payment has gone through; turn it off to stop real charges at once.',
++ get_it_at: 'https://dashboard.stripe.com/account/onboarding',
++ get_it_how: 'Finish Stripe\'s account activation first (business details and a bank account), or Stripe refuses live payments.',
++ test: false,
++ },
++ },
++ },
++};
++const CARD_SECRET_KEYS = new Set(['stripe.secret_key', 'stripe.publishable_key', 'stripe.webhook_secret']);
++const cardsClassify = (key) => (CARD_SECRET_KEYS.has(key) ? 'secret' : 'internal');
++
++/**
++ * The Stripe settings the adapter reads at use time: the panel's value when one is set, else the environment's
++ * (`base`, the loader's STRIPE_* values). `live` false or true makes a key of the other mode unusable (the adapter
++ * stays off and says why); unset keeps today's behaviour.
++ */
++function stripeSettings(store, base = {}) {
++ const pick = (key, fallback) => {
++ const v = store ? store.get(key) : undefined;
++ return typeof v === 'string' && v ? { value: v, source: 'vault' } : { value: fallback || '', source: fallback ? 'env' : null };
++ };
++ const out = {
++ apiBase: base.apiBase,
++ toleranceSec: base.toleranceSec,
++ get secretKey() { return pick('stripe.secret_key', base.secretKey).value; },
++ get publishableKey() { return pick('stripe.publishable_key', base.publishableKey).value; },
++ get webhookSecret() { return pick('stripe.webhook_secret', base.webhookSecret).value; },
++ get live() { const v = store ? store.get('stripe.live') : undefined; return typeof v === 'boolean' ? v : null; },
++ /** Where a value comes from (vault, env or null) and the env name it falls back to. */
++ source(key) {
++ const env = { 'stripe.secret_key': 'STRIPE_SECRET_KEY', 'stripe.publishable_key': 'STRIPE_PUBLISHABLE_KEY', 'stripe.webhook_secret': 'STRIPE_WEBHOOK_SECRET' }[key];
++ const fallback = { 'stripe.secret_key': base.secretKey, 'stripe.publishable_key': base.publishableKey, 'stripe.webhook_secret': base.webhookSecret }[key];
++ return { ...pick(key, fallback), env };
++ },
++ /** null when the key may be used, else the reason it may not (a key of the other mode than the live switch). */
++ get modeProblem() {
++ const live = out.live;
++ const key = out.secretKey;
++ if (live === null || !key) return null;
++ const isLive = /^(sk|rk)_live_/.test(key);
++ if (live && !isLive) return 'the live switch is on but the secret key is a test key';
++ if (!live && isLive) return 'the live switch is off but the secret key is a live key';
++ return null;
++ },
++ };
++ return out;
++}
++
const AMOUNT_RE = /^[0-9]+$/;
/** A top-up amount list from a JSON array, a JSON string, or today's comma-separated env value. */
@@@ -241,7 +355,22 @@ async function createVibesConfig({ db, env, log = console, schema = VIBES_SCHEMA
});
}
++/** The D11 `billing.cards` namespace: the card processor's keys (sealed) and the live switch. */
++async function createCardsConfig({ db, env, log = console, schema = CARDS_SCHEMA }) {
++ const store = await config.createConfigStore({
++ db, service: SERVICE, namespace: CARDS_NAMESPACE, schema,
++ env: env || {},
++ classify: cardsClassify,
++ onChange: async (change) => await auditChange(db, { ...change, namespace: CARDS_NAMESPACE, service: SERVICE }, log),
++ log,
++ });
++ if (store.sealed) {
++ try { await store.reseal(); } catch (err) { log.warn(`[config] ${CARDS_NAMESPACE} reseal at boot failed: ${err && err.message ? err.message.split('\n')[0] : 'unknown error'}`); }
++ }
++ return store;
++}
++
module.exports = {
-- SERVICE, NAMESPACE, VIBES_NAMESPACE, SCHEMA, VIBES_SCHEMA, parseAmounts, storeEnv, valuesFromEnv, applyValues,
-- vibesStoreEnv, applyVibesValues, createBillingConfig, createVibesConfig, auditChange,
++ SERVICE, NAMESPACE, VIBES_NAMESPACE, CARDS_NAMESPACE, SCHEMA, VIBES_SCHEMA, CARDS_SCHEMA, parseAmounts, storeEnv, valuesFromEnv, applyValues,
++ vibesStoreEnv, applyVibesValues, createBillingConfig, createVibesConfig, createCardsConfig, stripeSettings, auditChange,
};
ddiff --git a/server/config.js b/server/config.js
iindex cad5385..d9cf572 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -137,7 +137,9 @@ function loadConfig(env = process.env) {
maxSkewMs: int(env.POWERCHAT_MAX_SKEW_MS, 15 * 60 * 1000),
},
stripe: {
++ // The environment's values; the owner's billing.cards namespace wins when set (config-store.js stripeSettings).
secretKey: env.STRIPE_SECRET_KEY || '',
++ publishableKey: env.STRIPE_PUBLISHABLE_KEY || '',
webhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
apiBase: trim(env.STRIPE_API_BASE || 'https://api.stripe.com/v1'),
toleranceSec: int(env.STRIPE_TOLERANCE_SEC, 300),
ddiff --git a/server/internal-config.js b/server/internal-config.js
iindex bc5b2b9..5501a2d 100644
---- a/server/internal-config.js
++++ b/server/internal-config.js
@@@ -6,8 +6,9 @@
* Only Network's service token holds `billing.config.manage`; every route is guarded by it (server/api/auth.js).
* The acting subject is the owner's usr_ id in X-OV-Subject, and only for a caller that holds the capability;
* X-OV-Fresh: 1 is required for a write to an x-danger key, so a stale session can never turn Balance or the
-- * top-up switch on. Both namespaces (`billing.credits`, `billing.vibes`) are served here; their onChange writes
-- * Billing's audit line (server/config-store.js).
++ * top-up switch on. The namespaces (`billing.credits`, `billing.vibes`, `billing.cards`) are served here; their
++ * onChange writes Billing's audit line (server/config-store.js). POST /internal/config/:namespace/test/:key is the
++ * Setup tab's Test button for the Stripe keys (server/owner-inputs.js).
*
* The paths are full paths (the router is mounted at the app root) so the capability declaration's
* implementedBy list points at real routes.
@@@ -15,6 +16,7 @@
const express = require('express');
const { capabilities } = require('openvibe-contracts');
const { adminRoutes } = require('openvibe-shared/config');
++const { testRoute, testStripeKey, testStripeWebhook } = require('./owner-inputs');
const CAP = 'billing.config.manage';
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -23,7 +25,7 @@ function internalConfigRouter({ ctx, auth }) {
const r = express.Router();
// The store is the only caller of the config routes, so its body parser needs to run only on these paths.
r.use('/internal/config', express.json({ limit: '64kb' }));
-- const routes = adminRoutes([ctx.configStore, ctx.vibesConfigStore], {
++ const routes = adminRoutes([ctx.configStore, ctx.vibesConfigStore, ...(ctx.cardsConfigStore ? [ctx.cardsConfigStore] : [])], {
requireAdmin: auth.needs(CAP),
basePath: '/internal/config',
actor: (req) => {
@@@ -42,6 +44,20 @@ function internalConfigRouter({ ctx, auth }) {
r.post('/internal/config/:namespace/rollback', routes.rollback);
r.put('/internal/config/:namespace/secrets/:key', routes.secretPut);
r.delete('/internal/config/:namespace/secrets/:key', routes.secretDelete);
++ // The Setup tab's Test button (owner-setup design): one read-only Stripe call with the stored key, one line back.
++ r.post('/internal/config/:namespace/test/:key', auth.needs(CAP), testRoute({
++ log: ctx.log,
++ now: ctx.now,
++ resolve: (namespace, key) => {
++ const stripe = ctx.config.providers.stripe;
++ if (namespace !== 'billing.cards' || !stripe || typeof stripe.source !== 'function') return null;
++ if (key !== 'stripe.secret_key' && key !== 'stripe.webhook_secret') return null;
++ return stripe.source(key);
++ },
++ run: (namespace, key, value) => (key === 'stripe.secret_key'
++ ? testStripeKey(ctx.config.providers.stripe, value, { fetchImpl: ctx.fetchImpl || globalThis.fetch })
++ : testStripeWebhook(ctx.config.providers.stripe, value, { fetchImpl: ctx.fetchImpl || globalThis.fetch })),
++ }));
return r;
}
ddiff --git a/server/owner-inputs.js b/server/owner-inputs.js
nnew file mode 100644
iindex 0000000..7fb15e4
---- /dev/null
++++ b/server/owner-inputs.js
@@@ -0,0 +1,102 @@
++'use strict';
++/**
++ * Owner inputs (the owner-setup design of 2026-11-07): the Test button behind the Setup tab's card-processor rows.
++ *
++ * POST /internal/config/:namespace/test/:key (billing.config.manage, mounted in internal-config.js)
++ * → 200 { ok, message, state, source, namespace, key, tested_at } (409 config.not_set when nothing is set)
++ * openvibe-shared 4.125.0's test-hook shape ({ ok, message }, 409 config.not_set) with more beside it: state is
++ * connected (Stripe accepted it) or not_working (refused, or it cannot be used); source says where the value
++ * came from (vault: entered in the admin panel; env: the server's settings file, which the code falls back to
++ * and so is tested too).
++ *
++ * Every test is one read-only Stripe call (the account, or the list of webhook endpoints). Nothing is charged or
++ * created. The value is never in the answer, a log line or an error: Stripe's own message is scrubbed of it and cut
++ * to one short line.
++ */
++const TIMEOUT_MS = 10_000;
++
++/** One line, at most 200 characters, with every given secret replaced. */
++function oneLine(text, secrets = []) {
++ let s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
++ for (const v of secrets) {
++ if (typeof v !== 'string' || v.length < 4) continue;
++ s = s.split(v).join('[hidden]');
++ if (v.length >= 12) s = s.split(v.slice(0, 12)).join('[hidden]');
++ }
++ return s.length > 200 ? `${s.slice(0, 199)}…` : s;
++}
++
++async function stripeGet(cfg, path, key, fetchImpl) {
++ let res;
++ try {
++ res = await fetchImpl(`${cfg.apiBase}${path}`, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(TIMEOUT_MS) });
++ } catch (err) {
++ return { status: 0, json: null, error: err && err.name === 'TimeoutError' ? 'Stripe did not answer within 10 seconds.' : 'Stripe could not be reached.' };
++ }
++ const json = await res.json().catch(() => null);
++ return { status: res.status, json, error: null };
++}
++
++function refusal(r) {
++ if (r.error) return r.error;
++ const says = r.json && r.json.error && typeof r.json.error.message === 'string' ? r.json.error.message : '';
++ if (r.status === 401) return `Stripe refused the key (401)${says ? `: ${says}` : '.'}`;
++ if (r.status === 403) return `Stripe accepted the key but it may not read this (403)${says ? `: ${says}` : '.'} A restricted key needs read access to the account.`;
++ if (r.status === 429) return 'Stripe is limiting requests right now (429); try again in a minute.';
++ return `Stripe answered ${r.status}${says ? `: ${says}` : '.'}`;
++}
++
++/** The secret key: Stripe's account for it (GET /account), read-only. */
++async function testStripeKey(cfg, key, { fetchImpl }) {
++ const live = /^(sk|rk)_live_/.test(key);
++ const r = await stripeGet(cfg, '/account', key, fetchImpl);
++ if (!(r.status >= 200 && r.status < 300)) return { ok: false, message: refusal(r) };
++ const a = r.json || {};
++ const name = (a.settings && a.settings.dashboard && a.settings.dashboard.display_name) || (a.business_profile && a.business_profile.name) || '';
++ const charges = a.charges_enabled === false ? ' Stripe has not enabled charges on this account yet: finish its activation.' : '';
++ const head = `Stripe accepted the key: account ${a.id || '(unnamed)'}${name ? ` (${name})` : ''}, ${live ? 'live mode, real cards are charged' : 'test mode, no real card is charged'}.`;
++ if (cfg.live === true && !live) return { ok: false, message: `${head} But the live switch is on and this is a test key, so card payments stay off.` };
++ if (cfg.live === false && live) return { ok: false, message: `${head} But the live switch is off and this is a live key, so card payments stay off.` };
++ return { ok: a.charges_enabled !== false, message: `${head}${charges}` };
++}
++
++/**
++ * The webhook signing secret: Stripe never reveals a secret to compare with, so the test checks its form and that
++ * Stripe has an enabled endpoint sending to Billing (GET /webhook_endpoints with the secret key, read-only).
++ */
++async function testStripeWebhook(cfg, value, { fetchImpl }) {
++ if (!/^whsec_[A-Za-z0-9+/=_-]{8,}$/.test(value)) return { ok: false, message: 'That does not look like a Stripe signing secret: it starts with whsec_.' };
++ const key = cfg.secretKey;
++ if (!key) return { ok: false, message: 'The secret has the right form, but the Stripe secret key is not set, so Stripe cannot be asked for its webhook endpoints.' };
++ const r = await stripeGet(cfg, '/webhook_endpoints?limit=100', key, fetchImpl);
++ if (!(r.status >= 200 && r.status < 300)) return { ok: false, message: oneLine(refusal(r), [key]) };
++ const list = (r.json && Array.isArray(r.json.data)) ? r.json.data : [];
++ const ours = list.find((e) => e && e.status !== 'disabled' && /\/webhooks\/stripe\/?$/.test(String(e.url || '')));
++ if (!ours) return { ok: false, message: 'Stripe has no enabled webhook endpoint sending to Billing (/webhooks/stripe): add one, then copy its signing secret here.' };
++ return { ok: true, message: `Stripe sends payment notices to ${String(ours.url).replace(/[?#].*$/, '')}, and the secret has the right form. A test payment proves it matches.` };
++}
++
++/**
++ * The route handler (the capability guard runs before it). `resolve(namespace, key)` answers { value, source, env }
++ * or null when there is no test; `run(namespace, key, value)` answers { ok, message }.
++ */
++function testRoute({ resolve, run, log = console, now = () => Date.now() }) {
++ return async function configTest(req, res) {
++ const namespace = String(req.params.namespace || '');
++ const key = String(req.params.key || '');
++ const found = await Promise.resolve(resolve(namespace, key)).catch(() => null);
++ if (!found) return res.status(404).json({ type: 'about:blank', status: 404, code: 'config.test_not_found', detail: `${namespace} has no test for ${key}` });
++ const base = { namespace, key, source: found.source || null, tested_at: new Date(now()).toISOString() };
++ // As openvibe-shared 4.125.0's hook: nothing to test is 409 config.not_set (neither entered nor in the settings file).
++ if (!found.value) return res.status(409).type('application/problem+json').json({ type: 'about:blank', status: 409, code: 'config.not_set', detail: `${key} is not set, so there is nothing to test` });
++ let out;
++ try { out = await run(namespace, key, found.value); } catch (err) { out = { ok: false, message: `The test could not run: ${(err && err.message) || 'unknown error'}` }; }
++ const ok = Boolean(out && out.ok);
++ const from = found.source === 'env' && found.env ? ` (the value from the server's settings file, ${found.env})` : '';
++ const message = oneLine(`${(out && out.message) || (ok ? 'It works.' : 'It did not work.')}${from}`, [found.value]);
++ (log.info || log.log).call(log, `[config] test ${namespace} ${key}: ${ok ? 'connected' : 'not working'}`);
++ return res.json({ ...base, ok, state: ok ? 'connected' : 'not_working', message });
++ };
++}
++
++module.exports = { oneLine, testStripeKey, testStripeWebhook, testRoute };
ddiff --git a/server/providers/stripe.js b/server/providers/stripe.js
iindex 26ab673..d869be5 100644
---- a/server/providers/stripe.js
++++ b/server/providers/stripe.js
@@@ -1,8 +1,11 @@
'use strict';
/**
-- * Stripe adapter (DISABLED unless STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are set; card rails
-- * are off in production). Ported from Live server/monetization/payments.js, with what Live lacked:
++ * Stripe adapter (DISABLED unless a secret key and a webhook signing secret are set; card rails
++ * are off in production). The keys are read at use time from `cfg` (stripeSettings in config-store.js:
++ * the owner's sealed billing.cards value when set, else STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET), so a key
++ * entered in the admin panel takes effect without a restart. With the live switch set, a key of the other
++ * mode leaves the adapter off. Ported from Live server/monetization/payments.js, with what Live lacked:
*
* checkout.session.completed mode=payment → purchase settle (receipt stripe:<payment_intent>);
* mode=subscription → links the Stripe subscription to the intent
@@@ -18,7 +21,7 @@ const intents = require('../ops/intents');
const subscriptions = require('../ops/subscriptions');
function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
-- const enabled = !!(cfg.secretKey && cfg.webhookSecret);
++ const isEnabled = () => !!(cfg.secretKey && cfg.webhookSecret && !cfg.modeProblem);
async function api(method, path, params) {
const res = await fetchImpl(`${cfg.apiBase}${path}`, {
@@@ -155,7 +158,7 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
return await api('POST', `/subscriptions/${encodeURIComponent(stripeSubscriptionId)}`, p);
}
-- return { name: 'stripe', enabled, verify, parse, interpret, createCheckout, cancelAtPeriodEnd };
++ return { name: 'stripe', get enabled() { return isEnabled(); }, verify, parse, interpret, createCheckout, cancelAtPeriodEnd };
}
module.exports = { createStripe };
ddiff --git a/test/helpers/lint-schema.js b/test/helpers/lint-schema.js
nnew file mode 100644
iindex 0000000..d5cf829
---- /dev/null
++++ b/test/helpers/lint-schema.js
@@@ -0,0 +1,57 @@
++'use strict';
++/**
++ * A local stand-in for openvibe-shared/config's lintSchema and its annotation checks (Shared 4.125.0, Shared PR #310,
++ * not tagged when this was written): the owner-setup design's clarity rules, checked on a namespace's schema, so a
++ * schema that passes here also passes when the pin moves to 4.125.0. Returns a list of problems ([] is clean).
++ *
++ * - every field has a title and a description of at least 30 characters;
++ * - every enum option has words (x-enum-labels: { value: { label, description } });
++ * - an x-owner-input names a Setup group, says what it unlocks, links an https page and says what to pick there;
++ * - x-required-for, when present, names a feature.
++ */
++// openvibe-shared 4.125.0's OWNER_INPUT_GROUPS ids, and the members an x-owner-input may have.
++const GROUPS = new Set(['money', 'ai-providers', 'gpus', 'voices', 'chat-apps', 'sign-in-apps', 'data-sources',
++ 'design-publishing', 'domains', 'legal']);
++const MEMBERS = new Set(['group', 'unlocks', 'get_it_at', 'get_it_how', 'test']);
++
++function lintField(name, f, out) {
++ if (!f || typeof f !== 'object') return;
++ if (typeof f.title !== 'string' || !f.title.trim()) out.push(`${name}: no title`);
++ if (typeof f.description !== 'string' || f.description.trim().length < 30) out.push(`${name}: the description is missing or under 30 characters`);
++ const options = Array.isArray(f.enum) ? f.enum : (f.items && Array.isArray(f.items.enum) ? f.items.enum : null);
++ if (options) {
++ const labels = f['x-enum-labels'] || (f.items && f.items['x-enum-labels']);
++ for (const v of options) {
++ const l = labels && labels[String(v)];
++ if (!l || typeof l.label !== 'string' || !l.label.trim() || typeof l.description !== 'string' || !l.description.trim()) out.push(`${name}: the option ${JSON.stringify(v)} has no label and line`);
++ }
++ }
++ const oi = f['x-owner-input'];
++ if (oi !== undefined) {
++ if (!oi || typeof oi !== 'object') out.push(`${name}: x-owner-input must be an object`);
++ else {
++ for (const m of Object.keys(oi)) if (!MEMBERS.has(m)) out.push(`${name}: x-owner-input has an unknown member ${m}`);
++ if (!GROUPS.has(oi.group)) out.push(`${name}: x-owner-input.group ${JSON.stringify(oi.group)} is not a Setup group`);
++ if (typeof oi.unlocks !== 'string' || oi.unlocks.trim().length < 20) out.push(`${name}: x-owner-input.unlocks must say what it turns on`);
++ if (oi.get_it_at !== undefined && !/^https:\/\/[^\s]+$/.test(String(oi.get_it_at))) out.push(`${name}: x-owner-input.get_it_at must be an https URL`);
++ if (typeof oi.get_it_how !== 'string' || oi.get_it_how.trim().length < 10) out.push(`${name}: x-owner-input.get_it_how must say what to pick there`);
++ if (typeof oi.test !== 'boolean') out.push(`${name}: x-owner-input.test must be true or false`);
++ }
++ }
++ if (f['x-example'] !== undefined && !['string', 'number', 'boolean'].includes(typeof f['x-example'])) out.push(`${name}: x-example must be a string, a number or true/false`);
++ if (f['x-advanced'] !== undefined && typeof f['x-advanced'] !== 'boolean') out.push(`${name}: x-advanced must be true or false`);
++ if (f['x-enum-labels'] && options) for (const v of Object.keys(f['x-enum-labels'])) if (!options.map((o) => (typeof o === 'string' ? o : JSON.stringify(o))).includes(v)) out.push(`${name}: x-enum-labels names ${v}, which is not an option`);
++ if (f['x-required-for'] !== undefined && (typeof f['x-required-for'] !== 'string' || !f['x-required-for'].trim())) out.push(`${name}: x-required-for must name a feature`);
++ if (f.properties && typeof f.properties === 'object') for (const [k, sub] of Object.entries(f.properties)) lintField(`${name}.${k}`, sub, out);
++}
++
++function lintSchema(schema) {
++ const out = [];
++ if (!schema || typeof schema !== 'object') return ['no schema'];
++ if (typeof schema.title !== 'string' || !schema.title.trim()) out.push('(namespace): no title');
++ if (typeof schema.description !== 'string' || schema.description.trim().length < 30) out.push('(namespace): the description is missing or under 30 characters');
++ for (const [k, f] of Object.entries(schema.properties || {})) lintField(k, f, out);
++ return out;
++}
++
++module.exports = { lintSchema, GROUPS };
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex def4533..8735892 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -212,6 +212,16 @@ async function startStripe() {
calls.push({ method: req.method, url: req.url, body: Object.fromEntries(new URLSearchParams(raw)), auth: req.headers.authorization });
if (state.fail) return send(res, 500, { error: { message: 'stripe is down' } });
if (req.url === '/checkout/sessions') return send(res, 200, { id: `cs_test_${calls.length}`, url: `https://checkout.stripe.test/cs_${calls.length}` });
++ // The owner-setup test hook's read-only calls: the account for a key, and the webhook endpoints.
++ const bearer = String(req.headers.authorization || '').replace(/^Bearer /, '');
++ if (req.method === 'GET' && req.url === '/account') {
++ if (!(state.goodKeys || ['sk_test_x']).includes(bearer)) return send(res, 401, { error: { message: `Invalid API Key provided: ${bearer}` } });
++ return send(res, 200, { id: 'acct_TEST1', charges_enabled: true, settings: { dashboard: { display_name: 'OpenVibe test' } } });
++ }
++ if (req.method === 'GET' && req.url.startsWith('/webhook_endpoints')) {
++ if (!(state.goodKeys || ['sk_test_x']).includes(bearer)) return send(res, 401, { error: { message: `Invalid API Key provided: ${bearer}` } });
++ return send(res, 200, { data: state.endpoints || [{ id: 'we_1', status: 'enabled', url: 'https://billing.openvibe.network/webhooks/stripe' }] });
++ }
const m = req.url.match(/^\/subscriptions\/([^/]+)$/);
if (m) return send(res, 200, { id: decodeURIComponent(m[1]), cancel_at_period_end: true });
send(res, 404, { error: { message: 'no route' } });
ddiff --git a/test/owner-inputs.test.js b/test/owner-inputs.test.js
nnew file mode 100644
iindex 0000000..57d9a8f
---- /dev/null
++++ b/test/owner-inputs.test.js
@@@ -0,0 +1,168 @@
++'use strict';
++/**
++ * Owner inputs (the owner-setup design of 2026-11-07): the card processor's keys and live switch in `billing.cards`,
++ * listed by the admin panel's Setup tab under Money.
++ *
++ * Proves: the new fields carry x-owner-input, titles and 30+ character descriptions, and every field of every Billing
++ * namespace does; the Stripe adapter uses the panel's sealed value when it is set and STRIPE_* from the environment
++ * otherwise, at use time; the live switch leaves a key of the other mode unused; the test hook answers in one line
++ * and never carries the key, not even when Stripe echoes it; no log line or console line carries a key.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, check, done } = require('./helpers/app');
++const { lintSchema } = require('./helpers/lint-schema');
++
++const CAP = ['billing.config.manage'];
++const KEK = 'k1:' + crypto.randomBytes(32).toString('base64');
++// Made-up keys, built at run time so a secret scanner never reads a Stripe-shaped string in the source.
++const stripeKey = (mode, rest) => ['sk', mode, rest].join('_');
++const PANEL_KEY = stripeKey('test', 'madeUpPanelKeyNEVERSHOWN93ab');
++const LIVE_KEY = stripeKey('live', 'madeUpLiveKeyNEVERSHOWN11d0');
++const BAD_KEY = stripeKey('test', 'madeUpBadKeySTRIPEECHOESIT5e2f');
++const WEBHOOK = 'whsec_madeUpSigningSecretNEVERSHOWN77';
++const SECRETS = [PANEL_KEY, LIVE_KEY, BAD_KEY, WEBHOOK];
++
++(async () => {
++ console.log('owner inputs (billing.cards)');
++ const consoleLines = [];
++ const original = { log: console.log, warn: console.warn, error: console.error, info: console.info };
++ for (const k of Object.keys(original)) console[k] = (...a) => { consoleLines.push(a.join(' ')); original[k](...a); };
++ const t = await boot({ stripe: true, env: { OV_CONFIG_KEKS: KEK, OV_CONFIG_KEK_CURRENT: 'k1' } });
++ t.stripe.state.goodKeys = ['sk_test_x', PANEL_KEY, LIVE_KEY];
++ const owner = t.user(501);
++ const headers = { 'x-ov-subject': owner.id, 'x-ov-fresh': '1' };
++ const cfg = (method, path, body) => t.call(method, path, { cap: CAP, headers, body, key: null });
++ const put = (key, value) => cfg('PUT', `/internal/config/billing.cards/secrets/${key}`, { value, reason: 'setup' });
++ const test = (key) => cfg('POST', `/internal/config/billing.cards/test/${key}`);
++ const stripe = () => t.ctx.config.providers.stripe;
++
++ await check('every field of every Billing namespace has a title, a 30+ character description and labelled options', async () => {
++ const r = await cfg('GET', '/internal/config');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.json.namespaces.map((n) => n.namespace), ['billing.credits', 'billing.vibes', 'billing.cards']);
++ for (const ns of r.json.namespaces) assert.deepStrictEqual(lintSchema(ns.schema), [], `${ns.namespace}: ${lintSchema(ns.schema).join('; ')}`);
++ });
++
++ await check('the card processor is four owner inputs in the Money group; the keys are required for card payments', async () => {
++ const r = await cfg('GET', '/internal/config/billing.cards');
++ const p = r.json.schema.properties;
++ for (const k of ['stripe.secret_key', 'stripe.publishable_key', 'stripe.webhook_secret', 'stripe.live']) {
++ assert.strictEqual(p[k]['x-owner-input'].group, 'money', k);
++ assert.match(p[k]['x-owner-input'].get_it_at, /^https:\/\/dashboard\.stripe\.com\//, k);
++ }
++ assert.deepStrictEqual([p['stripe.secret_key']['x-required-for'], p['stripe.secret_key']['x-owner-input'].test], ['Card payments', true]);
++ assert.deepStrictEqual([p['stripe.webhook_secret']['x-required-for'], p['stripe.webhook_secret']['x-owner-input'].test], ['Card payments', true]);
++ assert.strictEqual(p['stripe.publishable_key']['x-required-for'], undefined, 'optional: today\'s checkout does not need it');
++ assert.strictEqual(p['stripe.live']['x-danger'], true);
++ assert.deepStrictEqual(r.json.classification, {}, 'nothing stored yet');
++ assert.strictEqual(r.json.sealed, true);
++ });
++
++ await check('without a panel value the environment\'s key is used, and the test says so', async () => {
++ assert.strictEqual(stripe().secretKey, 'sk_test_x');
++ const h = await t.call('GET', '/api/health', { token: null });
++ assert.strictEqual(h.json.providers.stripe, true);
++ const r = await test('stripe.secret_key');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.json.ok, r.json.state, r.json.source], [true, 'connected', 'env']);
++ assert.strictEqual(r.json.message, "Stripe accepted the key: account acct_TEST1 (OpenVibe test), test mode, no real card is charged. (the value from the server's settings file, STRIPE_SECRET_KEY)");
++ });
++
++ await check('a key entered in the panel is sealed, write-only and wins at use time', async () => {
++ const r = await put('stripe.secret_key', PANEL_KEY);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(!r.text.includes(PANEL_KEY));
++ assert.strictEqual(stripe().secretKey, PANEL_KEY);
++ const row = await t.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'billing.cards' AND state = 'active'").get();
++ assert.ok(!row.values_json.includes(PANEL_KEY), 'sealed at rest');
++ const carried = await cfg('POST', '/internal/config/billing.cards', { values: { 'stripe.secret_key': PANEL_KEY }, reason: 'not here' });
++ assert.deepStrictEqual([carried.status, carried.json.code], [422, 'config.secret_write_only']);
++ const before = t.stripe.calls.length;
++ const intent = await t.call('POST', '/api/v1/intents', { body: { provider: 'stripe', kind: 'purchase', subject: t.user(502), bits: 1000, amount_cents: 1 } });
++ assert.strictEqual(intent.status, 201, intent.text);
++ assert.strictEqual(t.stripe.calls.slice(before).find((c) => c.url === '/checkout/sessions').auth, `Bearer ${PANEL_KEY}`, 'the checkout used the panel key');
++ const tested = await test('stripe.secret_key');
++ assert.deepStrictEqual([tested.json.ok, tested.json.source], [true, 'vault']);
++ const summary = await cfg('GET', '/internal/config/billing.cards');
++ assert.ok(!summary.text.includes(PANEL_KEY));
++ assert.strictEqual(summary.json.secrets['stripe.secret_key'].hint, PANEL_KEY.slice(-4));
++ });
++
++ await check('a refused key is Not working in one line, and the echoed key never comes back', async () => {
++ await put('stripe.secret_key', BAD_KEY);
++ const r = await test('stripe.secret_key');
++ assert.deepStrictEqual([r.json.ok, r.json.state], [false, 'not_working']);
++ assert.match(r.json.message, /^Stripe refused the key \(401\): Invalid API Key provided: \[hidden\]/);
++ assert.ok(!r.text.includes(BAD_KEY) && !r.text.includes(BAD_KEY.slice(0, 12)));
++ assert.ok(r.json.message.length <= 200 && !r.json.message.includes('\n'));
++ });
++
++ await check('the live switch leaves a key of the other mode unused, and says why', async () => {
++ await put('stripe.secret_key', LIVE_KEY);
++ const off = await cfg('POST', '/internal/config/billing.cards', { values: { 'stripe.live': false }, merge: true, reason: 'test mode only' });
++ assert.strictEqual(off.status, 200, off.text);
++ assert.strictEqual(t.ctx.config.providers.stripe.modeProblem, 'the live switch is off but the secret key is a live key');
++ assert.strictEqual((await t.call('GET', '/api/health', { token: null })).json.providers.stripe, false, 'no real card is charged while the switch is off');
++ const r = await test('stripe.secret_key');
++ assert.deepStrictEqual([r.json.ok, r.json.state], [false, 'not_working']);
++ assert.match(r.json.message, /live switch is off and this is a live key, so card payments stay off/);
++ const stale = await t.call('POST', '/internal/config/billing.cards', { cap: CAP, headers: { 'x-ov-subject': owner.id }, body: { values: { 'stripe.live': true }, merge: true, reason: 'stale' }, key: null });
++ assert.deepStrictEqual([stale.status, stale.json.code], [403, 'config.fresh_required'], 'the live switch needs a fresh sign-in');
++ const on = await cfg('POST', '/internal/config/billing.cards', { values: { 'stripe.live': true }, merge: true, reason: 'go live' });
++ assert.strictEqual(on.status, 200, on.text);
++ assert.strictEqual((await t.call('GET', '/api/health', { token: null })).json.providers.stripe, true);
++ });
++
++ await check('the webhook signing secret: the panel value verifies deliveries; the test checks Stripe sends to Billing', async () => {
++ await put('stripe.secret_key', 'sk_test_x');
++ await cfg('POST', '/internal/config/billing.cards', { values: {}, unset: ['stripe.live'], merge: true, reason: 'back to unset' });
++ await put('stripe.webhook_secret', WEBHOOK);
++ assert.strictEqual(stripe().webhookSecret, WEBHOOK);
++ const oldSecret = await t.stripeEvent({ id: 'evt_old', type: 'ping', data: { object: {} } });
++ assert.strictEqual(oldSecret.status, 401, 'the environment\'s secret no longer verifies once the panel has one');
++ const newSecret = await t.stripeEvent({ id: 'evt_new', type: 'ping', data: { object: {} } }, { secret: WEBHOOK });
++ assert.notStrictEqual(newSecret.status, 401);
++ const r = await test('stripe.webhook_secret');
++ assert.deepStrictEqual([r.json.ok, r.json.state, r.json.source], [true, 'connected', 'vault']);
++ assert.match(r.json.message, /^Stripe sends payment notices to https:\/\/billing\.openvibe\.network\/webhooks\/stripe/);
++ t.stripe.state.endpoints = [];
++ const none = await test('stripe.webhook_secret');
++ assert.deepStrictEqual([none.json.ok, none.json.state], [false, 'not_working']);
++ assert.ok(!none.text.includes(WEBHOOK));
++ });
++
++ await check('removing the panel values falls back to the environment', async () => {
++ const del = await cfg('DELETE', '/internal/config/billing.cards/secrets/stripe.webhook_secret', { reason: 'back to env' });
++ assert.strictEqual(del.status, 200, del.text);
++ assert.strictEqual(stripe().webhookSecret, 'whsec_x');
++ const del2 = await cfg('DELETE', '/internal/config/billing.cards/secrets/stripe.secret_key', { reason: 'back to env' });
++ assert.strictEqual(del2.status, 200, del2.text);
++ assert.strictEqual(stripe().secretKey, 'sk_test_x');
++ });
++
++ await check('the test hook is guarded and answers only for keys with a test', async () => {
++ const denied = await t.call('POST', '/internal/config/billing.cards/test/stripe.secret_key', { cap: ['billing.balance.read'], headers, key: null });
++ assert.strictEqual(denied.status, 403, denied.text);
++ const none = await test('stripe.live');
++ assert.deepStrictEqual([none.status, none.json.code], [404, 'config.test_not_found']);
++ const pub = await test('stripe.publishable_key');
++ assert.strictEqual(pub.status, 404);
++ const notSet = await cfg('POST', '/internal/config/billing.vibes/test/vibes_pay');
++ assert.strictEqual(notSet.status, 404);
++ });
++
++ await check('no log line, console line or audit row carries a key', async () => {
++ for (const k of Object.keys(original)) console[k] = original[k];
++ const audit = JSON.stringify(await t.db.prepare('SELECT * FROM staff_audit').all());
++ for (const s of SECRETS) {
++ assert.ok(t.logs.every((line) => !line.includes(s)), `a log line carries ${s.slice(0, 14)}…`);
++ assert.ok(consoleLines.every((line) => !line.includes(s)), `a console line carries ${s.slice(0, 14)}…`);
++ assert.ok(!audit.includes(s), 'the audit carries a key');
++ }
++ assert.ok(t.logs.some((line) => /\[config\] test billing\.cards stripe\.secret_key: not working/.test(line)), 'the outcome is logged without the key');
++ });
++
++ await t.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/owner-settings.test.js b/test/owner-settings.test.js
iindex 8dd3fd8..a3dfa88 100644
---- a/test/owner-settings.test.js
++++ b/test/owner-settings.test.js
@@@ -50,8 +50,9 @@ const KEK = 'k1:' + crypto.randomBytes(32).toString('base64');
assert.deepStrictEqual([p.topup_amounts_cents.type, p.topup_amounts_cents.items.type, p.topup_amounts_cents['x-env'], p.topup_amounts_cents['x-danger']], ['array', 'integer', 'BILLING_CREDITS_TOPUP_AMOUNTS_CENTS', true]);
assert.deepStrictEqual([p.topup_fee_bps.minimum, p.topup_fee_bps.maximum, p.topup_fee_bps['x-danger']], [0, 10_000, true]);
assert.strictEqual(p.hold_lease_seconds['x-env'], 'BILLING_INFERENCE_LEASE_SECONDS');
-- // The brief's x-unit is "seconds"; Shared 4.110.0 only accepts nano_usd, ms and bps, so the unit is in the title.
-- assert.strictEqual(p.hold_lease_seconds['x-unit'], undefined);
++ // Shared 4.122.0 accepts the seconds unit; the fee is in basis points.
++ assert.strictEqual(p.hold_lease_seconds['x-unit'], 'seconds');
++ assert.strictEqual(p.topup_fee_bps['x-unit'], 'bps');
assert.strictEqual(t.ctx.config.credits.enabled, false);
const { topupTerms } = require('../server/ops/credits');
assert.strictEqual(topupTerms(t.ctx).configured, false);