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
2 changes: 1 addition & 1 deletion CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
- **Tiger_Module_Compat** `@api` — advisory "which Tiger versions was this module tested for?" metadata. · `library/Tiger/Module/Compat.php`
- **Tiger_Module_Dependency** `@api` — lightweight, lazy inter-module dependency alerts. · `library/Tiger/Module/Dependency.php`
- **Tiger_Module_Discovery** `@api` — find the modules present on disk (active or not). · `library/Tiger/Module/Discovery.php`
- **Tiger_Module_Github** `@api` — read public GitHub repos over cURL (no auth, public only). · `library/Tiger/Module/Github.php`
- **Tiger_Module_Github** `@api` — read GitHub repos over cURL. · `library/Tiger/Module/Github.php`
- **Tiger_Module_Installer** `@api` — install / update / remove modules from public GitHub repos. · `library/Tiger/Module/Installer.php`
- **Tiger_Module_Longform** `@api` — resolves a module listing's LONG-FORM copy and renders it safely. · `library/Tiger/Module/Longform.php`
- **Tiger_Module_Pricing** `@api` — the manifest `pricing` block, normalized. · `library/Tiger/Module/Pricing.php`
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows

## [Unreleased]

### Added

- **Authenticated module sources (private registries).** A module source may now carry a credential, so
the Module Manager can read and install from a PRIVATE registry/repo it is authorized for — not just
public ones. A source names the GitHub `org` its credential covers and an `auth` **reference** (a
credential type plus the config key, `ref`, that holds the secret); the raw secret is never stored on
the source, so it cannot leak through `toArray()`, a settings UI, or a diagnostics dump.
`Tiger_Module_Github` gains an org-scoped auth resolver (`setAuthResolver()`) plus a testable transport
seam, and the registry wires the resolver from the configured authenticated sources — so update
detection (`Tiger_Update_Checker`) and one-click install (`Tiger_Module_Installer`) both work for an
authorized private repo. With no authenticated source configured, everything stays public exactly as
before, and no shipped-default source is ever authenticated.

## [1.19.1] — 2026-10-07

### Fixed
Expand Down
82 changes: 74 additions & 8 deletions library/Tiger/Module/Github.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Module_Github — read public GitHub repos over cURL (no auth, public only).
* Tiger_Module_Github — read GitHub repos over cURL. PUBLIC by default; PRIVATE when authorized.
*
* The module installer never uses git or a token: it pulls `module.json` (the technical
* manifest) and `TIGER.md` (the vendor's human description) as RAW files, resolves a pinned
* release ref, and downloads the release tarball. Private repos simply 404 on raw — which the
* installer treats as "not installable" (public code is the price of admission).
* It pulls `module.json`/`theme.json` (the technical manifest) and `TIGER.md` (the vendor's human
* description) as RAW files, resolves a pinned release ref, and downloads the release tarball. With no
* credential resolver installed it is public-only, exactly as before — a private repo 404s on raw and
* the installer treats it as "not installable". When an org-scoped resolver is installed via
* {@see setAuthResolver()} (the registry wires it from authenticated sources), a request to a repo the
* resolver yields a token for is sent with an `Authorization: Bearer` header, so a PRIVATE repo the
* caller is authorized for becomes readable + installable from its authenticated tarball. Private
* release-ZIP *assets* (vendored-bundle modules) are a documented follow-up — source/theme private
* repos install from the tarball. A test transport ({@see setTransport()}) replaces the network.
*
* @api
*/
Expand All @@ -17,6 +22,49 @@ class Tiger_Module_Github
const API = 'https://api.github.com';
const UA = 'Tiger-Module-Installer';

/** @var callable|null fn(string $org, string $repo): string — a bearer token for a repo, or '' (public) */
protected static $authResolver = null;
/** @var callable|null test seam: fn(string $url, array $headers, ?string $toFile): array{code:int,body:?string} */
protected static $transport = null;

/**
* Install an org-scoped credential resolver. When set, {@see _http()} asks it for a bearer token for
* the repo a request targets and, if one comes back non-empty, authenticates the request — so a
* PRIVATE repo the caller is authorized for becomes readable/installable. Public repos (resolver
* returns '') are fetched exactly as before. The registry wires this from authenticated sources;
* null (the default) is public-only.
*/
public static function setAuthResolver(?callable $resolver): void
{
self::$authResolver = $resolver;
}

/** Swap the HTTP transport — tests inject a fake to assert headers without the network. Null = real cURL. */
public static function setTransport(?callable $transport): void
{
self::$transport = $transport;
}

/** The bearer token for a repo via the resolver (org-scoped), or '' when none/public. Never throws. */
protected static function _tokenFor($org, $repo): string
{
if (!self::$authResolver) { return ''; }
try { return (string) (self::$authResolver)((string) $org, (string) $repo); }
catch (\Throwable $e) { return ''; }
}

/** Recognize the {org, repo} a GitHub URL targets (raw / api / archive / codeload), or null. */
protected static function _repoFromUrl($url): ?array
{
$u = (string) $url;
if (preg_match('~(?:raw\.githubusercontent\.com|codeload\.github\.com)/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)~', $u, $m)
|| preg_match('~api\.github\.com/repos/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)~', $u, $m)
|| preg_match('~github\.com/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)/(?:archive|releases|tarball|zipball)~', $u, $m)) {
return ['org' => $m[1], 'repo' => $m[2]];
}
return null;
}

/**
* Parse a GitHub repo URL/slug → ['org','repo'], or null. Accepts …/org/repo(.git)(/…).
*
Expand Down Expand Up @@ -133,11 +181,29 @@ public static function get($url)
return self::_http($url);
}

/** HTTP GET via cURL (public, follows redirects, UA set). Body string / true(to file) / null. */
/** HTTP GET via cURL (follows redirects, UA set; authenticates when a resolver yields a token for the
* target repo). Body string / true(to file) / null. A test transport, if set, replaces the network. */
protected static function _http($url, $api = false, $toFile = null)
{
$headers = [];
if ($api) { $headers[] = 'Accept: application/vnd.github+json'; }
if ($r = self::_repoFromUrl($url)) {
$token = self::_tokenFor($r['org'], $r['repo']);
if ($token !== '') { $headers[] = 'Authorization: Bearer ' . $token; }
}

// Test seam: a fake transport captures the final headers (asserting auth) without the network.
if (self::$transport) {
$res = (array) (self::$transport)((string) $url, $headers, $toFile);
$code = (int) ($res['code'] ?? 0);
if ($code < 200 || $code >= 300) { return null; }
if ($toFile) { return @file_put_contents($toFile, (string) ($res['body'] ?? '')) !== false ? true : null; }
return $res['body'] ?? null;
}

if (!function_exists('curl_init')) {
$ctx = stream_context_create(['http' => ['user_agent' => self::UA, 'timeout' => 30]]);
$hdr = $headers ? implode("\r\n", $headers) . "\r\n" : '';
$ctx = stream_context_create(['http' => ['user_agent' => self::UA, 'timeout' => 30, 'header' => $hdr]]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) { return null; }
if ($toFile) { return @file_put_contents($toFile, $body) !== false ? true : null; }
Expand All @@ -154,7 +220,7 @@ protected static function _http($url, $api = false, $toFile = null)
CURLOPT_USERAGENT => self::UA,
CURLOPT_SSL_VERIFYPEER => true,
];
if ($api) { $opts[CURLOPT_HTTPHEADER] = ['Accept: application/vnd.github+json']; }
if ($headers) { $opts[CURLOPT_HTTPHEADER] = $headers; }
if ($toFile) {
$fh = fopen($toFile, 'wb');
$opts[CURLOPT_FILE] = $fh;
Expand Down
1 change: 1 addition & 0 deletions library/Tiger/Module/Installer.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class Tiger_Module_Installer
*/
public static function installFromUrl($repoUrl, $ref = null, array $opts = [])
{
Tiger_Module_Registry::ensureAuth(); // so a private company repo resolves + downloads (idempotent)
$r = Tiger_Module_Github::parseRepo($repoUrl);
if (!$r) {
throw new RuntimeException('Not a GitHub repository URL.');
Expand Down
69 changes: 69 additions & 0 deletions library/Tiger/Module/Registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,74 @@ class Tiger_Module_Registry
/** @var array<string,array> module-contributed sources (id => spec), registered in-memory per request. */
protected static $registered = [];

/** @var bool one-shot guard — the auth resolver is built + installed into Github once per request. */
protected static $authInstalled = false;

/**
* Build an org→bearer-token resolver from the configured AUTHENTICATED sources. For each fetchable
* source that declares an `org` + an `auth` reference, the referenced config secret is resolved and
* decrypted; the result maps the source's GitHub org to its token. The returned closure is what
* {@see Tiger_Module_Github::setAuthResolver()} consumes — so a private company repo under a covered
* org becomes readable/installable, while every other repo stays public (an empty token).
*
* Injectable for tests: pass an explicit $sources list and/or a $secret resolver (configKey→token) to
* exercise the mapping with no DB / config / crypto.
*
* @param array|null $sources Tiger_Module_Source[] (defaults to the live, merged source list)
* @param callable|null $secret fn(string $configKey): string (defaults to the decrypting resolver)
* @return callable fn(string $org, string $repo): string
*/
public static function authResolver(?array $sources = null, ?callable $secret = null): callable
{
$sources = $sources ?? self::sources();
$secret = $secret ?? static fn(string $k): string => self::_resolveSecret($k);

$tokens = []; // lowercased org => token
foreach ($sources as $s) {
if (!$s instanceof Tiger_Module_Source || !$s->hasAuth() || $s->org === '') { continue; }
$tok = (string) $secret($s->authRef());
if ($tok !== '') { $tokens[strtolower($s->org)] = $tok; }
}
return static fn($org, $repo): string => (string) ($tokens[strtolower((string) $org)] ?? '');
}

/**
* Install the credential resolver into Tiger_Module_Github — once per request (idempotent). This is
* what makes private-repo auth active across all three flows: Add (index/search), Updates detection
* (Tiger_Update_Checker), and apply (Tiger_Module_Installer). Public-only if anything fails.
*/
public static function ensureAuth(): void
{
if (self::$authInstalled) { return; }
self::$authInstalled = true;
try { Tiger_Module_Github::setAuthResolver(self::authResolver()); }
catch (Throwable $e) { /* leave Github public-only */ }
}

/** Resolve a config key to its (Tiger_Crypto-decrypted) secret; '' when absent. Tolerates a plaintext value. */
protected static function _resolveSecret(string $configKey): string
{
$raw = self::_configValue($configKey);
if ($raw === '') { return ''; }
if (class_exists('Tiger_Crypto')) {
try { $dec = Tiger_Crypto::decrypt($raw); if (is_string($dec) && $dec !== '') { return $dec; } }
catch (Throwable $e) { /* fall through — treat as plaintext (dev convenience) */ }
}
return $raw;
}

/** Read one global config value by key (the `config` table), '' when unset. */
protected static function _configValue(string $key): string
{
if ($key === '' || !class_exists('Tiger_Model_Config')) { return ''; }
try {
foreach ((new Tiger_Model_Config())->getForScope(Tiger_Model_Config::SCOPE_GLOBAL, '') as $row) {
if ((string) $row->config_key === $key) { return (string) $row->config_value; }
}
} catch (Throwable $e) { /* fall through */ }
return '';
}

/**
* Register a catalog source from a module (call it from the module's Bootstrap `_init*`). This is the
* one-call seam that lets any module add its own marketplace to the Add screen — no config editing, no
Expand Down Expand Up @@ -241,6 +309,7 @@ protected static function _title(array $m)
*/
public static function index($refresh = false)
{
self::ensureAuth(); // authenticate private sources before any source fetch (idempotent)
$merged = ['modules' => [], 'taxonomy' => []];
$bySlug = []; // slug => index into $merged['modules']
$seenTax = ['types' => [], 'categories' => []];
Expand Down
49 changes: 47 additions & 2 deletions library/Tiger/Module/Source.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@
* **live API** (a marketplace endpoint that serves the same index-shaped payload, *enriched* —
* ratings, download counts, a paid catalog). Both fetch a URL and yield the same
* `{modules, taxonomy}` shape; the `kind` records **provenance and trust** (a git index is
* public/reviewable; a live API is operator-run) and is the seam where a live-API fetch later
* gains authenticated / ETag-aware behavior.
* public/reviewable; a live API is operator-run).
*
* A source may be **authenticated** — a private registry. It names the GitHub `org` its credential
* covers and an `auth` **reference** (a credential `type` plus the config key, `ref`, that holds the
* secret); the raw secret is NEVER stored on the source, so it cannot leak through {@see toArray()},
* a settings UI, or a diagnostics dump. The registry resolves the reference to a token at fetch time
* and scopes it to that org's repos (see {@see Tiger_Module_Github::setAuthResolver()}). A source with
* no `auth` is public, exactly as before — this is the seam the `kind` docblock long reserved.
*
* Sources are ordered by `priority` **ascending** — lower is earlier and *wins a slug collision*,
* so an enriching marketplace (priority 0) overlays the plain directory (priority 10). Shipped
Expand Down Expand Up @@ -53,6 +59,10 @@ class Tiger_Module_Source
public $origin = 'connected';
/** @var string the module slug that contributed this source (origin='module'), else '' */
public $provider = '';
/** @var string the GitHub org this source's credential (if any) is scoped to; '' = public/no auth */
public $org = '';
/** @var array a credential REFERENCE for a private source — ['type'=>'github-token','ref'=>'<config-key>']; NEVER the raw secret */
public $auth = [];

/**
* Build a source from a spec array (missing keys take sane defaults).
Expand All @@ -72,6 +82,8 @@ public function __construct(array $spec)
$this->cache = (string) ($spec['cache'] ?? ($this->id !== '' ? 'registry-' . $this->id . '.json' : ''));
$this->origin = in_array($spec['origin'] ?? '', ['default', 'module', 'connected'], true) ? (string) $spec['origin'] : 'connected';
$this->provider = (string) ($spec['provider'] ?? '');
$this->org = trim((string) ($spec['org'] ?? ''));
$this->auth = self::_auth($spec);
}

/**
Expand All @@ -90,6 +102,8 @@ public function apply(array $spec): void
if (array_key_exists('enabled', $spec)) { $this->enabled = self::_bool($spec['enabled']); }
if (array_key_exists('removable', $spec)) { $this->removable = self::_bool($spec['removable']); }
if (array_key_exists('cache', $spec)) { $this->cache = (string) $spec['cache']; }
if (array_key_exists('org', $spec)) { $this->org = trim((string) $spec['org']); }
if (array_key_exists('auth', $spec) || array_key_exists('auth_ref', $spec)) { $this->auth = self::_auth($spec); }
}

/**
Expand Down Expand Up @@ -124,6 +138,7 @@ public function toArray(): array
'priority' => $this->priority, 'enabled' => $this->enabled, 'removable' => $this->removable,
'default' => $this->default, 'cache' => $this->cache,
'origin' => $this->origin, 'provider' => $this->provider,
'org' => $this->org, 'auth' => $this->auth,
];
}

Expand All @@ -138,4 +153,34 @@ protected static function _bool($v): bool
{
return is_bool($v) ? $v : (bool) filter_var($v, FILTER_VALIDATE_BOOLEAN);
}

/** True when this source carries a credential reference (an authenticated / private registry). */
public function hasAuth(): bool
{
return ($this->auth['ref'] ?? '') !== '';
}

/** The config key that holds this source's secret (the registry resolves + decrypts it), or ''. */
public function authRef(): string
{
return (string) ($this->auth['ref'] ?? '');
}

/**
* Whitelist a credential REFERENCE to exactly {type, ref}, dropping everything else — so a raw secret
* (a stray `token`/`password`/`secret` key) can NEVER be stored on the source and thus can never
* surface through toArray(), a settings UI, or a log. Accepts BOTH shapes: the nested programmatic
* form `['auth' => ['type'=>, 'ref'=>]]`, and the FLAT config form (one `config` row per field)
* `['auth_ref'=>, 'auth_type'=>]`. `ref` is required (it names the config key holding the secret);
* `type` defaults to 'github-token'. No ref → no auth (a public source).
*/
protected static function _auth($spec): array
{
if (!is_array($spec)) { return []; }
$nested = (isset($spec['auth']) && is_array($spec['auth'])) ? $spec['auth'] : [];
$ref = (string) ($nested['ref'] ?? ($spec['auth_ref'] ?? ''));
$type = (string) ($nested['type'] ?? ($spec['auth_type'] ?? 'github-token'));
if ($ref === '') { return []; }
return ['type' => ($type !== '' ? $type : 'github-token'), 'ref' => $ref];
}
}
1 change: 1 addition & 0 deletions library/Tiger/Update/Checker.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ public static function core($refresh = false)
*/
public static function modules($refresh = false)
{
Tiger_Module_Registry::ensureAuth(); // so latestRef() can read a private company repo (idempotent)
try {
$rows = (new Tiger_Model_Module())->bySlugMap();
} catch (Throwable $e) {
Expand Down
Loading
Loading