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;
