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
10 changes: 10 additions & 0 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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]

### 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 `<head>`. Core hygiene: the public PUMA layout now renders through TigerZF's
Expand Down
28 changes: 27 additions & 1 deletion ci/smoke-boot.php
Original file line number Diff line number Diff line change
Expand Up @@ -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";
1 change: 1 addition & 0 deletions library/Tiger/Controller/Plugin/Authorization.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
4 changes: 3 additions & 1 deletion library/Tiger/Controller/Plugin/PageDispatch.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions library/Tiger/Model/Org.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down
13 changes: 11 additions & 2 deletions library/Tiger/Model/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -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: [<org>, ''] (deduped; '' = global). */
/**
* The org scope for a cascade lookup: [<org>, ''] (deduped; '' = shared-across-sites fallback, which
* loses to a real org via `ORDER BY org_id DESC`). A blank org means "the current site" — it resolves
* to Tiger_Model_Org::siteOrgId() so a public read (which passes '') scopes to the site's own content
* now that content carries a real org_id, not just the shared '' rows.
*/
protected function _orgScope($orgId)
{
return array_values(array_unique([(string) $orgId, '']));
$orgId = (string) $orgId;
if ($orgId === '') {
$orgId = Tiger_Model_Org::siteOrgId();
}
return array_values(array_unique([$orgId, '']));
}
}
39 changes: 39 additions & 0 deletions library/Tiger/Model/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,38 @@ public static function actor()
return self::$_actor;
}

/**
* The "current org" — the tenant an insert is credited to, stamped into `org_id` the same way the
* actor is stamped into created_by/updated_by. Request-wide (static). The auth layer calls setOrg()
* per request with the user's active org; null leaves org_id at its column default ('' = platform/
* global — a system row, a shipped translation). The multi-site module overrides this per domain.
*
* @var string|null
*/
private static $_org = null;

/**
* Set the current org (an org_id). Auth calls this per request from the active membership; a
* multi-site module overrides it from the request host. An explicitly passed org_id always wins.
*
* @param string|null $orgId the acting org's id, or null for platform/global scope
* @return void
*/
public static function setOrg($orgId)
{
self::$_org = $orgId;
}

/**
* The current org_id, or null in a platform/global context.
*
* @return string|null the acting org's id, or null
*/
public static function org()
{
return self::$_org;
}

/**
* Insert a row: mint the UUID PK, stamp created_at/updated_at and (if an actor
* is set) created_by/updated_by. Any of these you pass explicitly wins.
Expand Down Expand Up @@ -107,6 +139,13 @@ public function insert(array $data)
}
}

// Tenant stamp: a row on an org_id table is credited to the current org, so content is owned
// rather than left at the '' default. Only when an org is set (an authenticated request) and the
// caller didn't pass org_id explicitly — system/global writes (no org) keep the column default.
if (self::$_org !== null && $this->_hasColumn('org_id') && !array_key_exists('org_id', $data)) {
$data['org_id'] = self::$_org;
}

parent::insert($data);

// Return the UUID we generated — NOT lastInsertId(), which is empty for a
Expand Down
24 changes: 24 additions & 0 deletions migrations/0033_page_org_backfill.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Backfill `page.org_id` for content that predates org_id write-stamping. Historically CMS pages (and
* blog articles — both live in `page`) were saved with `org_id = ''`; now that the base model stamps the
* authenticated org and public reads scope to the site org, that legacy `''` content is assigned to the
* **site org** (Tiger_Model_Org::siteOrgId() — the configured `tiger.site.org_id`, else the founding org)
* so it's owned rather than shared. A fresh install (empty `page`) no-ops; a stock install stamps its one
* site org; the read cascade [org, ''] means nothing 404s at any point during this transition.
*
* DATA migration (a PHP callable — see Tiger_Db_Migrator). One-way: `down` is a no-op.
*/
return [
'up' => [
function ($db) {
$org = Tiger_Model_Org::siteOrgId();
if ($org !== '') {
$db->update('page', ['org_id' => $org], "org_id = ''");
}
},
],
'down' => [],
];
2 changes: 1 addition & 1 deletion modules/blog/controllers/IndexController.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public function indexAction()
*/
public function viewAction()
{
$post = $this->_posts->resolveArticle((string) $this->getParam('slug', ''), $this->_locale(), '');
$post = $this->_posts->resolveArticle((string) $this->getParam('slug', ''), $this->_locale(), Tiger_Model_Org::siteOrgId());
if (!$post) {
throw new Zend_Controller_Action_Exception('Article not found', 404);
}
Expand Down
5 changes: 4 additions & 1 deletion modules/blog/models/Taxonomy.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,13 @@ public function slugify($text)
return trim((string) $text, '-');
}

/** org scope: tenant row overrides global (''), mirroring Tiger_Model_Page. */
/** org scope: tenant row overrides shared (''); a blank org means "the current site". Mirrors Tiger_Model_Page. */
protected function _orgScope($orgId)
{
$orgId = (string) $orgId;
if ($orgId === '') {
$orgId = Tiger_Model_Org::siteOrgId();
}
return $orgId !== '' ? [$orgId, ''] : [''];
}
}
Loading