factory/providers (0.2.3)
Installation
{
"repositories": [{
"type": "composer",
"url": " "
}
]
}composer require factory/providers:0.2.3About this package
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/.deletedthrough the kernel chokepoint, inside the transaction that makes the change, each naming the person who made it. The body carrieskeys_provided(names) andfingerprint(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:
- No row →
null. The app degrades honestly ("not available here yet") rather than offering a method that cannot complete. - Row disabled →
null. Off means off, immediately, fleet-wide. - Driver retired in a release →
null. A row pointing at a dropped adapter is ignored, not half-trusted. - 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.enableddefaults to false — a new row is off.credentialsisencrypted:array(ciphertext in the column, and$hiddenon the model as a backstop, not as the defence).credential_fingerprintis 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 |