Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
> before assuming something isn't built. `@api` = stable to build on; `@internal` = may change.
> Grouped by **capability** (across layers), not by directory.

**222 classes** across **33 capabilities** · **24 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md).
**226 classes** across **33 capabilities** · **25 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md).

## Capabilities (`library/Tiger`)

Expand Down Expand Up @@ -329,6 +329,10 @@
- **Tiger_Secret_Adapter_SecretsManager** `@api` — AWS Secrets Manager backend (the rotation-managed sibling of SSM). · `library/Tiger/Secret/Adapter/SecretsManager.php`
- **Tiger_Secret_Adapter_Ssm** `@api` — AWS Systems Manager Parameter Store (SecureString) backend. · `library/Tiger/Secret/Adapter/Ssm.php`
- **Tiger_Secret_Writer_Interface** `@api` — the OPTIONAL write side of a secret backend. · `library/Tiger/Secret/Writer/Interface.php`
- **Tiger_Sms** `@api` — provider-agnostic SMS send, the SMS sibling of Tiger_Mail. · `library/Tiger/Sms.php`
- **Tiger_Sms_Adapter_Abstract** `@api` — the base every SMS provider adapter extends. · `library/Tiger/Sms/Adapter/Abstract.php`
- **Tiger_Sms_Adapter_Sns** `@api` — the Amazon SNS SMS adapter (native to an AWS-hosted Tiger). · `library/Tiger/Sms/Adapter/Sns.php`
- **Tiger_Sms_Adapter_Twilio** `@api` — the Twilio SMS adapter (the industry-standard API). · `library/Tiger/Sms/Adapter/Twilio.php`

## Modules (`modules/*` — activatable features)

Expand All @@ -354,6 +358,7 @@
- **Secrets** (`secret`, app) — Provider-neutral secrets / key-store resolution (Tiger_Secret) — local-encrypted by default, with SSM / Secrets Manager / env / file / Vault adapters selectable by config. · `modules/secret`
- **SEO** (`seo`, app) · services: Head, Pages, Schema, Social · `modules/seo`
- **Signup** (`signup`, plugin) · services: Signup · `modules/signup`
- **SMS** (`sms`, app) · services: Settings · `modules/sms`
- **System** (`system`, plugin) · services: Acl, Dashboard, Logs, Modules, Nav, Settings, Sites, Updates · `modules/system`
- **Translations** (`translations`, app) · services: Translation · `modules/translations`

163 changes: 163 additions & 0 deletions library/Tiger/Sms.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Sms — provider-agnostic SMS send, the SMS sibling of Tiger_Mail.
*
* One facade, pluggable adapters. The configured provider is chosen by config and every adapter
* returns the same shape, so callers never care who delivered it:
*
* tiger.sms.provider = "twilio" | "sns" ; which adapter sends
* tiger.sms.from = "+15551234567" ; default sender (an origination number)
* tiger.sms.adapters.twilio.* = account_sid / auth_token_enc / from
* tiger.sms.adapters.sns.* = key / secret_key_enc / region / from (or the instance role on AWS)
*
* Graceful by design (the Tiger_Location contract): an unset provider, a missing/incapable adapter, or a
* provider error yields a clean `['ok'=>false,'error'=>...]` — never a fatal and never an exception into
* the caller. Numbers are normalized to E.164 before sending. Register more adapters with
* Tiger_Sms::register(). The built-in Twilio + SNS adapters self-register on first use.
*
* It powers the platform's SMS needs the same way Tiger_Mail powers email: auth one-time codes / 2FA,
* operational alerts (e.g. the Fleet Observer's sev-1 pages), and consent-gated campaigns.
*
* @api
*/
class Tiger_Sms
{
/** @var array<string,string> provider name => adapter class (lowercased keys) */
protected static $_registry = [];

/** @var bool whether the built-in adapters have been registered this request */
protected static $_seeded = false;

/**
* Register (or replace) a provider name → adapter class. Case-insensitive.
*
* @param string $name the provider name (e.g. "twilio")
* @param string $class the adapter class (extends Tiger_Sms_Adapter_Abstract)
* @return void
*/
public static function register(string $name, string $class): void
{
self::$_registry[strtolower(trim($name))] = $class;
}

/** Register the first-party adapters once (so the facade works with no module activation). */
protected static function _seed(): void
{
if (self::$_seeded) { return; }
self::$_seeded = true;
if (!isset(self::$_registry['twilio'])) { self::$_registry['twilio'] = 'Tiger_Sms_Adapter_Twilio'; }
if (!isset(self::$_registry['sns'])) { self::$_registry['sns'] = 'Tiger_Sms_Adapter_Sns'; }
}

/** The configured provider name (`tiger.sms.provider`), lowercased; '' when unset. */
public static function provider(): string
{
return strtolower(trim((string) self::_config('sms.provider', '')));
}

/** Whether SMS is usable here: a provider is selected AND its adapter reports itself configured. */
public static function isConfigured(): bool
{
$a = self::_adapter();
return $a !== null && $a->isConfigured();
}

/** The registered provider names (for a settings picker). @return string[] */
public static function providers(): array
{
self::_seed();
return array_keys(self::$_registry);
}

/**
* Send one SMS via the configured provider. The ONE call the platform uses.
*
* @param string $to the destination number (normalized to E.164 here)
* @param string $message the message body
* @param array $opts {from?:string} — override the default sender for this message
* @return array {ok:bool, id?:string, provider?:string, to?:string, error?:string}
*/
public static function send(string $to, string $message, array $opts = []): array
{
$adapter = self::_adapter();
if ($adapter === null) {
return ['ok' => false, 'error' => 'no SMS provider configured (set tiger.sms.provider)'];
}
$e164 = self::e164($to);
if ($e164 === '') {
return ['ok' => false, 'error' => 'invalid destination number: ' . $to];
}
if (trim($message) === '') {
return ['ok' => false, 'error' => 'empty message'];
}
$from = (string) ($opts['from'] ?? self::_config('sms.from', '') ?: $adapter->defaultFrom());
try {
$res = $adapter->send($e164, $message, $from, $opts);
$res['provider'] = self::provider();
$res['to'] = $e164;
return $res;
} catch (Throwable $e) {
if (class_exists('Tiger_Log')) { try { Tiger_Log::error('sms.send.failed', ['provider' => self::provider(), 'error' => $e->getMessage()]); } catch (Throwable $e2) { /* never fatal */ } }
return ['ok' => false, 'provider' => self::provider(), 'error' => $e->getMessage()];
}
}

/**
* Normalize a phone number to E.164 (`+<countrycode><number>`). Keeps a leading `+`; strips spaces,
* dashes and parentheses; a US-style 10-digit number gets a `+1`. Returns '' when it can't be made
* into a plausible E.164 number (so the caller fails cleanly rather than sending garbage).
*
* @param string $raw the number as entered
* @return string the E.164 number, or '' when invalid
*/
public static function e164(string $raw): string
{
$raw = trim($raw);
$plus = strncmp($raw, '+', 1) === 0;
$digits = preg_replace('/\D+/', '', $raw);
if ($digits === '') { return ''; }
if (!$plus && strlen($digits) === 10) { $digits = '1' . $digits; } // bare US 10-digit → +1
// E.164: 8–15 digits, leading digit non-zero.
if (strlen($digits) < 8 || strlen($digits) > 15 || $digits[0] === '0') { return ''; }
return '+' . $digits;
}

/** The configured adapter instance, or null when none is selected / the class is missing. */
protected static function _adapter(): ?Tiger_Sms_Adapter_Abstract
{
self::_seed();
$name = self::provider();
if ($name === '' || !isset(self::$_registry[$name])) { return null; }
$class = self::$_registry[$name];
if (!class_exists($class)) { return null; }
$a = new $class(self::_adapterConfig($name));
return $a instanceof Tiger_Sms_Adapter_Abstract ? $a : null;
}

/** The `tiger.sms.adapters.<name>.*` config map for one adapter. */
protected static function _adapterConfig(string $name): array
{
if (!Zend_Registry::isRegistered('Zend_Config')) { return []; }
$cfg = Zend_Registry::get('Zend_Config');
$sms = ($cfg instanceof Zend_Config && $cfg->get('tiger')) ? $cfg->get('tiger')->get('sms') : null;
$ad = ($sms instanceof Zend_Config) ? $sms->get('adapters') : null;
$one = ($ad instanceof Zend_Config) ? $ad->get($name) : null;
return ($one instanceof Zend_Config) ? $one->toArray() : [];
}

/** A `tiger.<dotKey>` config value with a default. */
protected static function _config(string $dotKey, $default = null)
{
if (!Zend_Registry::isRegistered('Zend_Config')) { return $default; }
$node = Zend_Registry::get('Zend_Config');
if (!($node instanceof Zend_Config)) { return $default; }
$node = $node->get('tiger');
foreach (explode('.', $dotKey) as $part) {
if (!($node instanceof Zend_Config)) { return $default; }
$node = $node->get($part);
}
return ($node === null || $node === '') ? $default : $node;
}
}
121 changes: 121 additions & 0 deletions library/Tiger/Sms/Adapter/Abstract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Sms_Adapter_Abstract — the base every SMS provider adapter extends.
*
* An adapter is constructed with its own config map (`tiger.sms.adapters.<name>.*`) and implements
* send(). Secrets are read with {@see _secret} (an encrypted `<key>_enc` is decrypted; a plaintext
* `<key>` is a fallback). `fields()` declares the adapter's settings so the admin SMS screen can render
* the right inputs without the module knowing each provider. Keep the HTTP in {@see _http} so adapters
* stay small and testable.
*
* @api
*/
abstract class Tiger_Sms_Adapter_Abstract
{
/** @var array the adapter's config (tiger.sms.adapters.<name>.*) */
protected $_config = [];

/**
* @param array $config the adapter's config map
*/
public function __construct(array $config = [])
{
$this->_config = $config;
}

/**
* Send one message. Implementations must never throw for an expected failure — return
* `['ok'=>false,'error'=>...]` so the facade can report it cleanly.
*
* @param string $to the destination, already E.164-normalized
* @param string $message the body
* @param string $from the sender (origination number / sender id)
* @param array $opts provider-specific options
* @return array {ok:bool, id?:string, error?:string}
*/
abstract public function send(string $to, string $message, string $from, array $opts = []): array;

/** Whether this adapter has the minimum config to send (credentials + a sender). @return bool */
abstract public function isConfigured(): bool;

/** The adapter's own default sender (`from`), or ''. */
public function defaultFrom(): string
{
return (string) $this->_cfg('from', '');
}

/** A human label for the provider picker (default: the class suffix). */
public function label(): string
{
$p = strrpos(static::class, '_');
return $p !== false ? substr(static::class, $p + 1) : static::class;
}

/**
* The settings fields the admin SMS screen renders for this provider. Each: {key,label,type,secret?}.
* `secret:true` means the value is write-only + stored encrypted (a blank submit keeps the current).
*
* @return array<int,array{key:string,label:string,type:string,secret?:bool,placeholder?:string}>
*/
public function fields(): array
{
return [];
}

// ----- helpers ----------------------------------------------------------------------------------

/** A plain adapter-config value, or $default when unset. */
protected function _cfg(string $key, $default = '')
{
return array_key_exists($key, $this->_config) && $this->_config[$key] !== '' ? $this->_config[$key] : $default;
}

/**
* A secret config value — the encrypted `<key>_enc` (decrypted) wins, else a plaintext `<key>`.
* Returns '' when unreadable (e.g. a rotated crypto key), so callers fail closed.
*
* @param string $key the base key name (e.g. 'auth_token' → reads auth_token_enc then auth_token)
* @return string
*/
protected function _secret(string $key): string
{
$enc = (string) $this->_cfg($key . '_enc', '');
if ($enc !== '' && class_exists('Tiger_Crypto')) {
try { return (string) Tiger_Crypto::decrypt($enc); } catch (Throwable $e) { return ''; }
}
return (string) $this->_cfg($key, '');
}

/**
* A small cURL helper. Returns [httpStatus, responseBody]; httpStatus 0 on a transport error.
*
* @param string $method GET | POST
* @param string $url
* @param array $headers raw header lines
* @param string $body request body (form- or query-encoded by the caller)
* @param int $timeout seconds
* @return array [int $status, string $body]
*/
protected function _http(string $method, string $url, array $headers = [], string $body = '', int $timeout = 15): array
{
if (!function_exists('curl_init')) { return [0, 'curl unavailable']; }
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HTTPHEADER => $headers,
]);
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); }
$out = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($out === false) { return [0, $err ?: 'transport error']; }
return [$status, (string) $out];
}
}
Loading
Loading