diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 53fadb0..f61c983 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -145,6 +145,16 @@ jobs: check "login /login" 200 "http://127.0.0.1:8000/login" check "/api gateway alive" 200 "http://127.0.0.1:8000/api" check "unknown slug 404" 404 "http://127.0.0.1:8000/no-such-page-xyzzy" + check "org-scoped CMS page" 200 "http://127.0.0.1:8000/ci-smoke-page" + + # The seeded page (org_id = the site org) must actually DISPATCH — its body has to render, not a + # 404 shell. This is the guard for the org_id write-stamp ↔ read-scope path: a mismatch 404s here. + curl -s "http://127.0.0.1:8000/ci-smoke-page" >page.html || true + if grep -q "CI_SMOKE_PAGE_OK" page.html; then + echo "PASS org-scoped CMS page body dispatched" + else + echo "FAIL org-scoped CMS page body not found (org write/read mismatch?)"; echo " body: $(head -c 300 page.html)"; fail=1 + fi # the /api gateway must answer with the JSON envelope contract (result key present), even for # an empty/refused message — proves the webservice layer is wired, not just returning a page. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0893c..1c40a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +### Changed +- **`org_id` is now stamped on every tenant write, like `created_by`.** `Tiger_Model_Table` auto-stamps + `org_id` from the current org (`setOrg()`, wired from the authenticated membership in the Authorization + plugin) on any table that has the column — so content is *owned* instead of left at the empty default, + with no per-module boilerplate. An explicit `org_id` still wins; system/global writes (no org) keep `''`. + Fixes CMS pages + blog articles, which never stored `org_id`. +- **Public read dispatch resolves the site org.** `Tiger_Model_Org::siteOrgId()` (configured + `tiger.site.org_id`, else the founding org) is what page/article dispatch scopes to; `_orgScope('')` + resolves to it, so a public read finds the site's own content, not just shared `''` rows. The read scope + stays `[org, '']`, so nothing 404s during the transition. This is the exact seam a future multi-site + module overrides per request host (`setSiteOrgId()`) — the base stays single-site and dumb. +- Migration `0033` backfills legacy `page.org_id = ''` content to the site org. + ### Added - **TigerSEO — Phase 1 (the head registry).** A new bundled `seo` module makes the data Tiger already collects actually reach the `
`. Core hygiene: the public PUMA layout now renders through TigerZF's diff --git a/ci/smoke-boot.php b/ci/smoke-boot.php index 96043be..4ad10f3 100644 --- a/ci/smoke-boot.php +++ b/ci/smoke-boot.php @@ -30,4 +30,30 @@ exit(1); } -echo "smoke boot OK — tiger-core v" . Tiger_Version::VERSION . ", org=$orgs user=$users\n"; +// Seed a PUBLISHED CMS page stamped with the site org, so the HTTP smoke can prove public page dispatch +// resolves org-owned content (the org_id write-stamp + read-scope path). Idempotent-ish: fresh CI DB. +$siteOrg = Tiger_Model_Org::siteOrgId(); +if ($siteOrg === '') { + fwrite(STDERR, "smoke: could not resolve the site org\n"); + exit(1); +} +Tiger_Model_Table::setOrg($siteOrg); // the base model will stamp org_id from this +$pageModel = new Tiger_Model_Page(); +$pageModel->insert([ + 'type' => Tiger_Model_Page::TYPE_PAGE, + 'page_key' => 'ci-smoke-page', + 'slug' => 'ci-smoke-page', + 'locale' => 'en', + 'title' => 'CI Smoke Page', + 'body' => 'CI_SMOKE_PAGE_OK', + 'format' => 'html', + 'status' => Tiger_Model_Page::STATUS_PUBLISHED, + 'published_at' => null, +]); +$seeded = $db->fetchRow("SELECT org_id, status FROM page WHERE slug = 'ci-smoke-page'"); +if (!$seeded || (string) $seeded['org_id'] !== (string) $siteOrg) { + fwrite(STDERR, "smoke: seeded page missing or not org-stamped (got org_id=" . ($seeded['org_id'] ?? 'none') . ", want $siteOrg)\n"); + exit(1); +} + +echo "smoke boot OK — tiger-core v" . Tiger_Version::VERSION . ", org=$orgs user=$users; seeded page org_id=" . $seeded['org_id'] . "\n"; diff --git a/library/Tiger/Controller/Plugin/Authorization.php b/library/Tiger/Controller/Plugin/Authorization.php index 95071a2..b582fdf 100644 --- a/library/Tiger/Controller/Plugin/Authorization.php +++ b/library/Tiger/Controller/Plugin/Authorization.php @@ -95,6 +95,7 @@ protected function _resolveRole() } Tiger_Model_Table::setActor($identity->user_id); // created_by/updated_by flow + Tiger_Model_Table::setOrg((string) ($identity->org_id ?? '')); // org_id (tenant) flow $role = self::ROLE_AUTHENTICATED; if (!empty($identity->org_id)) { diff --git a/library/Tiger/Controller/Plugin/PageDispatch.php b/library/Tiger/Controller/Plugin/PageDispatch.php index 1037a62..d6a89dc 100644 --- a/library/Tiger/Controller/Plugin/PageDispatch.php +++ b/library/Tiger/Controller/Plugin/PageDispatch.php @@ -46,7 +46,9 @@ public function routeShutdown(Zend_Controller_Request_Abstract $request) return; // the root belongs to IndexController (the landing) } $locale = defined('LANG') ? LANG : 'en'; - $orgId = ''; // global public pages for now (host->org mapping is future) + $orgId = Tiger_Model_Org::siteOrgId(); // the org owning this public site (root org on a stock + // install; a multi-site module resolves host->org). The + // read scope is [org, ''], so shared '' content still shows. try { // Only real pages answer at the site root — articles/posts route under /blog. diff --git a/library/Tiger/Model/Org.php b/library/Tiger/Model/Org.php index 40d6cdb..d4dcf17 100644 --- a/library/Tiger/Model/Org.php +++ b/library/Tiger/Model/Org.php @@ -23,6 +23,52 @@ class Tiger_Model_Org extends Tiger_Model_Table protected $_name = 'org'; protected $_primary = 'org_id'; + /** @var string|null the current-site org id, resolved once per request */ + private static $_siteOrgId = null; + + /** + * The current site's org — the tenant that owns the PUBLIC site being served. Public read dispatch + * (CMS pages, blog articles) scopes to this; content writes are stamped from the authenticated org + * (Tiger_Model_Table::setOrg). Resolution: the configured `tiger.site.org_id` (recorded at install / + * settable in Site settings), else the **founding org** (the oldest) as a heuristic. NB: org hierarchy + * (`parent_org_id`) is future — right now every org is a root, so "root" isn't a distinguishing query; + * the site org is the platform's founding org. A multi-site module overrides this per request from the + * request host (setSiteOrgId). Cached per request. + * + * @return string the site org id, or '' if no org exists yet (a pre-install install) + */ + public static function siteOrgId() + { + if (self::$_siteOrgId !== null) { + return self::$_siteOrgId; + } + $cfg = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + $t = $cfg ? $cfg->get('tiger') : null; + $s = $t ? $t->get('site') : null; + $id = ($s && $s->get('org_id')) ? (string) $s->org_id : ''; + if ($id === '') { + try { + $m = new self(); + $row = $m->fetchRow($m->activeSelect()->order('created_at ASC')->limit(1)); // the founding org + $id = $row ? (string) $row->org_id : ''; + } catch (Throwable $e) { + $id = ''; + } + } + return self::$_siteOrgId = $id; + } + + /** + * Override the current-site org (a multi-site module resolves this from the request host, early). + * + * @param string $orgId + * @return void + */ + public static function setSiteOrgId($orgId) + { + self::$_siteOrgId = (string) $orgId; + } + /** * Find an org by its URL-safe slug (the human/route-facing identifier). * diff --git a/library/Tiger/Model/Page.php b/library/Tiger/Model/Page.php index a630ba3..2b51015 100644 --- a/library/Tiger/Model/Page.php +++ b/library/Tiger/Model/Page.php @@ -233,9 +233,18 @@ public function datatable(array $opts) return ['total' => $total, 'filtered' => $filtered, 'rows' => $db->fetchAll($pageSel)]; } - /** The org scope for a cascade lookup: [