diff --git a/apps/community/app/(auth)/register/page.tsx b/apps/community/app/(auth)/register/page.tsx
index facdcde1..818aee32 100644
--- a/apps/community/app/(auth)/register/page.tsx
+++ b/apps/community/app/(auth)/register/page.tsx
@@ -4,6 +4,7 @@ import { AuthPage } from "@/components/auth/auth-page"
import { RegisterForm } from "@/components/auth/register-form"
import { issueChallenge } from "@/server/antispam"
import { boardAuthConfig } from "@/server/auth-config"
+import { termsAcceptance } from "@/server/legal"
import { registrationFields } from "@/server/profile-fields"
export const metadata: Metadata = { title: "Create account" }
@@ -13,6 +14,8 @@ export default async function RegisterPage() {
const { minPasswordLength, usernameMin, usernameMax } = await boardAuthConfig()
+ const terms = await termsAcceptance()
+
const customFields = (await registrationFields()).map((field) => ({
...field,
value: "",
@@ -28,6 +31,7 @@ export default async function RegisterPage() {
+}
diff --git a/apps/community/app/(board)/terms/page.tsx b/apps/community/app/(board)/terms/page.tsx
new file mode 100644
index 00000000..37762b09
--- /dev/null
+++ b/apps/community/app/(board)/terms/page.tsx
@@ -0,0 +1,9 @@
+import type { Metadata } from 'next'
+
+import { LegalDocument } from '@/components/legal/legal-document'
+
+export const metadata: Metadata = { title: 'Terms of service' }
+
+export default async function TermsPage() {
+ return
+}
diff --git a/apps/community/src/components/auth/register-form.tsx b/apps/community/src/components/auth/register-form.tsx
index 365a2f9f..cb475873 100644
--- a/apps/community/src/components/auth/register-form.tsx
+++ b/apps/community/src/components/auth/register-form.tsx
@@ -24,14 +24,21 @@ export interface RegistrationLimits {
readonly usernameMax: number
}
+export interface TermsInput {
+ readonly label: string
+ readonly href: string
+}
+
export function RegisterForm({
customFields = [],
challenge,
limits,
+ terms = null,
}: {
customFields?: readonly CustomFieldInput[]
challenge?: ChallengeInput
limits: RegistrationLimits
+ terms?: TermsInput | null | undefined
}) {
const [state, action] = useActionState(registerAction, EMPTY_STATE)
return (
@@ -95,6 +102,31 @@ export function RegisterForm({
>
)}
+ {terms !== null && (
+
+ )}
+
Create account
)
diff --git a/apps/community/src/components/legal/legal-document.tsx b/apps/community/src/components/legal/legal-document.tsx
new file mode 100644
index 00000000..ddfa0a3b
--- /dev/null
+++ b/apps/community/src/components/legal/legal-document.tsx
@@ -0,0 +1,25 @@
+import { notFound } from 'next/navigation'
+
+import { Card, CardContent } from '@meith/ui'
+
+import { PanelPage } from '@/components/shell/panel-page'
+import { legalDocument } from '@/server/legal'
+import type { LegalSlug } from '@/view/legal'
+
+export async function LegalDocument({ slug }: { slug: LegalSlug }) {
+ const document = await legalDocument(slug)
+ if (document === null) notFound()
+
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/community/src/components/shell/page-shell.tsx b/apps/community/src/components/shell/page-shell.tsx
index b352926b..13c25bdf 100644
--- a/apps/community/src/components/shell/page-shell.tsx
+++ b/apps/community/src/components/shell/page-shell.tsx
@@ -6,6 +6,7 @@ import { LogoutForm } from '@/components/account/logout-form'
import { currentLogo } from '@/server/branding'
import { ThemeSwitcher } from '@/components/shell/theme-switcher'
import { getContainer } from '@/server/container'
+import { legalFooterLinks } from '@/server/legal'
import { unreadMessageCount } from '@/server/messages'
import { touchActivity } from '@/server/relations'
import { touchCurrentLocation } from '@/server/presence'
@@ -97,7 +98,7 @@ export async function PageShell({
)
const footerModel = await filterView(
'view.footer',
- buildFooterModel([], boardTitle, preferences.timezone),
+ buildFooterModel(await legalFooterLinks(), boardTitle, preferences.timezone),
pluginContext,
)
diff --git a/apps/community/src/server/auth-actions.test.ts b/apps/community/src/server/auth-actions.test.ts
index 405050a7..43ca96ce 100644
--- a/apps/community/src/server/auth-actions.test.ts
+++ b/apps/community/src/server/auth-actions.test.ts
@@ -62,6 +62,13 @@ vi.mock('./antispam', async (importOriginal) => {
}
})
+const legal = vi.hoisted(() => ({ terms: 'Be nice to each other.' }))
+
+vi.mock('./legal', () => ({
+ termsAcceptance: async () =>
+ legal.terms.trim() === '' ? null : { label: 'Terms of service', href: '/terms' },
+}))
+
vi.mock('./auth-config', async (importOriginal) => {
const actual = await importOriginal()
return {
@@ -112,7 +119,12 @@ async function redirectOf(run: Promise): Promise {
throw new Error('expected the action to redirect, but it returned')
}
-const CREDS = { username: 'ivan', email: 'ivan@example.com', password: 'correct-horse' }
+const CREDS = {
+ username: 'ivan',
+ email: 'ivan@example.com',
+ password: 'correct-horse',
+ terms: '1',
+}
async function registerUser(over: Partial = {}): Promise {
await redirectOf(registerAction(EMPTY_STATE, form({ ...CREDS, ...over })))
@@ -127,6 +139,7 @@ beforeEach(() => {
mail.failReset = false
policy.activationMethod = 'none'
limiter.refuse = false
+ legal.terms = 'Be nice to each other.'
})
describe('registerAction', () => {
@@ -154,6 +167,21 @@ describe('registerAction', () => {
expect(state.error).toBeTruthy()
expect(JSON.stringify(state)).not.toContain(CREDS.password)
})
+
+ it('refuses an account when the terms were not accepted', async () => {
+ const state = await registerAction(EMPTY_STATE, form({ ...CREDS, terms: '' }))
+
+ expect(state.error).toContain('terms of service')
+ expect(state.values?.terms).toBeUndefined()
+ })
+
+ it('creates the account when the board publishes no terms', async () => {
+ legal.terms = ''
+
+ expect(
+ await redirectOf(registerAction(EMPTY_STATE, form({ ...CREDS, terms: '' }))),
+ ).toBe('/login?registered=1')
+ })
})
describe('loginAction', () => {
diff --git a/apps/community/src/server/auth-actions.ts b/apps/community/src/server/auth-actions.ts
index 50f1af20..e84e2865 100644
--- a/apps/community/src/server/auth-actions.ts
+++ b/apps/community/src/server/auth-actions.ts
@@ -11,6 +11,7 @@ import { spendResendLimit, verifyChallenge } from './antispam'
import { sendPasswordResetEmail, sendVerificationEmail } from './auth-mail'
import { configuredIdentity, getContainer } from './container'
import { formStateReporter } from './form-state-reporter'
+import { termsAcceptance } from './legal'
import {
profileFieldService,
registrationFieldContext,
@@ -51,13 +52,22 @@ export async function registerAction(
const username = field(form, 'username')
const email = field(form, 'email')
const password = field(form, 'password')
- const values = { username, email }
+ const accepted = field(form, 'terms') !== ''
+ const values = { username, email, ...(accepted ? { terms: '1' } : {}) }
const identity = await configuredIdentity()
let verification: { token: string; email: string; username: string } | null = null
try {
+ const terms = await termsAcceptance()
+ if (terms !== null && !accepted) {
+ return {
+ error: `Please read the ${terms.label.toLowerCase()} and tick the box to accept it.`,
+ values,
+ }
+ }
+
const challenge = await verifyChallenge(form)
if (!challenge.ok) return { error: challenge.reason, values }
diff --git a/apps/community/src/server/legal.ts b/apps/community/src/server/legal.ts
new file mode 100644
index 00000000..5c3a3ac6
--- /dev/null
+++ b/apps/community/src/server/legal.ts
@@ -0,0 +1,48 @@
+import 'server-only'
+
+import { renderMarkdown } from '@meith/markdown'
+import type { LinkModel } from '@meith/theme-kit'
+
+import { getSettings } from './settings'
+import {
+ TERMS_PAGE,
+ buildLegalLinks,
+ isPublished,
+ legalPage,
+ type LegalPage,
+} from '../view/legal'
+
+export interface LegalDocument {
+ readonly title: string
+ readonly href: string
+ readonly bodyHtml: string
+}
+
+async function bodyOf(page: LegalPage): Promise {
+ return (await getSettings()).get(page.settingKey)
+}
+
+export async function legalDocument(slug: string): Promise {
+ const page = legalPage(slug)
+ if (page === null) return null
+
+ const body = await bodyOf(page)
+ if (!isPublished(body)) return null
+
+ return {
+ title: page.title,
+ href: page.href,
+ bodyHtml: renderMarkdown(body, { headingOffset: 0 }).html,
+ }
+}
+
+export async function legalFooterLinks(): Promise {
+ const settings = await getSettings()
+ return buildLegalLinks((page) => settings.get(page.settingKey))
+}
+
+export async function termsAcceptance(): Promise {
+ return isPublished(await bodyOf(TERMS_PAGE))
+ ? { label: TERMS_PAGE.title, href: TERMS_PAGE.href }
+ : null
+}
diff --git a/apps/community/src/view/legal.test.ts b/apps/community/src/view/legal.test.ts
new file mode 100644
index 00000000..d388978d
--- /dev/null
+++ b/apps/community/src/view/legal.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+
+import { LEGAL_PAGES, buildLegalLinks, isPublished, legalPage } from './legal'
+
+const bodies = (values: Record) =>
+ buildLegalLinks((page) => values[page.settingKey] ?? '')
+
+describe('legalPage', () => {
+ it('answers for the slugs the board publishes', () => {
+ expect(legalPage('terms')?.href).toBe('/terms')
+ expect(legalPage('privacy')?.href).toBe('/privacy')
+ })
+
+ it('answers with nothing for anything else', () => {
+ expect(legalPage('cookies')).toBeNull()
+ })
+
+ it('names a setting for every page', () => {
+ for (const page of LEGAL_PAGES) expect(page.settingKey).toMatch(/^legal\./)
+ })
+})
+
+describe('isPublished', () => {
+ it('treats an empty body, and one that is only whitespace, as unpublished', () => {
+ expect(isPublished('')).toBe(false)
+ expect(isPublished(' \n ')).toBe(false)
+ expect(isPublished('## Terms')).toBe(true)
+ })
+})
+
+describe('buildLegalLinks', () => {
+ it('links the pages that have a body', () => {
+ expect(bodies({ 'legal.terms': 'terms', 'legal.privacy': 'privacy' })).toEqual([
+ { label: 'Terms', href: '/terms' },
+ { label: 'Privacy', href: '/privacy' },
+ ])
+ })
+
+ it('leaves out a page whose body was emptied', () => {
+ expect(bodies({ 'legal.terms': 'terms' })).toEqual([
+ { label: 'Terms', href: '/terms' },
+ ])
+ })
+
+ it('links nothing when the board publishes neither', () => {
+ expect(bodies({})).toEqual([])
+ })
+})
diff --git a/apps/community/src/view/legal.ts b/apps/community/src/view/legal.ts
new file mode 100644
index 00000000..53198a4d
--- /dev/null
+++ b/apps/community/src/view/legal.ts
@@ -0,0 +1,47 @@
+import type { LinkModel } from '@meith/theme-kit'
+
+export type LegalSlug = 'terms' | 'privacy'
+
+export interface LegalPage {
+ readonly slug: LegalSlug
+ readonly settingKey: 'legal.terms' | 'legal.privacy'
+ readonly title: string
+ readonly href: string
+ readonly footerLabel: string
+}
+
+export const LEGAL_PAGES: readonly LegalPage[] = [
+ {
+ slug: 'terms',
+ settingKey: 'legal.terms',
+ title: 'Terms of service',
+ href: '/terms',
+ footerLabel: 'Terms',
+ },
+ {
+ slug: 'privacy',
+ settingKey: 'legal.privacy',
+ title: 'Privacy policy',
+ href: '/privacy',
+ footerLabel: 'Privacy',
+ },
+]
+
+export const TERMS_PAGE: LegalPage = LEGAL_PAGES[0]!
+
+export function legalPage(slug: string): LegalPage | null {
+ return LEGAL_PAGES.find((page) => page.slug === slug) ?? null
+}
+
+export function isPublished(body: string): boolean {
+ return body.trim() !== ''
+}
+
+export function buildLegalLinks(
+ bodyOf: (page: LegalPage) => string,
+): readonly LinkModel[] {
+ return LEGAL_PAGES.filter((page) => isPublished(bodyOf(page))).map((page) => ({
+ label: page.footerLabel,
+ href: page.href,
+ }))
+}
diff --git a/apps/community/src/view/setting-groups.ts b/apps/community/src/view/setting-groups.ts
index 16fc99c5..1251e585 100644
--- a/apps/community/src/view/setting-groups.ts
+++ b/apps/community/src/view/setting-groups.ts
@@ -10,6 +10,7 @@ export const GROUP_LABELS: Record = {
reputation: 'Reputation',
security: 'Security',
antispam: 'Anti-spam',
+ legal: 'Legal',
}
export const GROUP_ORDER: readonly SettingGroup[] = [
@@ -22,6 +23,7 @@ export const GROUP_ORDER: readonly SettingGroup[] = [
'mail',
'security',
'antispam',
+ 'legal',
]
export const DEFAULT_SETTING_GROUP: SettingGroup = GROUP_ORDER[0]!
diff --git a/docs/operating.md b/docs/operating.md
index e9c6809c..356fd215 100644
--- a/docs/operating.md
+++ b/docs/operating.md
@@ -481,6 +481,54 @@ There is no cookie banner, because there is nothing on the board that needs one.
> its data, which is the operator's to decide — a board that adds its own
> tracking is adding its own obligations with it.
+## Terms and privacy
+
+Two documents, written by whoever runs the board, in
+`/admin/settings?group=legal`:
+
+| Setting | Published at | Linked from |
+|---|---|---|
+| **Terms of service** (`legal.terms`) | `/terms` | the footer, and the registration form |
+| **Privacy policy** (`legal.privacy`) | `/privacy` | the footer |
+
+Both are Markdown, rendered by the parser posts use — without the board's
+smilies and custom directives, which have no business in a legal notice and
+would change what it says the day somebody edits one. Both ship with a
+template rather than empty, because a board with no terms at all is the state
+nobody notices — and both templates are written to be replaced. Read them, make
+them describe what your community actually does, and take advice on them if
+what your board does warrants it. They are a starting point, not legal advice.
+
+They are ordinary settings, so `/admin/settings` edits them and so does the
+CLI, which is the easier route for a document you keep in a file:
+
+```bash
+community settings:set legal.terms "$(cat terms.md)"
+```
+
+Emptying one takes the page, its footer link, and — for the terms — the
+registration checkbox away together. That is the switch: there is no separate
+"enabled" toggle to leave inconsistent with the text.
+
+### Accepting the terms
+
+While the terms have a body, the registration form carries a checkbox and the
+server refuses to create the account without it. The refusal is on the server,
+not the form: the checkbox has `required` on it, but the board never trusts
+that, and a submission that arrives without the box gets the same error
+whichever way it was sent.
+
+What the board does *not* do is record the acceptance against the account.
+There is no stored timestamp and no version history, because a per-account
+record that nobody keeps the corresponding text for proves nothing — the terms
+are one editable document, and editing it changes what every reader sees at
+once. Existing members are not asked again when the text changes: the terms in
+force are the ones on the page, which is what they say themselves.
+
+> `/terms` and `/privacy` are board routes, so a forum whose slug is `terms` or
+> `privacy` is reachable at neither — the same as `/search`, `/online` and the
+> other names the board has already taken.
+
## Plugins
Same shape as a theme: add the package, a line in `community.config.ts`, a redeploy.
diff --git a/e2e/activation-no-js.spec.ts b/e2e/activation-no-js.spec.ts
index 1794b56b..651d8f11 100644
--- a/e2e/activation-no-js.spec.ts
+++ b/e2e/activation-no-js.spec.ts
@@ -19,6 +19,7 @@ async function register(page: Page, username: string): Promise {
await page.getByLabel('Username').fill(username)
await page.getByLabel('Email').fill(`${username}@example.test`)
await page.getByLabel('Password').fill(PASSWORD)
+ await page.getByLabel(/I have read and accept/).check()
await page.getByRole('button', { name: 'Create account' }).click()
}
diff --git a/e2e/admin-tabs-no-js.spec.ts b/e2e/admin-tabs-no-js.spec.ts
index 000a5cb5..95d2a591 100644
--- a/e2e/admin-tabs-no-js.spec.ts
+++ b/e2e/admin-tabs-no-js.spec.ts
@@ -628,6 +628,7 @@ test('a registration question is asked once the challenge is switched on', async
await applicant.getByLabel('Username').fill(username)
await applicant.getByLabel('Email').fill(`${username}@example.test`)
await applicant.getByLabel('Password').fill(PASSWORD)
+ await applicant.getByLabel(/I have read and accept/).check()
await applicant.getByLabel(question).fill('not the answer')
await applicant.getByRole('button', { name: 'Create account' }).click()
await expect(applicant).not.toHaveURL(/registered=1/)
diff --git a/e2e/legal-no-js.spec.ts b/e2e/legal-no-js.spec.ts
new file mode 100644
index 00000000..868695ff
--- /dev/null
+++ b/e2e/legal-no-js.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test } from '@playwright/test'
+
+import { PASSWORD } from './support/session'
+
+test.use({ javaScriptEnabled: false })
+
+test('the footer links both documents, and each one reads', async ({ page }) => {
+ await page.goto('/')
+
+ const footer = page.getByRole('navigation', { name: 'Footer' })
+ await expect(footer.getByRole('link', { name: 'Terms' })).toBeVisible()
+ await expect(footer.getByRole('link', { name: 'Privacy' })).toBeVisible()
+
+ await footer.getByRole('link', { name: 'Terms' }).click()
+ await expect(page).toHaveURL('/terms')
+ await expect(page.getByRole('heading', { name: 'Terms of service' })).toBeVisible()
+
+ await page.goto('/privacy')
+ await expect(page.getByRole('heading', { name: 'Privacy policy' })).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'What is collected' })).toBeVisible()
+})
+
+test('registration refuses the account until the terms are accepted', async ({ page }) => {
+ const username = `e2e_terms_${Date.now().toString(36)}`
+
+ await page.goto('/register')
+
+ const accept = page.getByLabel(/I have read and accept/)
+ await expect(accept).not.toBeChecked()
+ await expect(page.getByRole('link', { name: 'Terms of service' })).toHaveAttribute(
+ 'href',
+ '/terms',
+ )
+
+ await page.getByLabel('Username').fill(username)
+ await page.getByLabel('Email').fill(`${username}@example.test`)
+ await page.getByLabel('Password').fill(PASSWORD)
+ await page.getByRole('button', { name: 'Create account' }).click()
+
+ await expect(page).not.toHaveURL(/registered=1/)
+ await expect(page.getByText(/tick the box to accept it/)).toBeVisible()
+
+ await page.getByLabel('Username').fill(username)
+ await page.getByLabel('Email').fill(`${username}@example.test`)
+ await page.getByLabel('Password').fill(PASSWORD)
+ await page.getByLabel(/I have read and accept/).check()
+ await page.getByRole('button', { name: 'Create account' }).click()
+
+ await expect(page).toHaveURL(/\/login\?registered=1$/)
+})
diff --git a/e2e/reading-no-js.spec.ts b/e2e/reading-no-js.spec.ts
index 245eab4e..9ee2ba2c 100644
--- a/e2e/reading-no-js.spec.ts
+++ b/e2e/reading-no-js.spec.ts
@@ -21,6 +21,7 @@ test('the fixture board, registration, and login work without JavaScript', async
await page.getByLabel('Username').fill(username)
await page.getByLabel('Email').fill(`${username}@example.test`)
await page.getByLabel('Password').fill(password)
+ await page.getByLabel(/I have read and accept/).check()
await page.getByRole('button', { name: 'Create account' }).click()
await expect(page).toHaveURL(/\/login\?registered=1$/)
await expect(page.getByText('Account created. You can sign in now.')).toBeVisible()
diff --git a/e2e/support/session.ts b/e2e/support/session.ts
index fb673703..125a01c6 100644
--- a/e2e/support/session.ts
+++ b/e2e/support/session.ts
@@ -20,6 +20,7 @@ export async function signUp(page: Page, label: string): Promise {
await page.getByLabel('Username').fill(username)
await page.getByLabel('Email').fill(`${username}@example.test`)
await page.getByLabel('Password').fill(PASSWORD)
+ await page.getByLabel(/I have read and accept/).check()
await page.getByRole('button', { name: 'Create account' }).click()
await expect(page).toHaveURL(/\/login\?registered=1$/)
diff --git a/e2e/thanks-no-js.spec.ts b/e2e/thanks-no-js.spec.ts
index 1be58d57..506885bc 100644
--- a/e2e/thanks-no-js.spec.ts
+++ b/e2e/thanks-no-js.spec.ts
@@ -12,6 +12,7 @@ async function signIn(page: Page): Promise {
await page.getByLabel('Username').fill(username)
await page.getByLabel('Email').fill(`${username}@example.test`)
await page.getByLabel('Password').fill(PASSWORD)
+ await page.getByLabel(/I have read and accept/).check()
await page.getByRole('button', { name: 'Create account' }).click()
await expect(page).toHaveURL(/\/login\?registered=1$/)
diff --git a/packages/settings/src/definitions.ts b/packages/settings/src/definitions.ts
index a9876281..4175490a 100644
--- a/packages/settings/src/definitions.ts
+++ b/packages/settings/src/definitions.ts
@@ -1,5 +1,6 @@
import { z } from 'zod'
+import { DEFAULT_PRIVACY_POLICY, DEFAULT_TERMS_OF_SERVICE } from './legal'
import { isUsableOrigin } from './origin'
export type SettingGroup =
@@ -12,6 +13,7 @@ export type SettingGroup =
| 'reputation'
| 'security'
| 'antispam'
+ | 'legal'
interface SettingDefinitionBase {
readonly key: string
@@ -611,6 +613,36 @@ export const SETTING_DEFINITIONS = [
default: 0,
ui: { min: 0, max: 10_000 },
}),
+
+ define({
+ key: 'legal.terms',
+ group: 'legal',
+ label: 'Terms of service',
+ description:
+ 'Markdown, published at /terms and linked from the footer. Registration ' +
+ 'asks the visitor to accept it before the account is created, so emptying ' +
+ 'this box takes the page, the footer link and the checkbox away together. ' +
+ 'What ships is a template written to be replaced: read it, make it say ' +
+ 'what your community actually does, and take advice on it if it matters.',
+ schema: z.string().max(40_000),
+ default: DEFAULT_TERMS_OF_SERVICE,
+ invalidates: ['settings', 'layout'],
+ ui: { multiline: true },
+ }),
+ define({
+ key: 'legal.privacy',
+ group: 'legal',
+ label: 'Privacy policy',
+ description:
+ 'Markdown, published at /privacy and linked from the footer. Emptied, the ' +
+ 'page and the link go. It ships as a template describing what this ' +
+ 'software does by default — your host, your mail provider and anything ' +
+ 'you have added are yours to describe.',
+ schema: z.string().max(40_000),
+ default: DEFAULT_PRIVACY_POLICY,
+ invalidates: ['settings', 'layout'],
+ ui: { multiline: true },
+ }),
] as const
export type SettingKey = (typeof SETTING_DEFINITIONS)[number]['key']
diff --git a/packages/settings/src/index.ts b/packages/settings/src/index.ts
index 420d065e..c6b8c9a5 100644
--- a/packages/settings/src/index.ts
+++ b/packages/settings/src/index.ts
@@ -7,6 +7,8 @@ export {
type SettingValue,
} from './definitions'
+export { DEFAULT_PRIVACY_POLICY, DEFAULT_TERMS_OF_SERVICE } from './legal'
+
export {
SettingsSnapshot,
saveSettings,
diff --git a/packages/settings/src/legal.ts b/packages/settings/src/legal.ts
new file mode 100644
index 00000000..087d4b71
--- /dev/null
+++ b/packages/settings/src/legal.ts
@@ -0,0 +1,111 @@
+export const DEFAULT_TERMS_OF_SERVICE = `## The short version
+
+This community is run by its administrators. These terms are the agreement
+between them and you. By creating an account, and by posting, you accept them.
+
+## Your account
+
+- Give a working e-mail address. It is how the board reaches you about your own
+ account, and how you get back in when you forget your password.
+- Keep your password to yourself. Anything posted from your account is treated
+ as posted by you.
+- One account per person, unless an administrator has agreed otherwise.
+- Do not register on behalf of someone else, or claim to be someone you are not.
+
+## What you post
+
+You keep the rights to what you write. By posting it here you allow this
+community to store it and to show it to whoever can read the forum it is in,
+for as long as the community keeps running.
+
+Do not post anything that is unlawful where this board operates, that harasses
+or threatens another person, that infringes someone else's copyright, that
+exists to advertise, or that deliberately disrupts other people's discussions.
+
+## Moderation
+
+Moderators may edit, move, hide or delete any post, lock any thread, and warn,
+suspend or remove any account. They do not have to ask first, and a decision may
+be made about the effect of a post rather than its intent.
+
+If you think a decision was wrong, say so to the staff rather than in the
+thread — that is the route that can actually change it.
+
+## Ending your membership
+
+You may stop using the board at any time and may ask the administrators to close
+your account. Closing an account does not automatically delete what you have
+already posted: the discussions other members took part in stay readable. If you
+need something specific removed, ask, and the administrators will decide.
+
+## Availability
+
+The board is provided as it is. There is no guarantee that it stays up, that
+nothing is lost, or that it will keep running indefinitely. Keep your own copy
+of anything you cannot afford to lose.
+
+## Changes
+
+These terms can change. Continuing to use the board after a change means you
+accept the version in force at the time you use it.
+
+## Contact
+
+Questions about these terms go to the board's administrators.`
+
+export const DEFAULT_PRIVACY_POLICY = `## What this covers
+
+How this community handles information about the people who use it. The
+administrators of this board decide what happens to that information.
+
+## What is collected
+
+- **What you give us.** Your username, e-mail address, password (stored only as
+ a hash, never as text anyone can read), and anything you choose to put in your
+ profile or your posts.
+- **What using the board records.** The IP address a request came from, the time
+ of it, and the browser it announced itself as — kept so that abuse can be
+ traced and spam refused.
+- **Cookies.** One for your session, so the board knows you are signed in, and
+ ones remembering your preferences. Signing out clears the session cookie.
+
+## What it is used for
+
+Running the board: showing your posts to other members, sending the mail you
+asked for, keeping accounts secure, and dealing with spam and abuse. Nothing
+here is sold, and nothing is handed to advertisers.
+
+## What other people can see
+
+Your username, your profile, your posts, and when you were last active are
+visible to anyone who can read the forum in question — which, on a public forum,
+means anyone at all, including search engines. Your e-mail address and your IP
+address are not shown to other members; administrators and moderators can see
+them as part of moderating.
+
+## Who else is involved
+
+The board runs on servers rented from a hosting provider, and it may send mail
+through a mail provider. Those providers handle the data needed to do their job,
+and nothing further.
+
+## How long it is kept
+
+Your account and your posts are kept while your account exists. Technical logs,
+including IP addresses, are kept only as long as they are useful for security
+and moderation, and then discarded.
+
+## What you can ask for
+
+You can ask the administrators for a copy of what is held about you, for a
+correction to it, or for your account to be closed. Closing an account does not
+by itself delete posts other members replied to; ask if you need something
+specific removed, and it will be considered.
+
+## Changes
+
+This policy can change. The version on this page is the one in force.
+
+## Contact
+
+Questions about your data go to the board's administrators.`