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
95 changes: 95 additions & 0 deletions .github/workflows/release-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# The release validation, on Linux, exactly as the release checklist names it.
#
# WHY THIS EXISTS. `npm test` is the evidence behind "Supported": it renders the
# shipped shells on the REAL template engines -- PasarGuard's Jinja2 and
# Rebecca's pongo2 -- rather than on a stand-in. That makes the suite depend on
# three tools the Node runtime alone does not provide:
#
# Go tools/fixtures (the fixture renderer) and
# tools/engines/pongo2 (Rebecca's engine, v6.1.0, the version
# Rebecca pins) are both built from source
# Python 3 + tools/engines/jinja2_pasarguard.py renders the PasarGuard
# Jinja2 shells with the real Jinja2
# ShellCheck npm run lint:sh gates the installer and the release tooling
#
# A developer's machine may have all three; a fresh checkout may not. The point
# of running here is that "the suite passes" stops depending on whose machine it
# ran on, and that the four commands below are the same four the release
# checklist names, in the same order, each reporting its own result.
#
# NOT the Docs workflow. That one builds and publishes docs/ and never runs the
# suite; a green "build" check on a pull request says nothing about this.

name: Release validation

on:
pull_request:
paths:
- "src/**"
- "tools/**"
- "tests/**"
- "installer/**"
- "template/**"
- "package.json"
- "VERSION"
# Not in the release tarball, but it is the file a release-facing pull
# request usually ends on. Leaving it out means the head commit of such a
# pull request carries no check result at all, which reads as "unverified"
# rather than "unaffected".
- "CHANGELOG.md"
- ".github/workflows/release-validation.yml"
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4

# No `cache: npm`: this project has no dependencies and no lockfile, so
# the cache step would have nothing to hash and would fail.
- uses: actions/setup-node@v4
with:
node-version: "22"

- uses: actions/setup-go@v5
with:
go-version: "1.22"

- name: Install the test toolchain (Python 3 + Jinja2, ShellCheck)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends python3-jinja2 shellcheck

- name: Show the toolchain
run: |
node --version
go version
python3 -c 'import jinja2; print("jinja2", jinja2.__version__)'
shellcheck --version

# `npm test` runs `pretest` (the fixture renderer) first, which needs Go.
- name: npm test
run: npm test

# Every command runs even when an earlier one failed: the point of this
# job is the four results, not the first one. The job still fails if any
# step did, because a failed step fails the job whatever the later ones
# report. Without this, a red `npm test` would hide the other three.
- name: npm run verify
if: ${{ !cancelled() }}
run: npm run verify

- name: npm run lint:sh
if: ${{ !cancelled() }}
run: npm run lint:sh

- name: npm run build:panels
if: ${{ !cancelled() }}
run: npm run build:panels
78 changes: 78 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,84 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

Fixes found while hardening 1.3.0 against the panels' own source. Nothing changes
for an existing 3X-UI install, and no adapter or panel-behaviour change is
involved.

### Fixed

- **PasarGuard: the page no longer titles itself "Subscription".** PasarGuard's
page context carries the subscriber (`user.username`) and the admin's own
columns, but **not** the panel-wide subscription settings, so the template had
nothing to read and every page fell back to the generic word. The page is now
named by the admin's configured profile title when there is one, and by the
subscriber otherwise — the order PasarGuard itself resolves a title in. A
profile title that still carries a PasarGuard format placeholder
(`{DATA_LIMIT}`, `{EXPIRE_DATE}`, `{USERNAME}`, …) is refused rather than
printed literally, because the page cannot resolve those variables and a
literal placeholder is worse than the subscriber's own name. The subscriber's
address (`user.ip`) is still never read.
- **Rebecca: the page no longer titles itself "Subscription" either.** Rebecca's
page context carries **no profile title at all**: the panel-wide
`subscription_profile_title` is written into the `/info` `profile-title`
**header** (`subscriptionHeaders`) and the page render path
(`renderSubscriptionHTML` → `renderSubscriptionPageTemplate`) never passes it,
so the title the adapter reads from that header is invisible to the page. The
one identity the page does receive is `user.username`, so that now names the
page — the same fallback PasarGuard takes when no profile title is
configured. A value with no word in it at all (absent, empty,
whitespace-only) is not a name and the page keeps its generic fallback; the
value is never rewritten. No `{` guard is needed here, unlike PasarGuard:
Rebecca never runs a title through `str.format_map`. The subscriber's token
and address are still never read, and nothing changes for an existing 3X-UI
or PasarGuard install.
- **Country flags: a node name that carries an ISO alpha-2 code now gets that
country's flag.** `TR | Istanbul`, `RU-01`, `GB-LON-1`, `FI-01`, `DE` and the
rest previously found no regional-indicator pair and fell to the monogram
(`T-I`, `R-U`, `G-B`, `F-I`) — which on any platform reads as the code, which
is why the badge appeared to show `TR`/`RU`/`GB`/`FI` instead of a flag. A
two-letter token is read as a country only when it is uppercase, stands alone
between non-letters, and names a real country in the **same 258-code registry
the emoji path already uses**; `LON` out of `GB-LON-1`, `USA`, `A1B`, `ZZ-01`
and lowercase English words (`no`, `it`, `us`, `in`) are all still refused. An
explicit flag emoji still wins over a code in the same name, and the displayed
name is never rewritten. Country *names* and *city names* — `Turkey -
Istanbul`, `Türkiye`, `Turkiye`, `Finland Helsinki` — still draw the monogram:
a name table does not fit the frozen artifact budget, and guessing from an
ambiguous name is worse than a monogram. All 15 artifacts were re-baselined
(+179 B each; `pulsenova` is 204,705 B with 95 B of headroom).

### Documentation

- `docs/design/PGCLOCK-AUDIT.md` — a read-only audit of the third-party PGClock
template as a secondary reference. It confirms `user.username` as the exposed
subscriber identity, has no service-name, support-URL or flag logic, and is
unlicensed, so nothing was derived from it and `PROVENANCE.md` is unchanged.
- `docs/design/PASARGUARD-ADAPTER-AUDIT.md` §8 — the page context is not the
`/info` payload: the headers carry the values the panel *resolved*, the page
context carries the admin's own columns and nothing else.
- `docs/design/REBECCA-ADAPTER-AUDIT.md` §2 — the same distinction for Rebecca,
stated against `subscriptionHeaders` and `subscriptionTemplateContext`: the
page context carries no profile title at all, so the page's title and the
adapter's header-derived `title` field can legitimately differ.
- `docs/design/FLAG-RENDERER-AUDIT.md` §17 — the alpha-2 path is an **input**
extension; the return shape, `CODES` and every fallback are unchanged.

### Development

- **The release validation now runs on Linux, in CI.** The repository's only
workflow was `Docs`, which builds the documentation site and never ran the
suite — so a green check on a pull request said nothing about whether the page
still rendered. `.github/workflows/release-validation.yml` runs the four
release commands (`npm test`, `npm run verify`, `npm run lint:sh`,
`npm run build:panels`) on `ubuntu-latest`, installing the three tools the
suite needs and a Node runtime does not provide: **Go** (the fixture renderer
and the pongo2 harness), **Python 3 with Jinja2** (the PasarGuard harness) and
**ShellCheck**. Each command reports its own result even when an earlier one
fails, because the point of the job is the four results.

## [1.3.0] - 2026-09-26

Row-Template now installs on **PasarGuard** and **Rebecca** as well as 3X-UI,
Expand Down
120 changes: 120 additions & 0 deletions docs/design/FLAG-RENDERER-AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -976,3 +976,123 @@ See the **Phase 1 status** block at the head of this document for the shipped fi
for the one gate that is *not* satisfied on exit: the §10 ≥ 1 KB headroom floor.

FLAG RENDERER IMPLEMENTED — COMMITTED AT `4a95cbc` — PREVIEWS REFRESHED AT `a012108`

---

## 17. Input-side alpha-2 support — 1.3.0 hardening (added 2026-09-27)

**Status: IMPLEMENTED.** `flagOf` gained an **input** form. Its **return shape,
its registry and its fallbacks are unchanged.**

### 17.1 The defect

Operators name nodes with the country as an **ISO 3166-1 alpha-2 code**, not as a
flag emoji: `TR | Istanbul`, `RU-01`, `GB-LON-1`, `FI-01`, `DE`. `flagOf` only
ever looked for a **regional-indicator pair** (`U+1F1E6`–`U+1F1FF`), so all of
these returned `''` and the row fell to the **monogram** — `T-I`, `R-U`, `G-B`,
`F-I`. On Windows, where the emoji path itself already degrades to letters (§2),
the two states look alike, which is why the defect read as "the badge shows
`TR`/`RU`/`GB`/`FI` instead of a flag".

### 17.2 Why this does not contradict Correction B

Correction B in the Phase 0.6 table above reads *"`flagOf()` keeps returning the
**regional-indicator pair** — no alpha-2 change"*, and §5 rule 2 expands it:
*"`flagOf` keeps its public shape. It returns the **regional-indicator pair**,
not an alpha-2 code. `cfg.flag` stays `'🇩🇪'`."*

That rule is about the **output**: the function must not start returning `'DE'`
instead of `'🇩🇪'`, because `cfg.flag` is the key into `FLAGS` and the text of the
badge. It is preserved exactly:

| Contract | Before | After |
|---|---|---|
| return value | a regional-indicator pair, or `''` | **unchanged** |
| `cfg.flag` | `'🇩🇪'` | **unchanged** |
| `CODES` | the validity source, 258 assigned codes | **unchanged** |
| the monogram fallback | `flagOf(name) === ''` | **unchanged** |
| `cleanName` | strips indicators, tidies separators | **unchanged** |
| existing tests | 13 in `tests/flag.test.mjs` | **all pass verbatim** |

What changed is the **input** vocabulary: a bare uppercase two-letter token is now
read as a code *in addition to* the emoji scan. §8's `flagOf()` **unchanged** row
belongs to the gradient workstream, which never had cause to touch the input side;
it is not a freeze on the function's input contract.

### 17.3 The rule, and why it is narrow

A two-letter run is read as a code only when it is **uppercase** and **stands
alone between non-letters**:

```js
const CODE = /(?:^|[^A-Z])([A-Z]{2})(?![A-Z])/;
```

- **Uppercase only.** A case-insensitive match would make `no`, `it`, `us`, `in`,
`at`, `be`, `so` and `do` — all assigned codes — match inside ordinary English
node names (`no flag here`, `it support`, `us east`). Every one of those is
pinned as a refusal in the tests.
- **Bounded on both sides.** `LON` out of `GB-LON-1` is not a code, `USA` is not
a code, and `A1B` is not a code. `_` counts as a separator, so `TR_01` works.
- **Membership is `CODES`.** An unassigned pair (`ZZ`, `QQ`, `XX`) is refused
exactly as it is on the emoji path — the same 258-code registry, so the two
paths can never disagree about what a real country is. `UK` is accepted because
the registry already carries it as exceptionally reserved; `UK London` therefore
resolves to the UK flag emoji while `GB-LON-1` resolves to the GB one. Both are
real, and that is the registry's own doctrine, not a new decision.
- **The emoji wins.** An explicit flag emoji is the more specific claim, so it is
still preferred when both appear in one name.
- **The label is never rewritten.** An emoji means a flag and nothing else, but
two uppercase letters can be an operator's own wording, so the code is read and
**not** stripped from the displayed name.

### 17.4 Measured cost, and what did not fit

| Artifact | Before | After | Headroom |
|---|---|---|---|
| `pulsenova` | 204,526 | **204,705** | 274 → **95** |
| `editorial` | 204,107 | **204,286** | 693 → **514** |
| `signature` | 203,938 | **204,117** | 862 → **683** |
| `notebook` | 203,764 | **203,943** | 1,036 → **857** |

**+179 B**, on every artifact, because `flag.js` is in the shared app script (§9).
The ceiling was **not** raised; every artifact is inside 204,800 B. `pulsenova`
is the tightest at **95 B**, below the 258 B the Phase 1 block records and below
§10's ≥ 1 KB floor, which was already unmet on entry.

### 17.5 The boundary that was NOT crossed

Country **names**, **city names** and **transliterations** are not resolved, and
this is a deliberate stop rather than an oversight:

1. **A country-name table does not fit.** `Turkey`/`Türkiye`/`Turkiye`,
`Russia`, `Finland` and ~250 others, in the spellings operators actually use,
is a table of well over 1 KB — an order of magnitude more than the 95 B of
headroom that remains. It cannot be added without removing bytes from
somewhere else or raising the ceiling, and both are out of scope for this
change.
2. **A city table is worse.** `Istanbul → TR` is deterministic; `London → GB or
UK` already is not; and the table grows without a natural bound.
3. **The rule for ambiguity is "refuse".** The task's own instruction — *do not
infer from ambiguous names* — and §5's fidelity rule both point the same way.
A wrong flag is a factual error; the monogram is merely plain.

So `Turkey - Istanbul`, `Türkiye`, `Turkiye`, `Russia Moscow`, `United Kingdom
London` and `Finland Helsinki` still draw the monogram, and the tests **assert
that they do** — recording the boundary rather than pretending to cross it. Names
that carry an alpha-2 code or an emoji — including every reported real-world case
that had a code — now draw a flag.

### 17.6 Locks and tests

All **15** byte-locks were re-baselined (`tests/build.test.mjs`: the 4 individual
locks and the 13-row `FROZEN_ARTIFACTS` table), because `flag.js` is shared.
`tests/flag.test.mjs` went from **13 tests to 20** — seven added: the reported
cases; **all 676 letter pairs** against the 258-code registry; the
uppercase/bounded/assigned refusals; emoji-beats-code; a re-assertion that no
previous answer moved; that the label is left exactly as the operator wrote it;
and the country-name boundary. The previous 13 tests pass verbatim.

---

ALPHA-2 INPUT PATH ADDED — RETURN SHAPE, `CODES` AND FALLBACKS UNCHANGED — 15 LOCKS RE-BASELINED
7 changes: 7 additions & 0 deletions docs/design/PANEL-COMPATIBILITY-AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,13 @@ subscriber's own address is deliberately excluded).
| `datepicker` | `.datepicker` | — | — |
| `links[]` | `.links` | `links` context key (may be `[]`) | `links` / `user.links` |

> **The `subTitle` row is the `/info` mapping, not the page's.** Both panels
> resolve that header from panel-wide settings, but **neither page context
> carries it**: the page's own title is derived from the subscriber
> (`user.username`, and on PasarGuard the admin's own `profile_title` first).
> The header value and the page title can therefore legitimately differ. See
> `PASARGUARD-ADAPTER-AUDIT.md` §8 and `REBECCA-ADAPTER-AUDIT.md` §2.

Additional data neither panel needs to fake because the UI does not use it: the combined
`used` is computed as `download + upload` in `normalize()`; with no split available the
adapter supplies the combined value and the UI renders correctly (§0.5).
Expand Down
Loading
Loading