403Webshell
Server IP : 3.147.158.171  /  Your IP : 216.73.216.88
Web Server : Apache/2.4.67 (Amazon Linux) OpenSSL/3.5.5
System : Linux ip-172-31-2-178.us-east-2.compute.internal 6.1.172-216.329.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Wed May 20 06:31:34 UTC 2026 x86_64
User : ec2-user ( 1000)
PHP Version : 8.4.21
Disable Function : NONE
MySQL : OFF  |  cURL : ON  |  WGET : ON  |  Perl : ON  |  Python : OFF  |  Sudo : ON  |  Pkexec : OFF
Directory :  /tsai/repo/.claude/skills/affiliates/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /tsai/repo/.claude/skills/affiliates/SKILL.md
---
name: affiliates
description: Work with the Scike/TSAI affiliates feed — the /site-index endpoint that lists promoted network sites and powers the Angular banner app, the WordPress [tsai_projects] shortcode, and the store's scike_affiliates Drupal module. Use for promoting/demoting a site in the feed, rebuilding the index, clearing any consumer's cache, changing how the grid renders, or debugging a site that is missing from the network listing.
argument-hint: <banner|wordpress|drupal|index> (optional consumer)
---

# Affiliates

One feed, three consumers. Everything that shows "the Scike network" — logos, taglines, and recent
headlines from every promoted site — reads the **same** TSAI API endpoint. Change the data in one
place and all three surfaces follow.

**Argument:** `$ARGUMENTS` — an optional consumer hint (`banner`, `wordpress`, `drupal`, `index`).
With no argument, read section 1, then the section the request implies.

---

## 1. The data contract (read this first)

```
GET {api_url}/api/v1/site-index          → list of IndexedSite rows
GET {api_url}/api/v1/cms/remote-servers/ → {data: [{name, api_url, env}]} for federation
```

Every consumer does the same two calls: fetch the local server's sites, then fetch each remote
server's sites and merge, deduping the result. That is how a tsai-server site appears in a
scike-server listing and vice versa. See "Deduping" below — it is subtler than it looks.

**Row shape** (`IndexedSite` in `api/app/appTypes.py:427`, serialized camelCase):

| Field | Notes |
|---|---|
| `slug` | Short id, also the Drupal username of the site owner |
| `wpHost` | WordPress base URL — the dedupe key, and where article fetches go |
| `name`, `tagline`, `link` | Display fields |
| `chatUrl` | The site's chatbot URL |
| `logoFull`, `logo72`, `logo150`, `logo300` | Consumers fall back through these when one is empty |
| `indexedAt` | Last index time |
| `promoted` | **The public/private gate — see below** |

### `promoted` is derived, not hand-set

`api/scripts/index_sites.py:86` sets `promoted` from the site's WordPress `blog_public` setting
(i.e. whether it is `noindex`). A site that is discouraging search engines is not promoted, and every
public surface filters it out. To promote or demote a site, change **Settings → Reading → Search
engine visibility** in that site's WordPress admin, then re-index (section 5).

The Banner app is the one exception: it shows unpromoted sites too when loaded with `?logged_in=1`.

### Deduping: never key on slug

The same site comes back from more than one server under **different hostnames** — a dev machine
indexes `turmansolutions.test` while the tsai server indexes `turmansolutions.ai`, and
`johnturman.test` is `johnturman.net`. Keying on the full host misses all of those and the site is
listed twice.

Both the Drupal module (`AffiliateIndex::identityKey()`) and the WordPress plugin
(`TSAI_Site_Index::identity_key()`) key on **the host with its TLD removed**, so
`dev.turmansolutions.test` and `dev.turmansolutions.ai` collapse to `dev.turmansolutions`. Keep the
two implementations in step.

**Slug is the trap.** It looks like the natural key and it is wrong: `news.scike.ai` and
`news.turmansolutions.ai` both have the slug `news` and are genuinely different sites. Slugs are
only unique per server — `SlugValidator` checks availability against one API, so nothing prevents
two servers from issuing the same one. Key on slug and you silently hide a real site.

Verified against all three live feeds: 23 rows collapse to 16, every collapsed pair is the same site
by name, and both `news` rows survive.

Because of this, two sites on different servers can legitimately render with the **same display
name**. Every card therefore prints its host (`displayHost()` / `display_host()`, `www.` stripped)
under the title, so the pair is always distinguishable. The host is shown on every card, not only on
a collision, so cards stay consistent — and it tells a visitor where the link goes. Don't "fix" a
duplicate-looking name by changing the dedupe.

### Article lists do NOT come from the TSAI API

Recent headlines are fetched per site from that site's **own** public WordPress REST API:

```
{wpHost}/wp-json/wp/v2/posts?_embed=author,wp:featuredmedia&per_page=N&status=publish&orderby=date&order=desc
```

One request per site. This is the expensive part of rendering the grid and the first thing to
suspect when a listing is slow or a card shows "No recent articles."

---

## 2. The three consumers

| Consumer | Code | Renders |
|---|---|---|
| **Angular banner** | `chatbot/projects/banner/src/app/services/banner.service.ts` | Logos/taglines only, tabbed by server. No article lists. |
| **WordPress plugin** | `plugins/tsai-plugin/includes/class-tsai-site-index.php` + `class-tsai-projects.php` | `[tsai_projects]` shortcode — logos + taglines + recent articles |
| **Drupal store module** | `store/web/modules/custom/scike_affiliates/` | `[scike_affiliates]` filter, a block, and the maintenance-page splash |

The WordPress and Drupal versions are deliberate ports of each other — same attributes, same
fallback logic, same two TTLs. If you change rendering behavior in one, check whether the other
should match. The Angular banner is independent and styled separately; it does **not** share CSS.

---

## 3. The Drupal module (`scike_affiliates`)

Lives at `store/web/modules/custom/scike_affiliates/`. Three surfaces, one render element
(`#type => 'scike_affiliates'`, defined in `src/Element/Affiliates.php`) so they cannot drift.

### Embedding in a page

```
[scike_affiliates count=5 logo=150 remote=1 articles=1 heading="Our network"]
```

Attributes mirror the WordPress shortcode. `articles=0` skips all per-site WP requests — use it
anywhere the grid is decorative, it turns a ~3s cold render into ~1ms.

The filter must be enabled on the text format:

```bash
cd store && ./vendor/bin/drush php:eval '
$f = \Drupal::entityTypeManager()->getStorage("filter_format")->load("full_html");
$f->setFilterConfig("scike_affiliates", ["status" => TRUE, "weight" => 20]);
$f->save();'
```

### As a block

Place "Scike Affiliates" (category TSAI) in any region at `/admin/structure/block`, with per-placement
count/logo/remote/articles settings. Scope it with a Pages visibility condition rather than adding
code — that is how it appears on `/user/provisioning` without touching `tsai_provisioning`.

### The maintenance page

`scike_affiliates_preprocess_maintenance_page()` **replaces** `page.content` with a branded splash
(pulse graphic, headline, body) plus the grid. Note:

- It supersedes the message at `/admin/config/development/maintenance` — that text is no longer shown.
- Copy is configured at `/admin/config/services/scike-affiliates`, not in the maintenance settings.
- The splash logo (`maintenance.show_logo`) is **off by default**. The page header already carries
  the site brand, so a second large mark just repeats it — turn it on only for a theme whose
  maintenance header shows no logo of its own.
- **Turning maintenance mode on does not, by itself, take anonymous visitors off the site.**
  Maintenance mode is *state*, and core's `SiteMaintenanceModeForm` only writes it. The internal
  page cache is middleware and runs ahead of the kernel events `MaintenanceModeSubscriber` listens
  on, so every anonymous page already in that cache keeps serving the live site. The module closes
  this with `scike_affiliates_maintenance_mode_submit()`, which invalidates the `http_response` tag
  on form submit. **Anything that sets the state without going through that form — `drush
  state:set`, an update hook, a deploy script — must flush too**, or the splash silently never
  appears. Turning maintenance *off* needs no flush: the 503 is served `UNCACHEABLE`, so there is
  never a stale maintenance page to clear.
- The `maintenance_page` theme hook is **shared with `update.php`** (`DbUpdateController`) and the
  installer. The hook guards on `MAINTENANCE_MODE`, the `system.maintenance_mode` state, and the
  route name so it can never hide the database-update UI behind marketing copy. **Keep those guards
  if you edit the hook.**
- `tsai_store/templates/layout/maintenance-page.html.twig` overrides kart's template to drop kart's
  hardcoded generic maintenance icon and to fix its site branding: kart omits the logo and wraps the
  name in an `<h4>` that its `maintenance.css` paints orange, so the override mirrors kart's
  `block--system-branding-block.html.twig` markup instead. The maintenance page loads **neither**
  `kart/global-styling` nor `tsai_store/reskin`, so that markup would still be unstyled — the
  `tsai_store/maintenance` library (`css/maintenance.css`) restates the branding slice and the custom
  properties it reads. Edit those two together. The page still renders correctly with the module off.

### Settings

`/admin/config/services/scike-affiliates` — API URL (blank falls back to `$settings['tsai_api_url']`),
the three cache TTLs, request timeout, defaults, and the maintenance splash copy. There is a
**Clear affiliate cache** button there.

---

## 4. Clearing caches (each consumer caches separately)

| Consumer | How |
|---|---|
| Drupal store | `drush cache:tags scike_affiliates`, or the button on the settings form |
| WordPress | `TSAI_Site_Index::clear_cache()` — clears all three transients |
| Angular banner | Client-side only; a page reload refetches |

Default TTLs: site index 5m in the store / 1h in WordPress, articles 30m on success, 5m after a
failure (so a briefly-down site reappears quickly without hammering it). The index is cheap to
refetch; the per-site article requests are the expensive part, which is why they hold longer.

```bash
# Drupal
cd store && ./vendor/bin/drush cache:tags scike_affiliates

# WordPress (per site)
wp eval 'TSAI_Site_Index::clear_cache();' --path=sites/{site}
```

---

## 5. Rebuilding the index

The feed only contains what `index_sites.py` last wrote to `api/data/site_index.db`. Two things
rebuild it on their own:

- **The hourly timer.** `api/cron/tsai-index.timer` fires at `:25` past every hour on both servers
  (`:05` articles, `:15` calendar, `:35` comments, `:45` reconcile fill the other slots). Units live
  in `api/cron/`, are installed by `setup-timers.sh`, and that script now runs from
  `deploy-to-{tsai,scike}.json` — so editing a `.timer` in the repo lands on the next deploy rather
  than needing a manual SSH. Check it with
  `systemctl --user list-timers | grep tsai-index` and `journalctl --user -u tsai-index.service`.
- **Flipping search-engine visibility.** `TSAI_Site_Index::register()` in the WordPress plugin hooks
  `update_option_blog_public` and pushes a rebuild to the API immediately, so taking a site live (or
  hiding it) propagates in seconds instead of waiting for the timer.

Rebuild by hand after anything else the index reads — a new site, a changed logo or tagline:

```bash
# Via the API (async background task, 202, no auth)
curl -X POST {api_url}/api/v1/site/index/rebuild/

# Or directly
cd api && ./venv/bin/python scripts/index_sites.py --env {development|production|scike_ai}

# Or on a server, through the systemd unit (uses the venv and TSAI_ENV from tsai-cron.env)
systemctl --user start tsai-index.service
```

Then clear the consumer caches (section 4) — a rebuilt index stays invisible until the TTL lapses
(store: `index_ttl`, 5m; WordPress transients: 1h).

---

## 6. Debugging a missing or stale site

Work down this list; each step rules out one layer.

1. **Is it in the feed at all?**
   `curl -s {api_url}/api/v1/site-index | python3 -m json.tool | grep -A3 '"slug": "{slug}"'`
2. **Is `promoted` false?** → WordPress Settings → Reading (section 1), then re-index. If the setting
   is already correct, compare `indexedAt` against when it was changed: an index older than the
   change is the whole answer, and the push hook (section 5) either did not fire or the site's
   `tsai_api_url` option is blank. `wp option get tsai_api_url --path=sites/{site}` to check.
3. **In the feed but not on the page?** → a consumer cache (section 4).
4. **Card shows but no articles?** → that site's WP REST API. Check it directly:
   `curl -s '{wpHost}/wp-json/wp/v2/posts?per_page=1'`. A site behind basic auth, with the REST API
   disabled, or with a broken SSL cert will render an empty article list by design.
5. **Nothing renders anywhere?** → the API base URL. In Drupal check
   `drush php:eval 'print \Drupal::service("scike_affiliates.index")->baseUrl();'`; in WordPress
   check the API URL option in the plugin settings.
6. **Duplicate cards?** → check the dedupe key:
   `drush php:eval 'print \Drupal::service("scike_affiliates.index")->identityKey("{wpHost}");'`
   Two rows for the same site must produce the same key. Sites that differ only below the TLD
   (`a.example.ai` vs `a.example.test`) collapse; anything else is treated as distinct. See
   "Deduping" in section 1 before changing this — slug is not a safe key.

7. **Maintenance splash not showing (store serves the normal site logged out)?** → almost always the
   anonymous page cache, not the module. `curl -sI http://store.test/` — a `200` with
   `X-Drupal-Cache: HIT` means a stale cached response is being served ahead of maintenance mode;
   `drush cr` and retry. See the maintenance-page notes in section 3 for why. Only if it still
   serves `200` after a flush is it worth checking `maintenance.enabled` in
   `scike_affiliates.settings` and the guards at the top of the preprocess hook.

Drupal logs to the `scike_affiliates` channel; the WordPress plugin logs to PHP error_log with a
`[TSAI]` prefix. Both fail soft — a dead API yields an empty grid, never a fatal error.

---

## 7. Local testing

```bash
cd store && source ../setup-env.sh

# Does the feed resolve?
./vendor/bin/drush php:eval '
$i = \Drupal::service("scike_affiliates.index");
print $i->baseUrl() . " → " . count($i->getSites(TRUE)) . " sites\n";'

# Render the grid and time it
./vendor/bin/drush php:eval '
$b = ["#type" => "scike_affiliates", "#count" => 3];
$t = microtime(TRUE);
$h = (string) \Drupal::service("renderer")->renderInIsolation($b);
printf("%.2fs, %d cards\n", microtime(TRUE)-$t, substr_count($h, "scike-affiliates__card"));'
```

Requires the local FastAPI backend running on `http://127.0.0.1:8000` (see `api/` — `uvicorn
app.main:app --reload`), since that is what `$settings['tsai_api_url']` points at locally.

To preview the maintenance page:

```bash
cd store
./vendor/bin/drush state:set system.maintenance_mode 1
./vendor/bin/drush cr        # REQUIRED — drush bypasses the form that self-invalidates
curl -sI http://store.test/  # expect: HTTP/1.1 503
./vendor/bin/drush state:set system.maintenance_mode 0
```

Load `http://store.test/` **logged out** — admins are exempt and see the normal site, so a browser
already logged in will show no splash no matter what the state says. `curl` is the reliable check:
`503` means maintenance mode is live, `200` means it is not. If you get `200` with
`X-Drupal-Cache: HIT`, you skipped the `drush cr` above.

Youez - 2016 - github.com/yon3zu
LinuXploit