openvibers/openvibe.billing Commits
Card payments: the Stripe keys and the live switch are owner settings (billing.cards) (#205)
Changes
@@@ -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=@@@ -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 -->@@@ -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.",@@@ -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"@@@ -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",@@@ -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"@@@ -26,7 +26,7 @@ const { createKeyProvider, createIdentity, createProjects, createApps } = requirconst { 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@@@ -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,};@@@ -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),@@@ -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;}nnew file mode 100644@@@ -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 };@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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' } });nnew file mode 100644@@@ -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); });@@@ -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);
@@ -96,8 +96,10 @@ POWERCHAT_SITE_USERNAME=
@@ -96,8 +96,10 @@ POWERCHAT_SITE_USERNAME=
# Credit isTest deliveries (flagged test). Development/sandbox only.
# Credit isTest deliveries (flagged test). Development/sandbox only.
#POWERCHAT_ALLOW_TEST_FULFILLMENT=false
#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_SECRET_KEY=
+#STRIPE_PUBLISHABLE_KEY=
#STRIPE_WEBHOOK_SECRET=
#STRIPE_WEBHOOK_SECRET=
# PayPal (off in production)
# PayPal (off in production)
#PAYPAL_CLIENT_ID=
#PAYPAL_CLIENT_ID=
@@ -66,7 +66,7 @@ promo write changes both in one transaction.
@@ -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.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)
- 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)
- 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
- **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)
`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.
@@ -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).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.297.0
+- openvibe-contracts: v0.311.0
- openvibe-sdk: v0.93.0
- openvibe-sdk: v0.93.0
- openvibe-shared: v4.124.0
- openvibe-shared: v4.124.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"authoritative": true,
"authoritative": true,
"deployed": 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).",
"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",
"sdk": "openvibe-sdk v0.93.0",
"shared": "openvibe-shared v4.124.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.",
"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.",
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"owner": "billing",
"owner": "billing",
"status": "active",
"status": "active",
"visibility": "internal",
"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": [
"permissions": [
"config:manage"
"config:manage"
],
],
@@ -16,7 +16,8 @@
@@ -16,7 +16,8 @@
"implementedBy": [
"implementedBy": [
"GET /internal/config",
"GET /internal/config",
"GET|POST /internal/config/:namespace",
"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",
"inputSchema": "common.config-change@1",
"outputSchema": "common.config-namespace@1"
"outputSchema": "common.config-namespace@1"
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1103,9 +1103,9 @@
@@ -1103,9 +1103,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -26,7 +26,7 @@ const { createKeyProvider, createIdentity, createProjects, createApps } = requir
@@ -26,7 +26,7 @@ const { createKeyProvider, createIdentity, createProjects, createApps } = requir
const { createAuth } = require('./api/auth');
const { createAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { v1Router } = require('./api/v1');
const { internalRouter } = require('./api/internal');
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 { internalConfigRouter } = require('./internal-config');
const { createVibesWebhooks } = require('./ops/vibes-webhooks');
const { createVibesWebhooks } = require('./ops/vibes-webhooks');
const { createActorLimits } = require('./api/actor-limits');
const { createActorLimits } = require('./api/actor-limits');
@@ -57,7 +57,7 @@ async function createApp(opts = {}) {
@@ -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
// 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.
// 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
// 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
// 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.
// keep today's variables winning while they are set; a change applies live.
@@ -65,6 +65,10 @@ async function createApp(opts = {}) {
@@ -65,6 +65,10 @@ async function createApp(opts = {}) {
applyValues(config, ctx.configStore.get());
applyValues(config, ctx.configStore.get());
ctx.vibesConfigStore = await createVibesConfig({ db, env: ctx.env, log, onActivate: (values) => applyVibesValues(config, values) });
ctx.vibesConfigStore = await createVibesConfig({ db, env: ctx.env, log, onActivate: (values) => applyVibesValues(config, values) });
applyVibesValues(config, ctx.vibesConfigStore.get());
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.
// Network's project-membership reads (D12): the projects a person administers and one role check.
ctx.projects = opts.projects || createProjects(config, { fetchImpl });
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
// Network's app registry (D12 Vibes Pay): the app name/icon on the pay sheet. Best effort; Network's own
@@ -12,8 +12,14 @@
@@ -12,8 +12,14 @@
* BILLING_INFERENCE_LEASE_SECONDS as its pin; when neither is set the loader's 300 s default applies.
* 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
* `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 config = require('openvibe-shared/config');
const { MAX_RECEIPT_CENTS } = require('./ops/common');
const { MAX_RECEIPT_CENTS } = require('./ops/common');
@@ -24,44 +30,47 @@ const NAMESPACE = 'billing.credits';
@@ -24,44 +30,47 @@ const NAMESPACE = 'billing.credits';
const GROUP = 'Balance';
const GROUP = 'Balance';
const VIBES_NAMESPACE = 'billing.vibes';
const VIBES_NAMESPACE = 'billing.vibes';
const VIBES_GROUP = 'Vibes';
const VIBES_GROUP = 'Vibes';
+const CARDS_NAMESPACE = 'billing.cards';
+const CARDS_GROUP = 'Card payments';
const SCHEMA = {
const SCHEMA = {
$schema: 'https://json-schema.org/draft/2020-12/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',
type: 'object',
additionalProperties: false,
additionalProperties: false,
properties: {
properties: {
credits: {
credits: {
type: 'boolean', default: false,
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,
'x-group': GROUP, 'x-env': 'BILLING_CREDITS', 'x-danger': true,
},
},
sandbox: {
sandbox: {
type: 'boolean', default: false,
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',
'x-group': GROUP, 'x-env': 'BILLING_CREDITS_SANDBOX',
},
},
topup: {
topup: {
type: 'boolean', default: false,
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,
'x-group': GROUP, 'x-env': 'BILLING_CREDITS_TOPUP', 'x-danger': true,
},
},
topup_amounts_cents: {
topup_amounts_cents: {
type: 'array', items: { type: 'integer', minimum: 1, maximum: MAX_RECEIPT_CENTS }, minItems: 1, uniqueItems: true,
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,
'x-group': GROUP, 'x-env': 'BILLING_CREDITS_TOPUP_AMOUNTS_CENTS', 'x-danger': true,
},
},
topup_fee_bps: {
topup_fee_bps: {
type: 'integer', minimum: 0, maximum: 10_000,
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,
'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: {
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',
'x-group': GROUP, 'x-env': 'BILLING_INFERENCE_LEASE_SECONDS',
},
},
},
},
@@ -69,34 +78,139 @@ const SCHEMA = {
@@ -69,34 +78,139 @@ const SCHEMA = {
const VIBES_SCHEMA = {
const VIBES_SCHEMA = {
$schema: 'https://json-schema.org/draft/2020-12/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',
type: 'object',
additionalProperties: false,
additionalProperties: false,
properties: {
properties: {
vibes_pay: {
vibes_pay: {
type: 'boolean', default: false,
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,
'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_PAY', 'x-danger': true,
},
},
max_charge_vibes: {
max_charge_vibes: {
type: 'integer', minimum: 0, maximum: 10_000_000, default: 0,
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',
'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_MAX_CHARGE',
},
},
daily_app_spend_vibes_per_person: {
daily_app_spend_vibes_per_person: {
type: 'integer', minimum: 0, maximum: 10_000_000, default: 0,
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',
'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_DAILY_APP_SPEND',
},
},
from_balance: {
from_balance: {
type: 'boolean', default: false,
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,
'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]+$/;
const AMOUNT_RE = /^[0-9]+$/;
/** A top-up amount list from a JSON array, a JSON string, or today's comma-separated env value. */
/** 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
@@ -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 = {
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,
};
};
@@ -137,7 +137,9 @@ function loadConfig(env = process.env) {
@@ -137,7 +137,9 @@ function loadConfig(env = process.env) {
maxSkewMs: int(env.POWERCHAT_MAX_SKEW_MS, 15 * 60 * 1000),
maxSkewMs: int(env.POWERCHAT_MAX_SKEW_MS, 15 * 60 * 1000),
},
},
stripe: {
stripe: {
+ // The environment's values; the owner's billing.cards namespace wins when set (config-store.js stripeSettings).
secretKey: env.STRIPE_SECRET_KEY || '',
secretKey: env.STRIPE_SECRET_KEY || '',
+ publishableKey: env.STRIPE_PUBLISHABLE_KEY || '',
webhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
webhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
apiBase: trim(env.STRIPE_API_BASE || 'https://api.stripe.com/v1'),
apiBase: trim(env.STRIPE_API_BASE || 'https://api.stripe.com/v1'),
toleranceSec: int(env.STRIPE_TOLERANCE_SEC, 300),
toleranceSec: int(env.STRIPE_TOLERANCE_SEC, 300),
@@ -6,8 +6,9 @@
@@ -6,8 +6,9 @@
* Only Network's service token holds `billing.config.manage`; every route is guarded by it (server/api/auth.js).
* 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;
* 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
* 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
* The paths are full paths (the router is mounted at the app root) so the capability declaration's
* implementedBy list points at real routes.
* implementedBy list points at real routes.
@@ -15,6 +16,7 @@
@@ -15,6 +16,7 @@
const express = require('express');
const express = require('express');
const { capabilities } = require('openvibe-contracts');
const { capabilities } = require('openvibe-contracts');
const { adminRoutes } = require('openvibe-shared/config');
const { adminRoutes } = require('openvibe-shared/config');
+const { testRoute, testStripeKey, testStripeWebhook } = require('./owner-inputs');
const CAP = 'billing.config.manage';
const CAP = 'billing.config.manage';
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -23,7 +25,7 @@ function internalConfigRouter({ ctx, auth }) {
@@ -23,7 +25,7 @@ function internalConfigRouter({ ctx, auth }) {
const r = express.Router();
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.
// 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' }));
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),
requireAdmin: auth.needs(CAP),
basePath: '/internal/config',
basePath: '/internal/config',
actor: (req) => {
actor: (req) => {
@@ -42,6 +44,20 @@ function internalConfigRouter({ ctx, auth }) {
@@ -42,6 +44,20 @@ function internalConfigRouter({ ctx, auth }) {
r.post('/internal/config/:namespace/rollback', routes.rollback);
r.post('/internal/config/:namespace/rollback', routes.rollback);
r.put('/internal/config/:namespace/secrets/:key', routes.secretPut);
r.put('/internal/config/:namespace/secrets/:key', routes.secretPut);
r.delete('/internal/config/:namespace/secrets/:key', routes.secretDelete);
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;
return r;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -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 };
@@ -1,8 +1,11 @@
@@ -1,8 +1,11 @@
'use strict';
'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>);
* checkout.session.completed mode=payment → purchase settle (receipt stripe:<payment_intent>);
* mode=subscription → links the Stripe subscription to the intent
* mode=subscription → links the Stripe subscription to the intent
@@ -18,7 +21,7 @@ const intents = require('../ops/intents');
@@ -18,7 +21,7 @@ const intents = require('../ops/intents');
const subscriptions = require('../ops/subscriptions');
const subscriptions = require('../ops/subscriptions');
function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
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) {
async function api(method, path, params) {
const res = await fetchImpl(`${cfg.apiBase}${path}`, {
const res = await fetchImpl(`${cfg.apiBase}${path}`, {
@@ -155,7 +158,7 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
@@ -155,7 +158,7 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
return await api('POST', `/subscriptions/${encodeURIComponent(stripeSubscriptionId)}`, p);
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 };
module.exports = { createStripe };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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 };
@@ -212,6 +212,16 @@ async function startStripe() {
@@ -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 });
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 (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}` });
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\/([^/]+)$/);
const m = req.url.match(/^\/subscriptions\/([^/]+)$/);
if (m) return send(res, 200, { id: decodeURIComponent(m[1]), cancel_at_period_end: true });
if (m) return send(res, 200, { id: decodeURIComponent(m[1]), cancel_at_period_end: true });
send(res, 404, { error: { message: 'no route' } });
send(res, 404, { error: { message: 'no route' } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -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); });
@@ -50,8 +50,9 @@ const KEK = 'k1:' + crypto.randomBytes(32).toString('base64');
@@ -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_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.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');
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);
assert.strictEqual(t.ctx.config.credits.enabled, false);
const { topupTerms } = require('../server/ops/credits');
const { topupTerms } = require('../server/ops/credits');
assert.strictEqual(topupTerms(t.ctx).configured, false);
assert.strictEqual(topupTerms(t.ctx).configured, false);