diff --git a/CLAUDE.md b/CLAUDE.md index d82acc5..0086775 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) diff --git a/app/assets/css/leaflet.css b/app/assets/css/leaflet.css new file mode 100644 index 0000000..a05f26c --- /dev/null +++ b/app/assets/css/leaflet.css @@ -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; +} diff --git a/app/components/BanNotice.vue b/app/components/BanNotice.vue index 50066cf..e98f1f2 100644 --- a/app/components/BanNotice.vue +++ b/app/components/BanNotice.vue @@ -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.', @@ -60,13 +61,7 @@ async function submitAppeal() {

- + Appeal this ban
diff --git a/app/components/ExploreMap.vue b/app/components/ExploreMap.vue new file mode 100644 index 0000000..bcad313 --- /dev/null +++ b/app/components/ExploreMap.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/app/components/LocationMap.vue b/app/components/LocationMap.vue index 985ac52..a651dea 100644 --- a/app/components/LocationMap.vue +++ b/app/components/LocationMap.vue @@ -175,35 +175,9 @@ defineExpose({ diff --git a/app/components/LocationPicker.vue b/app/components/LocationPicker.vue index 749cabe..229a9ff 100644 --- a/app/components/LocationPicker.vue +++ b/app/components/LocationPicker.vue @@ -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 */ diff --git a/app/components/admin/AuthorBadge.vue b/app/components/admin/AuthorBadge.vue index 3333fe5..331ba0f 100644 --- a/app/components/admin/AuthorBadge.vue +++ b/app/components/admin/AuthorBadge.vue @@ -1,4 +1,6 @@ + + diff --git a/app/components/issue/TreeNode.vue b/app/components/issue/TreeNode.vue index 8c7ba96..3fe4adf 100644 --- a/app/components/issue/TreeNode.vue +++ b/app/components/issue/TreeNode.vue @@ -44,21 +44,6 @@ const nodeIconClass = computed(() => { if (isSolution.value) return 'text-primary-600' return 'text-gray-400' }) - -const outcomeVariant: Record = { - success: 'success', - partial: 'default', - failed: 'error', - inconclusive: 'default', - ongoing: 'warning', -} -const outcomeLabel: Record = { - success: 'Success', - partial: 'Partial', - failed: 'Failed', - inconclusive: 'Inconclusive', - ongoing: 'Ongoing', -}