33 Commits

Author SHA1 Message Date
hsiegeln
ca2a725953 ci(deploy): merge build+deploy into one job, manual trigger only
All checks were successful
ci / build-test (push) Successful in 4m0s
Two changes:

1. Merge build and deploy jobs into a single 'deploy' job. This
   eliminates the actions/upload-artifact@v3 round-trip, which was
   silently stripping dotfiles (.htaccess) from the artifact and
   leaving the deployed origin without security headers. The built
   dist/ (including .htaccess) now flows directly into rsync in the
   same workspace.

2. Remove the 'push: branches: [main]' trigger so deploy runs only
   on workflow_dispatch (manual click in Gitea Actions UI).
   Merges to main no longer auto-deploy — production promotion is
   an explicit user action.

The concurrency group at workflow level still prevents overlapping
deploys. All secrets remain unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:24:42 +02:00
hsiegeln
fdb0411c35 Sync main into feat/initial-build before merge-jobs refactor 2026-04-24 21:23:58 +02:00
hsiegeln
461b5e0cd6 Merge branch 'feat/initial-build' into main
Some checks failed
deploy / build (push) Successful in 36s
deploy / deploy (push) Failing after 14s
ci / build-test (push) Successful in 3m54s
Copy public/.htaccess into dist after Astro build (Astro/Vite drops
dotfiles from public/ otherwise, leaving the origin without HSTS).

# Conflicts:
#	.gitea/workflows/deploy.yml
2026-04-24 21:09:35 +02:00
hsiegeln
0d743402ac ci(deploy): copy public/.htaccess into dist after build
All checks were successful
ci / build-test (push) Successful in 3m47s
Astro/Vite drops dotfiles from public/ during build, so .htaccess
never makes it into dist/. The deployed Apache origin then has no
header rules to apply, leaving the site without HSTS, X-Frame-Options,
Referrer-Policy, etc. — caught today by the post-deploy smoke test
("HSTS missing").

Copy the file explicitly after build. test -f makes the step fail
loudly if public/.htaccess goes missing, rather than silently
shipping a header-less site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:08:51 +02:00
hsiegeln
28fcaf16c5 Merge branch 'feat/initial-build' into main
Some checks failed
ci / build-test (push) Successful in 4m2s
deploy / build (push) Successful in 30s
deploy / deploy (push) Failing after 13s
Revert to rsync, route through Hetzner's SSH port 222 (the shell port,
as opposed to port 22 which is SFTP-only).
2026-04-24 20:24:33 +02:00
hsiegeln
e3fbbbada7 ci(deploy): revert to rsync via SSH port 222 (Hetzner shell port)
All checks were successful
ci / build-test (push) Successful in 3m57s
Hetzner Webhosting exposes SSH on TWO ports:
  port 22  — SFTP only, refuses remote command exec
  port 222 — full SSH with shell, supports rsync

Previous deploys hit "exec request failed on channel 0" because we
were using port 22. Switch back from lftp to plain rsync, but route
it through port 222 with --rsync-path=/usr/bin/rsync (Hetzner's
locked-down PATH doesn't include rsync by default) and BatchMode=yes
to disable interactive prompts.

Mirrors the working local command:
  rsync -avz --rsync-path=/usr/bin/rsync \
    -e "ssh -p 222 -i ~/.ssh/id_ed25519_gitea -o BatchMode=yes" \
    ./ apibny@www691.your-server.de:/usr/www/users/apibny/www.cameleer.io

Keeps host-key pinning (StrictHostKeyChecking + UserKnownHostsFile)
which the local command omits because the user's personal known_hosts
already trusts the host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:24:27 +02:00
hsiegeln
cb21be71f0 Merge branch 'feat/initial-build' into main
Some checks failed
ci / build-test (push) Successful in 3m55s
deploy / build (push) Successful in 28s
deploy / deploy (push) Failing after 12s
Fix lftp auth (explicit -u USER, + unindented heredoc body).
2026-04-24 20:08:29 +02:00
hsiegeln
5417565e34 ci(deploy): fix lftp auth — explicit empty password + unindented script
All checks were successful
ci / build-test (push) Successful in 4m0s
Two issues from the previous lftp run:
- "GetPass() failed -- assume anonymous login" / "Password required":
  without `-u USER,` (trailing comma = empty password), lftp tries
  to prompt for a password instead of relying on the ssh key passed
  via sftp:connect-program.
- Heredoc body was indented with leading whitespace; lftp can mis-
  parse leading-whitespace lines as command continuations.

Also bump verbosity (`debug 3`) so the ssh command lftp launches
is logged — makes the next failure easier to read — and bound
retries to 1 so we fail fast in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:08:22 +02:00
hsiegeln
60813e44d9 Merge branch 'feat/initial-build' into main
Some checks failed
ci / build-test (push) Successful in 4m1s
deploy / build (push) Successful in 31s
deploy / deploy (push) Failing after 27s
Switch deploy from rsync-over-SSH to lftp mirror over SFTP —
Hetzner Webhosting is SFTP-only and refuses remote exec.
2026-04-24 19:49:54 +02:00
hsiegeln
64aa8f426b ci(deploy): switch from rsync to lftp mirror (SFTP-only hosting)
All checks were successful
ci / build-test (push) Successful in 3m55s
Hetzner Webhosting accepts SSH for file transfer but refuses remote
command exec, failing rsync with:

  exec request failed on channel 0
  rsync error: error in rsync protocol data stream (code 12)

rsync over SSH requires spawning a remote rsync binary, which isn't
possible on SFTP-only tiers. Switch the mirror to lftp, which speaks
SFTP end-to-end. Same semantics (upload + delete removed files), same
key + known_hosts pinning via sftp:connect-program.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:49:42 +02:00
hsiegeln
c438d67469 Merge branch 'feat/initial-build' into main
Some checks failed
deploy / build (push) Successful in 33s
ci / build-test (push) Successful in 4m18s
deploy / deploy (push) Failing after 13s
Brings in the CI infrastructure fixes:
- ci.yml: probe Chromium binary; fall back to Playwright (95977c8)
- tailwind: lift text-faint to meet WCAG AA contrast (2fde385)
- deploy.yml: pin artifact actions to v3 for Gitea (bbd68ec)
2026-04-24 19:12:55 +02:00
hsiegeln
bbd68eca1f ci(deploy): pin upload/download-artifact to v3 for Gitea Actions
All checks were successful
ci / build-test (push) Successful in 3m59s
actions/upload-artifact@v4 and download-artifact@v4 use the
@actions/artifact v2+ client, which targets a github.com-only
backend and fails on Gitea / Forgejo / GHES with:

  GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
  and download-artifact@v4+ are not currently supported on GHES.

Pin both to v3, which uses the older artifact protocol that Gitea
Actions implements.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:12:34 +02:00
bb6b8e63d7 .gitea/workflows/deploy.yml aktualisiert
Some checks failed
deploy / build (push) Failing after 32s
deploy / deploy (push) Has been skipped
ci / build-test (push) Failing after 57s
2026-04-24 19:04:16 +02:00
hsiegeln
2fde385ecf theme: lift text-faint to meet WCAG AA contrast
All checks were successful
ci / build-test (push) Successful in 3m39s
text-faint #6b7280 on bg #060a13 measures ~4.06:1 contrast — under the
4.5:1 normal-text threshold — which fails Lighthouse's color-contrast
audit and drops the accessibility score to 0.90 on /pricing and
/privacy (the only pages currently using this token).

#828b9b yields ~5.66:1, clears AA with margin, and stays visually
distinct from text-muted (#9aa3b2, ~7.8:1) so the design hierarchy
between text / text-muted / text-faint is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:57:40 +02:00
hsiegeln
95977c8d6c ci: probe Chromium binary, fall back to Playwright-bundled
Some checks failed
ci / build-test (push) Failing after 3m35s
The Ubuntu runner image ships /usr/bin/chromium-browser as a snap
forwarder stub that exits with "install via snap" when invoked but
is found on PATH. The previous detection used `command -v` only, so
it accepted the stub, set CHROME_PATH to it, and Lighthouse later
failed to launch Chrome (ECONNREFUSED on the debug port).

Probe each candidate with `--version` to confirm it actually runs.
When no working system binary exists, install Playwright's bundled
Chromium (supports linux/arm64) with --with-deps for system libs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:50:28 +02:00
b9b17df0ea .gitea/workflows/ci.yml aktualisiert
Some checks failed
ci / build-test (push) Failing after 2m12s
2026-04-24 18:25:52 +02:00
d772048fb4 .gitea/workflows/ci.yml aktualisiert
Some checks failed
ci / build-test (push) Has been cancelled
2026-04-24 18:10:49 +02:00
259871d34a Merge pull request 'feat/initial-build' (#3) from feat/initial-build into main
Some checks failed
ci / build-test (push) Failing after 1m3s
deploy / build (push) Failing after 51s
deploy / deploy (push) Has been skipped
Reviewed-on: #3
2026-04-24 18:09:37 +02:00
hsiegeln
295e2bcfff replaced TBD with TODO
Some checks failed
ci / build-test (push) Failing after 49s
ci / build-test (pull_request) Failing after 1m6s
2026-04-24 18:06:32 +02:00
hsiegeln
93131461b8 Fix CI build: read PUBLIC_* values from secrets context, broaden TODO guard
Some checks failed
ci / build-test (push) Failing after 46s
- Switch ci.yml + deploy.yml env bindings from ${{ vars.* }} to
  ${{ secrets.* }}. Gitea lets you put non-sensitive Actions values in
  either tab, and the secrets tab was used in practice — workflow was
  reading the wrong context and getting empty strings.
- Broaden the "no TODO markers ship" guard to accept both TODO: and
  legacy TBD: prefixes, matching the imprint/privacy page markers that
  were recently renamed.
- Document the secret-vs-variable choice in OPERATOR-CHECKLIST so the
  next operator doesn't get tripped up by the same thing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:04:16 +02:00
ba6069f14e Merge pull request 'replaced TBD with TODO' (#2) from feat/initial-build into main
Some checks failed
deploy / build (push) Has been cancelled
deploy / deploy (push) Has been cancelled
ci / build-test (push) Failing after 1m6s
Reviewed-on: #2
2026-04-24 18:00:52 +02:00
hsiegeln
9a4644bada replaced TBD with TODO
Some checks failed
ci / build-test (push) Failing after 51s
ci / build-test (pull_request) Failing after 1m4s
2026-04-24 17:58:49 +02:00
65667d9b50 Merge pull request 'feat/initial-build' (#1) from feat/initial-build into main
Some checks failed
ci / build-test (push) Failing after 1m4s
deploy / build (push) Failing after 33s
deploy / deploy (push) Has been skipped
Reviewed-on: #1
2026-04-24 17:56:09 +02:00
hsiegeln
7ecd1ff871 Split CI and deploy into separate workflows
Some checks failed
ci / build-test (push) Failing after 1m19s
ci / build-test (pull_request) Failing after 1m4s
- .gitea/workflows/ci.yml: builds, tests, lints, and runs Lighthouse on
  every push and PR to main. Runs on arm64 self-hosted Gitea runner.
- .gitea/workflows/deploy.yml: deploys to Hetzner on push to main or
  manual workflow_dispatch from Gitea UI. No Lighthouse (that's CI's
  job). Keeps the TBD-marker guard as a last-line safety check.

Both workflows live on the same concurrency group so no two deploys
race. On main push, CI and deploy run in parallel; CI is independent
and non-blocking for the deploy step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:46:04 +02:00
hsiegeln
ea6267d6f7 Make CI arm64-runner-aware for Gitea self-hosted act_runner
Runner: self-hosted arm64. Deploy target: amd64 (Hetzner). Cross-arch is
safe because Astro output is plain static HTML/CSS/JS — nothing in the
bundle is arch-specific.

Changes:
- runs-on: ubuntu-latest (most portable act_runner label — override per your
  runner's registered labels if needed).
- Install Chromium from apt at workflow time (Google Chrome has no Linux/arm64
  stable build; Chromium does). Handles both chromium and chromium-browser
  package names, sudo-less runners, and idempotently skips if already present.
- Export CHROME_PATH so LHCI picks the right binary.
- Add chromeFlags to lighthouserc.cjs: --no-sandbox --headless=new
  --disable-gpu --disable-dev-shm-usage (required in containerized/root
  Chromium on CI runners).
- timeout-minutes on both jobs.
- Defense-in-depth install of rsync + openssh in deploy job if the runner
  image doesn't ship them.
- Null-guard SFTP_KEY and SFTP_KNOWN_HOSTS secrets.
- Switch echo to printf for deterministic newline handling when writing key
  material to ~/.ssh files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:39:34 +02:00
hsiegeln
d98d73b14a Apply final-review cleanup: robots sitemap, CI guards, header parity
- Remove Sitemap line from robots.txt (no @astrojs/sitemap installed; was
  pointing to a 404 that would trip Google Search Console).
- Align Permissions-Policy across all three enforcement layers (middleware,
  .htaccess, Cloudflare Transform Rule in OPERATOR-CHECKLIST) by dropping the
  stray fullscreen=(self) from the middleware.
- Bump Lighthouse CI numberOfRuns from 1 to 3 to dampen CI-runner noise.
- Add CI guard that fails the build if any <TBD:...> marker survives into
  dist/ — prevents a legally incomplete imprint from shipping by accident.
- Add SFTP_* secret null-guard before the rsync --delete step so a missing
  secret fails loudly instead of targeting the SSH user's home root.
- Document the set:html compile-time-constant invariant in DualValueProps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:34:27 +02:00
hsiegeln
7e0d341c89 Add README and operator checklist for Hetzner + Cloudflare + Gitea setup 2026-04-24 17:25:53 +02:00
hsiegeln
92bef08357 Add Gitea Actions workflow: build, test, lint, Lighthouse, rsync deploy with header smoke check 2026-04-24 17:25:02 +02:00
hsiegeln
cc7802e461 Add Lighthouse CI config with >=95 thresholds across 4 categories 2026-04-24 17:24:37 +02:00
hsiegeln
04a1bd0aaf Add CI lint configs (html-validate, linkinator), fix nav a11y and URL routing
- .htmlvalidate.json with relaxed rules for design-system inline styles
- linkinator.config.json skipping mail, external auth/platform origins
- Fix lint:html npm script quoting for Windows-shell compatibility
- Switch astro build.format to 'directory' so /pricing resolves without MultiViews
- trailingSlash: 'ignore' lets both /pricing and /pricing/ work naturally
- Add aria-label to both <nav> landmarks (Primary, Footer) to satisfy html-validate

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:24:21 +02:00
hsiegeln
dfb8419b08 Add .htaccess for origin hardening, HTTPS redirect, and cache headers 2026-04-24 17:22:25 +02:00
hsiegeln
ecbf1f90d7 Add privacy policy page (GDPR-aligned, no-cookies posture documented) 2026-04-24 17:21:56 +02:00
hsiegeln
07de57dda5 Add imprint page (TMG §5 / DDG §5 structure, operator fields marked <TBD>) 2026-04-24 17:21:17 +02:00
18 changed files with 672 additions and 9 deletions

106
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,106 @@
# -----------------------------------------------------------------------------
# cameleer-website — CI (build + test + lint + Lighthouse)
#
# Runs automatically on every push and every PR against main. Does NOT deploy —
# see deploy.yml for that. This workflow exists so every commit gets the full
# quality gate before it can reach production.
#
# Runner: self-hosted arm64 Gitea runner (act_runner).
# Adjust `runs-on` labels if your runner is registered under different tags.
# Architecture note: arm64 build, amd64 deploy is fine — Astro's output is
# plain static HTML/CSS/JS with no arch-specific bits.
# -----------------------------------------------------------------------------
name: ci
on:
push:
pull_request:
branches: [main]
jobs:
build-test:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
PUBLIC_AUTH_SIGNIN_URL: ${{ secrets.PUBLIC_AUTH_SIGNIN_URL }}
PUBLIC_AUTH_SIGNUP_URL: ${{ secrets.PUBLIC_AUTH_SIGNUP_URL }}
PUBLIC_SALES_EMAIL: ${{ secrets.PUBLIC_SALES_EMAIL }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Lighthouse CI needs a Chrome/Chromium binary at runtime. Google Chrome
# has no Linux/arm64 build, so we use distro Chromium when available and
# fall back to Playwright's bundled Chromium (which supports linux/arm64)
# when not. The Ubuntu runner ships /usr/bin/chromium-browser as a snap
# forwarder stub that is on PATH but only prints "install via snap" when
# invoked — so we MUST probe each candidate by actually running it,
# not just `command -v`.
- name: Install Chromium for Lighthouse CI
shell: bash
run: |
set -euo pipefail
probe() {
local bin="${1:-}"
[ -n "$bin" ] && [ -x "$bin" ] && "$bin" --version >/dev/null 2>&1
}
CHROME_BIN=""
for cand in \
"$(command -v chromium 2>/dev/null || true)" \
"$(command -v chromium-browser 2>/dev/null || true)" \
"$(command -v google-chrome 2>/dev/null || true)"; do
if probe "$cand"; then CHROME_BIN="$cand"; break; fi
done
if [ -z "$CHROME_BIN" ]; then
echo "No working system Chromium — installing Playwright-bundled Chromium."
# --with-deps apt-installs the system libraries Chromium needs
# (libnss3, libatk1.0-0, etc.). Playwright handles sudo internally.
npx -y playwright@latest install --with-deps chromium
CHROME_BIN="$(find "$HOME/.cache/ms-playwright" \
-type f -name chrome -executable 2>/dev/null | head -n1)"
fi
if ! probe "$CHROME_BIN"; then
echo "Failed to install a working Chromium binary." >&2
exit 1
fi
echo "CHROME_PATH=$CHROME_BIN" >> "$GITHUB_ENV"
"$CHROME_BIN" --version
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
- name: Build site
run: npm run build
- name: Guard — no TBD markers may ship in built HTML
run: |
if grep -rlE '(TBD):' dist 2>/dev/null | grep -E '\.(html|svg)$'; then
echo "Built output contains unfilled <TBD:...>) markers."
echo "Fill in imprint.astro and privacy.astro operator fields before merging to main."
exit 1
fi
- name: Validate HTML
run: npm run lint:html
- name: Check internal links
run: npm run lint:links
- name: Lighthouse CI
env:
CHROME_PATH: ${{ env.CHROME_PATH }}
run: npx lhci autorun

123
.gitea/workflows/deploy.yml Normal file
View File

@@ -0,0 +1,123 @@
# -----------------------------------------------------------------------------
# cameleer-website — Deploy to Hetzner Webhosting L
#
# MANUAL TRIGGER ONLY. Runs exclusively on workflow_dispatch from the Gitea UI
# (Actions → deploy → Run workflow). Does NOT auto-deploy on push to main —
# merges to main must be explicitly promoted to production.
#
# Build and deploy run in a single job so the built dist/ (including
# dotfiles like .htaccess) flows directly into rsync. An earlier split-job
# design was abandoned because actions/upload-artifact@v3 excludes dotfiles
# by default and the v4 client does not work on Gitea Actions / GHES.
#
# Runner: self-hosted arm64 Gitea runner. Adjust `runs-on` if your runner's
# labels differ. Deploy target is Hetzner amd64 — arch mismatch is a non-issue
# because the bundle is static HTML/CSS/JS.
#
# Required secrets (repo settings → Actions → Secrets):
# SFTP_HOST, SFTP_USER, SFTP_PATH, SFTP_KEY, SFTP_KNOWN_HOSTS
# PUBLIC_AUTH_SIGNIN_URL, PUBLIC_AUTH_SIGNUP_URL, PUBLIC_SALES_EMAIL
# -----------------------------------------------------------------------------
name: deploy
on:
workflow_dispatch:
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 25
env:
PUBLIC_AUTH_SIGNIN_URL: ${{ secrets.PUBLIC_AUTH_SIGNIN_URL }}
PUBLIC_AUTH_SIGNUP_URL: ${{ secrets.PUBLIC_AUTH_SIGNUP_URL }}
PUBLIC_SALES_EMAIL: ${{ secrets.PUBLIC_SALES_EMAIL }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests (sanity check)
run: npm test
- name: Build site
run: npm run build
# Astro/Vite does not copy dotfiles from public/ into dist/, so .htaccess
# never reaches the deployed origin and Apache never sees the security
# headers it sets. Copy it explicitly. Fail if the source is missing
# rather than silently shipping a header-less site.
- name: Copy .htaccess into dist
run: |
test -f public/.htaccess
cp public/.htaccess dist/.htaccess
- name: Guard — no TBD markers may ship in built HTML
run: |
if grep -rlE '(TBD):' dist 2>/dev/null | grep -E '\.(html|svg)$'; then
echo "Built output contains unfilled <TBD:...>) markers."
echo "Fill in imprint.astro and privacy.astro operator fields before merging to main."
exit 1
fi
- name: Configure SSH
env:
SFTP_KEY: ${{ secrets.SFTP_KEY }}
SFTP_KNOWN_HOSTS: ${{ secrets.SFTP_KNOWN_HOSTS }}
run: |
set -e
: "${SFTP_KEY:?SFTP_KEY secret must be set}"
: "${SFTP_KNOWN_HOSTS:?SFTP_KNOWN_HOSTS secret must be set}"
mkdir -p ~/.ssh
printf '%s\n' "$SFTP_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$SFTP_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
# Ensure rsync + openssh are present even on a minimal runner image.
if ! command -v rsync >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=; fi
$SUDO apt-get update -qq
$SUDO apt-get install -y --no-install-recommends rsync openssh-client
fi
- name: Deploy via rsync
env:
SFTP_USER: ${{ secrets.SFTP_USER }}
SFTP_HOST: ${{ secrets.SFTP_HOST }}
SFTP_PATH: ${{ secrets.SFTP_PATH }}
run: |
# Fail loudly if any secret is missing — otherwise rsync --delete
# could be directed at the SSH user's home root.
: "${SFTP_USER:?SFTP_USER secret must be set}"
: "${SFTP_HOST:?SFTP_HOST secret must be set}"
: "${SFTP_PATH:?SFTP_PATH secret must be set}"
# Hetzner Webhosting splits SSH into two ports:
# port 22 — SFTP only, no remote command exec
# port 222 — full SSH with shell exec (rsync needs this)
# `--rsync-path=/usr/bin/rsync` tells the local rsync where to find
# the remote binary on Hetzner's locked-down PATH.
# `BatchMode=yes` disables interactive prompts.
rsync -avz --delete --rsync-path=/usr/bin/rsync \
-e "ssh -p 222 -i $HOME/.ssh/id_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts" \
dist/ "$SFTP_USER@$SFTP_HOST:$SFTP_PATH/"
- name: Post-deploy smoke test
run: |
set -e
echo "Checking security headers on www.cameleer.io..."
HEADERS=$(curl -sI https://www.cameleer.io/ || echo "")
echo "$HEADERS" | grep -i '^strict-transport-security:' || { echo "HSTS missing"; exit 1; }
echo "$HEADERS" | grep -i '^content-security-policy:' || { echo "CSP missing"; exit 1; }
echo "$HEADERS" | grep -i '^x-frame-options:' || { echo "XFO missing"; exit 1; }
echo "All required headers present on the live origin."

8
.htmlvalidate.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": ["html-validate:recommended"],
"rules": {
"require-sri": "off",
"no-inline-style": "off",
"void-style": "off"
}
}

96
OPERATOR-CHECKLIST.md Normal file
View File

@@ -0,0 +1,96 @@
# Operator Checklist — `cameleer-website`
One-time setup that lives outside code. Do these before the first `main` merge that ships live.
## 1. Hetzner Webhosting L
- [ ] Provision Webhosting L plan. Note the SSH hostname and SFTP path.
- [ ] In the Hetzner control panel, **enable SSH access** for the main user.
- [ ] Generate an ed25519 SSH key pair locally (once):
```bash
ssh-keygen -t ed25519 -f ~/.ssh/cameleer-website-deploy -C "cameleer-website CI"
```
- [ ] Add the **public** key to `~/.ssh/authorized_keys` on the Hetzner account.
- [ ] Test SSH: `ssh -i ~/.ssh/cameleer-website-deploy user@hetzner-host "ls -la"`.
- [ ] Create a subdirectory for the site (typical path: `public_html/www.cameleer.io/`).
- [ ] Grab the SSH host key for pinning:
```bash
ssh-keyscan -t ed25519 hetzner-host > hetzner-known-hosts.txt
```
- [ ] Install Let's Encrypt (or use Hetzner's built-in) for the origin hostname. Cloudflare Full (strict) requires a valid origin cert.
## 2. Cloudflare (zone: cameleer.io)
### DNS
- [ ] `A` record `www.cameleer.io` → Hetzner IP. **Proxied (orange).**
- [ ] `A` record `@` (apex) → Hetzner IP. **Proxied (orange).**
- [ ] `A`/`CNAME` for `auth.cameleer.io` → SaaS ingress. **Proxied.**
- [ ] `A`/`CNAME` for `platform.cameleer.io` → SaaS ingress. **Proxied.**
- [ ] NO bare MX. If email is needed at `@cameleer.io`, use **Cloudflare Email Routing** or a distinct hostname on a different provider.
### SSL/TLS
- [ ] Mode: **Full (strict)**.
- [ ] Minimum TLS: **1.2**.
- [ ] TLS 1.3: **on**.
- [ ] Always Use HTTPS: **on**.
- [ ] Automatic HTTPS Rewrites: **on**.
- [ ] HSTS: `max-age=31536000; includeSubDomains; preload`. (Add the domain to `https://hstspreload.org/` only after the site is stable and serving HSTS cleanly for a couple of weeks.)
### Security
- [ ] WAF → **Cloudflare Managed Ruleset**: enabled (Free plan includes this since 2024).
- [ ] Bot Fight Mode: **on**.
- [ ] Browser Integrity Check: **on**.
- [ ] Security Level: **medium**.
- [ ] Email Obfuscation: **on**.
- [ ] Rate Limiting rule: 20 req/min per IP on `/*` (marketing pages).
### Transform Rules (edge-level security headers)
Create a Transform Rule — "Modify Response Header" — matching `http.host eq "www.cameleer.io"`:
| Operation | Header | Value |
|-----------|--------|-------|
| Set | `Content-Security-Policy` | `default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; object-src 'none'` |
| Set | `X-Content-Type-Options` | `nosniff` |
| Set | `X-Frame-Options` | `DENY` |
| Set | `Referrer-Policy` | `strict-origin-when-cross-origin` |
| Set | `Permissions-Policy` | `geolocation=(), microphone=(), camera=(), payment=(), usb=()` |
### Page Rules / Redirect
- [ ] `cameleer.io/*` → `https://www.cameleer.io/$1` (301 permanent).
## 3. Gitea Actions secrets (in the repo settings)
Add these under Repository settings → Actions → Secrets (or variables):
| Name | Type | Value |
|------|------|-------|
| `SFTP_HOST` | secret | Hetzner SSH hostname |
| `SFTP_USER` | secret | Hetzner SSH user |
| `SFTP_PATH` | secret | Absolute path to document root (e.g., `/usr/home/cameleer/public_html/www.cameleer.io`) |
| `SFTP_KEY` | secret | Contents of `~/.ssh/cameleer-website-deploy` (private key, PEM) |
| `SFTP_KNOWN_HOSTS` | secret | Contents of `hetzner-known-hosts.txt` (captured via `ssh-keyscan`) |
| `PUBLIC_AUTH_SIGNIN_URL` | secret | `https://auth.cameleer.io/sign-in` |
| `PUBLIC_AUTH_SIGNUP_URL` | secret | `https://auth.cameleer.io/sign-in?first_screen=register` |
| `PUBLIC_SALES_EMAIL` | secret | `sales@cameleer.io` (or whatever sales alias you set up) |
These three are not actually secret (they end up in the built HTML), but Gitea's
Actions UI puts them in the **Secrets** tab alongside the SFTP credentials. The
workflows read them via the `${{ secrets.* }}` context.
## 4. Content TODO — before go-live
- [ ] Fill in `src/pages/imprint.astro` `operator` object with real legal details.
- [ ] Fill in `operatorContact` in `src/pages/privacy.astro`.
- [ ] Review the "Why us" / nJAMS wording in `src/components/sections/WhyUs.astro` for trademark safety.
- [ ] Confirm MID-tier retention: spec says **7 days**; `cameleer-saas/HOWTO.md` says **30 days**. Reconcile one side or the other.
## 5. First deploy
1. Merge a PR to `main`.
2. Watch the Gitea Actions run: `build`, then `deploy`.
3. The workflow includes a post-deploy smoke check — if HSTS / CSP / XFO are missing from the live response, the deploy fails and must be debugged at the Cloudflare Transform Rule layer.
4. Manually verify:
- `curl -sI https://www.cameleer.io/` returns all six security headers.
- `https://cameleer.io/` → `https://www.cameleer.io/` 301 redirect.
- Open the site in an incognito window on desktop + mobile.

44
README.md Normal file
View File

@@ -0,0 +1,44 @@
# cameleer-website
Marketing site for [cameleer.io](https://www.cameleer.io) — zero-code observability for Apache Camel.
This is a **static** Astro 5 site. Hosted on Hetzner Webhosting L, fronted by Cloudflare, deployed via Gitea Actions.
## Development
```bash
npm ci
npm run dev # http://localhost:4321
npm run test # vitest — auth config + middleware header tests
npm run build # produces dist/
npm run preview # serves dist/
```
## Quality gates (run in CI)
```bash
npm run lint:html # html-validate on dist/
npm run lint:links # linkinator on dist/
npm run lh # Lighthouse CI (>=0.95 on all 4 categories)
```
## Environment variables
See `.env.example`. All are `PUBLIC_*` (build-time, embedded in HTML).
| Var | Purpose |
|-----|---------|
| `PUBLIC_AUTH_SIGNIN_URL` | Logto sign-in URL (redirected to by "Sign in" buttons) |
| `PUBLIC_AUTH_SIGNUP_URL` | Logto sign-up URL (redirected to by "Start free trial") |
| `PUBLIC_SALES_EMAIL` | Sales email (`mailto:` target for "Talk to sales") |
## Deployment
Push to `main` → Gitea Actions runs tests, builds, lints, then `rsync`s `dist/` to Hetzner over SSH (ed25519 key, host-key-pinned). Rollback is `git revert && git push`.
See [`OPERATOR-CHECKLIST.md`](./OPERATOR-CHECKLIST.md) for the one-time Hetzner + Cloudflare setup.
## Design & plan
- `docs/superpowers/specs/2026-04-24-cameleer-website-design.md` — the approved spec.
- `docs/superpowers/plans/2026-04-24-cameleer-website.md` — the implementation plan that built this repo.

View File

@@ -4,9 +4,11 @@ import tailwind from '@astrojs/tailwind';
export default defineConfig({
site: 'https://www.cameleer.io',
output: 'static',
trailingSlash: 'never',
trailingSlash: 'ignore',
build: {
format: 'file',
// 'directory' outputs <page>/index.html so extensionless URLs like /pricing
// resolve natively under Apache without MultiViews or rewrite rules.
format: 'directory',
assets: 'assets',
inlineStylesheets: 'auto',
},

32
lighthouserc.cjs Normal file
View File

@@ -0,0 +1,32 @@
module.exports = {
ci: {
collect: {
staticDistDir: './dist',
url: [
'http://localhost/index.html',
'http://localhost/pricing/index.html',
'http://localhost/imprint/index.html',
'http://localhost/privacy/index.html',
],
numberOfRuns: 3,
settings: {
preset: 'desktop',
// Flags required when Chromium runs inside a CI container or as root
// (Gitea act_runner on arm64 uses containers). --headless=new is the
// modern Chromium headless mode. CHROME_PATH is set by the workflow.
chromeFlags: '--no-sandbox --headless=new --disable-gpu --disable-dev-shm-usage',
},
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.95 }],
'categories:accessibility': ['error', { minScore: 0.95 }],
'categories:best-practices': ['error', { minScore: 0.95 }],
'categories:seo': ['error', { minScore: 0.95 }],
},
},
upload: {
target: 'temporary-public-storage',
},
},
};

13
linkinator.config.json Normal file
View File

@@ -0,0 +1,13 @@
{
"recurse": true,
"silent": true,
"skip": [
"^https://auth\\.cameleer\\.io",
"^https://platform\\.cameleer\\.io",
"^https://www\\.cameleer\\.io",
"^mailto:",
"^https://ec\\.europa\\.eu"
],
"retry": true,
"concurrency": 10
}

View File

@@ -11,7 +11,7 @@
"astro": "astro",
"test": "vitest run",
"test:watch": "vitest",
"lint:html": "html-validate 'dist/**/*.html'",
"lint:html": "html-validate \"dist/**/*.html\"",
"lint:links": "linkinator dist --recurse --silent",
"lh": "lhci autorun"
},

55
public/.htaccess Normal file
View File

@@ -0,0 +1,55 @@
# ---------------------------------------------------------------
# www.cameleer.io — Apache config at the Hetzner origin.
# Defense in depth: Cloudflare handles most of this at the edge;
# these rules make sure the origin is hardened even without the CDN.
# ---------------------------------------------------------------
# Enable rewriting
RewriteEngine On
# Force HTTPS — redundant with Cloudflare but belts-and-braces.
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Redirect apex -> www.
RewriteCond %{HTTP_HOST} ^cameleer\.io$ [NC]
RewriteRule ^(.*)$ https://www.cameleer.io/$1 [L,R=301]
# Disable directory listings.
Options -Indexes
# Block access to dotfiles and sensitive extensions that should never be here.
<FilesMatch "^\.|\.(env|ini|log|sh|bak|sql|git)$">
Require all denied
</FilesMatch>
# Prevent MIME sniffing, clickjacking, etc. (primary copy also comes from Astro middleware
# and Cloudflare Transform Rules — these apply if either layer is bypassed).
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# Cache hashed build assets aggressively; HTML must be revalidated.
<FilesMatch "\.(css|js|woff2|svg|png|jpg|jpeg|webp|ico)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "\.html$">
Header set Cache-Control "public, max-age=3600, must-revalidate"
</FilesMatch>
# Remove Server header leak where possible.
Header unset X-Powered-By
</IfModule>
# Compression (Hetzner supports mod_deflate).
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml text/plain
</IfModule>
# Custom error pages (optional — fall back to default if not present).
ErrorDocument 404 /404.html
ErrorDocument 403 /404.html

View File

@@ -1,4 +1,2 @@
User-agent: *
Allow: /
Sitemap: https://www.cameleer.io/sitemap-index.xml

View File

@@ -14,7 +14,7 @@ const year = new Date().getFullYear();
</svg>
<span class="text-text-muted text-sm">© {year} Cameleer</span>
</div>
<nav class="flex items-center gap-8 text-sm text-text-muted">
<nav class="flex items-center gap-8 text-sm text-text-muted" aria-label="Footer">
<a href="/pricing" class="hover:text-text transition-colors">Pricing</a>
<a href="/imprint" class="hover:text-text transition-colors">Imprint</a>
<a href="/privacy" class="hover:text-text transition-colors">Privacy</a>

View File

@@ -14,7 +14,7 @@ import CTAButtons from './CTAButtons.astro';
</svg>
<span class="font-sans font-bold text-lg tracking-tight text-text group-hover:text-accent transition-colors">Cameleer</span>
</a>
<nav class="flex items-center gap-8 text-sm">
<nav class="flex items-center gap-8 text-sm" aria-label="Primary">
<a href="/pricing" class="text-text-muted hover:text-text transition-colors">Pricing</a>
</nav>
<CTAButtons size="md" />

View File

@@ -4,6 +4,8 @@ interface Tile {
capability: string;
}
// tile.capability is a compile-time constant defined below — never feed
// user-supplied or CMS content into set:html further down (XSS risk).
const tiles: Tile[] = [
{
outcome: 'Cut debugging time in half.',

View File

@@ -26,13 +26,13 @@ export function buildSecurityHeaders(): Record<string, string> {
"object-src 'none'",
].join('; ');
// Must match .htaccess and the Cloudflare Transform Rule in OPERATOR-CHECKLIST.md.
const permissionsPolicy = [
'geolocation=()',
'microphone=()',
'camera=()',
'payment=()',
'usb=()',
'fullscreen=(self)',
].join(', ');
return {

84
src/pages/imprint.astro Normal file
View File

@@ -0,0 +1,84 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import SiteHeader from '../components/SiteHeader.astro';
import SiteFooter from '../components/SiteFooter.astro';
// Imprint (Impressum) per TMG §5 / DDG §5.
// Values prefixed "<TODO:" MUST be replaced with real operator data before go-live.
// See docs/superpowers/specs/2026-04-24-cameleer-website-design.md §6.4.
const operator = {
legalName: '<TODO:legal name of operating entity>',
streetAddress: '<TODO:street and number>',
postalCity: '<TODO:postal code and city>',
country: 'Germany',
email: '<TODO:contact email>',
phone: '<TODO:phone (optional but recommended)>',
vatId: '<TODO:VAT ID per §27a UStG, or "not applicable">',
registerEntry: '<TODO:commercial register + court, or "not applicable">',
responsibleForContent: '<TODO:responsible party per §18 Abs. 2 MStV>',
};
---
<BaseLayout
title="Imprint — Cameleer"
description="Legal imprint (Impressum) for Cameleer per German TMG §5 / DDG §5."
>
<SiteHeader />
<main class="max-w-prose mx-auto px-6 py-16 md:py-24">
<h1 class="text-hero font-bold text-text mb-8">Imprint</h1>
<section class="mb-10">
<h2 class="text-lg font-bold text-text mb-3">Information pursuant to § 5 TMG / § 5 DDG</h2>
<address class="not-italic text-text-muted leading-relaxed">
{operator.legalName}<br />
{operator.streetAddress}<br />
{operator.postalCity}<br />
{operator.country}
</address>
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-text mb-3">Contact</h2>
<ul class="text-text-muted space-y-1">
<li>Email: <span class="font-mono text-accent">{operator.email}</span></li>
<li>Phone: {operator.phone}</li>
</ul>
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-text mb-3">VAT ID</h2>
<p class="text-text-muted">
VAT identification number pursuant to § 27 a UStG: <span class="font-mono">{operator.vatId}</span>
</p>
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-text mb-3">Commercial register</h2>
<p class="text-text-muted">{operator.registerEntry}</p>
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-text mb-3">Responsible for content per § 18 Abs. 2 MStV</h2>
<p class="text-text-muted">{operator.responsibleForContent}</p>
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-text mb-3">EU dispute resolution</h2>
<p class="text-text-muted">
The European Commission provides a platform for online dispute resolution (ODR) at
<a href="https://ec.europa.eu/consumers/odr/" class="text-accent hover:underline">https://ec.europa.eu/consumers/odr/</a>.
We are not obligated and do not participate in dispute resolution proceedings before a consumer arbitration board.
</p>
</section>
<section>
<h2 class="text-lg font-bold text-text mb-3">Liability for content and links</h2>
<p class="text-text-muted leading-relaxed mb-4">
As a service provider, we are responsible for our own content on these pages in accordance with § 7 para. 1 TMG and general laws. According to §§ 8 to 10 TMG, however, we are not obligated to monitor transmitted or stored third-party information or to investigate circumstances that indicate unlawful activity.
</p>
<p class="text-text-muted leading-relaxed">
Our website contains links to external websites of third parties, on whose contents we have no influence. Therefore, we cannot assume any liability for these external contents. The respective provider or operator of the pages is always responsible for the content of linked pages.
</p>
</section>
</main>
<SiteFooter />
</BaseLayout>

100
src/pages/privacy.astro Normal file
View File

@@ -0,0 +1,100 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import SiteHeader from '../components/SiteHeader.astro';
import SiteFooter from '../components/SiteFooter.astro';
const operatorContact = '<TODO:controller contact email (same as imprint)>';
const lastUpdated = '2026-04-24';
---
<BaseLayout
title="Privacy Policy — Cameleer"
description="Privacy policy for www.cameleer.io — what personal data we process (and don't), legal basis, and your rights under GDPR."
>
<SiteHeader />
<main class="max-w-prose mx-auto px-6 py-16 md:py-24">
<h1 class="text-hero font-bold text-text mb-2">Privacy Policy</h1>
<p class="text-text-faint text-sm mb-10">Last updated: {lastUpdated}</p>
<section class="mb-10">
<h2 class="text-lg font-bold text-text mb-3">1. Overview</h2>
<p class="text-text-muted leading-relaxed">
This policy describes what personal data is processed when you visit <span class="font-mono text-accent">www.cameleer.io</span>. Our goal is to collect as little data as technically possible.
<strong class="text-text">We do not set cookies. We do not run analytics scripts. We have no forms on this site.</strong>
If and when that changes, this policy will be updated and the change noted in the "Last updated" date above.
</p>
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-text mb-3">2. Controller</h2>
<p class="text-text-muted leading-relaxed">
The data controller responsible for processing on this site is the operator listed in our
<a href="/imprint" class="text-accent hover:underline">imprint</a>.
Contact for privacy matters: <span class="font-mono text-accent">{operatorContact}</span>.
</p>
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-text mb-3">3. Server access logs</h2>
<p class="text-text-muted leading-relaxed mb-3">
When you access this site, our hosting provider (Hetzner Online GmbH, Germany) automatically records standard access log data in order to operate and secure the service:
</p>
<ul class="list-disc list-inside text-text-muted space-y-1 ml-2">
<li>IP address</li>
<li>Date and time of the request</li>
<li>HTTP method, requested path, and response status</li>
<li>User-agent string and referrer</li>
</ul>
<p class="text-text-muted leading-relaxed mt-3">
Legal basis: Art. 6(1)(f) GDPR (legitimate interest in operating and securing the service). Logs are retained for the duration applied by our hosting provider and are not combined with other data sources.
</p>
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-text mb-3">4. Content delivery via Cloudflare</h2>
<p class="text-text-muted leading-relaxed">
This site is delivered through Cloudflare, Inc. (101 Townsend St, San Francisco, CA 94107, USA). Cloudflare inspects incoming traffic for security and performance purposes (DDoS protection, WAF, caching). Processing is governed by a Data Processing Agreement and the EU Standard Contractual Clauses.
Legal basis: Art. 6(1)(f) GDPR (legitimate interest in availability and security).
</p>
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-text mb-3">5. Cookies</h2>
<p class="text-text-muted leading-relaxed">
This site sets no cookies and uses no browser storage of any kind. No consent banner is required because no consent-requiring technology is in use.
</p>
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-text mb-3">6. External links</h2>
<p class="text-text-muted leading-relaxed">
Sign-in and sign-up links on this site navigate you to <span class="font-mono text-accent">auth.cameleer.io</span> (Logto identity service) and subsequently <span class="font-mono text-accent">platform.cameleer.io</span>. Those services have their own privacy policies, which apply from the moment you arrive there.
</p>
</section>
<section class="mb-10">
<h2 class="text-lg font-bold text-text mb-3">7. Your rights</h2>
<p class="text-text-muted leading-relaxed mb-3">
Under the GDPR, you have the right to:
</p>
<ul class="list-disc list-inside text-text-muted space-y-1 ml-2">
<li>request access to personal data we process about you (Art. 15)</li>
<li>request rectification of inaccurate data (Art. 16)</li>
<li>request erasure of your data (Art. 17)</li>
<li>request restriction of processing (Art. 18)</li>
<li>object to processing based on legitimate interest (Art. 21)</li>
<li>lodge a complaint with a supervisory authority (Art. 77)</li>
</ul>
<p class="text-text-muted leading-relaxed mt-3">
Contact us at <span class="font-mono text-accent">{operatorContact}</span> to exercise any of these rights.
</p>
</section>
<section>
<h2 class="text-lg font-bold text-text mb-3">8. Changes to this policy</h2>
<p class="text-text-muted leading-relaxed">
We may update this policy as our data processing changes (for example, if we later add analytics). The "Last updated" date at the top of this page reflects the most recent revision.
</p>
</section>
</main>
<SiteFooter />
</BaseLayout>

View File

@@ -25,7 +25,7 @@ export default {
text: {
DEFAULT: '#e8eaed',
muted: '#9aa3b2',
faint: '#6b7280',
faint: '#828b9b',
},
},
fontFamily: {