Report: one source for every file
Status: a report, written by dev@php.newsroom on 25 October 2026, the second of four on the split (Report: one repository into six, the first day in numbers is the first). It describes the design the editor asked for that afternoon and how it is built: what each page loads from where, the route every page carries, the Content-Security-Policy it now has, and the checks that keep it true. Measured at site v0.22.5, press v1.0.5, data v0.1.5.
What was asked
The first cut of the split (press v1.0.0, 17:00 UTC) copied the shared files into every site: each of the four repositories got v1's assets/ and data/, 726 files and 25.95 MB, because every page's Content-Security-Policy allowed scripts, styles and requests only from the page's own host. It worked, and it made four more sources for every script, stylesheet and data file. The editor stopped it:
Hold on, we can change and maintain those CSP. Since we don't want to have engineering issues (and ironically security issues) by having multiple sources for the same files.
So the rule became: every file has one source. A page's HTML comes from the site that owns its section; its assets from the press; its data from the data API; a page of another section from the site that owns that section. The policy is changed to allow exactly that, and maintained by the code that cuts the sites, never by hand.
Why copies are a security problem, not only an engineering one: five copies of a script are five things that can drift, and a fix to one is not a fix to the others. A reviewer who has read the script at the press has not read the copy a site serves. With one source, a script's sha256 in the press's manifest is the script every page runs.
The three sources
| Source | Address | What | Versioned by |
|---|---|---|---|
| The site that owns the section | https://<site>.php.oct.2026.newsroom.sgit.ai/<path> | the page, its markdown twin, the site's VERSION, its 404 router, .well-known/ | v1's version it was cut from (press.json) |
| The press | https://press.php.oct.2026.newsroom.sgit.ai/v/<press>/assets/ | every script, stylesheet, image, font, component and the manifest: 122 files | the press version, 1.0.5 |
| The data API | https://data.php.oct.2026.newsroom.sgit.ai/v/<data>/ | every JSON file a page links or a script fetches: 474 files | the data version, 0.1.5 |
Both the press and the data API serve immutable versions: a file under /v/1.0.5/ never changes, and a change is a new version. That has three consequences. A site names the exact version it was cut against, so a page and the scripts it runs always match. Caches cannot serve a stale file: GitHub Pages sends cache-control: max-age=600, but a new version is a new address. And any reader can check a file: the version's manifest.json lists every file's sha256. GitHub Pages also sends access-control-allow-origin: * on every file (checked on the live v1 site), which is what lets a page on refs fetch a JSON file from the data host and import a module from the press.

What the partition does to a page
The press's tools/partition.py takes v1's committed output at one commit and writes one site. For each page it owns, it:
- Rewrites every link. It reads every
hrefandsrc, resolves it against the page's path, and then: - an asset (
../assets/site.css) becomeshttps://press.php.oct.2026.newsroom.sgit.ai/v/1.0.5/assets/site.css; - a data file the API publishes (
../data/graph.json) becomeshttps://data.php.oct.2026.newsroom.sgit.ai/v/0.1.5/graph.json; - a page of another site's section (
../stack/index.html, from refs) becomeshttps://php.oct.2026.newsroom.sgit.ai/stack/; - a v1 source no site serves (a review's markdown) becomes its file on GitHub;
- a link within the site stays relative.
Root-relative links (/assets/…) are resolved from the site's root.
- Rewrites absolute v1 addresses of assets and data anywhere in the page (an image in its metadata, a link in a markdown twin) the same way.
- Extends its Content-Security-Policy (below).
- Writes its route, a
<meta name="p2s-route">right after<meta charset>(below). - Names its own address. A page that moved to refs says so in its canonical link, its
og:urland its markdown twin's self-link.
It also writes a redirect page at every address another site owns, the site's sitemap.xml, robots.txt (the main site, still staged, disallows everything), CNAME and .nojekyll, installs the site's gate and release, and records all of it in press.json: the press and data versions, the v1 commit, the sha256 of every file it wrote, every site's pages, the press's assets and the data's files.
The route, and the one resolver
Links in the HTML are rewritten when the site is cut. URLs a script builds at run time are not in the HTML, so the page carries a route and every script resolves through one function. The route on refs, abbreviated:
{"self": "https://refs.php.oct.2026.newsroom.sgit.ai",
"assets": "https://press.php.oct.2026.newsroom.sgit.ai/v/1.0.5/assets/",
"data": "https://data.php.oct.2026.newsroom.sgit.ai/v/0.1.5/",
"sections": {"admin": "https://team.…", "feeds": "https://updates.…", "sources": "https://refs.…",
"VERSION": "https://refs.…", "404.html": "https://refs.…", ".well-known": "https://refs.…", …},
"rest": "https://php.oct.2026.newsroom.sgit.ai"}
assets/route.js (36 lines, in v1 since v0.22.2) is the first script of every page, loaded without defer. It defines window.P2S_URL(u):
1. Resolve u against the page as loaded (window.P2S_BASE), not the address bar: the line moves the bar to a view (/sources/as/executive/), and the policy forbids <base> (base-uri 'none').
2. Without a route (v1's own site), or for another origin, return it unchanged. On v1 nothing changes; that was tested before it shipped.
3. assets/… goes to the route's assets, data/… to its data, a first segment the route names to that site, anything else to rest (the main site). A URL already on the page's own site stays as it is.
fetch() is wrapped to go through it, for strings and URL objects alike. The scripts that import or load other scripts call it themselves: the site tools (the Ask and Feedback buttons), the Ask pane (its libraries, the vault, the feedback pane), the phone app's now.js, and boot.js, which keeps only a guard. The resolver lives in v1's assets, so the press publishes it like every other script: one source for it too.
The policy, before and after
The policy was written by v1's build and hashes each page's inline scripts (admin/build/csp.py). The partition does not rewrite it; it adds hosts to six directives and leaves everything else as v1 wrote it, including every inline-script hash. On refs' sources page:
| Directive | v1 | On the split sites, added |
|---|---|---|
script-src | 'self' and the page's inline-script hashes | the press |
style-src | 'self' 'unsafe-inline' | the press |
img-src | 'self' data: blob: GitHub's two image hosts | the press |
font-src | 'self' data: | the press |
manifest-src | 'self' | the press |
connect-src | 'self', the vault API, OpenRouter, two geolocation services, GitHub's API | the press, the data API, and the seven addresses of the four sites (each site's permanent name and where it is served today) |
default-src, frame-src, worker-src, media-src, object-src, base-uri, form-action | as v1 wrote them | nothing |
The policy grows from 524 characters (v1's front page) to between 1,044 and 1,098. Every added source is an exact https:// host name: no wildcard, no scheme source, no path that could be widened. object-src 'none', base-uri 'none' and form-action 'self' are unchanged. worker-src is unchanged because a worker must be same-origin anyway, and no page starts one from the press.
What the change trusts, and why that is acceptable: every page now runs scripts from the press, so whoever can publish the press can change every page. That was already true of v1's own repository for v1's pages; it moves the trust, it does not add a party. The press and the data API are in the same organisation, meant to carry the same rulesets as v1 (no force-push, dev protected, main protected: step 0 of the plan; this session cannot read GitHub's settings, so that is the editor's to confirm), and a change to the press goes to security@php.newsroom for review before it is released (its CLAUDE.md). Pinned versions narrow it further: a site cut against press v1.0.5 runs v1.0.5's scripts until it is cut again; publishing v1.0.6 changes no page by itself. This was put to security in the release notes of press v1.0.1 and v1 v0.22.5.
Checked after the change, on all four sites: 1,099 policies (1,095 pages and four copies of the 404 router) keep every hash v1 wrote; all 198 executable inline scripts hash to a value their own page's policy allows (v1: 195 of 195); and in the browser, no page raised a policy violation.
What a site keeps
sites.json lists it: VERSION, 404.html, 404.md and .well-known/. Each site answers its own version check (the route maps VERSION to the site itself, since press v1.0.3) and its own 404, which is how a view address (/agencies/as/security/) and a moved address are answered on GitHub Pages. Everything else a page loads is somewhere else, once.
The checks that keep it true
| Check | Where | What it refuses |
|---|---|---|
| A released v1 | the partition, tools/assets_v1.py | a v1 clone with uncommitted changes: a site and the press's assets are cut only from a commit that passed v1's gate |
| The press holds this commit's assets | the partition | a press version whose assets differ, by one byte, from v1's at the commit being cut; it names the first files that differ |
| The data version exists | the partition | a data version that is not published |
| Every link to the press names an asset that version publishes | each site's gate | a link to a file the press does not serve |
| Every link to the API names a file that data version publishes | each site's gate | a link to a file the API does not serve |
| Every cross-site link names a page that site serves | each site's gate | a link to a page that is not there |
| Every file is what the press wrote | each site's gate | a hand edit |
| A published version never changes | the press and data releases | different bytes under a version already published |
| A new file has a schema | the data gate | a file whose shape nobody decided |
What the API had to gain
Moving every data/ request to the API meant the API had to hold every file a script fetches, not only the story's data. It gained eight in two releases: items, telemetry, contributors and collaborators (v0.1.2); control, personas, stack and subscriptions (v0.1.4), found by comparing every data/*.json name in the scripts and every data-src attribute in the pages with the catalogue. They are the "runtime" group. Two of them, telemetry and subscriptions, carry their lane's append token: write-only, public by design and already in v1's history. A published version keeps the token it was published with; a rotated token is a new version, and the old one stays readable. That was flagged to security in v1 v0.22.4's release.
What it costs
Every v1 release now needs the chain: the press takes the assets and releases, the data is imported and released, each site is cut and released (about five minutes by hand; see Report: one repository into six, how to work in it). Every page makes its asset requests to a second host, a DNS lookup and a TLS connection more on the first visit, the same on later ones. The policy is twice as long. In exchange: one source for every file; 2,904 copied files and 104 MB gone from the sites; and a gate on every site that can say exactly which press and data version each page was cut against.