diff --git a/docs/superpowers/plans/2026-05-18-issue-7802-url-base-path.md b/docs/superpowers/plans/2026-05-18-issue-7802-url-base-path.md
new file mode 100644
index 000000000..9e176915f
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-18-issue-7802-url-base-path.md
@@ -0,0 +1,1264 @@
+# URL base-path support (issue #7802) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make Etherpad emit prefix-correct asset URLs, manifest URLs, social-meta URLs, and admin links when served behind a reverse proxy that sets `X-Forwarded-Prefix` or `X-Ingress-Path` (in addition to the already-supported `x-proxy-path`).
+
+**Architecture:** Extend the existing `sanitizeProxyPath` helper to accept the two standard headers (gated on `settings.trustProxy === true`). Then thread the resulting `proxyPath` into the three remaining spots that don't already use it: the `/manifest.json` handler, `socialMeta.buildAbsoluteUrl`, and the leading-slash URLs in `index.html`/`pad.html`/`timeslider.html`/`export_html.html`. Fix the pre-existing `..`-count bug on ` ` in pad.html and timeslider.html.
+
+**Tech Stack:** TypeScript, Express, EJS templates, vitest + mocha.
+
+**Spec:** `docs/superpowers/specs/2026-05-18-issue-7802-url-base-path-support-design.md`
+
+---
+
+## File Structure
+
+Files created or modified by this plan. Each file has one focused responsibility:
+
+| File | Responsibility |
+|---|---|
+| `src/node/utils/sanitizeProxyPath.ts` | Single source of truth for "the URL prefix this request is being served under". Returns `''` or `/...`. Pure function. |
+| `src/tests/backend-new/specs/sanitizeProxyPath.test.ts` | Truth table for `sanitizeProxyPath` — extended with X-Forwarded-Prefix, X-Ingress-Path, and trustProxy gating. |
+| `src/node/hooks/express/pwa.ts` | `/manifest.json` route — now emits prefix-aware icon `src`, `start_url`. |
+| `src/tests/backend/specs/pwaManifest.ts` *(new)* | Supertest coverage for `/manifest.json` under proxy headers. |
+| `src/node/utils/socialMeta.ts` | `buildAbsoluteUrl` accepts an explicit `proxyPath`. |
+| `src/tests/backend/specs/socialMeta-unit.ts` | Extended with proxyPath cases. |
+| `src/node/hooks/express/specialpages.ts` | `proxyPath` already computed for pad/timeslider routes — passed into the EJS context and to `renderSocialMeta`. Also wired into the index route, where it's currently only used for the `entrypoint`. |
+| `src/templates/index.html` | Manifest link + jslicense link use `proxyPath`. |
+| `src/templates/pad.html` | Reconnect form action + jslicense link use `proxyPath`. Pre-existing `../../manifest.json` (one `..` too many) reduced to `../manifest.json` — strict improvement; same value at root mount, correct value under prefix. |
+| `src/templates/timeslider.html` | Reconnect form action + jslicense link use `proxyPath`. Pre-existing `../../../manifest.json` reduced to `../../manifest.json` — same rationale. |
+| `src/templates/export_html.html` + the export-HTML route handler | Manifest link uses `proxyPath`. |
+| `src/tests/backend/specs/urlBasePath.ts` *(new)* | End-to-end backend integration test — assert prefix appears everywhere after one supertest GET with `X-Ingress-Path`. |
+| `src/node/utils/Settings.ts` (doc comment only) | Document the three honored header names against `trustProxy`. |
+| `settings.json.template` (doc comment only) | Same. |
+
+---
+
+## Task 1: Extend sanitizeProxyPath to support standard headers under trustProxy
+
+**Files:**
+- Modify: `src/node/utils/sanitizeProxyPath.ts`
+- Modify: `src/tests/backend-new/specs/sanitizeProxyPath.test.ts`
+
+### Background
+
+Today: only `x-proxy-path` is read. HA ingress sends `X-Ingress-Path`; nginx subpath setups conventionally send `X-Forwarded-Prefix`. We add both. The custom `x-proxy-path` stays un-gated (it's an Etherpad convention an operator opted into). The two standard headers must be gated on `settings.trustProxy === true` because they can otherwise be set by any internet client when Etherpad runs on a public IP.
+
+Precedence (first non-empty wins): `x-proxy-path` → `x-forwarded-prefix` → `x-ingress-path`.
+
+### Steps
+
+- [ ] **Step 1: Write failing tests for the new behaviour**
+
+Append to `src/tests/backend-new/specs/sanitizeProxyPath.test.ts` (inside the top-level `describe('sanitizeProxyPath', ...)` block, after the existing `describe`s but before the closing brace):
+
+```typescript
+ describe('X-Forwarded-Prefix and X-Ingress-Path', () => {
+ const mockReqMulti = (headers: Record) => ({
+ header: (name: string) => headers[name.toLowerCase()],
+ });
+
+ it('reads X-Forwarded-Prefix when trustProxy is true', () => {
+ expect(sanitizeProxyPath(
+ mockReqMulti({'x-forwarded-prefix': '/foo'}),
+ {trustProxy: true})).toBe('/foo');
+ });
+
+ it('reads X-Ingress-Path when trustProxy is true', () => {
+ expect(sanitizeProxyPath(
+ mockReqMulti({'x-ingress-path': '/api/hassio_ingress/abc'}),
+ {trustProxy: true})).toBe('/api/hassio_ingress/abc');
+ });
+
+ it('ignores X-Forwarded-Prefix when trustProxy is false', () => {
+ expect(sanitizeProxyPath(
+ mockReqMulti({'x-forwarded-prefix': '/foo'}),
+ {trustProxy: false})).toBe('');
+ });
+
+ it('ignores X-Ingress-Path when trustProxy is false', () => {
+ expect(sanitizeProxyPath(
+ mockReqMulti({'x-ingress-path': '/foo'}),
+ {trustProxy: false})).toBe('');
+ });
+
+ it('x-proxy-path still works without trustProxy (legacy Etherpad convention)', () => {
+ expect(sanitizeProxyPath(
+ mockReqMulti({'x-proxy-path': '/legacy'}),
+ {trustProxy: false})).toBe('/legacy');
+ });
+
+ it('x-proxy-path wins over standard headers when all are present', () => {
+ expect(sanitizeProxyPath(
+ mockReqMulti({
+ 'x-proxy-path': '/legacy',
+ 'x-forwarded-prefix': '/forwarded',
+ 'x-ingress-path': '/ingress',
+ }),
+ {trustProxy: true})).toBe('/legacy');
+ });
+
+ it('x-forwarded-prefix beats x-ingress-path when both are present', () => {
+ expect(sanitizeProxyPath(
+ mockReqMulti({
+ 'x-forwarded-prefix': '/forwarded',
+ 'x-ingress-path': '/ingress',
+ }),
+ {trustProxy: true})).toBe('/forwarded');
+ });
+
+ it('sanitises standard headers the same as x-proxy-path', () => {
+ expect(sanitizeProxyPath(
+ mockReqMulti({'x-forwarded-prefix': '//evil.example/pwn'}),
+ {trustProxy: true})).toBe('/evil.example/pwn');
+ expect(sanitizeProxyPath(
+ mockReqMulti({'x-ingress-path': '/a/../b'}),
+ {trustProxy: true})).toBe('');
+ expect(sanitizeProxyPath(
+ mockReqMulti({'x-forwarded-prefix': 'pad'}),
+ {trustProxy: true})).toBe('/pad');
+ });
+
+ it('defaults trustProxy from settings when opts not provided', async () => {
+ // Verifies the default-path reads from settings — when opts omitted,
+ // the helper falls back to settings.trustProxy at call time.
+ const settings = (await import('../../../node/utils/Settings')).default;
+ const original = settings.trustProxy;
+ try {
+ settings.trustProxy = true;
+ expect(sanitizeProxyPath(
+ mockReqMulti({'x-forwarded-prefix': '/x'})))
+ .toBe('/x');
+ settings.trustProxy = false;
+ expect(sanitizeProxyPath(
+ mockReqMulti({'x-forwarded-prefix': '/x'})))
+ .toBe('');
+ } finally {
+ settings.trustProxy = original;
+ }
+ });
+ });
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+pnpm --filter ./src test:vitest -- src/tests/backend-new/specs/sanitizeProxyPath.test.ts
+```
+
+Expected: 9 failing tests (all the new ones), all 13 existing tests still pass.
+
+- [ ] **Step 3: Update `sanitizeProxyPath.ts` to support the new headers and the trustProxy gate**
+
+Replace the entire body of `src/node/utils/sanitizeProxyPath.ts` with:
+
+```typescript
+import settings from './Settings';
+
+/**
+ * Sanitize the URL-path prefix Etherpad is being served under.
+ *
+ * Headers checked in order; first non-empty (after sanitization) wins:
+ * 1. `x-proxy-path` — Etherpad's own convention; always honored because
+ * the operator must explicitly configure their proxy to send it.
+ * 2. `x-forwarded-prefix` — HAProxy / Traefik standard.
+ * 3. `x-ingress-path` — Home Assistant supervisor ingress.
+ *
+ * The two standard headers (everything other than x-proxy-path) are honored
+ * ONLY when `settings.trustProxy === true`, because they can otherwise be
+ * forged by any internet client when Etherpad runs on a public IP.
+ *
+ * The header value is woven into HTML, JS, CSS and HTTP Location headers,
+ * so the same value is also treated as untrusted input even when read from
+ * a trusted header. Sanitization rules:
+ * - Strips every character outside `[a-zA-Z0-9\-_\/\.]`.
+ * - Collapses a leading `//+` to a single `/` so the value can never be
+ * interpreted as a protocol-relative URL.
+ * - Prepends `/` if the (non-empty) result doesn't already start with one,
+ * so callers can always concatenate the value as an absolute path prefix.
+ * - Rejects values containing `..` segments.
+ *
+ * The output is always either the empty string or a string that starts
+ * with exactly one `/` and contains only `[A-Za-z0-9\-_./]`.
+ */
+
+const HEADER_NAMES = [
+ // [headerName, requiresTrustProxy]
+ ['x-proxy-path', false] as const,
+ ['x-forwarded-prefix', true] as const,
+ ['x-ingress-path', true] as const,
+];
+
+const cleanOne = (raw: string): string => {
+ let cleaned = raw.replace(/[^a-zA-Z0-9\-_\/\.]/g, '');
+ if (!cleaned) return '';
+ cleaned = cleaned.replace(/^\/{2,}/, '/');
+ if (cleaned[0] !== '/') cleaned = '/' + cleaned;
+ if (/(?:^|\/)\.\.(?:\/|$)/.test(cleaned)) return '';
+ return cleaned;
+};
+
+type ReqLike = {header: (n: string) => string|undefined};
+
+export const sanitizeProxyPath = (
+ req: ReqLike | string | undefined,
+ opts: {trustProxy?: boolean} = {},
+): string => {
+ // String form preserves the original behaviour for callers that pre-extracted
+ // the value themselves (e.g. tests). It's treated as a raw value with no
+ // header-gating: the caller has already decided to use it.
+ if (typeof req === 'string') return cleanOne(req);
+ if (!req || typeof req.header !== 'function') return '';
+ const trustProxy = opts.trustProxy ?? !!settings.trustProxy;
+ for (const [name, requiresTrust] of HEADER_NAMES) {
+ if (requiresTrust && !trustProxy) continue;
+ const raw = req.header(name) || '';
+ const cleaned = cleanOne(raw);
+ if (cleaned) return cleaned;
+ }
+ return '';
+};
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+```bash
+pnpm --filter ./src test:vitest -- src/tests/backend-new/specs/sanitizeProxyPath.test.ts
+```
+
+Expected: all 22 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/node/utils/sanitizeProxyPath.ts \
+ src/tests/backend-new/specs/sanitizeProxyPath.test.ts
+git commit -m "feat(proxy): accept X-Forwarded-Prefix and X-Ingress-Path under trustProxy (#7802)"
+```
+
+---
+
+## Task 2: Make `/manifest.json` prefix-aware
+
+**Files:**
+- Modify: `src/node/hooks/express/pwa.ts`
+- Test: `src/tests/backend/specs/pwaManifest.ts` *(new)*
+
+### Steps
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/tests/backend/specs/pwaManifest.ts`:
+
+```typescript
+'use strict';
+
+/**
+ * Coverage for /manifest.json prefix-awareness.
+ *
+ * Without a proxy header the manifest should emit today's values
+ * (leading-slash absolute paths). With a sanitised `x-proxy-path`,
+ * `x-forwarded-prefix` (requires trustProxy) or `x-ingress-path`
+ * (requires trustProxy), the manifest should emit prefixed paths so
+ * the PWA renders icons and start_url correctly when Etherpad is
+ * proxied under a subpath.
+ */
+
+const common = require('../common');
+import settings from 'ep_etherpad-lite/node/utils/Settings';
+
+let agent: any;
+
+describe(__filename, function () {
+ before(async function () { agent = await common.init(); });
+
+ describe('/manifest.json without proxy headers', function () {
+ it('emits leading-slash icon srcs and start_url=/', async function () {
+ const res = await agent.get('/manifest.json').expect(200);
+ const m = res.body;
+ if (m.start_url !== '/') {
+ throw new Error(`expected start_url "/", got ${JSON.stringify(m.start_url)}`);
+ }
+ const srcs = (m.icons || []).map((i: any) => i.src);
+ for (const s of srcs) {
+ if (!s.startsWith('/')) {
+ throw new Error(`expected leading-slash icon src, got ${s}`);
+ }
+ }
+ });
+ });
+
+ describe('/manifest.json with x-proxy-path', function () {
+ it('prefixes every icon src and start_url', async function () {
+ const res = await agent.get('/manifest.json')
+ .set('x-proxy-path', '/sub')
+ .expect(200);
+ const m = res.body;
+ if (m.start_url !== '/sub/') {
+ throw new Error(`expected start_url "/sub/", got ${JSON.stringify(m.start_url)}`);
+ }
+ const srcs = (m.icons || []).map((i: any) => i.src);
+ for (const s of srcs) {
+ if (!s.startsWith('/sub/')) {
+ throw new Error(`expected /sub/-prefixed icon src, got ${s}`);
+ }
+ }
+ });
+
+ it('sets Vary so caches don\'t collapse responses across prefixes', async function () {
+ const res = await agent.get('/manifest.json')
+ .set('x-proxy-path', '/sub')
+ .expect(200);
+ const vary = (res.headers.vary || '').toLowerCase();
+ if (!vary.includes('x-proxy-path')) {
+ throw new Error(`expected Vary to include x-proxy-path, got ${vary}`);
+ }
+ });
+ });
+
+ describe('/manifest.json with x-ingress-path (HA)', function () {
+ it('ignores the header when trustProxy is off', async function () {
+ const original = settings.trustProxy;
+ settings.trustProxy = false;
+ try {
+ const res = await agent.get('/manifest.json')
+ .set('x-ingress-path', '/api/hassio_ingress/abc')
+ .expect(200);
+ if (res.body.start_url !== '/') {
+ throw new Error(`expected start_url "/" when trustProxy=false, got ${res.body.start_url}`);
+ }
+ } finally {
+ settings.trustProxy = original;
+ }
+ });
+
+ it('honors the header when trustProxy is on', async function () {
+ const original = settings.trustProxy;
+ settings.trustProxy = true;
+ try {
+ const res = await agent.get('/manifest.json')
+ .set('x-ingress-path', '/api/hassio_ingress/abc')
+ .expect(200);
+ if (res.body.start_url !== '/api/hassio_ingress/abc/') {
+ throw new Error(`expected prefixed start_url, got ${res.body.start_url}`);
+ }
+ } finally {
+ settings.trustProxy = original;
+ }
+ });
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+pnpm --filter ./src test -- --grep pwaManifest
+```
+
+Expected: 3-4 failures saying start_url is `/` not `/sub/` (or similar), and Vary header missing.
+
+- [ ] **Step 3: Update `pwa.ts` to honor proxyPath**
+
+Replace `src/node/hooks/express/pwa.ts` with:
+
+```typescript
+import {ArgsExpressType} from "../../types/ArgsExpressType";
+import settings from '../../utils/Settings';
+import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath';
+
+const buildManifest = (proxyPath: string) => ({
+ name: settings.title || "Etherpad",
+ short_name: settings.title,
+ description: "A collaborative online editor",
+ icons: [
+ {
+ "src": `${proxyPath}/static/skins/colibris/images/fond.jpg`,
+ "sizes": "512x512",
+ "type": "image/png",
+ },
+ {
+ "src": `${proxyPath}/favicon.ico`,
+ "sizes": "64x64 32x32 24x24 16x16",
+ type: "image/png",
+ },
+ ],
+ start_url: `${proxyPath}/`,
+ display: "fullscreen",
+ theme_color: "#0f775b",
+ background_color: "#0f775b",
+});
+
+exports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => {
+ args.app.get('/manifest.json', (req:any, res:any) => {
+ const proxyPath = sanitizeProxyPath(req);
+ if (proxyPath) {
+ // Same pattern as admin.ts: caches must not collapse responses
+ // across requests that arrived with different prefix headers.
+ res.setHeader('Vary', 'x-proxy-path, x-forwarded-prefix, x-ingress-path');
+ res.setHeader('Cache-Control', 'private, no-store');
+ }
+ res.json(buildManifest(proxyPath));
+ });
+
+ return cb();
+};
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+pnpm --filter ./src test -- --grep pwaManifest
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/node/hooks/express/pwa.ts src/tests/backend/specs/pwaManifest.ts
+git commit -m "feat(pwa): make /manifest.json honor sanitised proxy-path (#7802)"
+```
+
+---
+
+## Task 3: Make `socialMeta.buildAbsoluteUrl` honor proxyPath
+
+**Files:**
+- Modify: `src/node/utils/socialMeta.ts`
+- Modify: `src/tests/backend/specs/socialMeta-unit.ts`
+- Modify: `src/node/hooks/express/specialpages.ts` (call sites)
+
+### Background
+
+`socialMeta.renderSocialMeta` calls `buildAbsoluteUrl(req, pathname, publicURL)` for `og:url` and `og:image`. When `publicURL` is set it's used verbatim (correct behaviour). When `publicURL` is null, the URL is built from the request's protocol+host with the bare `pathname`. Under a proxy with a path prefix, the prefix must be inserted.
+
+The fix is local to `buildAbsoluteUrl` (and its only-internal caller `resolveImageUrl`): they grow an explicit `proxyPath` parameter. `renderSocialMeta` reads `proxyPath` from `RenderOpts` and threads it down. `publicURL` precedence is unchanged.
+
+### Steps
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to the `describe(__filename, ...)` block in `src/tests/backend/specs/socialMeta-unit.ts` (the file already imports `renderSocialMeta`, `buildSocialMetaHtml`, etc. — reuse those imports):
+
+```typescript
+ describe('renderSocialMeta — proxyPath fallback (no publicURL)', function () {
+ const mkReq = (overrides: Record = {}) => ({
+ protocol: 'https',
+ get: (n: string) => n.toLowerCase() === 'host' ? 'pad.example' : undefined,
+ acceptsLanguages: () => 'en',
+ originalUrl: '/p/scratch',
+ ...overrides,
+ });
+
+ it('prefixes og:url with proxyPath when publicURL is null', function () {
+ const out = renderSocialMeta({
+ req: mkReq() as any,
+ settings: {title: 'Etherpad', favicon: null, publicURL: null},
+ availableLangs: {en: {}},
+ locales: {en: {}},
+ kind: 'pad',
+ padName: 'scratch',
+ proxyPath: '/api/hassio_ingress/abc',
+ });
+ if (!out.includes('content="https://pad.example/api/hassio_ingress/abc/p/scratch"')) {
+ throw new Error(`og:url missing proxyPath prefix:\n${out}`);
+ }
+ });
+
+ it('prefixes og:image with proxyPath when publicURL is null and favicon is not an absolute URL', function () {
+ const out = renderSocialMeta({
+ req: mkReq() as any,
+ settings: {title: 'Etherpad', favicon: null, publicURL: null},
+ availableLangs: {en: {}},
+ locales: {en: {}},
+ kind: 'pad',
+ padName: 'scratch',
+ proxyPath: '/sub',
+ });
+ if (!out.includes('content="https://pad.example/sub/favicon.ico"')) {
+ throw new Error(`og:image missing proxyPath prefix:\n${out}`);
+ }
+ });
+
+ it('publicURL still wins over proxyPath when both are set', function () {
+ const out = renderSocialMeta({
+ req: mkReq() as any,
+ settings: {
+ title: 'Etherpad',
+ favicon: null,
+ publicURL: 'https://pad.canonical.example',
+ },
+ availableLangs: {en: {}},
+ locales: {en: {}},
+ kind: 'pad',
+ padName: 'scratch',
+ proxyPath: '/sub',
+ });
+ if (!out.includes('content="https://pad.canonical.example/p/scratch"')) {
+ throw new Error(`publicURL should win over proxyPath:\n${out}`);
+ }
+ if (out.includes('/sub/')) {
+ throw new Error(`proxyPath leaked into URL when publicURL was set:\n${out}`);
+ }
+ });
+
+ it('proxyPath default of "" produces today\'s URL shape', function () {
+ const out = renderSocialMeta({
+ req: mkReq() as any,
+ settings: {title: 'Etherpad', favicon: null, publicURL: null},
+ availableLangs: {en: {}},
+ locales: {en: {}},
+ kind: 'pad',
+ padName: 'scratch',
+ // proxyPath omitted
+ });
+ if (!out.includes('content="https://pad.example/p/scratch"')) {
+ throw new Error(`default URL shape regressed:\n${out}`);
+ }
+ });
+ });
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+pnpm --filter ./src test -- --grep "proxyPath fallback"
+```
+
+Expected: 4 failures (RenderOpts has no proxyPath field; URLs don't include prefix).
+
+- [ ] **Step 3: Update `socialMeta.ts` to thread proxyPath through**
+
+In `src/node/utils/socialMeta.ts`:
+
+3a. Update `buildAbsoluteUrl` signature and body. Replace the existing function:
+
+```typescript
+const buildAbsoluteUrl = (
+ req: Request, pathname: string, publicURL: string | null | undefined,
+ proxyPath: string,
+): string => {
+ const trusted = sanitizePublicURL(publicURL);
+ if (trusted) return `${trusted}${pathname}`;
+ const proto = req.protocol === 'https' ? 'https' : 'http';
+ const host = sanitizeHost(req.get && req.get('host')) || 'localhost';
+ return `${proto}://${host}${proxyPath}${pathname}`;
+};
+```
+
+3b. Update `resolveImageUrl` to accept and forward proxyPath:
+
+```typescript
+const resolveImageUrl = (
+ req: Request, faviconSetting: string | null | undefined, publicURL: string | null | undefined,
+ proxyPath: string,
+): string => {
+ if (faviconSetting && /^https?:\/\//i.test(faviconSetting)) return faviconSetting;
+ return buildAbsoluteUrl(req, '/favicon.ico', publicURL, proxyPath);
+};
+```
+
+3c. Extend `RenderOpts`:
+
+```typescript
+export type RenderOpts = {
+ req: Request,
+ settings: SocialMetaSettings,
+ availableLangs: AvailableLangs,
+ locales: {[lang: string]: {[key: string]: string}},
+ kind: 'pad' | 'timeslider' | 'home',
+ padName?: string,
+ // URL-path prefix Etherpad is being served under (`''` when running at root).
+ // When set, used as a path prefix for from-request fallback URLs. Ignored
+ // when settings.publicURL is configured (publicURL encodes the canonical
+ // origin and any path component the operator wants).
+ proxyPath?: string,
+};
+```
+
+3d. In `renderSocialMeta`, read `proxyPath` once and thread it:
+
+```typescript
+export const renderSocialMeta = (o: RenderOpts): string => {
+ const renderLang = negotiateRenderLang(o.req, o.availableLangs);
+ const siteName = o.settings.title || 'Etherpad';
+ const description = resolveDescriptionWithOverride(
+ o.settings.socialMeta && o.settings.socialMeta.description,
+ o.locales, renderLang);
+ const proxyPath = o.proxyPath || '';
+ const imageUrl = resolveImageUrl(o.req, o.settings.favicon, o.settings.publicURL, proxyPath);
+ const imageAlt = `${siteName} logo`;
+
+ let title = siteName;
+ let pathname = (o.req && o.req.originalUrl) || '/';
+ if (o.padName) {
+ if (o.kind === 'pad') title = `${o.padName} | ${siteName}`;
+ else if (o.kind === 'timeslider') title = `${o.padName} (history) | ${siteName}`;
+ }
+ const qIdx = pathname.indexOf('?');
+ if (qIdx >= 0) pathname = pathname.slice(0, qIdx);
+
+ return buildSocialMetaHtml({
+ url: buildAbsoluteUrl(o.req, pathname, o.settings.publicURL, proxyPath),
+ siteName,
+ title,
+ description,
+ imageUrl,
+ imageAlt,
+ renderLang,
+ });
+};
+```
+
+Note: `req.originalUrl` already includes the path AS SEEN BY ETHERPAD (i.e. the proxy has already stripped the prefix). So we prepend `proxyPath` to recover the public path.
+
+- [ ] **Step 4: Update specialpages.ts call sites to pass proxyPath**
+
+In `src/node/hooks/express/specialpages.ts`, find each call to `renderSocialMeta(...)` and add `proxyPath` to the options. There are 3 call sites in the current code; the pattern at each is:
+
+```typescript
+const proxyPath = sanitizeProxyPath(req); // already present
+const socialMetaHtml = renderSocialMeta({
+ req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales,
+ kind: 'pad', padName: req.params.pad,
+ proxyPath, // <-- add this line
+});
+```
+
+Apply at lines ~204, ~246, and the home-page render (search for `renderSocialMeta` in the file).
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+```bash
+pnpm --filter ./src test -- --grep "socialMeta"
+```
+
+Expected: all socialMeta tests pass (new ones + existing).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/node/utils/socialMeta.ts \
+ src/tests/backend/specs/socialMeta-unit.ts \
+ src/node/hooks/express/specialpages.ts
+git commit -m "feat(social-meta): honor proxyPath in from-request og:url and og:image (#7802)"
+```
+
+---
+
+## Task 4: Fix leading-slash URLs in `index.html`
+
+**Files:**
+- Modify: `src/templates/index.html`
+- Modify: `src/node/hooks/express/specialpages.ts` (pass `proxyPath` to the template render)
+
+### Steps
+
+- [ ] **Step 1: Update the route handler to pass proxyPath**
+
+In `src/node/hooks/express/specialpages.ts`, find each `eejs.require('ep_etherpad-lite/templates/index.html', {...})` call (there are 2 — one in the dev-watch path around line 179, one in the production path around line 369). Add `proxyPath` to the render context (computed already as `proxyPath = sanitizeProxyPath(req)` just above each call — confirm in code, add the call if missing for the production path):
+
+```typescript
+// Around line 175-179 (dev/watch path):
+const proxyPath = sanitizeProxyPath(req);
+const socialMetaHtml = renderSocialMeta({...});
+res.send(eejs.require('ep_etherpad-lite/templates/index.html', {
+ req, entrypoint: proxyPath + '/watch/index?hash=' + hash, settings, socialMetaHtml,
+ proxyPath, // <-- add
+}));
+
+// Around line 369 (prod path):
+const proxyPath = sanitizeProxyPath(req); // add if not present
+res.send(eejs.require('ep_etherpad-lite/templates/index.html', {
+ req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml,
+ proxyPath, // <-- add
+}));
+```
+
+- [ ] **Step 2: Update `src/templates/index.html` to use proxyPath**
+
+Replace line 13:
+
+```html
+
+```
+
+with:
+
+```html
+
+```
+
+Replace line 251:
+
+```html
+
+```
+
+with:
+
+```html
+
+```
+
+(Using `typeof proxyPath !== 'undefined'` rather than `proxyPath ||` so that an explicit empty-string `proxyPath` doesn't surprise us either way — same value, but defensive against future template reuse.)
+
+- [ ] **Step 3: Manual smoke**
+
+```bash
+pnpm --filter ./src run dev &
+DEV_PID=$!
+sleep 5
+# Without proxy header — assert no regression
+curl -s http://127.0.0.1:9001/ | grep -E 'rel="manifest"|jslicense'
+# Expected: href="/manifest.json" and href="/javascript"
+curl -s -H 'x-proxy-path: /sub' http://127.0.0.1:9001/ | grep -E 'rel="manifest"|jslicense'
+# Expected: href="/sub/manifest.json" and href="/sub/javascript"
+kill $DEV_PID
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/templates/index.html src/node/hooks/express/specialpages.ts
+git commit -m "feat(templates): index.html manifest + jslicense links honor proxyPath (#7802)"
+```
+
+---
+
+## Task 5: Fix leading-slash and buggy-relative URLs in `pad.html`
+
+**Files:**
+- Modify: `src/templates/pad.html`
+- Modify: `src/node/hooks/express/specialpages.ts` (pass `proxyPath` to pad template render — likely already passed via Task 3 since the pad route already calls `sanitizeProxyPath(req)`; confirm)
+
+### Background
+
+`pad.html` is mostly already prefix-correct because its asset URLs are relative (e.g. `../static/css/pad.css`). Three exceptions:
+
+1. ` ` — one `..` too many. Resolves to `/manifest.json` from BOTH `/p/test` and `/sub/p/test` (the extra `..` is silently capped at root). Under a prefix, it should be `/sub/manifest.json`. Fix: drop one `..` → `../manifest.json`.
+2. `