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() {
+ {{ label }} +
+ + {{ items.length }} + ++ Skills wanted +
++ Know what expertise this needs? List it so the right people can find it. +
+ ++ You have reached the limit of 10 interests. Remove one to add another. +
++ + targets topics like: {{ previewNames }} + + + nothing close in the catalog yet, you will be first to know + +
++ checking the catalog... +
++ Semantic search is temporarily unavailable — showing the most recent case studies instead. +
++ Showing {{ studies.length }} case stud{{ studies.length === 1 ? 'y' : 'ies' }} +
++
#{{ study.id.toString().padStart(5, '0') }}
-- Implementer -
-- {{ study.implementer }} -
-- Timeline -
-- {{ dateRange }} -
-- Location -
-- Description -
-- Metrics -
- - {{ study.metrics.length }} - -- Funding -
-- Lessons learned -
-- Sources -
- - {{ study.sources.length }} - -- Links -
- - {{ study.links.length }} - -- Documented - {{ - new Date(study.createdAt).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - }) - }} -
-+ Implementer +
++ {{ study.implementer }} +
++ Timeline +
++ {{ dateRange }} +
++ Location +
++ Description +
++ Metrics +
+ + {{ study.metrics.length }} + ++ Funding +
++ Lessons learned +
++ Documented + {{ + new Date(study.createdAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + }} +
++ {{ LABEL_META[activeLabel]?.hint }} +
++ {{ issues.length }} issue{{ issues.length === 1 ? '' : 's' }} +
++ {{ currentStep.description }} +
++ {{ doneSummary }} +
++ Topics you care about, in your own words. They can be anything, even topics the + catalog does not cover yet. We use them to surface issues and wins that matter to you. +
++ Choose whether we may email you and what to include. Nothing is sent without + your opt-in. +
++ {{ solutions.length }} solution{{ solutions.length === 1 ? '' : 's' }} +
++ {{ visibleTags.length }} topic{{ visibleTags.length === 1 ? '' : 's' }} +
+The link may have been truncated by your email client. You can also turn the newsletter off from your account settings.
`, + 400, + ) + setResponseStatus(event, status) + return html + } + + await useDB() + .update(newsletterPrefs) + .set({ enabled: false, updatedAt: sql`now()` }) + .where(eq(newsletterPrefs.userId, userId)) + + return page( + "You're unsubscribed", + `You won't receive the newsletter anymore. Changed your mind? Re-enable it anytime in your account settings.
`, + ).html +}) diff --git a/server/api/revisions/[id].get.ts b/server/api/revisions/[id].get.ts index c41ab9c..5f16e5a 100644 --- a/server/api/revisions/[id].get.ts +++ b/server/api/revisions/[id].get.ts @@ -9,11 +9,7 @@ import { getIsAdmin } from '../../utils/is-admin' import { resolveDecideRole, isNodeOwner } from '../../utils/node-members' export default defineEventHandler(async (event) => { - const id = getRouterParam(event, 'id') - if (!id || isNaN(parseInt(id, 10))) { - throw createError({ statusCode: 400, statusMessage: 'Invalid revision ID' }) - } - const revisionId = parseInt(id, 10) + const revisionId = requireIdParam(event, { label: 'revision' }) const db = useDB() const row = await db.query.revisions.findFirst({ diff --git a/server/api/revisions/[id]/approve.post.ts b/server/api/revisions/[id]/approve.post.ts index 3101108..4bb3207 100644 --- a/server/api/revisions/[id]/approve.post.ts +++ b/server/api/revisions/[id]/approve.post.ts @@ -5,17 +5,14 @@ import { decideRevision, serializeRevision } from '../../../utils/revision-write export default defineEventHandler(async (event) => { const session = await requireUserSession(event) - const id = getRouterParam(event, 'id') - if (!id || isNaN(parseInt(id, 10))) { - throw createError({ statusCode: 400, statusMessage: 'Invalid revision ID' }) - } + const revisionId = requireIdParam(event, { label: 'revision' }) const body = await readBody<{ reason?: string | null }>(event).catch( () => ({}) as { reason?: string | null }, ) const revision = await decideRevision( session.user.id, - parseInt(id, 10), + revisionId, 'approve', body?.reason ?? null, event, diff --git a/server/api/revisions/[id]/reject.post.ts b/server/api/revisions/[id]/reject.post.ts index 50e1d24..8c3ad27 100644 --- a/server/api/revisions/[id]/reject.post.ts +++ b/server/api/revisions/[id]/reject.post.ts @@ -5,17 +5,14 @@ import { decideRevision, serializeRevision } from '../../../utils/revision-write export default defineEventHandler(async (event) => { const session = await requireUserSession(event) - const id = getRouterParam(event, 'id') - if (!id || isNaN(parseInt(id, 10))) { - throw createError({ statusCode: 400, statusMessage: 'Invalid revision ID' }) - } + const revisionId = requireIdParam(event, { label: 'revision' }) const body = await readBody<{ reason?: string | null }>(event).catch( () => ({}) as { reason?: string | null }, ) const revision = await decideRevision( session.user.id, - parseInt(id, 10), + revisionId, 'reject', body?.reason ?? null, event, diff --git a/server/api/revisions/[id]/withdraw.post.ts b/server/api/revisions/[id]/withdraw.post.ts index 633ef7c..a7fa9c0 100644 --- a/server/api/revisions/[id]/withdraw.post.ts +++ b/server/api/revisions/[id]/withdraw.post.ts @@ -5,11 +5,8 @@ import { withdrawRevision, serializeRevision } from '../../../utils/revision-wri export default defineEventHandler(async (event) => { const session = await requireUserSession(event) - const id = getRouterParam(event, 'id') - if (!id || isNaN(parseInt(id, 10))) { - throw createError({ statusCode: 400, statusMessage: 'Invalid revision ID' }) - } + const revisionId = requireIdParam(event, { label: 'revision' }) - const revision = await withdrawRevision(session.user.id, parseInt(id, 10)) + const revision = await withdrawRevision(session.user.id, revisionId) return { success: true, revision: revision ? serializeRevision(revision) : null } }) diff --git a/server/api/sdgs.get.ts b/server/api/sdgs.get.ts index f5f6ffe..266f238 100644 --- a/server/api/sdgs.get.ts +++ b/server/api/sdgs.get.ts @@ -1,4 +1,26 @@ +import { and, eq, ne, sql } from 'drizzle-orm' +import { issues, issueSdgs, sdgs } from '../database/schema' + export default defineEventHandler(async () => { const db = useDB() - return db.query.sdgs.findMany() + // `uses` counts approved, non-spam nodes mapped to each goal so browse UIs + // can hide or rank empty goals. + return db + .select({ + id: sdgs.id, + name: sdgs.name, + iconUrl: sdgs.iconUrl, + link: sdgs.link, + createdAt: sdgs.createdAt, + updatedAt: sdgs.updatedAt, + uses: sqlHi${opts.ownerName ? ` ${opts.ownerName}` : ''},
-${who} suggested an edit to ${opts.nodeLabel}.
- ${opts.note ? `"${opts.note}"
` : ''} +Hi${opts.ownerName ? ` ${escapeHtml(opts.ownerName)}` : ''},
+${who} suggested an edit to ${label}.
+ ${opts.note ? `"${escapeHtml(opts.note)}"
` : ''}Review the suggestion to approve or reject it.
`, }) @@ -102,9 +106,9 @@ export async function sendDecisionNotification( to: opts.proposerEmail, subject, html: ` -Hi${opts.proposerName ? ` ${opts.proposerName}` : ''},
-Your suggested edit to ${opts.nodeLabel} was ${verb}.
- ${opts.reason ? `"${opts.reason}"
` : ''} +Hi${opts.proposerName ? ` ${escapeHtml(opts.proposerName)}` : ''},
+Your suggested edit to ${escapeHtml(opts.nodeLabel)} was ${verb}.
+ ${opts.reason ? `"${escapeHtml(opts.reason)}"
` : ''} `, }) diff --git a/server/utils/email.ts b/server/utils/email.ts index 04d4a94..7168682 100644 --- a/server/utils/email.ts +++ b/server/utils/email.ts @@ -1,18 +1,5 @@ import type { H3Event } from 'h3' - -// Cloudflare Email Sending binding shape — generated by `wrangler types` -// includes the real SendEmail types, but Nitro doesn't pull that into our -// server-side TS context, so we keep a minimal local type. -interface SendEmailBinding { - send(input: { - to: string | string[] - from: { email: string; name?: string } - subject: string - html: string - text: string - replyTo?: string - }): Promise<{ messageId: string }> -} +import { resolveEmailBinding } from './email-binding' // Accepts "user@example.com" or "Display Name| + ${number} ${escapeHtml(section.title)} + |
| ${escapeHtml(section.footerLink.label)} → |
+
|