Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ CommunityFix exposes an MCP (Model Context Protocol) server at `POST /api/mcp` s
- Access tokens are opaque, valid 1h, sha256-hashed in `oauth_tokens`. Refresh tokens rotate on every use; presenting an already-rotated refresh token triggers **reuse detection** — the whole token family for that client+user is revoked.
- **Rate limiting:** KV-backed fixed-window limiter (`server/utils/rate-limit.ts`, counters in the `auth` KV namespace with a per-window TTL so KV evicts them — no purge needed; **fails open** if KV is unavailable so a KV blip can't take down auth/MCP). Per-IP on `/oauth/register` (10/h), `/oauth/token` + `/oauth/revoke` (60/5min); per-user on `/api/mcp` (300/min global, 40/10min writes, 60/min embedding-search tools). A single JSON-RPC request is capped at 50 batched calls (`MAX_BATCH`) so a giant batch of unmetered read tools can't bypass the global window.
- **Cleanup:** `purgeExpired()` reaps expired codes and dead token rows (kept until the refresh window closes so reuse detection works). Run by the `oauth:purge` scheduled task (`nitro.scheduledTasks`, 3:15am UTC).
- Tools exposed: `search_issues_solutions` (vector search), `get_issue`, `get_tree`, `create_issue` / `create_solution`, `update_issue` / `update_solution` (split by node kind — solutions require `parentId`; updates error if `id` resolves to the other kind), `create_case_study` / `update_case_study` / `get_case_study` / `list_case_studies`, `suggest_more`, `whoami`, `get_user`, `search_tags`, `get_whitepaper` (serves `content/whitepaper.md` from the `content` Nuxt Content collection — `getWhitepaper()` in `server/utils/mcp-guides.ts` `queryCollection`s it server-side and reads the `rawbody` field, enabled by a `schema` on the `content` collection in `content.config.ts`), and `get_guide` (authoring guides — no `slug` lists them, a `slug` returns the markdown; sourced from the `guides` Nuxt Content collection `content/guide/*.md` via the same `server/utils/mcp-guides.ts`, reading the collection's `rawbody` field). Issues and solutions have two text fields: `summary` (required, plaintext, ≤280 chars — a real standalone synopsis, **not** a truncated prefix of the description) and `description` (optional, markdown).
- Tools exposed: `search_issues_solutions` (vector search), `get_issue`, `get_tree`, `create_issue` / `create_solution`, `update_issue` / `update_solution` (split by node kind — solutions require `parentId`; updates error if `id` resolves to the other kind), `create_case_study` / `update_case_study` / `get_case_study` / `list_case_studies`, `propose_edit` / `list_revisions` / `review_revision` (wiki-style editing — non-owner edits land as pending revision proposals, `applied: true/false` in the response), `suggest_more`, `whoami`, `get_user`, `search_tags`, `get_whitepaper` (serves `content/whitepaper.md` from the `content` Nuxt Content collection — `getWhitepaper()` in `server/utils/mcp-guides.ts` `queryCollection`s it server-side and reads the `rawbody` field, enabled by a `schema` on the `content` collection in `content.config.ts`), and `get_guide` (the single **authoring guide** `content/guide/authoring.md`, slug `authoring` — instruction-style, covers modeling/writing/evidence/MCP etiquette for humans and AI agents; no `slug` lists guides, a `slug` returns the markdown; sourced from the `guides` Nuxt Content collection via the same `server/utils/mcp-guides.ts`, reading the collection's `rawbody` field). Issues and solutions have two text fields: `summary` (required, plaintext, ≤280 chars — a real standalone synopsis, **not** a truncated prefix of the description) and `description` (optional, markdown).
- **Dedup is advisory, not enforced:** there is no server-side duplicate gate. Server instructions + tool descriptions tell clients to `search_issues_solutions` before creating and to update the closest match instead of adding a near-duplicate, but `create_*` will create whatever it is asked to.
- Tools advertise MCP `annotations` (readOnly/destructive/idempotent hints) + `title`, declare permissive `outputSchema`, and return `structuredContent` (object-shaped results only). Input is Zod-validated against the published schema (`server/utils/mcp-schemas.ts`) before any tool runs. Writes log a `[mcp.audit]` line with the acting `clientId`.
- Tools advertise MCP `annotations` (readOnly/destructive/idempotent hints) + `title`, declare permissive `outputSchema`, and return `structuredContent` (object-shaped results only). Input is Zod-validated against the published schema (`server/utils/mcp-schemas.ts`) before any tool runs. Write tools accept an optional `model` field (self-reported model id of the AI client); writes log a `[mcp.audit]` line with the acting `clientId` and `model`.
- Tool business logic lives in `server/utils/mcp-tools.ts`; the JSON-RPC plumbing in `server/api/mcp/index.post.ts`
- Tables: `oauth_clients`, `oauth_codes`, `oauth_tokens` (migration `0007_cool_peter_quill.sql`); `resource` audience columns on `oauth_codes`/`oauth_tokens` (migration `0016_resource_audience_binding.sql`)

Expand Down
30 changes: 30 additions & 0 deletions app/assets/css/leaflet.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/* Leaflet control overrides shared by every map component (ExploreMap,
LocationMap, LocationPicker). Leaflet renders its controls with global
class names, so these live here instead of per-component scoped copies. */
.leaflet-control-zoom {
border: none !important;
border-radius: .5rem !important;
overflow: hidden !important;
box-shadow: 0 2px 8px #0000001f !important;
}

.leaflet-control-zoom a {
background: #fff !important;
color: #6b7280 !important;
border: none !important;
border-bottom: 1px solid #f3f4f6 !important;
width: 32px !important;
height: 32px !important;
line-height: 32px !important;
font-size: 16px !important;
transition: background .15s !important;
}

.leaflet-control-zoom a:last-child {
border-bottom: none !important;
}

.leaflet-control-zoom a:hover {
background: #f9fafb !important;
color: #374151 !important;
}
11 changes: 3 additions & 8 deletions app/components/BanNotice.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ const emit = defineEmits<{
}>()

const toast = useToast()
const { track } = useUmami()
const submitting = ref(false)

async function submitAppeal() {
submitting.value = true
try {
await $fetch('/api/user/ban-appeal', { method: 'POST' })
umami.track('Ban appeal submitted')
track('Ban appeal submitted')
toast.add({
title: 'Appeal submitted',
description: 'Your appeal is under review.',
Expand Down Expand Up @@ -60,13 +61,7 @@ async function submitAppeal() {
</p>
</div>
<div v-else-if="!banStatus.appealStatus">
<UButton
color="primary"
data-umami-event="Appeal ban"
size="sm"
:loading="submitting"
@click="submitAppeal"
>
<UButton color="primary" size="sm" :loading="submitting" @click="submitAppeal">
Appeal this ban
</UButton>
</div>
Expand Down
165 changes: 165 additions & 0 deletions app/components/ExploreMap.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<script setup lang="ts">
// World map of every geolocated node and case study (see /api/map). Same
// Leaflet setup and Voyager tiles as LocationMap, but with many points and
// link-through popups instead of a single pin + area.
export interface MapPoint {
kind: 'issue' | 'solution' | 'case-study'
id: number
title: string
locationName: string | null
outcome: string | null
lat: number
lng: number
}

const props = defineProps<{ points: MapPoint[] }>()
const emit = defineEmits<{ open: [kind: MapPoint['kind']] }>()

const mapEl = ref<HTMLElement>()
let map: any = null
let L: any = null

// Semantic palette, tuned for the light Voyager tiles: problems read as red,
// proposals carry the brand blue, implemented work reads as green.
const KIND_COLORS: Record<MapPoint['kind'], string> = {
issue: '#e11d48',
solution: '#2563eb',
'case-study': '#059669',
}

const KIND_LABELS: Record<MapPoint['kind'], string> = {
issue: 'Issue',
solution: 'Solution',
'case-study': 'Case study',
}

function escapeHtml(s: string): string {
return s.replace(
/[<>&"']/g,
(c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' })[c]!,
)
}

function popupHtml(p: MapPoint): string {
const href = p.kind === 'case-study' ? `/case-study/${p.id}` : `/issue/${p.id}`
const meta = [KIND_LABELS[p.kind], p.outcome && p.kind === 'case-study' ? p.outcome : null]
.filter(Boolean)
.join(' · ')
const place =
p.kind !== 'case-study' && p.locationName
? `<div class="explore-popup-place">${escapeHtml(p.locationName)}</div>`
: ''
return `<div class="explore-popup">
<div class="explore-popup-kind" style="color:${KIND_COLORS[p.kind]}">${escapeHtml(meta)}</div>
<a href="${href}" class="explore-popup-title">${escapeHtml(p.title)}</a>
${place}
</div>`
}

async function initMap() {
if (!mapEl.value || map) return

await import('leaflet/dist/leaflet.css')
const leaflet = await import('leaflet')
L = leaflet.default || leaflet

map = L.map(mapEl.value, {
center: [20, 0],
zoom: 2,
zoomControl: false,
attributionControl: false,
worldCopyJump: true,
})

L.control.zoom({ position: 'bottomright' }).addTo(map)
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
subdomains: 'abcd',
}).addTo(map)

renderPoints()
}

function renderPoints() {
if (!map || !L || props.points.length === 0) return

const bounds = L.latLngBounds([])
for (const p of props.points) {
if (!Number.isFinite(p.lat) || !Number.isFinite(p.lng)) continue
const marker = L.circleMarker([p.lat, p.lng], {
radius: 7,
color: '#ffffff',
weight: 1.5,
fillColor: KIND_COLORS[p.kind],
fillOpacity: 0.85,
}).addTo(map)
marker.bindPopup(popupHtml(p), { closeButton: false })
marker.on('popupopen', () => emit('open', p.kind))
bounds.extend([p.lat, p.lng])
}

if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [32, 32] as [number, number], maxZoom: 6 })
}
}

onMounted(initMap)
onBeforeUnmount(() => {
map?.remove()
map = null
L = null
})
</script>

<template>
<div ref="mapEl" class="explore-map" />
</template>

<style scoped>
.explore-map {
width: 100%;
height: 100%;
}
/* Zoom-control styling is shared in assets/css/leaflet.css */
:deep(.leaflet-popup-content-wrapper) {
border-radius: .75rem;
box-shadow: 0 4px 16px #00000024;
}

:deep(.leaflet-popup-content) {
margin: .75rem .9rem;
}

:deep(.explore-popup) {
display: flex;
flex-direction: column;
gap: .2rem;
max-width: 220px;
}

:deep(.explore-popup-kind) {
font-family: var(--font-mono);
font-size: .65rem;
text-transform: uppercase;
letter-spacing: .06em;
}

:deep(.explore-popup-title) {
font-size: .85rem;
font-weight: 500;
color: #111827;
line-height: 1.3;
text-decoration-color: var(--color-primary-500, #2563eb);
}

:deep(.explore-popup-title:hover) {
text-decoration: underline;
text-decoration-thickness: 2px;
text-decoration-color: var(--color-primary-500, #2563eb);
}

:deep(.explore-popup-place) {
font-size: .7rem;
color: #6b7280;
}
</style>
28 changes: 1 addition & 27 deletions app/components/LocationMap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -175,35 +175,9 @@ defineExpose({
</template>

<style scoped>
/* Zoom-control styling is shared in assets/css/leaflet.css */
.location-map {
width: 100%;
height: 100%;
}

:deep(.leaflet-control-zoom) {
border: none !important;
border-radius: .5rem !important;
overflow: hidden !important;
box-shadow: 0 2px 8px #0000001f !important;
}

:deep(.leaflet-control-zoom a) {
background: #fff !important;
color: #6b7280 !important;
border: none !important;
border-bottom: 1px solid #f3f4f6 !important;
width: 32px !important;
height: 32px !important;
line-height: 32px !important;
font-size: 16px !important;
}

:deep(.leaflet-control-zoom a:last-child) {
border-bottom: none !important;
}

:deep(.leaflet-control-zoom a:hover) {
background: #f9fafb !important;
color: #374151 !important;
}
</style>
29 changes: 1 addition & 28 deletions app/components/LocationPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -377,32 +377,5 @@ onBeforeUnmount(() => {
transform: rotate(-45deg);
box-shadow: 0 3px 10px #00000040;
}
/* Leaflet zoom control overrides */
:deep(.leaflet-control-zoom) {
border: none !important;
border-radius: .5rem !important;
overflow: hidden !important;
box-shadow: 0 2px 8px #0000001f !important;
}

:deep(.leaflet-control-zoom a) {
background: #fff !important;
color: #6b7280 !important;
border: none !important;
border-bottom: 1px solid #f3f4f6 !important;
width: 32px !important;
height: 32px !important;
line-height: 32px !important;
font-size: 16px !important;
transition: background .15s !important;
}

:deep(.leaflet-control-zoom a:last-child) {
border-bottom: none !important;
}

:deep(.leaflet-control-zoom a:hover) {
background: #f9fafb !important;
color: #374151 !important;
}
/* Zoom-control styling is shared in assets/css/leaflet.css */
</style>
15 changes: 2 additions & 13 deletions app/components/admin/AuthorBadge.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { formatRelative } from '~/utils/relative-time'

// Compact author summary for queue cards — trust score + history at a glance,
// so the moderator doesn't have to click through to the user profile.
defineProps<{
Expand All @@ -16,19 +18,6 @@ defineProps<{
timestamp?: string | null
}>()

function formatRelative(date: string | null | undefined) {
if (!date) return ''
const d = new Date(date)
const diffMin = Math.floor((Date.now() - d.getTime()) / 60000)
if (diffMin < 1) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffH = Math.floor(diffMin / 60)
if (diffH < 24) return `${diffH}h ago`
const diffD = Math.floor(diffH / 24)
if (diffD < 30) return `${diffD}d ago`
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}

function isNewAccount(createdAt?: string) {
if (!createdAt) return false
return Date.now() - new Date(createdAt).getTime() < 7 * 24 * 60 * 60 * 1000
Expand Down
Loading
Loading