A tiny shared KV API for OpenStreetMap-authenticated single-page apps, plus a demo SPA.
Each SPA (“project”) stores and reads JSON records with an id and tags. Writes are attributed to a verified OSM user. The API runs as a Cloudflare Worker on D1. You administer projects from Cursor through an MCP endpoint on the same Worker.
Public repo: FixMyBerlin/key-value-db (AGPL-3.0).
| What | URL |
|---|---|
| API (Worker) | https://key-value-store.fixmycity.workers.dev |
| Health | https://key-value-store.fixmycity.workers.dev/v1/health |
| Admin MCP | https://key-value-store.fixmycity.workers.dev/mcp |
| Demo on GitHub Pages | https://fixmyberlin.github.io/key-value-db/ |
Local API (bun run dev-api) |
http://localhost:8787 |
Local demo (bun run dev-demo) |
http://127.0.0.1:33477/key-value-db/ |
KV_HOST is key-value-store.fixmycity.workers.dev (Cloudflare account subdomain fixmycity, not fixmyberlin). GitHub environment cloudflare has that as the KV_HOST variable.
What it can do (details in docs/API.md):
- Per-app projects with a public key, origin allowlist, and admin-only settings.
- JSON entries with tags, versions, and OSM-verified authors. Public or login-only reads.
- Tag queries (
match=all|any),updated_since, cursor paging, andIf-Match. - Optional owner-only writes (
write_scope = owner) and a hard time limit per entry (entry_ttl_s). - Batch writes, and "delete my data" per project (
DELETE /me/entries). - A cleanup job every 15 min for expired entries, old token cache rows, and unused user records.
Privacy: PRIVACY.md. For agents and contributors: AGENTS.md. The original design is in PLAN.md; docs/API.md is the current reference.
| Path | Package | Role |
|---|---|---|
apps/api |
@kv/api |
Cloudflare Worker (Hono + D1). REST under /v1, admin under /admin, MCP under /mcp. |
packages/kv-client |
@osm-editor-kit/key-value-db-client |
Typed fetch client for SPAs. |
apps/demo |
@kv/demo |
React SPA on GitHub Pages: OSM login and end-to-end API exercise. |
- From Cursor, call MCP
create_project(orPOST https://key-value-store.fixmycity.workers.dev/admin/projects) with a slug, name, and origins. Origins arescheme://host[:port]with no path. Include the production origin andhttp://127.0.0.1:<vite-port>if you develop locally. - Put the returned
api_keyandhttps://key-value-store.fixmycity.workers.devinto the SPA’s public env (VITE_KV_*or equivalent). The key is public by design (X-Api-Key); origin allowlist plus OSM tokens are what restrict use. - Wire
packages/kv-clientas in the snippet below. OSM login stays in the SPA (osm-apiv4 redirect PKCE); the Worker only verifies the Bearer token.
Existing live projects include demo (this repo’s Pages app) and others created via MCP. Keep demo on write_access = any_osm_user.
The demo origin must be http://127.0.0.1:33477 (OSM only allows http redirect URIs on 127.0.0.1, and the Vite port is fixed). Do not use localhost.
Local D1 is a file under apps/api/.wrangler/ (gitignored). It is not the production database. apps/demo/.env.development holds a public project key for this machine’s local demo row. If you clone the repo elsewhere and get 401 invalid_project_key, recreate the project (curl below) and paste the new api_key.
# terminal 1
bun install
cp apps/api/.dev.vars.example apps/api/.dev.vars # if missing; real JSON map, see below
bun run db-migrate-local
bun run dev-api
# terminal 2 — create project (once per local D1)
curl -sS -X POST http://localhost:8787/admin/projects \
-H "Authorization: Bearer <admin key from .dev.vars>" \
-H "Content-Type: application/json" \
-d '{"slug":"demo","name":"Demo","origins":["http://127.0.0.1:33477","https://fixmyberlin.github.io"]}'
# paste the returned api_key into apps/demo/.env.development as VITE_KV_API_KEY
# terminal 3
bun run dev-demo
# open http://127.0.0.1:33477/key-value-db/ (this is local Vite, not GitHub Pages)
bun run check-ciADMIN_KEYS_JSON in .dev.vars and in the Worker secret is a JSON map such as {"tordans":"<64 hex>"}. The Bearer value for curl and Cursor MCP is the hex string (the map value), not the whole JSON. Do not paste the hex alone into the Cloudflare dashboard secret field.
The demo shows setup banners instead of crashing while VITE_KV_API_KEY is still REPLACE_ME.
Fire the token-cache cleanup cron against local wrangler with:
bunx wrangler dev --test-scheduled
# then POST http://localhost:8787/__scheduledapps/demo/.env.development and .env.production are public config, checked into git. Vite bakes every VITE_* value into the JavaScript bundle. Anyone can read them in DevTools. That is intentional.
| File | Used when | VITE_KV_BASE_URL |
VITE_KV_API_KEY |
|---|---|---|---|
.env.development |
bun run dev-demo |
local wrangler, http://localhost:8787 |
key from creating demo on local D1 |
.env.production |
vite build / GitHub Pages |
https://key-value-store.fixmycity.workers.dev |
key from creating demo on remote D1 |
GitHub Actions does not need these as secrets or variables. deploy-demo.yml runs vite build, which reads .env.production. The only extra CI env is VITE_BUILD_SHA.
Do not put ADMIN_KEYS_JSON or .dev.vars in git or in the Pages workflow. That admin key is the real secret (Worker + local wrangler + Cursor MCP only).
It is the project’s public identifier (kv_ + hex), sent as X-Api-Key. It is not a login and not an admin key. It only selects the project. Browsers from other sites cannot use it because the API also requires an Origin on the project allowlist. Writes still need a logged-in OSM user.
Local D1 and production D1 are different databases, so the two env files usually have different keys. Paste the key from whichever API you just created the project on.
They are two places the demo runs, not one URL:
| Where you open the demo | Browser origin (CORS / project allowlist, no path) | OSM OAuth redirect (exact match, includes path) |
|---|---|---|
| Local Vite | http://127.0.0.1:33477 |
http://127.0.0.1:33477/key-value-db/osm-oauth-land.html |
| GitHub Pages | https://fixmyberlin.github.io |
https://fixmyberlin.github.io/key-value-db/osm-oauth-land.html |
OSM only allows http redirects on 127.0.0.1, not localhost, so Vite is pinned to that host and port. After login, OSM sends the browser back to the land page of the site you started from. Both URIs are registered on the same OSM OAuth app. VITE_OSM_OAUTH_CLIENT_ID is the same in both env files (public PKCE client id).
import { createKvClient } from '@osm-editor-kit/key-value-db-client'
import { getAuthToken } from 'osm-api'
const kv = createKvClient<MyData>({
baseUrl: import.meta.env.VITE_KV_BASE_URL,
project: import.meta.env.VITE_KV_PROJECT,
apiKey: import.meta.env.VITE_KV_API_KEY,
getOsmToken: () => getAuthToken() ?? null,
})OSM login uses osm-api v4 in redirect PKCE mode (not popup: OSM sends COOP: same-origin). Copy apps/demo/public/osm-oauth-land.html, derive the redirect URL from import.meta.env.BASE_URL + osm-oauth-land.html, await authReady before isLoggedIn().
User-level ~/.cursor/mcp.json (admin key never in git). Live:
{
"mcpServers": {
"kv-admin": {
"url": "https://key-value-store.fixmycity.workers.dev/mcp",
"headers": { "Authorization": "Bearer <admin key>" }
}
}
}Local wrangler: http://localhost:8787/mcp with the same Bearer key from .dev.vars.
ADMIN_KEYS_JSON: Cloudflare Worker secret (wrangler secret put ADMIN_KEYS_JSON) and localapps/api/.dev.vars(gitignored). Copy fromapps/api/.dev.vars.example. Never commit the real value.- GitHub Actions does not get the admin key. The
cloudflareenvironment holdsCLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_ID, and variableKV_HOST=key-value-store.fixmycity.workers.dev.
Owner steps already done for this repo. Repeat only if the account, D1 database, or OSM app is gone.
- Cloudflare account and
workers.devsubdomain (KV_HOST=key-value-store.<account>.workers.dev). wrangler d1 create key-value-store --jurisdiction eu(jurisdiction is fixed at create time). Copydatabase_idintoapps/api/wrangler.jsonc.wrangler secret put ADMIN_KEYS_JSON(JSON map, e.g.{"tordans":"<64 hex>"}). Same value in gitignoredapps/api/.dev.vars.- API token with Workers Scripts Edit and D1 Edit; GitHub environment
cloudflaresecretsCLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_ID, variableKV_HOST. - OSM OAuth 2 application (non-confidential): redirect URIs
http://127.0.0.1:33477/key-value-db/osm-oauth-land.htmlandhttps://fixmyberlin.github.io/key-value-db/osm-oauth-land.html. Scoperead_prefs. Client id in both demo env files asVITE_OSM_OAUTH_CLIENT_ID. - Public GitHub repo, Pages source GitHub Actions, environments
cloudflareandgithub-pages. deploy-apithen createdemoon remote D1 (MCP or/admin/projects) with originshttp://127.0.0.1:33477andhttps://fixmyberlin.github.io; put thatapi_keyin.env.production.
AGPL-3.0. Every package.json uses "license": "AGPL-3.0".