factory/providers (0.2.3)

Published 2026-08-06 01:35:46 +00:00 by bmiadmin

Installation

{
	"repositories": [{
			"type": "composer",
			"url": ""
		}
	]
}
composer require factory/providers:0.2.3

About this package

SaaS Factory shared provider registry: payment, KYC, SMS, maps and storage providers configured ONCE at the platform centre and inherited by every project as a capability — never as a credential. Fail-closed resolution; secrets are server-side only and cannot be serialised.

factory/providers — v0.1.0

The shared-provider registry. One place where the platform holds the keys to the outside world — payment rails, KYC vendors, the SMS aggregator, maps, object storage — and one contract by which twelve products consume them.

The invariant the whole package exists to hold (DASHBOARD_DOCTRINE §4):

A project receives a capability, never a credential.

An app is told "mobile money works here, through Campay, at 175 bps". It is never told the Campay password, because it never needs it: the app asks the platform to move the money and the platform holds the key. That is enforced by type rather than by discipline — {@see ProviderHandle} has no field a secret could occupy, and {@see Credentials} refuses to become JSON, a queue payload or a session value at all.

Provides

  • Driver catalogue — config('providers.catalogue'), five classes (payment, kyc, sms, maps, storage) each with a closed driver list. Every driver declares the credential keys it requires and the settings it accepts; payment drivers also declare the currencies they can settle. Code, not data: adding a driver is a release, because a row naming a driver with no adapter resolves to nothing at charge time.
  • Registry — ProviderRegistry:
    • resolve(ProviderClass) → ?ProviderHandle, highest priority first.
    • credentials(ProviderClass, ?driver) → Credentials, server-side only.
    • capabilities() → the project-edge map, one usable handle per class.
    • paymentForCurrency('XAF') → the handle only if routing and configuration and the rail's settlement list all agree.
    • health() → per class: configured / usable / critical, for the cockpit.
  • Cockpit API — Http\Controllers\ProviderController, all platform_admin:
    • GET /api/providers — registry rows (key names + a 12-hex fingerprint, never a value), the shipped catalogue, and health.
    • POST /api/providers — configure a driver. 409 if that driver already has a row; 422 for an uncatalogued driver, an undeclared credential/setting key, or an enable without every credential the driver needs.
    • PUT /api/providers/{uid} — rotate keys, retune fees, enable/disable. Credentials replace rather than merge; omit the field to leave them untouched.
    • DELETE /api/providers/{uid} — forget the driver, and report the new health.
  • Project edge — GET /api/capabilities (authenticated, scoped, not role gated): {capabilities: {class: handle}, payment: handle|null, currency}. Nothing in that payload is a secret, and nothing in it is a fingerprint.
  • Audited writes — provider.configured / .enabled / .disabled / .updated / .deleted through the kernel chokepoint, inside the transaction that makes the change, each naming the person who made it. The body carries keys_provided (names) and fingerprint (digest) — enough to reconstruct a rotation, never enough to repeat one.

The two halves of "can I charge this?"

factory/jurisdiction owns routing: which processor takes XAF? This package owns configuration: do we hold working keys for that processor? Neither can answer alone, and paymentForCurrency() refuses unless both do:

routing table provider row catalogue settles it result
XAF → flutterwave flutterwave live XAF ✓ charge
XAF → flutterwave campay live — refuse (rail not routed)
XAF → campay campay live XAF ✓ charge
XAF → mpesa_daraja mpesa_daraja live KES only refuse (cannot clear)
JPY — unrouted anything — refuse

Composition, not replacement: jurisdiction keeps its Dart-mirrored wire shape and knows nothing about this package. The dependency runs one way.

The four refusals

Every one of them has cost somebody, somewhere, real money:

  1. No row → null. The app degrades honestly ("not available here yet") rather than offering a method that cannot complete.
  2. Row disabled → null. Off means off, immediately, fleet-wide.
  3. Driver retired in a release → null. A row pointing at a dropped adapter is ignored, not half-trusted.
  4. Credentials incomplete → null, and the row cannot be enabled at all. An enabled-but-unauthenticatable provider is a button customers press that always fails.

resolve() returns null and credentials() throws, deliberately: a caller asking whether a capability exists can degrade; a caller asking for the key is already committed to reaching a wire, and a null there becomes a request that succeeds while authenticating as nobody.

Data

  • provider_configs — global, not product-scoped: configure once at the centre, inherit everywhere. UNIQUE(class, driver), because two rows for one driver make "which credential is live" unanswerable at exactly the wrong moment. enabled defaults to false — a new row is off. credentials is encrypted:array (ciphertext in the column, and $hidden on the model as a backstop, not as the defence). credential_fingerprint is the 12-hex digest.

Config expected from the host app

None. config/providers.php is merged by the provider and reads no env keys — deliberately, and asserted in DoctrineTest. env('CAMPAY_PASSWORD') would look harmless and would put a live key into config:cache output and into every stack trace that dumps config. Secrets enter through one door: POST /api/providers, encrypted at rest with the application key.

Publish the catalogue only if you need to edit it in an app: php artisan vendor:publish --tag=factory-providers-config.

Using it from a driver

$handle = $registry->paymentForCurrency($order->currency);

if ($handle === null) {
    // Nothing guesses. The order does not become a charge.
    return $this->refuse('No payment rail is configured for '.$order->currency);
}

$credentials = $registry->credentials(ProviderClass::Payment, $handle->driver);

$response = Http::withBasicAuth(
    $credentials->get('username'),
    $credentials->get('password'),
)->post($endpoint, [...]);

$credentials cannot be logged, dumped into an exception context, queued, or JSON-encoded. $handle can be — it is what the app already has.

Not in this package

The vendor adapters themselves. This registry answers who, whether, and with what fee; the code that speaks Campay's or Stripe's HTTP protocol lives with the shape that uses it. Adding an adapter means adding its driver here in the same release, so the two can never disagree about what a key is called.

Dependencies

Dependencies

ID Version
factory/auth ^0.1 || ^0.2 || ^0.3
factory/infra ^0.1 || ^0.2
factory/jurisdiction ^0.2 || ^0.3 || ^0.4
factory/kernel ^0.3 || ^0.4 || ^0.5 || ^0.6 || ^0.7 || ^0.8 || ^0.9
illuminate/database ^10.0 || ^11.0 || ^12.0 || ^13.0
illuminate/http ^10.0 || ^11.0 || ^12.0 || ^13.0
illuminate/routing ^10.0 || ^11.0 || ^12.0 || ^13.0
illuminate/support ^10.0 || ^11.0 || ^12.0 || ^13.0
php ^8.2

Development dependencies

ID Version
orchestra/testbench ^8.0 || ^9.0 || ^10.0 || ^11.0
phpunit/phpunit ^10.1 || ^11.0 || ^12.0
Details
Composer
2026-08-06 01:35:46 +00:00
7
proprietary
88 KiB
Assets (1)
Versions (4) View all
0.2.6 2026-08-16
0.2.5 2026-08-14
0.2.4 2026-08-07
0.2.3 2026-08-06