Skip to content

fix(inbound): host-gate SEO routes to the posternonline.com zone (v1.4.4) - #580

Merged
Mackaye (skyphusion-mackaye) merged 3 commits into
mainfrom
fix/v1.4.4-seo-host-gate
Aug 7, 2026
Merged

fix(inbound): host-gate SEO routes to the posternonline.com zone (v1.4.4)#580
Mackaye (skyphusion-mackaye) merged 3 commits into
mainfrom
fix/v1.4.4-seo-host-gate

Conversation

@skyphusion-albini

@skyphusion-albini Albini (skyphusion-albini) commented Aug 7, 2026

Copy link
Copy Markdown
Member

Closes #579

Why (per task)

1. Host-gate the SEO routes (the blocker). /robots.txt and /sitemap.xml
(inbound/src/api.ts) hardcode DEMO_CANONICAL_ORIGIN = "https://demo.posternonline.com" and were served on EVERY host. Postern is a
self-hostable product (DEPLOY.md); an operator deploying on their own mail
domain served a robots.txt/sitemap.xml advertising the Skyphusion Labs demo
instead of their own mailbox. redirectApexToDemo already host-gates the same
zone; these two did not. Fix: extracted a shared isPosternonlineZone(hostname)
predicate (apex or any *.posternonline.com subdomain), built from
isPosternonlineSubdomain() which serveDemoLanding now also uses instead of
its own inline endsWith check, so there is one definition of the zone
boundary instead of three. Off the zone, both routes fall through to the
ordinary not_found path (no isApi match for /robots.txt//sitemap.xml),
restoring exactly what v1.4.3 served (verified live before this change:
postern.skyphusion.org/robots.txt was 404).

2. Sitemap lastmod. serveSitemap computed new Date() at request time, so
every crawl read "modified today" forever and crawlers discount that signal.
Replaced with SITEMAP_LASTMOD, a content-derived constant bumped only when
the two listed pages (landing, webmail) actually change.

3. Version on /health. The wire had no way to answer "confirm the live
Worker version" (this repo's own doctrine: verify the artifact, not the
pipeline). Added inbound/src/version.ts (a guarded literal, matching
mcp/src/version.ts's shape exactly, including why it is not a
package.json import: tsconfig.json's include scopes to src/**/*.ts,
so importing above src breaks the build) plus inbound/version.test.ts
asserting it equals inbound/package.json. GET /health now returns
{"ok":true,"service":"postern","version":"1.4.4"}; HEAD /health behavior
(status/headers match GET, no body) is untouched -- covered by the existing
test. Updated docs/OPERATIONS.md to describe the new field.

4. CodeQL workflow -- fixed AND widened to 4-language coverage (revised
after initial review).
The lockfile fix alone (first commit) only revived
javascript-typescript. Mackaye pulled the code-scanning analyses list keyed
by analysis_key and found that .github/workflows/codeql.yml (#577) had
silently DISPLACED GitHub's default CodeQL setup, which was analyzing FOUR
languages through 2026-08-06:

2026-08-07  cat=/language:actions              key=.github/workflows/codeql.yml:analyze
2026-08-07  cat=/language:actions              key=.github/workflows/codeql.yml:analyze
2026-08-06  cat=/language:javascript-typescript key=dynamic/github-code-scanning/codeql:analyze
2026-08-06  cat=/language:go                   key=dynamic/github-code-scanning/codeql:analyze
2026-08-06  cat=/language:python               key=dynamic/github-code-scanning/codeql:analyze
2026-08-06  cat=/language:actions              key=dynamic/github-code-scanning/codeql:analyze

dynamic/github-code-scanning/codeql (default setup) was covering
actions/javascript-typescript/go/python; the advanced workflow's two-language
matrix, with javascript-typescript additionally dying at setup-node
before upload, left real coverage at ONE language (actions) while the
check still read green -- worse than visible red, because it looks fixed.
go (relay/) and python (imap/, clients/python/) had never been in the
advanced matrix at all.

Fix (second commit, b64a1ad): widened the matrix to all four languages.
Verified rather than assumed the two open questions:

  • Build modes: confirmed against GitHub's own build-mode-support docs
    that Go does NOT support build-mode: none (compiled language) --
    requires autobuild or manual. relay/ is an ordinary Go module, so
    autobuild (gated if: matrix.build-mode == 'autobuild') needs no manual
    build step. Added actions/setup-go@v7 (go leg only) tracking
    relay/go.mod, mirroring how ci.yml's own relay job pins its
    toolchain, so CodeQL's Go extraction never drifts from what the code
    actually builds with. python supports build-mode: none like the
    existing two languages -- no extra setup step.
  • Setup-step guards: the existing if: matrix.language == 'javascript-typescript' guard on setup-node/install is a positive match
    on that one language value, so it does not fire for the new go/python
    matrix entries -- confirmed live (see leg-by-leg result below), not just
    read from the YAML.

Did not change the workflow name: or job name: template (org ruleset
binds required status checks coverage/CodeQL/ci to those names).

Verified in real CI at PR head b64a1ad (the pull_request-triggered
run of this workflow, not the separate GitHub-default dynamic re-check that
also fired): exactly 4 Analyze (...) legs, all success:

Analyze (go)                   success
Analyze (actions)               success
Analyze (python)               success
Analyze (javascript-typescript) success

and the aggregate CodeQL required status check is green. Leg count and
language names are the acceptance criterion here, not just "the check is
green" -- a two-leg green run is the exact failure mode this fixes.

5. Version pins + CHANGELOG. Landed all four pins the shared
tag-preflight.sh asserts at 1.4.4: inbound/package.json (+
package-lock.json, refreshed via npm install --package-lock-only, no
dependency changes), clients/python/pyproject.toml,
clients/python/postern_client/__init__.py, and a CHANGELOG.md ## v1.4.4
section. Also removed the ## Unreleased block describing the MCP
advertised-version fix: CHANGELOG.md's own preamble says
postern-mcp-v* tags "get no section here" (npm is that train's ledger), and
the block's "stuck at 1.3.0" text described the source at the time (the fix
already merged to main in #573/#578), not anything ever published. Did
not touch mcp/package.json or mcp/src/version.ts -- that republish
rides its own postern-mcp-v* tag track and its own PR.

npm run routes:emit re-run in the same commit; contracts/api-routes.json
picked up the updated note: on the robots/sitemap rows stating the zone
gate (contracts/api-params.json unchanged -- neither route takes
parameters).

Gate (task 1-3/5, run against fc9b59d; task 4 gate below against b64a1ad)

$ cd inbound && npm run typecheck
> tsc --noEmit
(exit 0, no output)

$ cd inbound && npx vitest run
 Test Files  64 passed (64)
      Tests  815 passed (815)

$ cd mcp && npm run typecheck
> tsc --noEmit
(exit 0, no output)

$ cd mcp && npx vitest run
 Test Files  8 passed (8)
      Tests  85 passed (85)

$ cd relay && go vet ./...
(exit 0, no output)
$ cd relay && go build -o /tmp/albini-v144-relay .
(exit 0, no output)

$ cd inbound && npm run routes:emit && git status --porcelain contracts/
 M contracts/api-routes.json   (only the two note: strings; api-params.json unchanged)

Task 4 gate: .github/workflows/codeql.yml has no local runner (it only
means anything as live GitHub Actions), so it was verified by pushing and
reading the real run -- see the leg-by-leg result above.

Repo-wide em-dash/en-dash scan (git ls-files -z | xargs -0 grep -lP '[\x{2013}\x{2014}]') over the full tracked tree, run again after b64a1ad:
zero hits.

Negative control (task 1, required)

Reverted only the isPosternonlineZone(url.hostname) conditions on the two
route guards (api.ts unchanged otherwise), reran api.test.ts:

 ❯ 404s robots.txt and sitemap.xml off the posternonline.com zone (self-hoster host)
   AssertionError: expected 200 to be 404
   - 404
   + 200
   at api.test.ts:80:27   (req("GET", "/robots.txt") against postern.example)
 Test Files  1 failed (1)
      Tests  1 failed | 10 passed (11)

Restored the host gate, reran: 11/11 green (see gate above, full suite
815/815). The test does observe the defect it exists to guard.

Notes / self-disclosure

  • Opened fix: SEO routes leak the demo zone to self-hosted deploys #579 to track this work; no existing issue covered it.
  • Tool-use error Inbound email -> agent-friendly worker (CF Email Routing + D1 + Vectorize, lean v1) #1, self-caught and corrected: partway through task 3 I
    used the Edit tool directly on docs/OPERATIONS.md, and it landed in the
    shared /home/conrad/dev/postern tree instead of my own clone via my own
    shell -- a violation of the file-tools rule (Edit runs as the parent
    process identity; wrong tree entirely, not even my clone). Caught it via
    git diff/git status in that tree, ran git checkout -- there to
    revert (confirmed clean after), redid the identical edit correctly in my
    own clone via python3 under sudo -u albini.
  • Tool-use error feat(inbound): R2 attachments, chunked embeddings, FTS5, DMARC #2, same family, during the task-4 correction: applied
    the new codeql.yml content with an unwrapped cp/rm outside the
    sudo -u albini string. It landed in /home/mackaye/dev/postern -- which
    turned out to be a symlink (~mackaye/dev -> /home/conrad/dev, set
    2026-07-27) to the SAME shared tree as error Inbound email -> agent-friendly worker (CF Email Routing + D1 + Vectorize, lean v1) #1, reached via a path I
    hadn't accounted for. Caught it the same way (git status/git diff
    showed the stray change under the lead's own identity), reverted with
    git checkout -- there (confirmed clean), and redid the copy correctly
    entirely inside one sudo -u albini bash -lc '...' invocation. Neither
    slip was committed, pushed, or left any trace outside the working tree;
    no secret exposure either time. Flagging both plainly rather than
    smoothing them over.
  • The nanoid audit finding was fixed in round 2 (see the section
    below) after the lead reviewed the triage and asked for it to be
    folded into this release rather than left red. Original triage stands: it
    was newly-disclosed and pre-existing, not introduced by this PR.
  • Nothing else in flight. This PR is ready for review; I have not merged and
    will not.

Round 2 (after further review: codeql comment correction + nanoid fold-in)

Two corrections from the lead, both landed in commit 86284e7 (new head).

1. Corrected the .github/workflows/codeql.yml header comment. My
earlier commit (b64a1ad) carried a claim from the lead's initial finding
that real CodeQL coverage had "collapsed to ONE language (actions)" -- WRONG,
and the lead's own error, which he corrected and I'm relaying precisely
rather than softening: the code-scanning/analyses API defaults to the
default branch, so reading main-only data read as an unanalyzed repo.
GitHub's default setup was, and is, still enabled and still analyzes all
four languages ON PULL REQUESTS -- provable on this very PR (a separate
dynamic/github-code-scanning/codeql run passes go/python alongside our own
workflow's run). What actually stopped was PUBLICATION TO refs/heads/main
specifically: the last default-setup upload for javascript-typescript/go/
python against main was 2026-08-06T04:41Z, and since 08-07 (when #577 added
this advanced workflow) only /language:actions published there, because
this workflow also runs on push to main with no row for go/python and a
javascript-typescript leg that died before uploading. So main's STORED
results for three languages went stale while PR-time analysis kept covering
all four -- a publication gap on the default branch, not an unanalyzed repo.
Rewrote the comment to say exactly that, referencing b64a1ad by sha rather
than rewriting its commit message (history stays as it is; this is the
follow-up correction).

2. Bumped transitive nanoid to 3.3.18 (satisfies >=3.3.17, clears
GHSA-2v37-7h3g-55p8) in inbound/package-lock.json and
mcp/package-lock.json via npm audit fix. Verified before pushing that it
dragged nothing else: git diff on both lockfiles shows only the nanoid
entry's version/resolved/integrity changed (3 lines each), and both
package.json files are byte-identical to before. nanoid arrives via
vitest -> vite -> postcss, a dev-only chain; never reaches the deployed
Worker or the published postern-mcp package. Did not touch
mcp/package.json or mcp/src/version.ts (per the lead's fence -- MCP
republish stays its own tag track). Added two lines to CHANGELOG.md
## v1.4.4: the widened CodeQL coverage (previously unrecorded) and this
security bump with its advisory id.

Acceptance criteria (as specified, not just "CI is green")

At head 86284e7:

  • ci, inbound, mcp: all success. Confirmed by job step, not just
    job conclusion -- both inbound and mcp show Run npm run typecheck and
    Run npm test as individual green steps (mcp also Run npm run build),
    none skipped. npm audit (dependency vulnerability gate) also green in
    both, npm audit --audit-level=high re-run locally in both packages:
    found 0 vulnerabilities.
  • CodeQL: this workflow's own pull_request run produced exactly 4
    Analyze (...) legs -- actions, javascript-typescript, go, python
    -- all success. Checked the raw job logs for the SARIF-rejection failure
    mode the lead flagged ("CodeQL analyses from advanced configurations
    cannot be processed when the default setup is enabled"): not present in
    either the go or python leg. Both logs show Successfully uploaded results / Analysis upload status is complete.

Full local gate re-run after round 2 (inbound typecheck + 815/815 vitest,
mcp typecheck + 85/85 vitest + build, both npm audit clean) -- all green,
matching CI.

Nothing in flight. Ready for review; have not merged and will not.

…4.4)

/robots.txt and /sitemap.xml hardcode the demo's canonical origin and were
served on every host. Postern is self-hostable, so an operator deploying on
their own mail domain served a robots.txt/sitemap.xml advertising the
Skyphusion Labs demo instead of their own mailbox. redirectApexToDemo already
host-gates the same zone; these two did not. Gated on a shared
isPosternonlineZone() predicate (extracted from the apex-redirect and
demo-landing checks) so a non-demo host falls through to the ordinary
not_found path, restoring exactly what production served on v1.4.3.

Bundled in the same release:
- serveSitemap computed <lastmod> from new Date() at request time, so every
  crawl read "modified today" forever. Replaced with a content-derived
  constant.
- GET /health had no version field, so the doctrine of verifying the live
  Worker artifact (not the deploy pipeline) had no wire answer. Single-sourced
  in inbound/src/version.ts, guarded by a test against package.json, mirroring
  the MCP advertised-version fix (mcp/#573).
- .github/workflows/codeql.yml failed its javascript-typescript leg on every
  push: actions/setup-node with cache: npm cannot find a root lockfile (none
  exists; lockfiles live at inbound/, mcp/, webmail/e2e/). Both matrix legs
  run build-mode: none, so CodeQL never needed node_modules; dropped the
  unneeded setup-node/install steps rather than teaching them about three
  lockfiles.
- Removed the CHANGELOG "Unreleased" block describing the MCP version fix: it
  violated the file's own stated policy that postern-mcp-v* tags get no
  section here (npm is that train's ledger), and its "stuck at 1.3.0" text
  described the source at the time, not anything ever published.

Version pins landed for v1.4.4: inbound/package.json (+ package-lock.json),
clients/python/pyproject.toml, clients/python/postern_client/__init__.py, and
a CHANGELOG.md ## v1.4.4 section, per the shared tag-preflight gate.

Closes #579

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Adversarial security audit

Generated 2026-08-07T23:08:33.337Z · 7fe95e0...fd9170e

PR hardens SEO host-gating and adds version endpoint; no authz/injection/SSRF/secrets/data-leak vulnerabilities introduced.

Severity Location Finding
- - No findings

@github-code-quality

github-code-quality Bot commented Aug 7, 2026

Copy link
Copy Markdown

Code Coverage Overview

Languages: JavaScript

JavaScript

The overall coverage in commit 86284e7 in the fix/v1.4.4-seo-host-... branch remains at 95%, unchanged from commit 7fe95e0 in the main branch.

Show a code coverage summary of the most impacted files.
File main 7fe95e0 fix/v1.4.4-seo-host-... 86284e7 +/-
src/api.ts 92% 92% 0%
package.json 0% 100% +100%
src/version.ts 0% 100% +100%

Updated August 07, 2026 23:09 UTC

The lockfile-cache fix alone (previous commit) only revived the
javascript-typescript leg. #577 added this advanced workflow with a
two-language matrix (actions, javascript-typescript) that silently
displaced GitHub's default CodeQL setup, which was analyzing FOUR
languages through 2026-08-06 (confirmed via the code-scanning analyses
list: analysis_key dynamic/github-code-scanning/codeql:analyze for
actions, javascript-typescript, go, and python). Go (relay/) and
Python (imap/, clients/python/) were never in the advanced matrix at
all, so real coverage had silently collapsed to one language while the
check still reported green -- a worse state than visible red, because
it looks fixed.

Widened the matrix to all four languages the repo actually contains,
matching what default setup was demonstrably analyzing:
- go does not support build-mode: none (compiled language; verified
  against GitHub's build-mode support docs). Uses autobuild instead --
  relay/ is an ordinary Go module, no manual build step needed. Added
  actions/setup-go@v7 (go only), tracking relay/go.mod the same way
  ci.yml's own relay job does so CodeQL never drifts from the toolchain
  the code actually builds with.
- python supports build-mode: none like javascript-typescript and
  actions; no additional setup step needed.
- the existing javascript-typescript-only setup-node/install guard is
  unaffected by the wider matrix: it is a positive match on that one
  language value, so go and python never touch it.
- added the autobuild step, gated on matrix.build-mode == 'autobuild',
  so it only runs for the go leg.

Did not change the workflow name: or the job name: template (org
ruleset binds required status checks to those names).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… codeql.yml comment

Two things, both requested by the lead after reviewing b64a1ad.

1. Corrects the header comment in .github/workflows/codeql.yml that
   commit b64a1ad landed. That comment claimed real CodeQL coverage
   had "collapsed to ONE language (actions)" -- wrong, and the lead's
   own error, not something this correction blames on the prior read.
   The code-scanning/analyses API defaults to the default branch;
   reading main-only data read as an unanalyzed repo. GitHub's default
   setup was, and is, still enabled and still analyzes all four
   languages on pull requests (provable on postern#580 itself, a
   separate dynamic/github-code-scanning/codeql run passing go and
   python). What actually stopped was PUBLICATION TO refs/heads/main
   specifically: this workflow's push-to-main trigger, once added,
   left go/python with no matrix row and no publisher, and the
   javascript-typescript leg died in setup-node before uploading
   either. So main's stored results for three languages went stale
   while PR-time analysis kept covering all four -- a publication gap
   on the default branch, not an unanalyzed repo. History is not
   rewritten; this is the follow-up correction commit, referencing
   b64a1ad by sha.

2. Bumps the transitive nanoid dependency to 3.3.18 (>=3.3.17 clears
   GHSA-2v37-7h3g-55p8, a high-severity advisory: custom generators
   can loop indefinitely when size is zero) in inbound/package-lock.json
   and mcp/package-lock.json via `npm audit fix`. Verified: main and
   this branch both pinned nanoid 3.3.16 identically before this
   commit, so the advisory is newly disclosed, not introduced by this
   PR's earlier lockfile refresh -- but it fails the required `ci`
   check either way and main is equally exposed, so fixing it here
   rather than leaving a known-red gate. nanoid arrives via
   vitest -> vite -> postcss, a dev-only chain that never reaches the
   deployed Worker or the published postern-mcp package. Confirmed the
   fix touched only nanoid's lockfile entry in both packages (no other
   package version dragged); neither package.json changed, and
   mcp/package.json's own version and mcp/src/version.ts are
   untouched -- this is not the MCP republish, which stays its own
   postern-mcp-v* tag track.

CHANGELOG.md ## v1.4.4 gains a line for each.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Reviewed against the artifacts rather than the report; every claim below I re-derived myself.

The blocker (#579) is correctly fixed. isPosternonlineZone is a single definition of the zone boundary and serveDemoLanding now shares it instead of carrying an inline copy. endsWith(".posternonline.com") requires the literal dot, so evilposternonline.com does not match. Leaving redirectApexToDemo on its own narrower apex/www check was the right call and not an oversight: folding it into the zone predicate would have made demo.posternonline.com redirect to itself.

The test discriminates the defect. Both directions are asserted, and the negative control was run: reverting only the two gate conditions turned the self-hoster test red at the exact assertion (expected 200 to be 404), then restored. A guard nobody has watched fail is not yet a guard; this one has been watched.

Verified independently by me:

  • nanoid is the ONLY changed package in either lockfile. I diffed every entry in inbound/ and mcp/ against b64a1ad: one version change each, nothing added, nothing removed.
  • CodeQL run 31225785760 produced four Analyze (...) legs, all green, and I grepped the full run log for both SARIF-rejection signatures: zero matches.
  • npm audit passes AND typecheck/test show success rather than skipped in both jobs. On every earlier head the audit gate failed first and those steps never ran, so this is the first head where CI actually executed the suite.
  • All five version pins agree at 1.4.4; release-gate green.

Merging with a merge commit, not a squash. Squashing would collapse b64a1ad and its correction into one commit and destroy the "this corrects b64a1ad" relationship, which is the thing that makes the record auditable. Ancestry is not evidence of preservation, so that structure has to survive deliberately.

Two things here were the author's judgment, not the dispatch, and both are the reason this is a good release:

The CHANGELOG header stating no PROJECTION_VERSION / POSTERN_IMAP_UIDVALIDITY bump and that door rolls from this tag are image-refresh only. That interlock turned a routine roll into a production incident on this repo eight days ago. An operator reading the tag needs exactly that sentence and nobody asked for it.

The setup-go pin to relay/go.mod, so CodeQL cannot drift from the toolchain ci.yml builds relay with, plus checking GitHub's docs to establish that go rejects build-mode: none rather than assuming it.

One correction that is mine, recorded here so the PR reads honestly: the "coverage collapsed to one language" framing in b64a1ad came from me and was wrong. I read code-scanning/analyses, which defaults to the default branch, and generalised a main-only observation to the whole repo; PR-time analysis had been covering all four languages throughout. This PR's own CI is what disproved it. The follow-up commit corrects the characterisation without rewriting history, which is the right shape.

@skyphusion-mackaye
Mackaye (skyphusion-mackaye) merged commit 4df4503 into main Aug 7, 2026
18 checks passed
@skyphusion-mackaye
Mackaye (skyphusion-mackaye) deleted the fix/v1.4.4-seo-host-gate branch August 7, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: SEO routes leak the demo zone to self-hosted deploys

2 participants