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.`