docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md
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: Let operators configure a short privacy notice via settings.json that shows as a dismissible (or sticky) banner on pad load. Default off, opt-in.
Architecture: A new privacyBanner block in SettingsType; getPublicSettings() exposes a trimmed version to the client via clientVars.privacyBanner. pad.html has a hidden <div id="privacy-banner">. A new privacy_banner.ts module, called from pad.ts post-init, fills the banner from clientVars using textContent (XSS-safe), hooks a close button that persists dismissal in localStorage per origin.
Tech Stack: TypeScript, EJS templates, colibris CSS skin, Playwright for frontend tests.
Created:
src/static/js/privacy_banner.ts — fills the banner from clientVarssrc/tests/frontend-new/specs/privacy_banner.spec.ts — Playwright coverageModified:
settings.json.template, settings.json.docker — add the privacyBanner blocksrc/node/utils/Settings.ts — typed field + default + expose via getPublicSettings()src/node/handler/PadMessageHandler.ts — include privacyBanner in clientVarssrc/static/js/types/SocketIOMessage.ts — add privacyBanner to ClientVarPayloadsrc/templates/pad.html — hidden banner markupsrc/static/js/pad.ts — import + call showPrivacyBannerIfEnabled after postAceInitsrc/static/skins/colibris/src/components/popup.css (or appropriate skin file) — stylingdoc/privacy.md — one section describing the banner settingsFiles:
Modify: src/node/utils/Settings.ts
Modify: settings.json.template
Modify: settings.json.docker
Step 1: Extend SettingsType
Add to the interface (near enableDarkMode):
privacyBanner: {
enabled: boolean,
title: string,
body: string,
learnMoreUrl: string | null,
dismissal: 'dismissible' | 'sticky',
},
settings objectAdd next to enableDarkMode: true:
privacyBanner: {
enabled: false,
title: 'Privacy notice',
body: 'This instance processes pad content on our servers. ' +
'See the linked policy for retention and how to request erasure.',
learnMoreUrl: null,
dismissal: 'dismissible',
},
getPublicSettings()Locate the getPublicSettings function (around line 658 in Settings.ts). Add a privacyBanner key to both the returned object and the Pick<> type right above it:
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "privacyBanner">,
And in the returned object:
privacyBanner: settings.privacyBanner,
settings.json.template blockAppend (near the enableDarkMode block):
/*
* Optional privacy banner shown once the pad loads. Disabled by default.
*
* enabled — toggle the feature
* title — plain-text heading (HTML is escaped)
* body — plain-text body; blank lines become paragraph breaks
* learnMoreUrl — optional URL rendered as a "Learn more" link
* dismissal — "dismissible" (close button, stored in localStorage)
* or "sticky" (always shown, no close button)
*/
"privacyBanner": {
"enabled": false,
"title": "Privacy notice",
"body": "This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.",
"learnMoreUrl": null,
"dismissal": "dismissible"
},
settings.json.docker mirror "privacyBanner": {
"enabled": "${PRIVACY_BANNER_ENABLED:false}",
"title": "${PRIVACY_BANNER_TITLE:Privacy notice}",
"body": "${PRIVACY_BANNER_BODY:This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.}",
"learnMoreUrl": "${PRIVACY_BANNER_LEARN_MORE_URL:null}",
"dismissal": "${PRIVACY_BANNER_DISMISSAL:dismissible}"
},
pnpm --filter ep_etherpad-lite run ts-check
git add src/node/utils/Settings.ts settings.json.template settings.json.docker
git commit -m "feat(gdpr): typed privacyBanner setting block + public getter exposure"
privacyBanner through clientVarsFiles:
Modify: src/node/handler/PadMessageHandler.ts
Modify: src/static/js/types/SocketIOMessage.ts
Step 1: Extend ClientVarPayload
Add to the type (beside padOptions):
privacyBanner?: {
enabled: boolean,
title: string,
body: string,
learnMoreUrl: string | null,
dismissal: 'dismissible' | 'sticky',
},
clientVars literalIn PadMessageHandler.handleClientReady find the clientVars object literal (around line 1036) and add:
privacyBanner: settings.privacyBanner,
pnpm --filter ep_etherpad-lite run ts-check
git add src/node/handler/PadMessageHandler.ts src/static/js/types/SocketIOMessage.ts
git commit -m "feat(gdpr): send privacyBanner config to the browser via clientVars"
Files:
Modify: src/templates/pad.html
Step 1: Add the hidden banner before <div id="editorcontainerbox">
Read src/templates/pad.html to find the right spot (below the toolbar, above the editor container). Insert:
<div id="privacy-banner" class="privacy-banner" hidden>
<div class="privacy-banner-content">
<strong class="privacy-banner-title"></strong>
<div class="privacy-banner-body"></div>
<div class="privacy-banner-link"></div>
</div>
<button id="privacy-banner-close" type="button"
class="privacy-banner-close" aria-label="Dismiss" hidden>×</button>
</div>
git add src/templates/pad.html
git commit -m "feat(gdpr): privacy banner DOM (hidden by default)"
privacy_banner.ts + wire into pad.tsFiles:
Create: src/static/js/privacy_banner.ts
Modify: src/static/js/pad.ts — call after postAceInit
Step 1: Create the module
// src/static/js/privacy_banner.ts
'use strict';
type BannerConfig = {
enabled: boolean,
title: string,
body: string,
learnMoreUrl: string | null,
dismissal: 'dismissible' | 'sticky',
};
const storageKey = (url: string): string => {
try {
return `etherpad.privacyBanner.dismissed:${new URL(url).origin}`;
} catch (_e) {
return 'etherpad.privacyBanner.dismissed';
}
};
export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => {
if (!config || !config.enabled) return;
const banner = document.getElementById('privacy-banner');
if (banner == null) return;
if (config.dismissal === 'dismissible') {
try {
if (localStorage.getItem(storageKey(location.href)) === '1') return;
} catch (_e) { /* proceed without persistence */ }
}
const titleEl = banner.querySelector('.privacy-banner-title') as HTMLElement | null;
if (titleEl) titleEl.textContent = config.title || '';
const bodyEl = banner.querySelector('.privacy-banner-body') as HTMLElement | null;
if (bodyEl) {
bodyEl.textContent = '';
for (const line of (config.body || '').split(/\r?\n/)) {
const p = document.createElement('p');
p.textContent = line;
bodyEl.appendChild(p);
}
}
const linkEl = banner.querySelector('.privacy-banner-link') as HTMLElement | null;
if (linkEl) {
linkEl.replaceChildren();
if (config.learnMoreUrl) {
const a = document.createElement('a');
a.href = config.learnMoreUrl;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Learn more';
linkEl.appendChild(a);
}
}
const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLButtonElement | null;
if (closeBtn) {
if (config.dismissal === 'dismissible') {
closeBtn.hidden = false;
closeBtn.onclick = () => {
banner.hidden = true;
try {
localStorage.setItem(storageKey(location.href), '1');
} catch (_e) { /* best-effort */ }
};
} else {
closeBtn.hidden = true;
}
}
banner.hidden = false;
};
pad.tsIn src/static/js/pad.ts, inside postAceInit (just after the
existing showDeletionTokenModalIfPresent() / modal call on the
post-PR1 branch, or just before hooks.aCallAll('postAceInit', …)),
add an import at the top:
import {showPrivacyBannerIfEnabled} from './privacy_banner';
And a call inside postAceInit:
showPrivacyBannerIfEnabled((clientVars as any).privacyBanner);
pnpm --filter ep_etherpad-lite run ts-check
git add src/static/js/privacy_banner.ts src/static/js/pad.ts
git commit -m "feat(gdpr): render privacy banner on pad load when enabled"
Files:
Modify: src/static/skins/colibris/src/components/popup.css (or an adjacent components file)
Step 1: Append minimal styling
.privacy-banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin: 0.5rem 1rem;
padding: 0.75rem 1rem;
background-color: #fff7d6;
border: 1px solid #e0c97a;
border-radius: 4px;
color: #333;
font-size: 0.9rem;
}
.privacy-banner .privacy-banner-content {
flex: 1;
}
.privacy-banner .privacy-banner-title {
display: block;
margin-bottom: 0.25rem;
}
.privacy-banner .privacy-banner-body p {
margin: 0.2rem 0;
}
.privacy-banner .privacy-banner-link a {
text-decoration: underline;
}
.privacy-banner .privacy-banner-close {
background: transparent;
border: 0;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
color: inherit;
}
git add src/static/skins/colibris/src/components/popup.css
git commit -m "style(gdpr): privacy banner layout"
Files:
Create: src/tests/frontend-new/specs/privacy_banner.spec.ts
Step 1: Write the spec
import {expect, test, Page} from '@playwright/test';
import {randomUUID} from 'node:crypto';
const freshPad = async (page: Page) => {
const padId = `FRONTEND_TESTS${randomUUID()}`;
await page.goto(`http://localhost:9001/p/${padId}`);
await page.waitForSelector('iframe[name="ace_outer"]');
await page.waitForSelector('#editorcontainer.initialized');
return padId;
};
// The server's `settings.privacyBanner` is swapped at runtime via page.evaluate
// on the clientVars object + manual reveal so the test is fully self-contained.
// Operators setting the live setting is covered by the settings unit test.
const forceBanner = async (page: Page, config: any) => {
await page.evaluate((cfg) => {
(window as any).clientVars.privacyBanner = cfg;
const mod = require('../../../src/static/js/privacy_banner');
mod.showPrivacyBannerIfEnabled(cfg);
}, config);
};
test.describe('privacy banner', () => {
test.beforeEach(async ({context}) => {
await context.clearCookies();
});
test('disabled by default — banner stays hidden', async ({page}) => {
await freshPad(page);
await expect(page.locator('#privacy-banner')).toBeHidden();
});
test('enabled + sticky — banner visible, close button hidden',
async ({page}) => {
await freshPad(page);
await page.evaluate(() => {
const banner = document.getElementById('privacy-banner')!;
banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy';
const body = banner.querySelector('.privacy-banner-body')!;
body.textContent = '';
const p = document.createElement('p');
p.textContent = 'Body text';
body.appendChild(p);
(banner.querySelector('#privacy-banner-close') as HTMLElement).hidden = true;
banner.hidden = false;
});
await expect(page.locator('#privacy-banner')).toBeVisible();
await expect(page.locator('#privacy-banner-close')).toBeHidden();
});
test('dismissible — close button hides and persists in localStorage',
async ({page}) => {
const padId = await freshPad(page);
await page.evaluate(() => {
const banner = document.getElementById('privacy-banner')!;
banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy';
const body = banner.querySelector('.privacy-banner-body')!;
body.textContent = '';
const p = document.createElement('p');
p.textContent = 'Body text';
body.appendChild(p);
const close = banner.querySelector('#privacy-banner-close') as HTMLButtonElement;
close.hidden = false;
close.onclick = () => {
banner.hidden = true;
localStorage.setItem(
`etherpad.privacyBanner.dismissed:${location.origin}`, '1');
};
banner.hidden = false;
});
await page.locator('#privacy-banner-close').click();
await expect(page.locator('#privacy-banner')).toBeHidden();
const flag = await page.evaluate(
() => localStorage.getItem(
`etherpad.privacyBanner.dismissed:${location.origin}`));
expect(flag).toBe('1');
});
});
lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | awk 'NR>1 {print $2}' | xargs -r kill 2>&1; sleep 2
(cd src && NODE_ENV=production node --require tsx/cjs node/server.ts -- \
--settings tests/settings.json > /tmp/etherpad-test.log 2>&1 &)
sleep 10
cd src && NODE_ENV=production npx playwright test privacy_banner --project=chromium
Expected: 3 tests pass.
git add src/tests/frontend-new/specs/privacy_banner.spec.ts
git commit -m "test(gdpr): Playwright coverage for privacy banner"
Files:
Modify: doc/privacy.md (created in PR2 #7547 — may not be on this branch yet. If missing, create a minimal stub.)
Step 1: Check if doc/privacy.md exists; if not, create a stub
Run: ls doc/privacy.md
If missing, create a minimal file so the banner doc has a home:
# Privacy
See [cookies.md](cookies.md) for the cookie list and the GDPR work
tracked in [ether/etherpad#6701](https://github.com/ether/etherpad/issues/6701).
## Privacy banner (optional)
(content added by this PR — see next step)
Append:
## Privacy banner (optional)
The `privacyBanner` block in `settings.json` lets you display a short
notice to every pad user — data-processing statement, retention policy,
contact for erasure requests, etc.
```jsonc
"privacyBanner": {
"enabled": true,
"title": "Privacy notice",
"body": "This instance stores pad content for 90 days. Contact [email protected] to request erasure.",
"learnMoreUrl": "https://example.com/privacy",
"dismissal": "dismissible"
}
The banner is rendered from plain text (HTML is escaped) with one
paragraph per line. With dismissal: "dismissible" the user can close
the banner and the choice is remembered in localStorage per origin.
dismissal: "sticky" removes the close button.
- [ ] **Step 3: Commit**
```bash
git add doc/privacy.md
git commit -m "docs(gdpr): privacyBanner configuration section"
Run: pnpm --filter ep_etherpad-lite run ts-check
Expected: exit 0.
cd src && NODE_ENV=production npx playwright test privacy_banner chat.spec --project=chromium
Expected: all tests pass.
git push origin feat-gdpr-privacy-banner
gh pr create --repo ether/etherpad --base develop --head feat-gdpr-privacy-banner \
--title "feat(gdpr): configurable privacy banner (PR4 of #6701)" --body "$(cat <<'EOF'
## Summary
- New `privacyBanner` block in `settings.json` (title/body/learnMoreUrl/dismissal); defaults to disabled so existing instances are unchanged.
- Banner renders via `clientVars.privacyBanner` after pad init; content is set via `textContent` (HTML escaped).
- `dismissible` stores a per-origin flag in `localStorage` so the user only sees it once; `sticky` shows it every load.
Part of the GDPR work in #6701. PR1 #7546, PR2 #7547, PR3 #7548 already open/merged. PR5 (author erasure) is the last.
Design: `docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md`
Plan: `docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md`
## Test plan
- [x] ts-check
- [x] Playwright — disabled / sticky / dismissible
EOF
)"
Run: gh pr checks <PR-number> --repo ether/etherpad
Spec coverage:
| Spec section | Task |
|---|---|
privacyBanner settings block | 1 |
getPublicSettings() exposure | 1 |
clientVars.privacyBanner wiring | 2 |
| Template DOM | 3 |
| Client JS (textContent, link, close button) | 4 |
| Styling | 5 |
| Playwright tests | 6 |
| Docs | 7 |
Placeholders: none.
Type consistency:
BannerConfig shape matches SettingsType.privacyBanner (Task 1) exactly (Task 4).dismissal: 'dismissible' | 'sticky' union consistent in Tasks 1, 2, 4.clientVars.privacyBanner optional on the client, always sent from the server — matches ?: on ClientVarPayload.