Skip to content
Closed
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
14 changes: 13 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.

**229 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).
**240 classes** across **33 capabilities** · **26 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 @@ -120,6 +120,8 @@
- **Tiger_License_Store_Option** `@api` — the default license store, backed by the lazy `option` tier. · `library/Tiger/License/Store/Option.php`
- **Tiger_License_Vendor** `@api` — CONNECT + pin a paid module's vendor (its `[owner]/TigerVendor` repo). · `library/Tiger/License/Vendor.php`
- **Tiger_Model_Module** `@api` — the module lifecycle registry (see migration 0023). · `library/Tiger/Model/Module.php`
- **Tiger_Model_ModuleInstallPin** `@api` — a consumer install's version policy + rollback anchor (TIGER-334, 0054). · `library/Tiger/Model/ModuleInstallPin.php`
- **Tiger_Model_ModuleRelease** `@api` — a module release moving through QA (TIGER-334, migration 0054). · `library/Tiger/Model/ModuleRelease.php`
- **Tiger_Model_UpdateHistory** `@api` — the durable log of one-click update runs (table `update_history`). · `library/Tiger/Model/UpdateHistory.php`
- **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`
Expand Down Expand Up @@ -306,6 +308,9 @@
- **Tiger_Model_AgentRun** `@api` — one turn's execution + control record (see migration 0036). · `library/Tiger/Model/AgentRun.php`
- **Tiger_Model_Comment** `@api` — the one comment/review store (migration 0045). · `library/Tiger/Model/Comment.php`
- **Tiger_Model_CommentAggregate** `@api` — the denormalized per-subject rollup (migration 0046). · `library/Tiger/Model/CommentAggregate.php`
- **Tiger_Model_GitCredential** `@api` — a least-privilege, encrypted-at-rest credential (TIGER-334, migration 0054). · `library/Tiger/Model/GitCredential.php`
- **Tiger_Model_GitOperationLog** `@api` — the append-only git audit trail (TIGER-334, migration 0054). · `library/Tiger/Model/GitOperationLog.php`
- **Tiger_Model_GitRepoConnection** `@api` — a module's connection to its GitHub repo (TIGER-334, migration 0054). · `library/Tiger/Model/GitRepoConnection.php`
- **Tiger_Model_Menu** `@api` — Menu — custom navigation menus (see migration 0017). · `library/Tiger/Model/Menu.php`
- **Tiger_Model_Message** `@api` — the message body store (migration 0047, TIGER-114). · `library/Tiger/Model/Message.php`
- **Tiger_Model_MessageBlock** `@api` — one user muting another, within an org (migration 0049). · `library/Tiger/Model/MessageBlock.php`
Expand All @@ -319,6 +324,12 @@

- **Tiger_Comment** `@api` — the subject registry and policy gate for comments, ratings and reviews. · `library/Tiger/Comment.php`
- **Tiger_Comment_Spam** `@api` — the spam-check registry, and the first checker: the in-platform AI agent. · `library/Tiger/Comment/Spam.php`
- **Tiger_Git** `@api` — the facade + shared constants for the platform's one git/GitHub layer (TIGER-334). · `library/Tiger/Git.php`
- **Tiger_Git_Credential** `@api` — the write-only credential manager (TIGER-334 / TIGER-365). · `library/Tiger/Git/Credential.php`
- **Tiger_Git_Environment** `@api` — detect which transport this instance can use (TIGER-334). · `library/Tiger/Git/Environment.php`
- **Tiger_Git_Exception** `@api` — a structured, agent-readable, SECRET-FREE git failure (TIGER-334). · `library/Tiger/Git/Exception.php`
- **Tiger_Git_Transport_Interface** `@api` — HOW a git operation is carried out (TIGER-334). · `library/Tiger/Git/Transport/Interface.php`
- **Tiger_Git_WorkingTree** `@api` — one module's git working tree (TIGER-334). · `library/Tiger/Git/WorkingTree.php`
- **Tiger_Message** `@api` — the platform's way of telling people things (TIGER-114). · `library/Tiger/Message.php`
- **Tiger_Secret** `@api` — provider-neutral resolution of secrets and sensitive config values. · `library/Tiger/Secret.php`
- **Tiger_Secret_Adapter_Chain** `@api` — try several backends in order; the first hit wins. · `library/Tiger/Secret/Adapter/Chain.php`
Expand Down Expand Up @@ -348,6 +359,7 @@
- **CMS** (`cms`, app) · services: Menu, Page, Paths, Settings · `modules/cms`
- **Code** (`code`, developer) · services: Code · `modules/code`
- **Comments** (`comment`, app) · services: Comment, Render, Settings, Subjects · `modules/comment`
- **Git** (`git`, app) · `modules/git`
- **Identity** (`identity`, plugin) · services: Identity · `modules/identity`
- **Matomo** (`matomo`, app) · services: Matomo · `modules/matomo`
- **Mcp** (`mcp`, module) · services: Settings · `modules/mcp`
Expand Down
112 changes: 112 additions & 0 deletions library/Tiger/Git.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Git — the facade + shared constants for the platform's one git/GitHub layer (TIGER-334).
*
* This is the single trusted entry point for every git interaction in the platform: versioning a
* custom module in a private repo, cutting a release through QA, and receiving approved updates — with
* no local machine, no terminal, and (on a shared-hosting account with `exec` disabled) no git binary.
* No other module talks to GitHub directly.
*
* The design is two interchangeable TRANSPORTS behind one interface
* (Tiger_Git_Transport_Interface): a ShellTransport that drives the `git` binary where the host allows
* it, and an ApiTransport that creates commits through the GitHub API with no working `.git` directory
* at all. Callers never know which is active; capability detection (Tiger_Git_Environment) chooses.
*
* This class holds only cross-cutting constants + small pure helpers. The work lives in the services
* (Tiger_Git_Service_*), the transports, and the models. Authorization is expressed as ACL privileges
* — never a role-string compare (TIGER-335): the sharp owner-only operations (tag, release submit) are
* a CAPABILITY resource, RESOURCE_RELEASE, checked with `_isAllowed('tag', Tiger_Git::RESOURCE_RELEASE)`.
*
* @api
* @since 1.38.0
*/
class Tiger_Git
{
/** Transport names (Tiger_Git_Environment picks one per instance; stored on the connection). */
const TRANSPORT_SHELL = 'shell';
const TRANSPORT_API = 'api';

/**
* The release CAPABILITY resource (ACL). The owner-only operations — creating a semver tag and
* submitting a release to QA — must be STRICTER than the service's own grant (editors/admins reach
* the service, but only an owner may tag/submit). Expressing that as a privilege on a capability
* resource — `_isAllowed('tag', Tiger_Git::RESOURCE_RELEASE)` — is the proven Tiger_Message_Compose
* / Tiger_Agent_Forge shape; a resource named after a role tier would just be a role compare with
* one level of indirection. Granted to the module-owner role in modules/git/configs/acl.ini.
*/
const RESOURCE_RELEASE = 'Tiger_Git_Release';

/** Release-capability privileges. */
const PRIV_TAG = 'tag';
const PRIV_SUBMIT = 'submit';

/** A connection's lifecycle status. */
const STATUS_CONNECTED = 'connected'; // verified, read (and write when enabled) work
const STATUS_DEGRADED = 'degraded'; // credential revoked/expired at GitHub; local reads still OK
const STATUS_REVOKED = 'revoked'; // disconnected by the owner

/** Update policy per installed module. */
const POLICY_AUTO = 'auto';
const POLICY_NOTIFY = 'notify'; // default
const POLICY_PINNED = 'pinned';

/** The only hosts Tiger_Git ever reaches (outbound allowlist). */
const ALLOWED_HOSTS = ['github.com', 'api.github.com', 'raw.githubusercontent.com', 'codeload.github.com'];

/**
* Is a string a valid semantic version (optionally `v`-prefixed, optional pre-release/build)?
* Tag creation and the manifest version are both validated against this.
*
* @param string $version e.g. "1.0.0", "v2.3.1-beta.1"
* @return bool
*/
public static function isSemver($version)
{
return (bool) preg_match(
'/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/',
(string) $version
);
}

/**
* Normalize a version for comparison: strip a leading `v`. (Not a comparator — just canonicalizes
* what a tag and a manifest might each write differently.)
*
* @param string $version
* @return string
*/
public static function normalizeVersion($version)
{
return ltrim((string) $version, 'vV');
}

/**
* Is $host one of the hosts Tiger_Git is permitted to reach? Enforced before any outbound request;
* a transport must refuse anything else rather than follow a redirect off-allowlist.
*
* @param string $host
* @return bool
*/
public static function isAllowedHost($host)
{
return in_array(strtolower((string) $host), self::ALLOWED_HOSTS, true);
}

/**
* Validate an `owner/repo` pair to GitHub's own character rules (so it can never be bent into a
* path-traversal or a second path segment). Returns [owner, repo] or null.
*
* @param string $owner
* @param string $repo
* @return array{0:string,1:string}|null
*/
public static function repoSlug($owner, $repo)
{
$owner = (string) $owner; $repo = (string) $repo;
$ok = '/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/';
if (!preg_match($ok, $owner) || !preg_match($ok, $repo)) { return null; }
return [$owner, $repo];
}
}
157 changes: 157 additions & 0 deletions library/Tiger/Git/Credential.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Git_Credential — the write-only credential manager (TIGER-334 / TIGER-365).
*
* The one place a git credential (a fine-grained token or a deploy key) is written, resolved for use,
* rotated, and revoked. The security contract:
* - Secrets are encrypted at rest with Tiger_Crypto (libsodium secretbox; the key lives in local.ini,
* never the DB). The connection stores only a `credential_ref`, never the secret.
* - WRITE-ONLY on every surface. `store()`, `rotate()` and `revoke()` return a SAFE public view
* (id, type, fingerprint, public key, scopes) and NOTHING sensitive. There is no public getter for
* a secret; `resolve()` is the single read path, it returns the plaintext to a TRANSPORT at the
* moment of use, and nothing logs or echoes it. A controller never calls `resolve()`.
* - A reference may be a local `git_credential` id OR a `Tiger_Secret` ref (e.g. an SSM parameter) —
* the same provider-neutral indirection the fleet already uses — so a shared secret need not be
* copied into every install's DB.
* - Rotation verifies the NEW credential before the OLD one is revoked (R2), so a bad rotation can't
* lock the owner out.
*
* @api
* @since 1.38.0
*/
class Tiger_Git_Credential
{
/** A Tiger_Secret ref looks like `<scheme>://…`; anything else is a local git_credential id. */
private static function isSecretRef(string $ref): bool
{
return (bool) preg_match('~^[a-z][a-z0-9+.-]*://~', $ref);
}

/**
* Store a new credential, encrypted. Returns the SAFE public view (NO secret). The caller then
* records the returned `ref` on the connection.
*
* @param string $orgId
* @param string $moduleKey
* @param string $type Tiger_Model_GitCredential::TYPE_TOKEN|TYPE_DEPLOY_KEY
* @param string $secret the plaintext token or private key (consumed, never returned)
* @param array $meta public_material?, fingerprint?, scopes?
* @return array{ref:string, public:array}
*/
public function store(string $orgId, string $moduleKey, string $type, string $secret, array $meta = []): array
{
if ($secret === '') {
throw new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_MISSING, 'a credential value is required');
}
if (!Tiger_Crypto::isConfigured()) {
throw new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_INVALID, 'encryption is not configured on this install');
}
$model = new Tiger_Model_GitCredential();
$id = (string) $model->insert([
'org_id' => $orgId,
'module_key' => $moduleKey,
'type' => $type,
'ciphertext' => Tiger_Crypto::encrypt($secret),
'public_material' => $meta['public_material'] ?? null,
'fingerprint' => $meta['fingerprint'] ?? null,
'scopes' => $meta['scopes'] ?? null,
'status' => 'active',
]);
sodium_memzero($secret);
$row = $model->findById($id);
return ['ref' => $id, 'public' => Tiger_Model_GitCredential::publicView($row)];
}

/**
* Resolve a credential reference to its PLAINTEXT secret, for a transport about to make a request.
* The ONLY read path. Returns null when the ref is unknown/revoked/unusable (the transport then
* marks the connection degraded). NEVER call this from a controller or log its result.
*
* @param string $ref a git_credential id or a Tiger_Secret ref
* @return string|null the plaintext secret, or null
* @internal for transports only
*/
public function resolve(string $ref): ?string
{
if ($ref === '') { return null; }
if (self::isSecretRef($ref)) {
$v = Tiger_Secret::get($ref);
return ($v === null || $v === '') ? null : $v;
}
$row = (new Tiger_Model_GitCredential())->findById($ref);
if (!$row || $row->revoked_at !== null) { return null; }
try {
return Tiger_Crypto::decrypt((string) $row->ciphertext);
} catch (\Throwable $e) {
return null; // unusable secret — treated as "not resolvable", never surfaced
}
}

/**
* Rotate: store the new credential, let the caller VERIFY it works, then revoke the old one. The
* old secret is only revoked after the new one is recorded, so a rotation never leaves the owner
* with no working credential. Returns the new ref + public view; the caller verifies, then calls
* `revoke($oldRef)` once verification passes.
*
* @return array{ref:string, public:array}
*/
public function rotate(string $orgId, string $moduleKey, string $type, string $newSecret, array $meta = []): array
{
return $this->store($orgId, $moduleKey, $type, $newSecret, $meta);
}

/**
* Revoke a local credential (mark revoked + soft-delete so the ciphertext stops resolving). A
* Tiger_Secret ref is not ours to revoke — the caller removes it at its authority.
*
* @return bool true if a local credential was revoked
*/
public function revoke(string $ref): bool
{
if ($ref === '' || self::isSecretRef($ref)) { return false; }
$model = new Tiger_Model_GitCredential();
$row = $model->findById($ref);
if (!$row) { return false; }
$row->revoked_at = gmdate('Y-m-d H:i:s');
$row->status = 'revoked';
$row->save();
$model->softDelete($model->getAdapter()->quoteInto('git_credential_id = ?', $ref));
return true;
}

/**
* Generate an Ed25519 deploy keypair (for the ShellTransport path). Returns the OpenSSH PUBLIC key
* line to show the owner (they paste it into the repo's deploy-key box) and its fingerprint; the
* returned `secret` is the private material to hand straight to `store()` and then forget. The
* private key is NEVER returned to any API surface.
*
* @param string $comment a label embedded in the public key (e.g. "tiger:my_module")
* @return array{public_openssh:string, fingerprint:string, secret:string}
*/
public function generateDeployKey(string $comment = 'tiger-git'): array
{
$pair = sodium_crypto_sign_keypair();
$pub = sodium_crypto_sign_publickey($pair); // 32 bytes ed25519 public
$secret = sodium_crypto_sign_secretkey($pair); // 64 bytes (seed+public)

// OpenSSH public-key wire format: string "ssh-ed25519" + string <32-byte key>, base64'd.
$blob = self::sshString('ssh-ed25519') . self::sshString($pub);
$publicLine = 'ssh-ed25519 ' . base64_encode($blob) . ' ' . $comment;
// Fingerprint is SHA256 of the blob, base64 (no padding) — the `SHA256:…` GitHub shows.
$fingerprint = 'SHA256:' . rtrim(base64_encode(hash('sha256', $blob, true)), '=');

return [
'public_openssh' => $publicLine,
'fingerprint' => $fingerprint,
'secret' => base64_encode($secret), // stored encrypted; the shell path unwraps it
];
}

/** SSH wire "string": 4-byte big-endian length prefix + bytes. */
private static function sshString(string $s): string
{
return pack('N', strlen($s)) . $s;
}
}
Loading
Loading