diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 29406784..6edb67d3 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -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` diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe0c37f..3bb7f190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/library/Tiger/Module/Github.php b/library/Tiger/Module/Github.php index 9f177282..45ce0578 100644 --- a/library/Tiger/Module/Github.php +++ b/library/Tiger/Module/Github.php @@ -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 */ @@ -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)(/…). * @@ -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; } @@ -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; diff --git a/library/Tiger/Module/Installer.php b/library/Tiger/Module/Installer.php index 15e9a776..8043b077 100644 --- a/library/Tiger/Module/Installer.php +++ b/library/Tiger/Module/Installer.php @@ -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.'); diff --git a/library/Tiger/Module/Registry.php b/library/Tiger/Module/Registry.php index b47c6c9b..6faebcbd 100644 --- a/library/Tiger/Module/Registry.php +++ b/library/Tiger/Module/Registry.php @@ -54,6 +54,74 @@ class Tiger_Module_Registry /** @var 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 @@ -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' => []]; diff --git a/library/Tiger/Module/Source.php b/library/Tiger/Module/Source.php index 7b4cf4e8..d728731e 100644 --- a/library/Tiger/Module/Source.php +++ b/library/Tiger/Module/Source.php @@ -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 @@ -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'=>'']; NEVER the raw secret */ + public $auth = []; /** * Build a source from a spec array (missing keys take sane defaults). @@ -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); } /** @@ -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); } } /** @@ -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, ]; } @@ -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]; + } } diff --git a/library/Tiger/Update/Checker.php b/library/Tiger/Update/Checker.php index 30cf9945..9c0df545 100644 --- a/library/Tiger/Update/Checker.php +++ b/library/Tiger/Update/Checker.php @@ -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) { diff --git a/tests/Unit/Module/GithubTest.php b/tests/Unit/Module/GithubTest.php index d4dc81e2..810f4fb6 100644 --- a/tests/Unit/Module/GithubTest.php +++ b/tests/Unit/Module/GithubTest.php @@ -37,6 +37,8 @@ protected function setUp(): void protected function tearDown(): void { + Tiger_Module_Github::setTransport(null); // never let an auth/transport seam leak between tests + Tiger_Module_Github::setAuthResolver(null); $this->rrmdir($this->tmp); parent::tearDown(); } @@ -104,6 +106,83 @@ public function downloadReturnsFalseAndLeavesNoFileWhenTheUrlFails(): void $this->assertFileDoesNotExist($dest, 'a failed download must not leave a partial file'); } + // ---- auth: the org-scoped resolver + the transport seam (no real network) -- + + /** Capture the headers each request would send, and answer 200 with a canned body. */ + private function captureTransport(array &$sink): callable + { + return static function (string $url, array $headers, ?string $toFile) use (&$sink): array { + $sink[] = ['url' => $url, 'headers' => $headers]; + return ['code' => 200, 'body' => '{}']; + }; + } + + #[Test] + public function itAuthenticatesARepoTheResolverCovers(): void + { + $seen = []; + Tiger_Module_Github::setTransport($this->captureTransport($seen)); + Tiger_Module_Github::setAuthResolver( + static fn($org, $repo) => ($org === 'WebTigers' && $repo === 'TigerMarketing') ? 'ghp_secret' : '' + ); + + Tiger_Module_Github::fetchRaw('WebTigers', 'TigerMarketing', 'v0.3.0', 'theme.json'); + + $this->assertContains('Authorization: Bearer ghp_secret', $seen[0]['headers'], + 'a private repo the resolver covers is sent an auth header'); + } + + #[Test] + public function itSendsNoAuthForARepoOutsideTheResolverScope(): void + { + $seen = []; + Tiger_Module_Github::setTransport($this->captureTransport($seen)); + Tiger_Module_Github::setAuthResolver(static fn($org, $repo) => $org === 'WebTigers' ? 'ghp_secret' : ''); + + Tiger_Module_Github::fetchRaw('SomeoneElse', 'PublicRepo', 'main', 'module.json'); + + $this->assertStringNotContainsString('Authorization', implode("\n", $seen[0]['headers']), + 'an out-of-scope repo is fetched unauthenticated'); + } + + #[Test] + public function withNoResolverItStaysPublicOnly(): void + { + $seen = []; + Tiger_Module_Github::setTransport($this->captureTransport($seen)); // no resolver installed + + Tiger_Module_Github::fetchRaw('WebTigers', 'TigerMarketing', 'v0.3.0', 'theme.json'); + + $this->assertStringNotContainsString('Authorization', implode("\n", $seen[0]['headers']), + 'public-only when no resolver is wired'); + } + + #[Test] + public function aThrowingResolverDegradesToPublicNeverCrashes(): void + { + $seen = []; + Tiger_Module_Github::setTransport($this->captureTransport($seen)); + Tiger_Module_Github::setAuthResolver(static function ($org, $repo) { throw new \RuntimeException('boom'); }); + + $this->assertSame('{}', Tiger_Module_Github::fetchRaw('WebTigers', 'TigerMarketing', 'v0.3.0', 'theme.json'), + 'a resolver that throws must not break the fetch'); + $this->assertStringNotContainsString('Authorization', implode("\n", $seen[0]['headers']), + 'a throwing resolver yields no auth'); + } + + #[Test] + public function theTokenRidesTheTarballDownloadToo(): void + { + $seen = []; + Tiger_Module_Github::setTransport($this->captureTransport($seen)); + Tiger_Module_Github::setAuthResolver(static fn($org, $repo) => 'ghp_secret'); + + Tiger_Module_Github::download(Tiger_Module_Github::tarballUrl('WebTigers', 'TigerMarketing', 'v0.3.0'), $this->tmp . '/a.tgz'); + + $this->assertContains('Authorization: Bearer ghp_secret', $seen[0]['headers'], + 'the archive download of a private repo is authenticated'); + } + private function rrmdir(string $dir): void { if (!is_dir($dir)) { return; } diff --git a/tests/Unit/Module/RegistryTest.php b/tests/Unit/Module/RegistryTest.php index 3fa0f533..c043b363 100644 --- a/tests/Unit/Module/RegistryTest.php +++ b/tests/Unit/Module/RegistryTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\Test; use Tiger\Tests\Support\UnitTestCase; use Tiger_Module_Registry; +use Tiger_Module_Source; /** * Tiger_Module_Registry — the client for the open Vendor Registry. Driven with NO network by PRE-SEEDING @@ -446,4 +447,45 @@ public function the_marketplace_source_activates_when_its_url_is_configured_and_ $this->assertSame('Marketplace', $bySlug['widget']['vendor'], 'marketplace #0 (priority 0) wins the shared slug'); $this->assertSame('webtigers', $bySlug['widget']['source_id']); } + + // ---- authenticated (private) sources → org-scoped token resolver ----------- + + #[Test] + public function authResolverMapsEachAuthedSourceOrgToItsDecryptedToken(): void + { + $sources = [ + new Tiger_Module_Source(['id' => 'tiger-vendors', 'url' => 'u']), // public → ignored + new Tiger_Module_Source(['id' => 'company', 'url' => 'u', 'org' => 'WebTigers', + 'auth' => ['type' => 'github-token', 'ref' => 'tiger.modules.sources.company.token']]), + new Tiger_Module_Source(['id' => 'noorg', 'url' => 'u', 'auth' => ['ref' => 'some.key']]), // no org → ignored + ]; + $secret = static fn(string $k): string => $k === 'tiger.modules.sources.company.token' ? 'ghp_live' : ''; + $resolve = Tiger_Module_Registry::authResolver($sources, $secret); + + $this->assertSame('ghp_live', $resolve('WebTigers', 'TigerMarketing'), 'a covered org resolves its token'); + $this->assertSame('ghp_live', $resolve('webtigers', 'TigerServer'), 'org match is case-insensitive'); + $this->assertSame('', $resolve('SomeoneElse', 'Repo'), 'an uncovered org gets no token (public)'); + } + + #[Test] + public function authResolverSkipsASourceWhoseSecretCannotBeResolved(): void + { + $sources = [new Tiger_Module_Source(['id' => 'company', 'url' => 'u', 'org' => 'WebTigers', 'auth' => ['ref' => 'missing.key']])]; + $resolve = Tiger_Module_Registry::authResolver($sources, static fn(string $k): string => ''); // secret unresolved + $this->assertSame('', $resolve('WebTigers', 'X'), 'an empty secret leaves the org uncovered'); + } + + #[Test] + public function noShippedDefaultSourceIsAuthenticated(): void + { + // A company/private feed is ALWAYS admin-configured ('connected'), NEVER a shipped default — so a + // customer install never carries a credential and never discovers WebTigers-only modules. This guard + // fails loudly if an authenticated source is ever baked into the defaults. + $defaults = array_filter(Tiger_Module_Registry::sources(), static fn($s) => $s->default === true); + $this->assertNotEmpty($defaults, 'the platform ships default sources'); + foreach ($defaults as $s) { + $this->assertFalse($s->hasAuth(), "shipped default source '{$s->id}' must carry no credential (company feeds are admin-only)"); + $this->assertSame('', $s->org, "shipped default source '{$s->id}' must be public (no org scope)"); + } + } } diff --git a/tests/Unit/Module/SourceTest.php b/tests/Unit/Module/SourceTest.php index c6325557..10a18e04 100644 --- a/tests/Unit/Module/SourceTest.php +++ b/tests/Unit/Module/SourceTest.php @@ -102,7 +102,57 @@ public function to_array_round_trips_the_public_shape(): void { $spec = ['id' => 'acme', 'label' => 'Acme', 'kind' => 'live-api', 'url' => 'https://a/i.json', 'priority' => 3, 'enabled' => true, 'removable' => false, 'default' => true, 'cache' => 'registry-acme.json', - 'origin' => 'module', 'provider' => 'acme-mod']; + 'origin' => 'module', 'provider' => 'acme-mod', + 'org' => 'Acme', 'auth' => ['type' => 'github-token', 'ref' => 'k']]; $this->assertSame($spec, (new Tiger_Module_Source($spec))->toArray()); } + + #[Test] + public function auth_keeps_only_a_reference_and_never_a_raw_secret(): void + { + $s = new Tiger_Module_Source([ + 'id' => 'company', 'org' => 'WebTigers', + 'auth' => ['type' => 'github-token', 'ref' => 'tiger.modules.sources.company.token', + 'token' => 'ghp_SHOULD_NOT_STICK', 'secret' => 'nope'], // stray secrets must be dropped + ]); + $this->assertSame('WebTigers', $s->org); + $this->assertTrue($s->hasAuth()); + $this->assertSame('tiger.modules.sources.company.token', $s->authRef()); + $this->assertSame(['type' => 'github-token', 'ref' => 'tiger.modules.sources.company.token'], $s->auth, + 'only {type, ref} survive — the raw secret keys are dropped'); + + $json = json_encode($s->toArray()); + $this->assertStringNotContainsString('ghp_SHOULD_NOT_STICK', $json, 'a raw token never rides in the public shape'); + $this->assertStringNotContainsString('"token"', $json); + $this->assertStringNotContainsString('secret', $json); + } + + #[Test] + public function a_source_without_auth_is_public(): void + { + $s = new Tiger_Module_Source(['id' => 'x', 'url' => 'u']); + $this->assertSame('', $s->org); + $this->assertFalse($s->hasAuth()); + $this->assertSame('', $s->authRef()); + $this->assertSame([], $s->auth); + } + + #[Test] + public function auth_requires_a_ref_to_count(): void + { + $this->assertFalse((new Tiger_Module_Source(['id' => 'x', 'auth' => ['type' => 'github-token']]))->hasAuth(), + 'a type with no ref is not a usable reference'); + $this->assertSame([], (new Tiger_Module_Source(['id' => 'x', 'auth' => ['ref' => '']]))->auth, 'an empty ref is dropped'); + $this->assertSame([], (new Tiger_Module_Source(['id' => 'x', 'auth' => 'not-an-array']))->auth, 'a non-array auth is dropped'); + } + + #[Test] + public function apply_can_overlay_org_and_auth(): void + { + $s = new Tiger_Module_Source(['id' => 'x', 'url' => 'u']); + $s->apply(['org' => 'WebTigers', 'auth' => ['ref' => 'k']]); + $this->assertSame('WebTigers', $s->org); + $this->assertTrue($s->hasAuth()); + $this->assertSame('k', $s->authRef()); + } }