chore(ops): signed images, a secret gate before commit, and a monthly restore drill

Published manifests are now cosign-signed keyless with a documented
digest-pinning path, a lefthook pre-commit runs gitleaks over staged
changes, responses opt into a credentialless embedder policy, the
deploy webhook can require a fresh timestamp, and a monthly drill
decrypts and parses the newest offhost backup so a broken key or format
surfaces before an emergency does.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 00:10:01 +02:00
1 parent 410c62c292
commit ce3b4c70c8
11 files changed
+433 -4

No files matched your search

+5
View File
@@ -68,6 +68,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. When unset the notification omits the link.
# DEPLOY_LOGS_URL=""
# Optional replay hardening: require a fresh X-Deploy-Webhook-Timestamp
# header (unix seconds / ms or ISO-8601, +/- 5 minutes) on every webhook
# call. Leave unset for Coolify's stock sender (it attaches no
# timestamp); see docs/ops/deploy.md.
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
# -----------------------------------------------------------------------------
+35
View File
@@ -268,6 +268,12 @@ jobs:
permissions:
contents: read
packages: write
# v1.16.4 — keyless image signing. The job requests an OIDC
# token from GitHub's issuer; cosign exchanges it for a
# short-lived Fulcio certificate bound to this workflow identity
# and records the signature in the Rekor transparency log. No
# long-lived signing key exists anywhere in this pipeline.
id-token: write
steps:
- name: Download digest artifacts
@@ -333,6 +339,35 @@ jobs:
run: |
docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}'
# v1.16.4 — sign the multi-arch manifest index (keyless, Sigstore).
# Signing the INDEX digest covers both per-platform manifests in
# one signature, and the digest is the only stable identity — every
# tag (`:latest`, `:1.16.4`, …) is a mutable alias that a registry
# compromise could repoint. Operators verify with:
#
# cosign verify \
# --certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
# ghcr.io/mbombeck/healthlog:<tag>
#
# See docs/ops/deploy.md ("Verify the image signature") for the
# digest-pinned deploy recommendation that pairs with this.
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign multi-arch manifest (keyless)
env:
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
run: |
digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_VERSION}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
if [ -z "$digest" ]; then
echo "::error::Could not resolve the manifest digest for ${IMAGE_REF}:${IMAGE_VERSION} — signing aborted."
exit 1
fi
echo "Signing ${IMAGE_REF}@${digest}"
cosign sign --yes "${IMAGE_REF}@${digest}"
echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${digest}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${digest}"
# v1.4.15: trigger the Coolify deploy automatically once GHCR has
# the new image. Replaces the former manual SSH + force-pull recipe
# on the deployment host.
+4
View File
@@ -77,6 +77,10 @@ services:
# .env reach the container (the `environment:` block is a whitelist).
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
# Optional replay hardening: when "true", the deploy webhook
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
# See docs/ops/deploy.md.
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
# TLS leaf SPKI-change alarm baseline (optional). The native client
# pins the served TLS leaf; this is the known-good pin set the monitor
# alarms against on a rotation. Comma-separated for the dual-pin
+27
View File
@@ -85,3 +85,30 @@ pnpm tsx scripts/restore-backup.ts \
The script downloads the object, decrypts it, and writes the JSON dump
to disk. Importing the JSON back into a HealthLog instance is left to
the operator (use `prisma db seed` or a custom script).
## Monthly restore drill (automatic)
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
04:11 on the 1st of each month) exercises the read path end-to-end:
fetch the most recent backup object from the bucket, decrypt it under
the current `BACKUP_ENCRYPTION_KEY`, JSON-parse it, and sanity-check
the payload shape. It performs **no database restore** — it validates
the artefact, not the import path.
Outcomes:
- **Success** — record counts, object age, and sizes land in the
wide-event meta (`job.restore_drill`).
- **Stale chain** — the newest object is older than 3 days: the nightly
uploader has stalled (or the lifecycle rule is too aggressive). The
drill pages via the worker error reporter (stderr + GlitchTip).
- **Failure** — empty bucket, fetch error, decryption failure (wrong or
rotated key), malformed JSON: pages the same way. A decryption
failure right after a `BACKUP_ENCRYPTION_KEY` change means the new
key cannot read the existing objects — re-encrypt or accept that
pre-rotation backups are only readable with the retired key.
- **Not configured** — deployments without the `BACKUP_S3_*` vars skip
silently (wide-event warning only).
The drill needs no IAM grant beyond the uploader's existing
`GetObject` + `ListBucket`.
+74 -1
View File
@@ -70,7 +70,7 @@ roll back:
release pair shipped on the v1.4.x line.
3. If a column-drop migration ships in the rollback target, hand-roll
the column back via a fresh forward migration (`pnpm prisma migrate
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
@@ -90,3 +90,76 @@ pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
Pass `--only=prod` / `--only=demo` to check a single target, or
`--attempts=N --interval=ms` to widen the retry window while a slow
pull settles.
## Verify the image signature
Every release image (the multi-arch manifest **index**, covering both
`amd64` and `arm64`) is signed keyless via Sigstore in the
`docker-publish.yml` merge job: GitHub's OIDC token is exchanged for a
short-lived Fulcio certificate bound to the workflow identity, and the
signature lands in the Rekor transparency log. There is no long-lived
signing key to leak or rotate.
Verify before (or after) a deploy with [cosign](https://docs.sigstore.dev/cosign/system_config/installation/):
```sh
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \
ghcr.io/mbombeck/healthlog:1.16.4
```
A passing verification proves the image was built by this repository's
release workflow on a `v*` tag — not by anyone who merely obtained push
access to the GHCR package.
### Prefer digest-pinned deploys
Tags (`:latest`, `:1.16.4`) are mutable aliases: a registry-side
compromise or an accidental re-push can repoint them. The digest is the
image's only immutable identity, and it is what the signature actually
covers. For any deploy where supply-chain integrity matters more than
convenience:
1. Resolve and verify the digest once:
```sh
cosign verify ... ghcr.io/mbombeck/healthlog:1.16.4 \
| jq -r '.[0].critical.image."docker-manifest-digest"'
```
2. Pin the compose `image:` (or the Coolify image reference) to
`ghcr.io/mbombeck/healthlog@sha256:<digest>` instead of the tag.
The day-to-day `:latest` + `pull_policy: always` flow stays the
documented default for self-hosters; digest pinning is the recommended
hardening for operators with a threat model that includes the registry.
## Deploy-status webhook: replay protection
`POST /api/internal/deploy-webhook` authenticates via the static
`X-Deploy-Webhook-Secret` header. Since v1.16.4 it additionally
enforces a freshness window: a request carrying an
`X-Deploy-Webhook-Timestamp` header (unix seconds, unix milliseconds,
or ISO-8601) is rejected with `401 {"status":"stale"}` when the
timestamp lies more than **5 minutes** from server time — so a captured
request cannot be replayed later to re-page admins or forge deploy
outcomes in the audit log.
Two modes:
- **Default (tolerant).** Requests _without_ the header pass — Coolify's
stock notification sender does not attach one. A header that is
present but stale or unparseable is always rejected.
- **Strict.** Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` in the app
environment to make the header mandatory, closing the replay window
completely. Use this when the sender (or a reverse proxy in front of
the app) can attach the timestamp, e.g.:
```sh
curl -X POST https://<host>/api/internal/deploy-webhook \
-H "X-Deploy-Webhook-Secret: $SECRET" \
-H "X-Deploy-Webhook-Timestamp: $(date +%s)" \
-H "Content-Type: application/json" \
-d '{"status":"success"}'
```
+32
View File
@@ -0,0 +1,32 @@
# Git-hook manager (v1.16.4). `pnpm install` wires the hooks via the
# `prepare` script in package.json; no manual step needed.
#
# Pre-commit secret scan: `gitleaks protect --staged` scans exactly the
# staged hunks against the repo's `.gitleaks.toml` before every commit,
# so a secret-shaped string never reaches the object store (where only
# a history rewrite can remove it again).
#
# Requirements: the `gitleaks` binary must be on PATH — the command
# below fails with install pointers when it is missing rather than
# silently skipping the scan.
#
# Opt-out (one commit): LEFTHOOK=0 git commit ...
# Opt-out (skip one job): LEFTHOOK_EXCLUDE=gitleaks git commit ...
# Opt-out (permanent): pnpm lefthook uninstall
# Per project policy (`--no-verify` is banned), prefer fixing the
# finding over bypassing the hook.
pre-commit:
commands:
gitleaks:
run: |
if ! command -v gitleaks >/dev/null 2>&1; then
echo "ERROR: gitleaks binary not found on PATH — the staged-secret scan cannot run." >&2
echo "Install it first, e.g.:" >&2
echo " brew install gitleaks # macOS" >&2
echo " apt/apk/pacman install gitleaks # Linux distros" >&2
echo " https://github.com/gitleaks/gitleaks#installing" >&2
echo "One-off bypass (use sparingly): LEFTHOOK=0 git commit ..." >&2
exit 1
fi
gitleaks protect --staged --redact --no-banner
+2
View File
@@ -24,6 +24,7 @@
"private": true,
"packageManager": "pnpm@10.31.0",
"scripts": {
"prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'",
"dev": "next dev",
"prebuild": "node scripts/generate-sw-version.mjs",
"build": "next build",
@@ -109,6 +110,7 @@
"eslint": "^9",
"eslint-config-next": "16.2.6",
"knip": "^6.16.1",
"lefthook": "^2.1.9",
"pdf-parse": "^2.4.5",
"pg": "^8.21.0",
"prettier": "^3.8.3",
+100
View File
@@ -183,6 +183,9 @@ importers:
knip:
specifier: ^6.16.1
version: 6.16.1
lefthook:
specifier: ^2.1.9
version: 2.1.9
pdf-parse:
specifier: ^2.4.5
version: 2.4.5
@@ -4792,6 +4795,60 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
lefthook-darwin-arm64@2.1.9:
resolution: {integrity: sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==}
cpu: [arm64]
os: [darwin]
lefthook-darwin-x64@2.1.9:
resolution: {integrity: sha512-dwo5Tke2XcQCM56DGHgFKBfRbJIL6xs2wZ0zG1TUVZgl4t4mQUt6LiZ4V/ZQfYHTZF9qywvXoIlR5N35qOaiVQ==}
cpu: [x64]
os: [darwin]
lefthook-freebsd-arm64@2.1.9:
resolution: {integrity: sha512-+09PVap6nl6xsaHch5JLtq7WvIR++U1Q2MzA2ai0M4uB/VP3AqrvKqHw6+9hjyKnIH+HHL83uqi77EAY+LaxLA==}
cpu: [arm64]
os: [freebsd]
lefthook-freebsd-x64@2.1.9:
resolution: {integrity: sha512-8XresjKIYpkE9ARgCtBEZgJZxAU3T4MIqzj4zNy15XRT59I1Us+QdqXTNm+pkZ41Yd2X/nxs2Pkvbq3NWWlIGw==}
cpu: [x64]
os: [freebsd]
lefthook-linux-arm64@2.1.9:
resolution: {integrity: sha512-1oNIQfwrPe6rgU2KcDM3aF6+hpZDCKx1TmawQKpXUY5gVsbZ7MqX0Sk/1lnnWxqPm+kQQ5f6J2dpFWd+4xH8jg==}
cpu: [arm64]
os: [linux]
lefthook-linux-x64@2.1.9:
resolution: {integrity: sha512-fT+7Q+BJyGp+CslFQkNXmdFRgyVXsPHPi9NAsDX0a6QOyNnoORByAsvx6zeAKuF5rL3BBgNfho1/v2RuGxGy9w==}
cpu: [x64]
os: [linux]
lefthook-openbsd-arm64@2.1.9:
resolution: {integrity: sha512-4bVuafBk3dddVNo0+3hMbjcJs4mqYAstxpPMmX2ufkudSTYFNIhWoqwuGVQV/SS/xdcOKJAldW4qayAzed2ysw==}
cpu: [arm64]
os: [openbsd]
lefthook-openbsd-x64@2.1.9:
resolution: {integrity: sha512-PmPoMmLP/wQQWcQ9u2YH86bTZ3UCfBsxuEmVTEyPU2U8R1qSTp5r/Gs3G8cN5Mxo91XB9oBERtF1n+xD3W6aVA==}
cpu: [x64]
os: [openbsd]
lefthook-windows-arm64@2.1.9:
resolution: {integrity: sha512-KphfkBKmwBnmolyrdhIl3lrBaOyTcCgXBT2AB/9OHnEXhOLvv5uTCUkrD4YRAxXPtFKq6UvnapIeoL3GZq0bdA==}
cpu: [arm64]
os: [win32]
lefthook-windows-x64@2.1.9:
resolution: {integrity: sha512-2qlUtkJHZ3MyUxgV5XTEmcrIoNZA07iwaquoswAcqv/1MeBFXlD+O+koFRfrzWng2O5WYEbpJnd8tvaYnV8fTA==}
cpu: [x64]
os: [win32]
lefthook@2.1.9:
resolution: {integrity: sha512-bwDaIOViTktE8kJLf9jP0p+H2/RDTlFFlc43Am2YgUsX22hI6Sq4RbzsrecwzY5y+MHTipOH7WsmWSEniePHWQ==}
hasBin: true
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -11428,6 +11485,49 @@ snapshots:
dependencies:
readable-stream: 2.3.8
lefthook-darwin-arm64@2.1.9:
optional: true
lefthook-darwin-x64@2.1.9:
optional: true
lefthook-freebsd-arm64@2.1.9:
optional: true
lefthook-freebsd-x64@2.1.9:
optional: true
lefthook-linux-arm64@2.1.9:
optional: true
lefthook-linux-x64@2.1.9:
optional: true
lefthook-openbsd-arm64@2.1.9:
optional: true
lefthook-openbsd-x64@2.1.9:
optional: true
lefthook-windows-arm64@2.1.9:
optional: true
lefthook-windows-x64@2.1.9:
optional: true
lefthook@2.1.9:
optionalDependencies:
lefthook-darwin-arm64: 2.1.9
lefthook-darwin-x64: 2.1.9
lefthook-freebsd-arm64: 2.1.9
lefthook-freebsd-x64: 2.1.9
lefthook-linux-arm64: 2.1.9
lefthook-linux-x64: 2.1.9
lefthook-openbsd-arm64: 2.1.9
lefthook-openbsd-x64: 2.1.9
lefthook-windows-arm64: 2.1.9
lefthook-windows-x64: 2.1.9
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -330,3 +330,84 @@ describe("GET /api/internal/deploy-webhook (reachability check)", () => {
expect(res.status).toBe(401);
});
});
describe("POST /api/internal/deploy-webhook (timestamp replay protection)", () => {
const ORIGINAL_REQUIRE = process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
afterEach(() => {
if (ORIGINAL_REQUIRE === undefined) {
delete process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
} else {
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = ORIGINAL_REQUIRE;
}
});
function timestamped(ts: string): NextRequest {
return jsonRequest(
{ status: "success" },
{
"x-deploy-webhook-secret": "test-secret",
"x-deploy-webhook-timestamp": ts,
},
);
}
it("accepts a fresh unix-seconds timestamp", async () => {
const res = await POST(timestamped(String(Math.floor(Date.now() / 1000))));
expect(res.status).toBe(200);
});
it("accepts a fresh ISO-8601 timestamp", async () => {
const res = await POST(timestamped(new Date().toISOString()));
expect(res.status).toBe(200);
});
it("rejects a timestamp older than the 5-minute window", async () => {
const stale = Math.floor((Date.now() - 6 * 60 * 1000) / 1000);
const res = await POST(timestamped(String(stale)));
expect(res.status).toBe(401);
expect(auditLog).not.toHaveBeenCalled();
});
it("rejects a timestamp too far in the future (clock-skew bound)", async () => {
const future = Math.floor((Date.now() + 6 * 60 * 1000) / 1000);
const res = await POST(timestamped(String(future)));
expect(res.status).toBe(401);
});
it("rejects an unparseable timestamp value", async () => {
const res = await POST(timestamped("not-a-timestamp"));
expect(res.status).toBe(401);
});
it("accepts a request without the header in tolerant default mode", async () => {
delete process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
const res = await POST(
jsonRequest(
{ status: "success" },
{ "x-deploy-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
});
it("rejects a request without the header when DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true", async () => {
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = "true";
const res = await POST(
jsonRequest(
{ status: "success" },
{ "x-deploy-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(401);
expect(auditLog).not.toHaveBeenCalled();
});
it("still rejects a stale timestamp before any audit-log write", async () => {
const stale = Math.floor((Date.now() - 10 * 60 * 1000) / 1000);
const res = await POST(timestamped(String(stale)));
expect(res.status).toBe(401);
expect(auditLog).not.toHaveBeenCalled();
expect(dispatchLocalisedNotification).not.toHaveBeenCalled();
});
});
@@ -85,6 +85,58 @@ function normalizePayload(payload: CoolifyDeployPayload): NormalizedEvent {
};
}
/**
* Replay protection (v1.16.4). A captured webhook request carries a
* static shared secret, so without a freshness check it can be replayed
* indefinitely — re-paging admins with stale failure alerts and
* polluting the audit log with forged deploy outcomes.
*
* The sender attaches `X-Deploy-Webhook-Timestamp` (unix seconds or
* ISO-8601). Requests whose timestamp falls outside a ±5-minute window
* are rejected. Two modes:
*
* - Default (tolerant): a request WITHOUT the header passes — Coolify's
* stock notification sender does not attach one, and breaking the
* live integration would cost more than the residual replay window.
* A header that IS present but stale/invalid is always rejected, so
* a replayed capture of a timestamped request never lands.
* - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: the header becomes
* mandatory. For operators whose sender (or reverse proxy) can
* attach the timestamp; closes the replay window completely.
*
* See docs/ops/deploy.md ("Deploy-status webhook") for the operator
* guidance.
*/
const TIMESTAMP_WINDOW_MS = 5 * 60 * 1000;
function parseTimestamp(raw: string): number | null {
// Unix seconds (10 digits covers 2001-2286) or milliseconds.
if (/^\d{10}$/.test(raw)) return Number(raw) * 1000;
if (/^\d{13}$/.test(raw)) return Number(raw);
const iso = Date.parse(raw);
return Number.isFinite(iso) ? iso : null;
}
function checkTimestampFreshness(
request: NextRequest,
now: number = Date.now(),
): { ok: boolean; reason?: string } {
const raw = request.headers.get("x-deploy-webhook-timestamp");
const required =
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "true" ||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "1";
if (raw === null || raw === "") {
if (required) return { ok: false, reason: "timestamp_missing" };
return { ok: true };
}
const ts = parseTimestamp(raw);
if (ts === null) return { ok: false, reason: "timestamp_invalid" };
if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) {
return { ok: false, reason: "timestamp_outside_window" };
}
return { ok: true };
}
function hasValidSecret(request: NextRequest): boolean {
const expected = process.env.DEPLOY_WEBHOOK_SECRET;
if (!expected) {
@@ -173,6 +225,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
// Freshness AFTER the secret check: an unauthenticated caller learns
// nothing about the timestamp policy, and a replayed-but-stale request
// from a captured legitimate call is rejected here.
const freshness = checkTimestampFreshness(request);
if (!freshness.ok) {
annotate({
meta: { deploy_webhook_replay_reason: freshness.reason ?? "unknown" },
});
return NextResponse.json({ status: "stale" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
const { data: payload, error: jsonError } =
+10 -3
View File
@@ -290,6 +290,15 @@ export function proxy(request: NextRequest) {
// Flash/PDF crossdomain.xml channel that some scanners still flag.
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
response.headers.set("Cross-Origin-Resource-Policy", "same-origin");
// COEP `credentialless` completes the cross-origin-isolation triad
// without the operational hazard of `require-corp`: cross-origin
// no-CORS subresources load with credentials stripped instead of
// being blocked outright. The app's CSP already pins `img-src` to
// `'self' data:` (Gravatar is proxied same-origin since v1.5.5), so
// no shipped surface loads cross-origin subresources at all — the
// header is pure defence-in-depth against a future regression and
// unlocks `crossOriginIsolated` capabilities should they be needed.
response.headers.set("Cross-Origin-Embedder-Policy", "credentialless");
response.headers.set("X-Permitted-Cross-Domain-Policies", "none");
// CSP — permissive in dev, strict in production. Third-party hosts in
@@ -321,9 +330,7 @@ export function proxy(request: NextRequest) {
const isWhoopRoute =
pathname.startsWith("/settings/integrations/whoop") ||
pathname.startsWith("/api/whoop/");
const whoopConnectSrc = isWhoopRoute
? " https://api.prod.whoop.com"
: "";
const whoopConnectSrc = isWhoopRoute ? " https://api.prod.whoop.com" : "";
// v1.5.5 — Gravatar host removed from `img-src`. The /me payload
// used to return `gravatarUrl: https://www.gravatar.com/avatar/<sha256(email)>`,
// which leaked the email digest to Automattic on every authenticated