Index: CHANGELOG.de.md
===================================================================
--- CHANGELOG.de.md	(revision c867b7b644ca4de55b3a9138e1951f420cfc1d31)
+++ CHANGELOG.de.md	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -5,4 +5,17 @@
 
 ## [Unreleased]
+
+### Hinzugefügt
+- **App-Zugriff über OAuth 2.0 (ActivityPub Client-to-Server, Phase 1).** Klonkt
+  spricht jetzt den standardmäßigen AP-C2S-Auth-Handshake, damit native und
+  Web-Clients (zuerst die Shaer-Apps) sich verbinden können: dynamische
+  Client-Registrierung (RFC 7591), ein PKCE-Authorization-Code-Flow mit einem
+  Zustimmungsbildschirm, auf dem du wählst, als welche deiner Seiten die App
+  posten darf, und Bearer-Tokens (gehasht gespeichert, einmalige Codes). Das
+  Actor-Dokument bewirbt die OAuth- und uploadMedia-Endpunkte und
+  `/.well-known/oauth-authorization-server` (RFC 8414) liefert die Metadaten,
+  sodass Clients alles entdecken statt Pfade festzuschreiben. Nur öffentliche
+  Clients + PKCE, keine Client-Secrets. Die Token-annehmende Outbox (POST) ist
+  die nächste Phase.
 
 ### Behoben
Index: CHANGELOG.md
===================================================================
--- CHANGELOG.md	(revision c867b7b644ca4de55b3a9138e1951f420cfc1d31)
+++ CHANGELOG.md	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -5,4 +5,15 @@
 
 ## [Unreleased]
+
+### Added
+- **App access via OAuth 2.0 (ActivityPub Client-to-Server, phase 1).** Klonkt
+  now speaks the standard AP C2S auth handshake so native and web clients (the
+  Shaer apps first) can connect: dynamic client registration (RFC 7591), a
+  PKCE authorization-code flow with a consent screen that picks which of your
+  sites the app may post as, and bearer tokens (stored hashed, single-use
+  codes). The actor document advertises the OAuth and uploadMedia endpoints and
+  `/.well-known/oauth-authorization-server` (RFC 8414) exposes the metadata, so
+  clients discover everything instead of hardcoding paths. Public clients + PKCE
+  only, no client secrets. The token-accepting outbox (POST) is the next phase.
 
 ### Fixed
Index: CHANGELOG.nl.md
===================================================================
--- CHANGELOG.nl.md	(revision c867b7b644ca4de55b3a9138e1951f420cfc1d31)
+++ CHANGELOG.nl.md	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -5,4 +5,16 @@
 
 ## [Unreleased]
+
+### Toegevoegd
+- **App-toegang via OAuth 2.0 (ActivityPub Client-to-Server, fase 1).** Klonkt
+  spreekt nu de standaard AP C2S-authenticatie, zodat native en web-apps (de
+  Shaer-apps als eerste) kunnen verbinden: dynamische client-registratie
+  (RFC 7591), een PKCE authorization-code-flow met een toestemmingsscherm waarop
+  je kiest namens welke site de app mag posten, en bearer-tokens (gehasht
+  opgeslagen, eenmalige codes). De actor-doc adverteert de OAuth- en
+  uploadMedia-endpoints en `/.well-known/oauth-authorization-server` (RFC 8414)
+  geeft de metadata, dus apps ontdekken alles in plaats van paden vast te
+  spijkeren. Alleen publieke clients + PKCE, geen client-secrets. De outbox die
+  de tokens accepteert (POST) is de volgende fase.
 
 ### Opgelost
Index: package.json
===================================================================
--- package.json	(revision c867b7b644ca4de55b3a9138e1951f420cfc1d31)
+++ package.json	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -1,5 +1,5 @@
 {
   "name": "klonkt",
-  "version": "1.5.0",
+  "version": "1.6.0-dev",
   "description": "Klonkt — self-hosted multi-site music & blog platform (solo, hub or circles). Node + SQLite + htmx.",
   "license": "AGPL-3.0-or-later",
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision c867b7b644ca4de55b3a9138e1951f420cfc1d31)
+++ src/config/database.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -282,4 +282,32 @@
       created_at DATETIME DEFAULT CURRENT_TIMESTAMP
     );
+    -- ActivityPub C2S (client-to-server): OAuth 2.0 for native/web clients (Shaer).
+    -- Public clients + PKCE (RFC 8252); tokens stored hashed; token is per user+site.
+    CREATE TABLE IF NOT EXISTS oauth_clients (
+      client_id TEXT PRIMARY KEY,
+      client_name TEXT,
+      redirect_uris TEXT NOT NULL,        -- JSON array
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+    );
+    CREATE TABLE IF NOT EXISTS oauth_codes (
+      code TEXT PRIMARY KEY,
+      client_id TEXT NOT NULL,
+      user_id TEXT NOT NULL,
+      site_slug TEXT NOT NULL,
+      redirect_uri TEXT NOT NULL,
+      code_challenge TEXT,                -- PKCE S256 (verplicht voor public clients)
+      scope TEXT,
+      expires_at DATETIME NOT NULL,
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+    );
+    CREATE TABLE IF NOT EXISTS oauth_tokens (
+      token_hash TEXT PRIMARY KEY,        -- sha256(bearer); het token zelf slaan we nooit op
+      client_id TEXT NOT NULL,
+      user_id TEXT NOT NULL,
+      site_slug TEXT NOT NULL,
+      scope TEXT,
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+      last_used_at DATETIME
+    );
     CREATE TABLE IF NOT EXISTS ap_outbox (
       id TEXT PRIMARY KEY,            -- note path segment (uuid) → /ap/notes/<id>
Index: src/routes/oauth.js
===================================================================
--- src/routes/oauth.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
+++ src/routes/oauth.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -0,0 +1,130 @@
+/**
+ * OAuth 2.0 routes for ActivityPub Client-to-Server (native/web clients).
+ *
+ *   POST /oauth/register            dynamic client registration (RFC 7591 subset)
+ *   GET  /.well-known/oauth-authorization-server   server metadata (RFC 8414)
+ *   GET  /oauth/authorize           consent screen (session-authenticated)
+ *   POST /oauth/authorize           user grants → redirect back with ?code
+ *   POST /oauth/token               code + PKCE verifier → bearer token
+ *
+ * Auth model: PUBLIC clients + PKCE only (see OAuthService). The consent screen
+ * reuses Klonkt's normal login session; the token it mints is scoped to one
+ * user + one of their sites.
+ */
+import express from 'express';
+import db from '../config/database.js';
+import OAuth from '../services/OAuthService.js';
+import { requireAuth } from '../middleware/auth.js';
+import { renderPage } from '../middleware/render.js';
+import PermissionsService from '../services/PermissionsService.js';
+import { apEnabled } from '../services/SettingsService.js';
+
+const router = express.Router();
+router.use((req, res, next) => { if (!apEnabled()) return next('router'); next(); });
+
+const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
+
+// Sites this user may post as (owner or co-admin). The consent screen lists these.
+function manageableSites(user) {
+  return db.prepare('SELECT id, slug, title, owner_id FROM sites ORDER BY created_at')
+    .all()
+    .filter((s) => PermissionsService.canAdminSite(user, s));
+}
+
+// Append query params to a redirect URI WITHOUT re-serializing it: native custom
+// schemes (com.shaer.app:/cb) get mangled by new URL().toString() (→ //cb/), and
+// RFC 6749 §4.1.2 says to append to the registered URI as-is. The URI is already
+// validated against the registered set before we ever call this.
+function redirectWith(redirectUri, params) {
+  const q = Object.entries(params)
+    .filter(([, v]) => v !== undefined && v !== null && v !== '')
+    .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
+    .join('&');
+  const sep = redirectUri.includes('?') ? '&' : '?';
+  return q ? `${redirectUri}${sep}${q}` : redirectUri;
+}
+
+// Bounce back to the client with an OAuth error (RFC 6749 §4.1.2.1) when we have
+// a validated redirect_uri; otherwise render a plain error (open-redirect guard).
+function authError(res, redirectUri, state, error, desc) {
+  if (redirectUri) return res.redirect(redirectWith(redirectUri, { error, error_description: desc, state }));
+  return res.status(400).json({ error, error_description: desc });
+}
+
+// ── RFC 8414: server metadata ────────────────────────────────────────────
+router.get('/.well-known/oauth-authorization-server', (req, res) => {
+  const base = baseUrl(req);
+  res.type('application/json').json({
+    issuer: base,
+    authorization_endpoint: `${base}/oauth/authorize`,
+    token_endpoint: `${base}/oauth/token`,
+    registration_endpoint: `${base}/oauth/register`,
+    response_types_supported: ['code'],
+    grant_types_supported: ['authorization_code'],
+    code_challenge_methods_supported: ['S256'],
+    token_endpoint_auth_methods_supported: ['none'],
+    scopes_supported: ['c2s'],
+  });
+});
+
+// ── RFC 7591: dynamic client registration ────────────────────────────────
+router.post('/oauth/register', (req, res) => {
+  const out = OAuth.registerClient({ client_name: req.body.client_name, redirect_uris: req.body.redirect_uris });
+  if (out.error) return res.status(400).json(out);
+  return res.status(201).json(out);
+});
+
+// ── Authorization: consent screen ────────────────────────────────────────
+router.get('/oauth/authorize', requireAuth, (req, res) => {
+  const { client_id, redirect_uri, response_type, code_challenge, code_challenge_method, scope, state } = req.query;
+  const client = OAuth.getClient(client_id);
+  // Pre-redirect validation errors must NOT bounce to an unvalidated URI.
+  if (!client) return res.status(400).json({ error: 'invalid_client' });
+  if (!client.redirect_uris.includes(String(redirect_uri || ''))) return res.status(400).json({ error: 'invalid_request', error_description: 'redirect_uri not registered' });
+  if (response_type !== 'code') return authError(res, redirect_uri, state, 'unsupported_response_type');
+  if (code_challenge_method !== 'S256' || !code_challenge) return authError(res, redirect_uri, state, 'invalid_request', 'PKCE S256 required');
+
+  const sites = manageableSites(req.session.user);
+  if (!sites.length) return authError(res, redirect_uri, state, 'access_denied', 'no manageable sites for this account');
+
+  return renderPage(req, res, 'pages/oauth-consent', {
+    pageTitleKey: 'oauth.title', bodyClass: 'on-special',
+    client, sites, params: { client_id, redirect_uri, code_challenge, scope: scope || 'c2s', state: state || '' },
+  });
+});
+
+router.post('/oauth/authorize', requireAuth, (req, res) => {
+  const { client_id, redirect_uri, code_challenge, scope, state, site_slug, decision } = req.body;
+  const client = OAuth.getClient(client_id);
+  if (!client || !client.redirect_uris.includes(String(redirect_uri || ''))) {
+    return res.status(400).json({ error: 'invalid_request', error_description: 'client/redirect mismatch' });
+  }
+  if (decision !== 'allow') return authError(res, redirect_uri, state, 'access_denied');
+
+  const site = db.prepare('SELECT id, slug, owner_id FROM sites WHERE slug = ?').get(String(site_slug || ''));
+  if (!site || !PermissionsService.canAdminSite(req.session.user, site)) {
+    return authError(res, redirect_uri, state, 'access_denied', 'not allowed to post as this site');
+  }
+  const out = OAuth.createCode({
+    clientId: client_id, userId: req.session.user.id, siteSlug: site.slug,
+    redirectUri: redirect_uri, codeChallenge: code_challenge, scope,
+  });
+  if (out.error) return authError(res, redirect_uri, state, out.error, out.error_description);
+  return res.redirect(redirectWith(redirect_uri, { code: out.code, state }));
+});
+
+// ── Token exchange ───────────────────────────────────────────────────────
+router.post('/oauth/token', (req, res) => {
+  res.set('Cache-Control', 'no-store');
+  if (req.body.grant_type !== 'authorization_code') {
+    return res.status(400).json({ error: 'unsupported_grant_type' });
+  }
+  const out = OAuth.exchangeCode({
+    code: req.body.code, client_id: req.body.client_id,
+    redirect_uri: req.body.redirect_uri, code_verifier: req.body.code_verifier,
+  });
+  if (out.error) return res.status(400).json(out);
+  return res.json(out);
+});
+
+export default router;
Index: src/server.js
===================================================================
--- src/server.js	(revision c867b7b644ca4de55b3a9138e1951f420cfc1d31)
+++ src/server.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -59,4 +59,5 @@
 import ogRoutes from './routes/og.js';
 import apRoutes from './routes/activitypub.js';
+import oauthRoutes from './routes/oauth.js';
 import { apWants, startDeliveryWorker, selfHealTimeline } from './services/ActivityPubService.js';
 
@@ -282,4 +283,7 @@
 // ActivityPub: WebFinger + /ap/* (site-agnostic, resolves the site by slug).
 app.use(apRoutes);
+// ActivityPub C2S: OAuth 2.0 (native/web clients). Site-agnostic; the consent
+// screen picks which site the token can post as.
+app.use(oauthRoutes);
 
 // Themed OG cards (/og/:slug.png) — resolve the site by slug themselves, so they
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision c867b7b644ca4de55b3a9138e1951f420cfc1d31)
+++ src/services/ActivityPubService.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -164,5 +164,13 @@
     following: `${id}/following`,
     featured: `${id}/featured`,
-    endpoints: { sharedInbox: `${base}/ap/inbox` },
+    // C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths.
+    // All four are ActivityPub-spec `endpoints` terms. Dynamic client registration
+    // (RFC 7591) is discovered via /.well-known/oauth-authorization-server, not here.
+    endpoints: {
+      sharedInbox: `${base}/ap/inbox`,
+      oauthAuthorizationEndpoint: `${base}/oauth/authorize`,
+      oauthTokenEndpoint: `${base}/oauth/token`,
+      uploadMedia: `${id}/uploadMedia`,
+    },
     publicKey: {
       id: `${id}#main-key`,
Index: src/services/OAuthService.js
===================================================================
--- src/services/OAuthService.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
+++ src/services/OAuthService.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -0,0 +1,111 @@
+/**
+ * OAuthService — OAuth 2.0 for ActivityPub Client-to-Server (C2S).
+ *
+ * The AP spec recommends OAuth 2.0 bearer tokens for C2S; the actor document
+ * advertises endpoints.oauthAuthorizationEndpoint / oauthTokenEndpoint, and
+ * RFC 8414 (/.well-known/oauth-authorization-server) advertises the
+ * registration endpoint. Design choices (v1):
+ *   - PUBLIC clients only (native apps, RFC 8252): no client secrets,
+ *     PKCE S256 is REQUIRED on the authorization-code flow.
+ *   - A token is scoped to ONE user + ONE site (multi-site hub: the consent
+ *     page picks the site). Scope string is informational ('c2s').
+ *   - Tokens are stored hashed (sha256); codes are single-use, 10 min TTL.
+ */
+
+import crypto from 'crypto';
+import db from '../config/database.js';
+
+const CODE_TTL_MS = 10 * 60 * 1000;
+
+const b64url = (buf) => buf.toString('base64url');
+const sha256 = (s) => crypto.createHash('sha256').update(s).digest();
+
+// Redirect URIs: https:// (web) or a custom scheme with a dot (reverse-DNS,
+// RFC 8252 §7.1, e.g. com.shaer.app:/callback). Plain http only for loopback.
+export function validRedirectUri(uri) {
+  try {
+    const u = new URL(uri);
+    if (u.protocol === 'https:') return true;
+    if (u.protocol === 'http:') return u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '[::1]';
+    return /^[a-z0-9-]+(\.[a-z0-9-]+)+:$/i.test(u.protocol); // custom reverse-DNS scheme
+  } catch { return false; }
+}
+
+// RFC 7591 (subset): register a public client. Returns the stored metadata.
+export function registerClient({ client_name, redirect_uris }) {
+  const name = String(client_name || '').trim().slice(0, 120);
+  const uris = (Array.isArray(redirect_uris) ? redirect_uris : [redirect_uris]).filter(Boolean).map(String);
+  if (!name) return { error: 'invalid_client_metadata', error_description: 'client_name is required' };
+  if (!uris.length || !uris.every(validRedirectUri)) {
+    return { error: 'invalid_redirect_uri', error_description: 'redirect_uris must be https, loopback http, or a reverse-DNS custom scheme' };
+  }
+  const clientId = b64url(crypto.randomBytes(18));
+  db.prepare('INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES (?,?,?)')
+    .run(clientId, name, JSON.stringify(uris));
+  return {
+    client_id: clientId,
+    client_name: name,
+    redirect_uris: uris,
+    token_endpoint_auth_method: 'none', // public client: PKCE, no secret
+    grant_types: ['authorization_code'],
+    response_types: ['code'],
+  };
+}
+
+export function getClient(clientId) {
+  const row = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(String(clientId || ''));
+  if (!row) return null;
+  let uris = []; try { uris = JSON.parse(row.redirect_uris); } catch { /* corrupt row */ }
+  return { client_id: row.client_id, client_name: row.client_name, redirect_uris: uris };
+}
+
+// Authorization step (after user consent): mint a single-use code.
+export function createCode({ clientId, userId, siteSlug, redirectUri, codeChallenge, scope }) {
+  if (!codeChallenge || !/^[A-Za-z0-9_-]{43}$/.test(String(codeChallenge))) {
+    return { error: 'invalid_request', error_description: 'PKCE S256 code_challenge is required' };
+  }
+  const code = b64url(crypto.randomBytes(24));
+  db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, site_slug, redirect_uri, code_challenge, scope, expires_at)
+              VALUES (?,?,?,?,?,?,?,?)`)
+    .run(code, clientId, userId, siteSlug, redirectUri, codeChallenge, scope || 'c2s',
+         new Date(Date.now() + CODE_TTL_MS).toISOString());
+  return { code };
+}
+
+// Token step: exchange code + PKCE verifier for a bearer token.
+export function exchangeCode({ code, client_id, redirect_uri, code_verifier }) {
+  const row = db.prepare('SELECT * FROM oauth_codes WHERE code = ?').get(String(code || ''));
+  // Single use: delete immediately, whatever happens next (replay protection).
+  if (row) db.prepare('DELETE FROM oauth_codes WHERE code = ?').run(row.code);
+  if (!row) return { error: 'invalid_grant' };
+  if (Date.parse(row.expires_at) < Date.now()) return { error: 'invalid_grant', error_description: 'code expired' };
+  if (row.client_id !== String(client_id || '')) return { error: 'invalid_grant', error_description: 'client mismatch' };
+  if (row.redirect_uri !== String(redirect_uri || '')) return { error: 'invalid_grant', error_description: 'redirect_uri mismatch' };
+  const expected = b64url(sha256(String(code_verifier || '')));
+  if (expected !== row.code_challenge) return { error: 'invalid_grant', error_description: 'PKCE verification failed' };
+  const token = b64url(crypto.randomBytes(32));
+  db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
+    .run(b64url(sha256(token)), row.client_id, row.user_id, row.site_slug, row.scope);
+  return { access_token: token, token_type: 'Bearer', scope: row.scope };
+}
+
+// Resolve "Authorization: Bearer <token>" → { user, site } or null. The C2S
+// caller must additionally check the site matches the URL and permissions.
+export function verifyBearer(authHeader) {
+  const m = /^Bearer\s+([A-Za-z0-9_-]{20,})$/i.exec(String(authHeader || '').trim());
+  if (!m) return null;
+  const hash = b64url(sha256(m[1]));
+  const row = db.prepare('SELECT * FROM oauth_tokens WHERE token_hash = ?').get(hash);
+  if (!row) return null;
+  try { db.prepare('UPDATE oauth_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash); } catch { /* non-fatal */ }
+  const user = db.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);
+  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
+  if (!user || !site) return null;
+  return { user, site, scope: row.scope, client_id: row.client_id };
+}
+
+export function revokeToken(token) {
+  try { db.prepare('DELETE FROM oauth_tokens WHERE token_hash = ?').run(b64url(sha256(String(token || '')))); } catch { /* ignore */ }
+}
+
+export default { registerClient, getClient, createCode, exchangeCode, verifyBearer, revokeToken, validRedirectUri };
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision c867b7b644ca4de55b3a9138e1951f420cfc1d31)
+++ src/services/i18n.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -113,5 +113,5 @@
     'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse',
     'fedi.reply': 'Reageer', 'fedi.reply_ph': 'Je antwoord aan de fediverse…', 'fedi.send': 'Versturen', 'fedi.you': 'Jij',
-    'fedi.remote_title': 'Reageer via de fediverse', 'fedi.follow_heading': 'Volgen via de fediverse', 'fedi.profile_follow': 'Volg via de fediverse', 'profile.since': 'Op Klonkt sinds', 'profile.free': 'Gratis', 'fedi.follow_intro': 'Je staat op het punt te volgen:', 'fedi.follow_btn': 'Volgen', 'fedi.cancel': 'Annuleren', 'fedi.followed_title': 'Volgverzoek verstuurd ✅', 'fedi.followed_done': 'Je volgverzoek is onderweg. Zodra de andere kant het accepteert, verschijnen hun berichten in je tijdlijn.', 'fedi.view_profile': 'Bekijk profiel →', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres:', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Like intrekken', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'jouw server', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is verstuurd. Hij verschijnt zo bij de originele post op de fediverse, niet op deze pagina. Bekijk hem daar:', 'fedi.reply_where': 'Je reactie verschijnt bij de originele post op de fediverse, niet op deze pagina. Via de link hierboven zie je hem daar.', 'fedi.remote_back': '← Terug naar je site', 'fedi.like_btn': 'Like deze post', 'fedi.or_reply': 'of reageer:', 'fedi.liked_title': 'Geliket', 'fedi.liked_done': 'Je like is onderweg naar de fediverse.', 'fedi.boost_btn': 'Boost deze post', 'fedi.boosted_title': 'Geboost', 'fedi.boosted_done': 'Je boost is onderweg naar de fediverse.', 'fedi.remote_interact': 'Interageer via de fediverse', 'fedi.report_open': 'Deze post rapporteren', 'fedi.report_where': 'De melding gaat naar de instance en hun moderator(s).', 'fedi.report_ph': 'Wat is er mis? (optioneel)', 'fedi.report_send': 'Rapporteren', 'fedi.reported_title': 'Gerapporteerd', 'fedi.reported_done': 'Je melding is naar de server van de gebruiker gestuurd. Hun moderators bekijken het.', 'fedi.delete_confirm': 'Deze reactie verwijderen?', 'fedi.mod_remove_confirm': 'Deze reactie uit je thread verwijderen? Hij komt niet terug, ook niet via thread-aanvulling.', 'fedi.mod_report_confirm': 'Deze reactie rapporteren bij de server van de auteur?', 'fedi.manage_title': 'Mijn fediverse-reacties', 'fedi.manage_empty': 'Je hebt nog geen reacties verstuurd.', 'fedi.goto_post': 'Naar de post', 'fedi.edit': 'Bewerken', 'fedi.save_edit': 'Opslaan', 'fedi.bm_label': 'Interageer via mijn site', 'fedi.bm_help': 'Sleep deze knop naar je bladwijzerbalk. Klik ’m daarna op elke fediverse-post (Mastodon, een andere Klonkt…) om er via jouw site op te reageren, te liken of te boosten.', 'tl.title': 'Krant', 'tl.lead': 'Volg accounts in de fediverse en zie hun berichten hier.', 'tl.follow_btn': 'Volgen', 'tl.following': 'Je volgt', 'tl.unfollow': 'Ontvolgen', 'tl.autoboost': 'Uitgelicht', 'tl.autoboost_follow': 'uitlichten in cirkel', 'tl.autoboost_hint': 'Hun nieuwe posts verschijnen doorlopend in jouw Cirkel (lokaal, geen fediverse-boost).', 'tl.pending': 'in afwachting', 'tl.unboost': 'Boost intrekken', 'tl.feed': 'Berichten', 'tl.tab_feed': 'Krant', 'tl.tab_following': 'Volgend', 'tl.tab_replies': 'Reacties', 'tl.tab_followers': 'Volgers', 'tl.followers': 'Volgers', 'tl.followers_lead': 'Wie jou volgt in de fediverse, met de laatste geslaagde bezorging. Rood = nog nooit bezorgd of laatste poging mislukt — kandidaat om op te ruimen na een check.', 'tl.empty_followers': 'Nog geen volgers.', 'tl.last_delivery': 'Laatste bezorging', 'tl.never_delivered': 'Nog nooit bezorgd', 'tl.delivery_failed': 'laatste poging mislukt', 'tl.remove_follower': 'Verwijderen', 'tl.remove_confirm': 'Deze volger verwijderen? Een actief account moet je dan opnieuw volgen.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'jij volgt', 'tl.dir_follower': 'volgt jou', 'tl.dir_mutual': 'wederzijds', 'tl.connect_empty': 'Nog geen connecties. Volg iemand hierboven om te beginnen.', 'tl.unreachable': 'Niet bereikbaar', 'tl.unreachable_lead': 'Deze volgers konden we niet bereiken (nooit bezorgd of laatste poging mislukt). Ruim ze op na een handmatige check.', 'msg.tab': 'Berichten', 'msg.title': 'Berichten', 'msg.filter_all': 'Alles', 'msg.filter_conv': 'Gesprekken', 'msg.filter_act': 'Activiteit', 'msg.filter_sent': 'Verzonden', 'msg.you': 'Jij', 'msg.sent_reply': 'reageerde via de fediverse', 'msg.and_more': 'en {n} anderen', 'msg.liked_many': 'liketen je post', 'msg.boosted_many': 'boostten je post', 'msg.private': 'privé', 'msg.private_hint': 'Alleen aan jou gericht; staat niet op de publieke postpagina.', 'msg.new': 'Nieuw sinds je laatste bezoek', 'msg.empty': 'Nog geen berichten. Reacties, vermeldingen en activiteit verschijnen hier.', 'tl.empty_following': 'Je volgt nog niemand.', 'tl.empty': 'Nog niks — volg iemand om hun berichten hier te zien.', 'tl.view_original': 'Bekijk origineel →', 'tl.open_player': 'Open de speler', 'tl.paste_ph': 'Plak een fediverse-post-URL', 'tl.paste_go': 'Openen', 'tl.boosted': 'boostte dit', 'tl.read_more': 'Meer lezen', 'tl.show_less': 'Minder', 'poll.vote': 'Stem', 'poll.votes': 'stemmen', 'poll.closed': 'gesloten', 'poll.open': 'open', 'poll.aria': 'Peiling', 'poll.voter_one': 'stemmer', 'poll.voter_many': 'stemmers', 'poll.closes': 'sluit op', 'poll.multiple': 'meerkeuze', 'poll.fedi_only': 'Stemmen kan vanuit de fediverse — volg deze site en stem in je eigen app.', 'poll.voted_title': 'Stem verstuurd', 'poll.voted_done': 'Je stem is verstuurd naar de poll. De uitslag werkt bij zodra de maker die doorstuurt.',
+    'fedi.remote_title': 'Reageer via de fediverse', 'fedi.follow_heading': 'Volgen via de fediverse', 'fedi.profile_follow': 'Volg via de fediverse', 'profile.since': 'Op Klonkt sinds', 'profile.free': 'Gratis', 'fedi.follow_intro': 'Je staat op het punt te volgen:', 'fedi.follow_btn': 'Volgen', 'fedi.cancel': 'Annuleren', 'fedi.followed_title': 'Volgverzoek verstuurd ✅', 'fedi.followed_done': 'Je volgverzoek is onderweg. Zodra de andere kant het accepteert, verschijnen hun berichten in je tijdlijn.', 'fedi.view_profile': 'Bekijk profiel →', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres:', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Like intrekken', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'jouw server', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is verstuurd. Hij verschijnt zo bij de originele post op de fediverse, niet op deze pagina. Bekijk hem daar:', 'fedi.reply_where': 'Je reactie verschijnt bij de originele post op de fediverse, niet op deze pagina. Via de link hierboven zie je hem daar.', 'fedi.remote_back': '← Terug naar je site', 'fedi.like_btn': 'Like deze post', 'fedi.or_reply': 'of reageer:', 'fedi.liked_title': 'Geliket', 'fedi.liked_done': 'Je like is onderweg naar de fediverse.', 'fedi.boost_btn': 'Boost deze post', 'fedi.boosted_title': 'Geboost', 'fedi.boosted_done': 'Je boost is onderweg naar de fediverse.', 'fedi.remote_interact': 'Interageer via de fediverse', 'fedi.report_open': 'Deze post rapporteren', 'fedi.report_where': 'De melding gaat naar de instance en hun moderator(s).', 'fedi.report_ph': 'Wat is er mis? (optioneel)', 'fedi.report_send': 'Rapporteren', 'fedi.reported_title': 'Gerapporteerd', 'fedi.reported_done': 'Je melding is naar de server van de gebruiker gestuurd. Hun moderators bekijken het.', 'fedi.delete_confirm': 'Deze reactie verwijderen?', 'fedi.mod_remove_confirm': 'Deze reactie uit je thread verwijderen? Hij komt niet terug, ook niet via thread-aanvulling.', 'fedi.mod_report_confirm': 'Deze reactie rapporteren bij de server van de auteur?', 'fedi.manage_title': 'Mijn fediverse-reacties', 'fedi.manage_empty': 'Je hebt nog geen reacties verstuurd.', 'fedi.goto_post': 'Naar de post', 'fedi.edit': 'Bewerken', 'fedi.save_edit': 'Opslaan', 'fedi.bm_label': 'Interageer via mijn site', 'fedi.bm_help': 'Sleep deze knop naar je bladwijzerbalk. Klik ’m daarna op elke fediverse-post (Mastodon, een andere Klonkt…) om er via jouw site op te reageren, te liken of te boosten.', 'tl.title': 'Krant', 'tl.lead': 'Volg accounts in de fediverse en zie hun berichten hier.', 'tl.follow_btn': 'Volgen', 'tl.following': 'Je volgt', 'tl.unfollow': 'Ontvolgen', 'tl.autoboost': 'Uitgelicht', 'tl.autoboost_follow': 'uitlichten in cirkel', 'tl.autoboost_hint': 'Hun nieuwe posts verschijnen doorlopend in jouw Cirkel (lokaal, geen fediverse-boost).', 'tl.pending': 'in afwachting', 'tl.unboost': 'Boost intrekken', 'tl.feed': 'Berichten', 'tl.tab_feed': 'Krant', 'tl.tab_following': 'Volgend', 'tl.tab_replies': 'Reacties', 'tl.tab_followers': 'Volgers', 'tl.followers': 'Volgers', 'tl.followers_lead': 'Wie jou volgt in de fediverse, met de laatste geslaagde bezorging. Rood = nog nooit bezorgd of laatste poging mislukt — kandidaat om op te ruimen na een check.', 'tl.empty_followers': 'Nog geen volgers.', 'tl.last_delivery': 'Laatste bezorging', 'tl.never_delivered': 'Nog nooit bezorgd', 'tl.delivery_failed': 'laatste poging mislukt', 'tl.remove_follower': 'Verwijderen', 'tl.remove_confirm': 'Deze volger verwijderen? Een actief account moet je dan opnieuw volgen.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'jij volgt', 'tl.dir_follower': 'volgt jou', 'tl.dir_mutual': 'wederzijds', 'tl.connect_empty': 'Nog geen connecties. Volg iemand hierboven om te beginnen.', 'tl.unreachable': 'Niet bereikbaar', 'tl.unreachable_lead': 'Deze volgers konden we niet bereiken (nooit bezorgd of laatste poging mislukt). Ruim ze op na een handmatige check.', 'msg.tab': 'Berichten', 'msg.title': 'Berichten', 'msg.filter_all': 'Alles', 'msg.filter_conv': 'Gesprekken', 'msg.filter_act': 'Activiteit', 'msg.filter_sent': 'Verzonden', 'msg.you': 'Jij', 'msg.sent_reply': 'reageerde via de fediverse', 'msg.and_more': 'en {n} anderen', 'msg.liked_many': 'liketen je post', 'msg.boosted_many': 'boostten je post', 'msg.private': 'privé', 'msg.private_hint': 'Alleen aan jou gericht; staat niet op de publieke postpagina.', 'msg.new': 'Nieuw sinds je laatste bezoek', 'msg.empty': 'Nog geen berichten. Reacties, vermeldingen en activiteit verschijnen hier.', 'oauth.title': 'App toegang geven', 'oauth.wants_access': 'wil verbinding maken met je Klonkt-account.', 'oauth.post_as': 'Plaatsen als', 'oauth.scope_read': 'Je berichten, reacties en meldingen lezen', 'oauth.scope_write': 'Namens jou posten, reageren, liken en volgen', 'oauth.allow': 'Toestaan', 'oauth.deny': 'Weigeren', 'oauth.foot': 'Je kunt de toegang later intrekken. Geef alleen apps toegang die je vertrouwt.', 'tl.empty_following': 'Je volgt nog niemand.', 'tl.empty': 'Nog niks — volg iemand om hun berichten hier te zien.', 'tl.view_original': 'Bekijk origineel →', 'tl.open_player': 'Open de speler', 'tl.paste_ph': 'Plak een fediverse-post-URL', 'tl.paste_go': 'Openen', 'tl.boosted': 'boostte dit', 'tl.read_more': 'Meer lezen', 'tl.show_less': 'Minder', 'poll.vote': 'Stem', 'poll.votes': 'stemmen', 'poll.closed': 'gesloten', 'poll.open': 'open', 'poll.aria': 'Peiling', 'poll.voter_one': 'stemmer', 'poll.voter_many': 'stemmers', 'poll.closes': 'sluit op', 'poll.multiple': 'meerkeuze', 'poll.fedi_only': 'Stemmen kan vanuit de fediverse — volg deze site en stem in je eigen app.', 'poll.voted_title': 'Stem verstuurd', 'poll.voted_done': 'Je stem is verstuurd naar de poll. De uitslag werkt bij zodra de maker die doorstuurt.',
     'comments.to_start': 'om de conversatie te starten.',
     'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren',
@@ -1042,5 +1042,5 @@
     'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse',
     'fedi.reply': 'Reply', 'fedi.reply_ph': 'Your reply to the fediverse…', 'fedi.send': 'Send', 'fedi.you': 'You',
-    'fedi.remote_title': 'Reply via the fediverse', 'fedi.follow_heading': 'Follow via the fediverse', 'fedi.profile_follow': 'Follow via the fediverse', 'profile.since': 'On Klonkt since', 'profile.free': 'Free', 'fedi.follow_intro': 'You are about to follow:', 'fedi.follow_btn': 'Follow', 'fedi.cancel': 'Cancel', 'fedi.followed_title': 'Follow request sent ✅', 'fedi.followed_done': 'Your follow request is on its way. Once accepted, their posts show up in your timeline.', 'fedi.view_profile': 'View profile →', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address:', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Unlike', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'your server', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply has been sent. It will show up on the original post on the fediverse, not on this page. See it there:', 'fedi.reply_where': 'Your reply appears on the original post on the fediverse, not on this page. Use the link above to see it there.', 'fedi.remote_back': '← Back to your site', 'fedi.like_btn': 'Like this post', 'fedi.or_reply': 'or reply:', 'fedi.liked_title': 'Liked', 'fedi.liked_done': 'Your like is on its way to the fediverse.', 'fedi.boost_btn': 'Boost this post', 'fedi.boosted_title': 'Boosted', 'fedi.boosted_done': 'Your boost is on its way to the fediverse.', 'fedi.remote_interact': 'Interact via the fediverse', 'fedi.report_open': 'Report this post', 'fedi.report_where': 'The report goes to the instance and their moderator(s).', 'fedi.report_ph': 'What is wrong? (optional)', 'fedi.report_send': 'Report', 'fedi.reported_title': 'Reported', 'fedi.reported_done': 'Your report has been sent to their server. Their moderators will review it.', 'fedi.delete_confirm': 'Delete this reply?', 'fedi.mod_remove_confirm': 'Remove this reply from your thread? It will not come back, not even via thread-filling.', 'fedi.mod_report_confirm': 'Report this reply to its author’s server?', 'fedi.manage_title': 'My fediverse replies', 'fedi.manage_empty': 'You have not sent any replies yet.', 'fedi.goto_post': 'Go to post', 'fedi.edit': 'Edit', 'fedi.save_edit': 'Save', 'fedi.bm_label': 'Interact via my site', 'fedi.bm_help': 'Drag this button to your bookmarks bar. Then click it on any fediverse post (Mastodon, another Klonkt…) to reply, like or boost it via your own site.', 'tl.title': 'News', 'tl.lead': 'Follow accounts on the fediverse and see their posts here.', 'tl.follow_btn': 'Follow', 'tl.following': 'Following', 'tl.unfollow': 'Unfollow', 'tl.autoboost': 'Featured', 'tl.autoboost_follow': 'feature in circle', 'tl.autoboost_hint': 'Their new posts keep showing in your Circle (local, no fediverse boost).', 'tl.pending': 'pending', 'tl.unboost': 'Unboost', 'tl.feed': 'Posts', 'tl.tab_feed': 'News', 'tl.tab_following': 'Following', 'tl.tab_replies': 'Replies', 'tl.tab_followers': 'Followers', 'tl.followers': 'Followers', 'tl.followers_lead': 'Who follows you on the fediverse, with the last successful delivery. Red = never delivered or last attempt failed — a candidate to clean up after a check.', 'tl.empty_followers': 'No followers yet.', 'tl.last_delivery': 'Last delivery', 'tl.never_delivered': 'Never delivered', 'tl.delivery_failed': 'last attempt failed', 'tl.remove_follower': 'Remove', 'tl.remove_confirm': 'Remove this follower? An active account would have to follow you again.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'you follow', 'tl.dir_follower': 'follows you', 'tl.dir_mutual': 'mutual', 'tl.connect_empty': 'No connections yet. Follow someone above to get started.', 'tl.unreachable': 'Unreachable', 'tl.unreachable_lead': 'We could not reach these followers (never delivered or last attempt failed). Clean them up after a manual check.', 'msg.tab': 'Messages', 'msg.title': 'Messages', 'msg.filter_all': 'All', 'msg.filter_conv': 'Conversations', 'msg.filter_act': 'Activity', 'msg.filter_sent': 'Sent', 'msg.you': 'You', 'msg.sent_reply': 'replied via the fediverse', 'msg.and_more': 'and {n} others', 'msg.liked_many': 'liked your post', 'msg.boosted_many': 'boosted your post', 'msg.private': 'private', 'msg.private_hint': 'Addressed to you only; not shown on the public post page.', 'msg.new': 'New since your last visit', 'msg.empty': 'No messages yet. Replies, mentions and activity show up here.', 'tl.empty_following': 'You do not follow anyone yet.', 'tl.empty': 'Nothing yet — follow someone to see their posts here.', 'tl.view_original': 'View original →', 'tl.open_player': 'Open the player', 'tl.paste_ph': 'Paste a fediverse post URL', 'tl.paste_go': 'Open', 'tl.boosted': 'boosted', 'tl.read_more': 'Read more', 'tl.show_less': 'Show less', 'poll.vote': 'Vote', 'poll.votes': 'votes', 'poll.closed': 'closed', 'poll.open': 'open', 'poll.aria': 'Poll', 'poll.voter_one': 'voter', 'poll.voter_many': 'voters', 'poll.closes': 'closes', 'poll.multiple': 'multiple choice', 'poll.fedi_only': 'Voting happens on the fediverse — follow this site and vote from your own app.', 'poll.voted_title': 'Vote sent', 'poll.voted_done': 'Your vote is on its way to the poll. The results refresh once the author sends the update.',
+    'fedi.remote_title': 'Reply via the fediverse', 'fedi.follow_heading': 'Follow via the fediverse', 'fedi.profile_follow': 'Follow via the fediverse', 'profile.since': 'On Klonkt since', 'profile.free': 'Free', 'fedi.follow_intro': 'You are about to follow:', 'fedi.follow_btn': 'Follow', 'fedi.cancel': 'Cancel', 'fedi.followed_title': 'Follow request sent ✅', 'fedi.followed_done': 'Your follow request is on its way. Once accepted, their posts show up in your timeline.', 'fedi.view_profile': 'View profile →', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address:', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Unlike', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'your server', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply has been sent. It will show up on the original post on the fediverse, not on this page. See it there:', 'fedi.reply_where': 'Your reply appears on the original post on the fediverse, not on this page. Use the link above to see it there.', 'fedi.remote_back': '← Back to your site', 'fedi.like_btn': 'Like this post', 'fedi.or_reply': 'or reply:', 'fedi.liked_title': 'Liked', 'fedi.liked_done': 'Your like is on its way to the fediverse.', 'fedi.boost_btn': 'Boost this post', 'fedi.boosted_title': 'Boosted', 'fedi.boosted_done': 'Your boost is on its way to the fediverse.', 'fedi.remote_interact': 'Interact via the fediverse', 'fedi.report_open': 'Report this post', 'fedi.report_where': 'The report goes to the instance and their moderator(s).', 'fedi.report_ph': 'What is wrong? (optional)', 'fedi.report_send': 'Report', 'fedi.reported_title': 'Reported', 'fedi.reported_done': 'Your report has been sent to their server. Their moderators will review it.', 'fedi.delete_confirm': 'Delete this reply?', 'fedi.mod_remove_confirm': 'Remove this reply from your thread? It will not come back, not even via thread-filling.', 'fedi.mod_report_confirm': 'Report this reply to its author’s server?', 'fedi.manage_title': 'My fediverse replies', 'fedi.manage_empty': 'You have not sent any replies yet.', 'fedi.goto_post': 'Go to post', 'fedi.edit': 'Edit', 'fedi.save_edit': 'Save', 'fedi.bm_label': 'Interact via my site', 'fedi.bm_help': 'Drag this button to your bookmarks bar. Then click it on any fediverse post (Mastodon, another Klonkt…) to reply, like or boost it via your own site.', 'tl.title': 'News', 'tl.lead': 'Follow accounts on the fediverse and see their posts here.', 'tl.follow_btn': 'Follow', 'tl.following': 'Following', 'tl.unfollow': 'Unfollow', 'tl.autoboost': 'Featured', 'tl.autoboost_follow': 'feature in circle', 'tl.autoboost_hint': 'Their new posts keep showing in your Circle (local, no fediverse boost).', 'tl.pending': 'pending', 'tl.unboost': 'Unboost', 'tl.feed': 'Posts', 'tl.tab_feed': 'News', 'tl.tab_following': 'Following', 'tl.tab_replies': 'Replies', 'tl.tab_followers': 'Followers', 'tl.followers': 'Followers', 'tl.followers_lead': 'Who follows you on the fediverse, with the last successful delivery. Red = never delivered or last attempt failed — a candidate to clean up after a check.', 'tl.empty_followers': 'No followers yet.', 'tl.last_delivery': 'Last delivery', 'tl.never_delivered': 'Never delivered', 'tl.delivery_failed': 'last attempt failed', 'tl.remove_follower': 'Remove', 'tl.remove_confirm': 'Remove this follower? An active account would have to follow you again.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'you follow', 'tl.dir_follower': 'follows you', 'tl.dir_mutual': 'mutual', 'tl.connect_empty': 'No connections yet. Follow someone above to get started.', 'tl.unreachable': 'Unreachable', 'tl.unreachable_lead': 'We could not reach these followers (never delivered or last attempt failed). Clean them up after a manual check.', 'msg.tab': 'Messages', 'msg.title': 'Messages', 'msg.filter_all': 'All', 'msg.filter_conv': 'Conversations', 'msg.filter_act': 'Activity', 'msg.filter_sent': 'Sent', 'msg.you': 'You', 'msg.sent_reply': 'replied via the fediverse', 'msg.and_more': 'and {n} others', 'msg.liked_many': 'liked your post', 'msg.boosted_many': 'boosted your post', 'msg.private': 'private', 'msg.private_hint': 'Addressed to you only; not shown on the public post page.', 'msg.new': 'New since your last visit', 'msg.empty': 'No messages yet. Replies, mentions and activity show up here.', 'oauth.title': 'Authorize app', 'oauth.wants_access': 'wants to connect to your Klonkt account.', 'oauth.post_as': 'Post as', 'oauth.scope_read': 'Read your posts, replies and notifications', 'oauth.scope_write': 'Post, reply, like and follow on your behalf', 'oauth.allow': 'Allow', 'oauth.deny': 'Deny', 'oauth.foot': 'You can revoke access later. Only authorize apps you trust.', 'tl.empty_following': 'You do not follow anyone yet.', 'tl.empty': 'Nothing yet — follow someone to see their posts here.', 'tl.view_original': 'View original →', 'tl.open_player': 'Open the player', 'tl.paste_ph': 'Paste a fediverse post URL', 'tl.paste_go': 'Open', 'tl.boosted': 'boosted', 'tl.read_more': 'Read more', 'tl.show_less': 'Show less', 'poll.vote': 'Vote', 'poll.votes': 'votes', 'poll.closed': 'closed', 'poll.open': 'open', 'poll.aria': 'Poll', 'poll.voter_one': 'voter', 'poll.voter_many': 'voters', 'poll.closes': 'closes', 'poll.multiple': 'multiple choice', 'poll.fedi_only': 'Voting happens on the fediverse — follow this site and vote from your own app.', 'poll.voted_title': 'Vote sent', 'poll.voted_done': 'Your vote is on its way to the poll. The results refresh once the author sends the update.',
     'comments.to_start': 'to start the conversation.',
     'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel',
@@ -1969,5 +1969,5 @@
     'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse',
     'fedi.reply': 'Antworten', 'fedi.reply_ph': 'Deine Antwort an das Fediverse…', 'fedi.send': 'Senden', 'fedi.you': 'Du',
-    'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.follow_heading': 'Über das Fediverse folgen', 'fedi.profile_follow': 'Über das Fediverse folgen', 'profile.since': 'Auf Klonkt seit', 'profile.free': 'Kostenlos', 'fedi.follow_intro': 'Du folgst gleich:', 'fedi.follow_btn': 'Folgen', 'fedi.cancel': 'Abbrechen', 'fedi.followed_title': 'Folge-Anfrage gesendet ✅', 'fedi.followed_done': 'Deine Folge-Anfrage ist unterwegs. Sobald sie akzeptiert wird, erscheinen ihre Beiträge in deiner Timeline.', 'fedi.view_profile': 'Profil ansehen →', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse:', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.like_short': 'Liken', 'fedi.unlike_short': 'Like zurücknehmen', 'fedi.boost_short': 'Boosten', 'fedi.remote_ph': 'dein Server', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort wurde gesendet. Sie erscheint gleich beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Sieh sie dir dort an:', 'fedi.reply_where': 'Deine Antwort erscheint beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Über den Link oben siehst du sie dort.', 'fedi.remote_back': '← Zurück zu deiner Seite', 'fedi.like_btn': 'Diesen Beitrag liken', 'fedi.or_reply': 'oder antworten:', 'fedi.liked_title': 'Geliked', 'fedi.liked_done': 'Dein Like ist unterwegs ins Fediverse.', 'fedi.boost_btn': 'Diesen Beitrag boosten', 'fedi.boosted_title': 'Geboostet', 'fedi.boosted_done': 'Dein Boost ist unterwegs ins Fediverse.', 'fedi.remote_interact': 'Übers Fediverse interagieren', 'fedi.report_open': 'Diesen Beitrag melden', 'fedi.report_where': 'Die Meldung geht an die Instanz und deren Moderator(en).', 'fedi.report_ph': 'Was ist das Problem? (optional)', 'fedi.report_send': 'Melden', 'fedi.reported_title': 'Gemeldet', 'fedi.reported_done': 'Deine Meldung wurde an den Server der Person gesendet. Deren Moderatoren prüfen sie.', 'fedi.delete_confirm': 'Diese Antwort löschen?', 'fedi.mod_remove_confirm': 'Diese Antwort aus deinem Thread entfernen? Sie kommt nicht zurück, auch nicht über Thread-Auffüllung.', 'fedi.mod_report_confirm': 'Diese Antwort beim Server des Autors melden?', 'fedi.manage_title': 'Meine Fediverse-Antworten', 'fedi.manage_empty': 'Du hast noch keine Antworten gesendet.', 'fedi.goto_post': 'Zur Post', 'fedi.edit': 'Bearbeiten', 'fedi.save_edit': 'Speichern', 'fedi.bm_label': 'Über meine Seite interagieren', 'fedi.bm_help': 'Zieh diesen Button in deine Lesezeichenleiste. Klick ihn dann auf einem beliebigen Fediverse-Beitrag (Mastodon, ein anderes Klonkt…), um über deine eigene Seite zu antworten, zu liken oder zu boosten.', 'tl.title': 'Zeitung', 'tl.lead': 'Folge Konten im Fediverse und sieh ihre Beiträge hier.', 'tl.follow_btn': 'Folgen', 'tl.following': 'Du folgst', 'tl.unfollow': 'Entfolgen', 'tl.autoboost': 'Hervorgehoben', 'tl.autoboost_follow': 'im Zirkel hervorheben', 'tl.autoboost_hint': 'Ihre neuen Beiträge erscheinen laufend in deinem Zirkel (lokal, kein Fediverse-Boost).', 'tl.pending': 'ausstehend', 'tl.unboost': 'Boost zurücknehmen', 'tl.feed': 'Beiträge', 'tl.tab_feed': 'Zeitung', 'tl.tab_following': 'Folge ich', 'tl.tab_replies': 'Antworten', 'tl.tab_followers': 'Follower', 'tl.followers': 'Follower', 'tl.followers_lead': 'Wer dir im Fediverse folgt, mit der letzten erfolgreichen Zustellung. Rot = nie zugestellt oder letzter Versuch fehlgeschlagen — nach einer Prüfung ein Kandidat zum Aufräumen.', 'tl.empty_followers': 'Noch keine Follower.', 'tl.last_delivery': 'Letzte Zustellung', 'tl.never_delivered': 'Nie zugestellt', 'tl.delivery_failed': 'letzter Versuch fehlgeschlagen', 'tl.remove_follower': 'Entfernen', 'tl.remove_confirm': 'Diesen Follower entfernen? Ein aktives Konto müsste dir erneut folgen.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'du folgst', 'tl.dir_follower': 'folgt dir', 'tl.dir_mutual': 'gegenseitig', 'tl.connect_empty': 'Noch keine Verbindungen. Folge oben jemandem, um zu starten.', 'tl.unreachable': 'Nicht erreichbar', 'tl.unreachable_lead': 'Diese Follower konnten wir nicht erreichen (nie zugestellt oder letzter Versuch fehlgeschlagen). Räume sie nach einer Prüfung auf.', 'msg.tab': 'Nachrichten', 'msg.title': 'Nachrichten', 'msg.filter_all': 'Alle', 'msg.filter_conv': 'Gespräche', 'msg.filter_act': 'Aktivität', 'msg.filter_sent': 'Gesendet', 'msg.you': 'Du', 'msg.sent_reply': 'hat über das Fediverse geantwortet', 'msg.and_more': 'und {n} andere', 'msg.liked_many': 'gefällt dein Beitrag', 'msg.boosted_many': 'teilten deinen Beitrag', 'msg.private': 'privat', 'msg.private_hint': 'Nur an dich gerichtet; erscheint nicht auf der öffentlichen Beitragsseite.', 'msg.new': 'Neu seit deinem letzten Besuch', 'msg.empty': 'Noch keine Nachrichten. Antworten, Erwähnungen und Aktivität erscheinen hier.', 'tl.empty_following': 'Du folgst noch niemandem.', 'tl.empty': 'Noch nichts — folge jemandem, um Beiträge hier zu sehen.', 'tl.view_original': 'Original ansehen →', 'tl.open_player': 'Player öffnen', 'tl.paste_ph': 'URL eines Fediverse-Beitrags einfügen', 'tl.paste_go': 'Öffnen', 'tl.boosted': 'hat geteilt', 'tl.read_more': 'Mehr lesen', 'tl.show_less': 'Weniger', 'poll.vote': 'Abstimmen', 'poll.votes': 'Stimmen', 'poll.closed': 'geschlossen', 'poll.open': 'offen', 'poll.aria': 'Umfrage', 'poll.voter_one': 'Teilnehmer', 'poll.voter_many': 'Teilnehmer', 'poll.closes': 'endet am', 'poll.multiple': 'Mehrfachauswahl', 'poll.fedi_only': 'Abstimmen geht über das Fediverse — folge dieser Seite und stimme in deiner eigenen App ab.', 'poll.voted_title': 'Stimme gesendet', 'poll.voted_done': 'Deine Stimme ist unterwegs zur Umfrage. Die Ergebnisse aktualisieren sich, sobald die Autorin oder der Autor das Update sendet.',
+    'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.follow_heading': 'Über das Fediverse folgen', 'fedi.profile_follow': 'Über das Fediverse folgen', 'profile.since': 'Auf Klonkt seit', 'profile.free': 'Kostenlos', 'fedi.follow_intro': 'Du folgst gleich:', 'fedi.follow_btn': 'Folgen', 'fedi.cancel': 'Abbrechen', 'fedi.followed_title': 'Folge-Anfrage gesendet ✅', 'fedi.followed_done': 'Deine Folge-Anfrage ist unterwegs. Sobald sie akzeptiert wird, erscheinen ihre Beiträge in deiner Timeline.', 'fedi.view_profile': 'Profil ansehen →', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse:', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.like_short': 'Liken', 'fedi.unlike_short': 'Like zurücknehmen', 'fedi.boost_short': 'Boosten', 'fedi.remote_ph': 'dein Server', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort wurde gesendet. Sie erscheint gleich beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Sieh sie dir dort an:', 'fedi.reply_where': 'Deine Antwort erscheint beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Über den Link oben siehst du sie dort.', 'fedi.remote_back': '← Zurück zu deiner Seite', 'fedi.like_btn': 'Diesen Beitrag liken', 'fedi.or_reply': 'oder antworten:', 'fedi.liked_title': 'Geliked', 'fedi.liked_done': 'Dein Like ist unterwegs ins Fediverse.', 'fedi.boost_btn': 'Diesen Beitrag boosten', 'fedi.boosted_title': 'Geboostet', 'fedi.boosted_done': 'Dein Boost ist unterwegs ins Fediverse.', 'fedi.remote_interact': 'Übers Fediverse interagieren', 'fedi.report_open': 'Diesen Beitrag melden', 'fedi.report_where': 'Die Meldung geht an die Instanz und deren Moderator(en).', 'fedi.report_ph': 'Was ist das Problem? (optional)', 'fedi.report_send': 'Melden', 'fedi.reported_title': 'Gemeldet', 'fedi.reported_done': 'Deine Meldung wurde an den Server der Person gesendet. Deren Moderatoren prüfen sie.', 'fedi.delete_confirm': 'Diese Antwort löschen?', 'fedi.mod_remove_confirm': 'Diese Antwort aus deinem Thread entfernen? Sie kommt nicht zurück, auch nicht über Thread-Auffüllung.', 'fedi.mod_report_confirm': 'Diese Antwort beim Server des Autors melden?', 'fedi.manage_title': 'Meine Fediverse-Antworten', 'fedi.manage_empty': 'Du hast noch keine Antworten gesendet.', 'fedi.goto_post': 'Zur Post', 'fedi.edit': 'Bearbeiten', 'fedi.save_edit': 'Speichern', 'fedi.bm_label': 'Über meine Seite interagieren', 'fedi.bm_help': 'Zieh diesen Button in deine Lesezeichenleiste. Klick ihn dann auf einem beliebigen Fediverse-Beitrag (Mastodon, ein anderes Klonkt…), um über deine eigene Seite zu antworten, zu liken oder zu boosten.', 'tl.title': 'Zeitung', 'tl.lead': 'Folge Konten im Fediverse und sieh ihre Beiträge hier.', 'tl.follow_btn': 'Folgen', 'tl.following': 'Du folgst', 'tl.unfollow': 'Entfolgen', 'tl.autoboost': 'Hervorgehoben', 'tl.autoboost_follow': 'im Zirkel hervorheben', 'tl.autoboost_hint': 'Ihre neuen Beiträge erscheinen laufend in deinem Zirkel (lokal, kein Fediverse-Boost).', 'tl.pending': 'ausstehend', 'tl.unboost': 'Boost zurücknehmen', 'tl.feed': 'Beiträge', 'tl.tab_feed': 'Zeitung', 'tl.tab_following': 'Folge ich', 'tl.tab_replies': 'Antworten', 'tl.tab_followers': 'Follower', 'tl.followers': 'Follower', 'tl.followers_lead': 'Wer dir im Fediverse folgt, mit der letzten erfolgreichen Zustellung. Rot = nie zugestellt oder letzter Versuch fehlgeschlagen — nach einer Prüfung ein Kandidat zum Aufräumen.', 'tl.empty_followers': 'Noch keine Follower.', 'tl.last_delivery': 'Letzte Zustellung', 'tl.never_delivered': 'Nie zugestellt', 'tl.delivery_failed': 'letzter Versuch fehlgeschlagen', 'tl.remove_follower': 'Entfernen', 'tl.remove_confirm': 'Diesen Follower entfernen? Ein aktives Konto müsste dir erneut folgen.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'du folgst', 'tl.dir_follower': 'folgt dir', 'tl.dir_mutual': 'gegenseitig', 'tl.connect_empty': 'Noch keine Verbindungen. Folge oben jemandem, um zu starten.', 'tl.unreachable': 'Nicht erreichbar', 'tl.unreachable_lead': 'Diese Follower konnten wir nicht erreichen (nie zugestellt oder letzter Versuch fehlgeschlagen). Räume sie nach einer Prüfung auf.', 'msg.tab': 'Nachrichten', 'msg.title': 'Nachrichten', 'msg.filter_all': 'Alle', 'msg.filter_conv': 'Gespräche', 'msg.filter_act': 'Aktivität', 'msg.filter_sent': 'Gesendet', 'msg.you': 'Du', 'msg.sent_reply': 'hat über das Fediverse geantwortet', 'msg.and_more': 'und {n} andere', 'msg.liked_many': 'gefällt dein Beitrag', 'msg.boosted_many': 'teilten deinen Beitrag', 'msg.private': 'privat', 'msg.private_hint': 'Nur an dich gerichtet; erscheint nicht auf der öffentlichen Beitragsseite.', 'msg.new': 'Neu seit deinem letzten Besuch', 'msg.empty': 'Noch keine Nachrichten. Antworten, Erwähnungen und Aktivität erscheinen hier.', 'oauth.title': 'App autorisieren', 'oauth.wants_access': 'möchte sich mit deinem Klonkt-Konto verbinden.', 'oauth.post_as': 'Posten als', 'oauth.scope_read': 'Deine Beiträge, Antworten und Meldungen lesen', 'oauth.scope_write': 'In deinem Namen posten, antworten, liken und folgen', 'oauth.allow': 'Erlauben', 'oauth.deny': 'Ablehnen', 'oauth.foot': 'Du kannst den Zugriff später widerrufen. Autorisiere nur Apps, denen du vertraust.', 'tl.empty_following': 'Du folgst noch niemandem.', 'tl.empty': 'Noch nichts — folge jemandem, um Beiträge hier zu sehen.', 'tl.view_original': 'Original ansehen →', 'tl.open_player': 'Player öffnen', 'tl.paste_ph': 'URL eines Fediverse-Beitrags einfügen', 'tl.paste_go': 'Öffnen', 'tl.boosted': 'hat geteilt', 'tl.read_more': 'Mehr lesen', 'tl.show_less': 'Weniger', 'poll.vote': 'Abstimmen', 'poll.votes': 'Stimmen', 'poll.closed': 'geschlossen', 'poll.open': 'offen', 'poll.aria': 'Umfrage', 'poll.voter_one': 'Teilnehmer', 'poll.voter_many': 'Teilnehmer', 'poll.closes': 'endet am', 'poll.multiple': 'Mehrfachauswahl', 'poll.fedi_only': 'Abstimmen geht über das Fediverse — folge dieser Seite und stimme in deiner eigenen App ab.', 'poll.voted_title': 'Stimme gesendet', 'poll.voted_done': 'Deine Stimme ist unterwegs zur Umfrage. Die Ergebnisse aktualisieren sich, sobald die Autorin oder der Autor das Update sendet.',
     'comments.to_start': 'um das Gespräch zu starten.',
     'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen',
Index: src/views/pages/oauth-consent.ejs
===================================================================
--- src/views/pages/oauth-consent.ejs	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
+++ src/views/pages/oauth-consent.ejs	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -0,0 +1,60 @@
+<div class="oa-wrap">
+  <h1 class="oa-title"><%= t('oauth.title') %></h1>
+  <p class="oa-lead">
+    <strong><%= client.client_name %></strong>
+    <%= t('oauth.wants_access') %>
+  </p>
+
+  <form method="post" action="/oauth/authorize" class="oa-form">
+    <input type="hidden" name="client_id" value="<%= params.client_id %>">
+    <input type="hidden" name="redirect_uri" value="<%= params.redirect_uri %>">
+    <input type="hidden" name="code_challenge" value="<%= params.code_challenge %>">
+    <input type="hidden" name="scope" value="<%= params.scope %>">
+    <input type="hidden" name="state" value="<%= params.state %>">
+
+    <label class="oa-label"><%= t('oauth.post_as') %></label>
+    <% if (sites.length === 1) { %>
+      <input type="hidden" name="site_slug" value="<%= sites[0].slug %>">
+      <div class="oa-single"><%= sites[0].title || sites[0].slug %> <span class="oa-slug">@<%= sites[0].slug %></span></div>
+    <% } else { %>
+      <select name="site_slug" class="oa-select">
+        <% sites.forEach(function(s){ %>
+          <option value="<%= s.slug %>"><%= s.title || s.slug %> (@<%= s.slug %>)</option>
+        <% }); %>
+      </select>
+    <% } %>
+
+    <ul class="oa-scopes">
+      <li><%= t('oauth.scope_read') %></li>
+      <li><%= t('oauth.scope_write') %></li>
+    </ul>
+
+    <div class="oa-actions">
+      <button type="submit" name="decision" value="deny" class="btn oa-deny"><%= t('oauth.deny') %></button>
+      <button type="submit" name="decision" value="allow" class="btn btn-primary oa-allow"><%= t('oauth.allow') %></button>
+    </div>
+  </form>
+  <p class="oa-foot"><%= t('oauth.foot') %></p>
+</div>
+
+<style>
+  .oa-wrap { max-width: 460px; margin: 2.5rem auto; padding: 1.75rem 1.5rem; border-radius: 16px;
+    background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent);
+    border: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); }
+  .oa-title { margin: 0 0 .6rem; font-size: 1.4rem; }
+  .oa-lead { color: var(--ink-soft, #999); line-height: 1.55; margin: 0 0 1.4rem; }
+  .oa-lead strong { color: var(--ink, inherit); }
+  .oa-label { display: block; font-weight: 700; font-size: .82rem; margin: 0 0 .4rem; color: var(--ink-soft, #888); text-transform: uppercase; letter-spacing: .03em; }
+  .oa-single { padding: .6rem .8rem; border-radius: 10px; background: color-mix(in srgb, var(--ink, #000) 5%, transparent); font-weight: 600; }
+  .oa-slug { color: var(--ink-soft, #999); font-weight: 400; }
+  .oa-select { width: 100%; padding: .6rem .8rem; border-radius: 10px; font: inherit;
+    border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); background: transparent; color: var(--ink, inherit); }
+  .oa-scopes { list-style: none; padding: .9rem 0 0; margin: .9rem 0 0; border-top: 1px solid color-mix(in srgb, var(--ink, #000) 8%, transparent);
+    display: flex; flex-direction: column; gap: .5rem; color: var(--ink-soft, #aaa); }
+  .oa-scopes li { position: relative; padding-left: 1.5rem; line-height: 1.4; }
+  .oa-scopes li::before { content: "✓"; position: absolute; left: 0; color: #2fa85a; font-weight: 700; }
+  .oa-actions { display: flex; gap: .7rem; margin-top: 1.5rem; }
+  .oa-deny { flex: 0 0 auto; }
+  .oa-allow { flex: 1; }
+  .oa-foot { color: var(--ink-soft, #888); font-size: .78rem; line-height: 1.5; margin: 1.1rem 0 0; }
+</style>
Index: test/activitypub-as2.test.js
===================================================================
--- test/activitypub-as2.test.js	(revision c867b7b644ca4de55b3a9138e1951f420cfc1d31)
+++ test/activitypub-as2.test.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -30,4 +30,6 @@
   'totalItems', 'orderedItems', 'items', 'first', 'last', 'partOf', 'next', 'prev',
   'preferredUsername', 'inbox', 'outbox', 'followers', 'following', 'endpoints', 'sharedInbox',
+  // ActivityPub §4.1 `endpoints` vocabulary (same category as sharedInbox), used for C2S.
+  'oauthAuthorizationEndpoint', 'oauthTokenEndpoint', 'uploadMedia',
   'publicKey', 'owner', 'publicKeyPem',
   'Note', 'Person', 'Create', 'Update', 'Delete', 'Tombstone', 'Announce', 'Like', 'Follow',
Index: test/oauth-c2s.test.js
===================================================================
--- test/oauth-c2s.test.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
+++ test/oauth-c2s.test.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -0,0 +1,89 @@
+// OAuth 2.0 C2S: PKCE authorization-code round-trip, replay protection, bearer
+// resolution, and redirect-URI validation. In-memory DB.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'crypto';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+const OAuth = await import('../src/services/OAuthService.js');
+dbMod.initializeDatabase();
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'robin', 'r@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+
+const b64url = (b) => b.toString('base64url');
+function pkce() {
+  const verifier = b64url(crypto.randomBytes(32));
+  const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
+  return { verifier, challenge };
+}
+
+test('validRedirectUri: https, loopback and reverse-DNS ok; plain http not', () => {
+  assert.equal(OAuth.validRedirectUri('https://app.example/cb'), true);
+  assert.equal(OAuth.validRedirectUri('http://127.0.0.1:1234/cb'), true);
+  assert.equal(OAuth.validRedirectUri('com.shaer.app:/callback'), true);
+  assert.equal(OAuth.validRedirectUri('http://evil.example/cb'), false);
+  assert.equal(OAuth.validRedirectUri('not a url'), false);
+});
+
+test('register rejects a bad redirect_uri', () => {
+  const bad = OAuth.registerClient({ client_name: 'X', redirect_uris: ['http://evil.example/cb'] });
+  assert.equal(bad.error, 'invalid_redirect_uri');
+});
+
+test('full PKCE flow yields a working bearer token', () => {
+  const reg = OAuth.registerClient({ client_name: 'Shaer', redirect_uris: ['com.shaer.app:/cb'] });
+  assert.ok(reg.client_id);
+  assert.equal(reg.token_endpoint_auth_method, 'none');
+
+  const { verifier, challenge } = pkce();
+  const { code } = OAuth.createCode({
+    clientId: reg.client_id, userId: 'u1', siteSlug: 'me',
+    redirectUri: 'com.shaer.app:/cb', codeChallenge: challenge, scope: 'c2s',
+  });
+  assert.ok(code);
+
+  const tok = OAuth.exchangeCode({ code, client_id: reg.client_id, redirect_uri: 'com.shaer.app:/cb', code_verifier: verifier });
+  assert.equal(tok.token_type, 'Bearer');
+  assert.ok(tok.access_token);
+
+  const who = OAuth.verifyBearer('Bearer ' + tok.access_token);
+  assert.equal(who.user.id, 'u1');
+  assert.equal(who.site.slug, 'me');
+
+  // Replay: the same code must not work twice.
+  const replay = OAuth.exchangeCode({ code, client_id: reg.client_id, redirect_uri: 'com.shaer.app:/cb', code_verifier: verifier });
+  assert.equal(replay.error, 'invalid_grant');
+});
+
+test('createCode requires a PKCE challenge', () => {
+  const r = OAuth.createCode({ clientId: 'c', userId: 'u1', siteSlug: 'me', redirectUri: 'com.shaer.app:/cb', codeChallenge: '' });
+  assert.equal(r.error, 'invalid_request');
+});
+
+test('wrong PKCE verifier is rejected', () => {
+  const reg = OAuth.registerClient({ client_name: 'Shaer2', redirect_uris: ['com.shaer.app:/cb'] });
+  const { challenge } = pkce();
+  const { code } = OAuth.createCode({ clientId: reg.client_id, userId: 'u1', siteSlug: 'me', redirectUri: 'com.shaer.app:/cb', codeChallenge: challenge });
+  const bad = OAuth.exchangeCode({ code, client_id: reg.client_id, redirect_uri: 'com.shaer.app:/cb', code_verifier: 'wrong-verifier' });
+  assert.equal(bad.error, 'invalid_grant');
+});
+
+test('verifyBearer returns null for garbage and revoked tokens', () => {
+  assert.equal(OAuth.verifyBearer('Bearer nope-nope-nope-nope-nope'), null);
+  assert.equal(OAuth.verifyBearer(''), null);
+  const reg = OAuth.registerClient({ client_name: 'Shaer3', redirect_uris: ['com.shaer.app:/cb'] });
+  const { verifier, challenge } = pkce();
+  const { code } = OAuth.createCode({ clientId: reg.client_id, userId: 'u1', siteSlug: 'me', redirectUri: 'com.shaer.app:/cb', codeChallenge: challenge });
+  const tok = OAuth.exchangeCode({ code, client_id: reg.client_id, redirect_uri: 'com.shaer.app:/cb', code_verifier: verifier });
+  OAuth.revokeToken(tok.access_token);
+  assert.equal(OAuth.verifyBearer('Bearer ' + tok.access_token), null);
+});
