feat(mcp): introduce OAuth 2.1 auth and enforce addon gating

OAuth 2.1 authentication for MCP:
- Add OAuth 2.1 authorization server with PKCE support (routes/oauth.ts)
- Add OAuth service for client CRUD, auth-code flow, and token management (services/oauthService.ts)
- Add typed scope definitions and enforcement helpers (mcp/scopes.ts)
- Add OAuth consent UI page (OAuthAuthorizePage.tsx)
- Add client-side scope labels and descriptions (api/oauthScopes.ts)
- Integrate OAuth token auth into MCP handler alongside existing static tokens
- All OAuth endpoints gated on `mcp` addon

Addon gating across MCP tools, resources, and prompts:
- Add typed ADDON_IDS constant (server/src/addons.ts) replacing all string literals
- Gate budget tools and resources (trip-budget, per-person, settlement) on `budget` addon
- Gate packing tools and resources (trip-packing, trip-packing-bags, trip-todos) on `packing` addon
- Gate todos tools on `packing` addon (mirrors web UI Lists tab behavior)
- Expand atlas gate to cover full tool body (bucket-list + country tools no longer leak)
- Expand collab gate to cover full tool body (collab notes no longer leak)
- Gate packing-list and budget-overview MCP prompts on their respective addons
- Gate get_trip_summary sections per addon; blank packing/budget/collab_notes/todos when disabled
- Remove trip-files resource and files field from get_trip_summary
- Replace all isAddonEnabled('literal') calls with ADDON_IDS constants

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jubnl
2026-04-09 22:25:58 +02:00
parent 5c0d819fc1
commit 830f6c0706
32 changed files with 2589 additions and 669 deletions
+430 -430
View File
@@ -1877,6 +1877,397 @@
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
@@ -5373,6 +5764,45 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -11133,436 +11563,6 @@
}
}
},
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/vitest": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
+3
View File
@@ -12,6 +12,7 @@ import VacayPage from './pages/VacayPage'
import AtlasPage from './pages/AtlasPage'
import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import { ToastContainer } from './components/shared/Toast'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
@@ -163,6 +164,8 @@ export default function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/register" element={<LoginPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
<Route
path="/dashboard"
element={
+37
View File
@@ -72,6 +72,43 @@ export const authApi = {
},
}
export const oauthApi = {
/** Validate OAuth authorize params — called by consent page on load */
validate: (params: {
response_type: string
client_id: string
redirect_uri: string
scope: string
state?: string
code_challenge: string
code_challenge_method: string
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
/** Submit user consent (approve or deny) */
authorize: (body: {
client_id: string
redirect_uri: string
scope: string
state?: string
code_challenge: string
code_challenge_method: string
approved: boolean
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
clients: {
list: () => apiClient.get('/oauth/clients').then(r => r.data),
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
apiClient.post('/oauth/clients', data).then(r => r.data),
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
},
sessions: {
list: () => apiClient.get('/oauth/sessions').then(r => r.data),
revoke: (id: number) => apiClient.delete(`/oauth/sessions/${id}`).then(r => r.data),
},
}
export const tripsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
+43
View File
@@ -0,0 +1,43 @@
// Human-readable scope definitions for the OAuth consent page.
// Must stay in sync with server/src/mcp/scopes.ts
export interface ScopeInfo {
label: string
description: string
group: string
}
export const SCOPE_GROUPS: Record<string, ScopeInfo> = {
'trips:read': { label: 'View trips & itineraries', description: 'Read trips, days, day notes, members, and share links', group: 'Trips' },
'trips:write': { label: 'Edit trips & itineraries', description: 'Create and update trips, days, notes, and manage members', group: 'Trips' },
'trips:delete': { label: 'Delete trips', description: 'Permanently delete entire trips — this action is irreversible', group: 'Trips' },
'places:read': { label: 'View places & map data', description: 'Read places, day assignments, tags, categories, and visited countries', group: 'Places' },
'places:write': { label: 'Manage places', description: 'Create, update, and delete places, assignments, tags, and atlas entries', group: 'Places' },
'packing:read': { label: 'View packing lists', description: 'Read packing items, bags, and category assignees', group: 'Packing' },
'packing:write': { label: 'Manage packing lists', description: 'Add, update, delete, toggle, and reorder packing items and bags', group: 'Packing' },
'budget:read': { label: 'View budget', description: 'Read budget items and expense breakdown', group: 'Budget' },
'budget:write': { label: 'Manage budget', description: 'Create, update, and delete budget items', group: 'Budget' },
'reservations:read': { label: 'View reservations', description: 'Read reservations and accommodation details', group: 'Reservations' },
'reservations:write': { label: 'Manage reservations', description: 'Create, update, delete, and reorder reservations', group: 'Reservations' },
'collab:read': { label: 'View collaboration', description: 'Read collab notes, polls, messages, and to-do items', group: 'Collaboration' },
'collab:write': { label: 'Manage collaboration', description: 'Create, update, and delete collab notes, todos, polls, and messages', group: 'Collaboration' },
'notifications:read': { label: 'View notifications', description: 'Read in-app notifications and unread counts', group: 'Notifications' },
'notifications:write': { label: 'Manage notifications', description: 'Mark notifications as read and respond to them', group: 'Notifications' },
'vacay:read': { label: 'View vacation plans', description: 'Read vacation planning data, entries, and stats', group: 'Vacation' },
'vacay:write': { label: 'Manage vacation plans', description: 'Create and manage vacation entries, holidays, and team plans', group: 'Vacation' },
'media:read': { label: 'Maps & weather data', description: 'Search locations, resolve map URLs, and fetch weather forecasts', group: 'Media' },
}
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
// Group all scopes for the client registration form
export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.group))]
export function getScopesByGroup(): Record<string, Array<{ scope: string } & ScopeInfo>> {
const groups: Record<string, Array<{ scope: string } & ScopeInfo>> = {}
for (const [scope, info] of Object.entries(SCOPE_GROUPS)) {
if (!groups[info.group]) groups[info.group] = []
groups[info.group].push({ scope, ...info })
}
return groups
}
@@ -2,11 +2,85 @@ import Section from './Section'
import React, { useEffect, useState } from 'react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { Trash2, Copy, Terminal, Plus, Check } from 'lucide-react'
import { authApi } from '../../api/client'
import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ShieldCheck, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
import { authApi, oauthApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import PhotoProvidersSection from './PhotoProvidersSection'
import { getScopesByGroup, ALL_SCOPES } from '../../api/oauthScopes'
interface OAuthPreset {
id: string
label: string
name: string
uris: string
scopes: string[]
}
const OAUTH_PRESETS: OAuthPreset[] = [
{
id: 'claude-web',
label: 'Claude.ai',
name: 'Claude.ai',
uris: 'https://claude.ai/api/mcp/auth_callback',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
},
{
id: 'claude-desktop',
label: 'Claude Desktop',
name: 'Claude Desktop',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
},
{
id: 'cursor',
label: 'Cursor',
name: 'Cursor',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
},
{
id: 'vscode',
label: 'VS Code',
name: 'VS Code / Copilot',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => s.endsWith(':read')),
},
{
id: 'windsurf',
label: 'Windsurf',
name: 'Windsurf',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
},
{
id: 'zed',
label: 'Zed',
name: 'Zed',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
},
]
interface OAuthClient {
id: string
name: string
client_id: string
redirect_uris: string[]
allowed_scopes: string[]
created_at: string
client_secret?: string // only present on create
}
interface OAuthSession {
id: number
client_id: string
client_name: string
scopes: string[]
access_token_expires_at: string
refresh_token_expires_at: string
created_at: string
}
interface McpToken {
id: number
@@ -26,6 +100,23 @@ export default function IntegrationsTab(): React.ReactElement {
loadAddons()
}, [loadAddons])
// OAuth clients state
const [oauthClients, setOauthClients] = useState<OAuthClient[]>([])
const [oauthSessions, setOauthSessions] = useState<OAuthSession[]>([])
const [oauthCreateOpen, setOauthCreateOpen] = useState(false)
const [oauthNewName, setOauthNewName] = useState('')
const [oauthNewUris, setOauthNewUris] = useState('')
const [oauthNewScopes, setOauthNewScopes] = useState<string[]>([])
const [oauthCreating, setOauthCreating] = useState(false)
const [oauthCreatedClient, setOauthCreatedClient] = useState<OAuthClient | null>(null)
const [oauthDeleteId, setOauthDeleteId] = useState<string | null>(null)
const [oauthRevokeId, setOauthRevokeId] = useState<number | null>(null)
const [oauthRotateId, setOauthRotateId] = useState<string | null>(null)
const [oauthRotatedSecret, setOauthRotatedSecret] = useState<string | null>(null)
const [oauthRotating, setOauthRotating] = useState(false)
const [oauthScopesOpen, setOauthScopesOpen] = useState<Record<string, boolean>>({})
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
// MCP state
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
const [mcpModalOpen, setMcpModalOpen] = useState(false)
@@ -89,6 +180,69 @@ export default function IntegrationsTab(): React.ReactElement {
})
}
// Load OAuth clients and sessions
useEffect(() => {
if (mcpEnabled) {
oauthApi.clients.list().then(d => setOauthClients(d.clients || [])).catch(() => {})
oauthApi.sessions.list().then(d => setOauthSessions(d.sessions || [])).catch(() => {})
}
}, [mcpEnabled])
const handleCreateOAuthClient = async () => {
if (!oauthNewName.trim() || !oauthNewUris.trim()) return
setOauthCreating(true)
try {
const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
setOauthCreatedClient(d.client)
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
setOauthNewName('')
setOauthNewUris('')
setOauthNewScopes([])
} catch {
toast.error(t('settings.oauth.toast.createError'))
} finally {
setOauthCreating(false)
}
}
const handleDeleteOAuthClient = async (id: string) => {
try {
await oauthApi.clients.delete(id)
setOauthClients(prev => prev.filter(c => c.id !== id))
setOauthDeleteId(null)
toast.success(t('settings.oauth.toast.deleted'))
} catch {
toast.error(t('settings.oauth.toast.deleteError'))
}
}
const handleRotateSecret = async (id: string) => {
setOauthRotating(true)
try {
const d = await oauthApi.clients.rotate(id)
setOauthRotatedSecret(d.client_secret)
setOauthRotateId(null)
} catch {
toast.error(t('settings.oauth.toast.rotateError'))
} finally {
setOauthRotating(false)
}
}
const handleRevokeSession = async (id: number) => {
try {
await oauthApi.sessions.revoke(id)
setOauthSessions(prev => prev.filter(s => s.id !== id))
setOauthRevokeId(null)
toast.success(t('settings.oauth.toast.revoked'))
} catch {
toast.error(t('settings.oauth.toast.revokeError'))
}
}
const scopesByGroup = getScopesByGroup()
return (
<>
<PhotoProvidersSection />
@@ -126,46 +280,146 @@ export default function IntegrationsTab(): React.ReactElement {
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
</div>
{/* Token list */}
{/* OAuth Clients */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label>
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
<div className="flex items-center gap-2 mb-3">
<ShieldCheck className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clients')}</label>
</div>
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
<div className="flex justify-end mb-2">
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
</button>
</div>
{mcpTokens.length === 0 ? (
{oauthClients.length === 0 ? (
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
{t('settings.mcp.noTokens')}
{t('settings.oauth.noClients')}
</p>
) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{mcpTokens.map((token, i) => (
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{token.token_prefix}...
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
{token.last_used_at && (
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
)}
</p>
{oauthClients.map((client, i) => (
<div key={client.id} className="px-4 py-3"
style={{ borderBottom: i < oauthClients.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex items-center gap-3">
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.clientId')}: {client.client_id}
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
</p>
<div className="flex flex-wrap gap-1 mt-1.5">
{(oauthScopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
<span key={s} className="px-1.5 py-0.5 rounded text-xs" style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>{s}</span>
))}
{client.allowed_scopes.length > 5 && (
<button
onClick={() => setOauthScopesExpanded(prev => ({ ...prev, [client.id]: !prev[client.id] }))}
className="px-1.5 py-0.5 rounded text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
{oauthScopesExpanded[client.id] ? '' : `+${client.allowed_scopes.length - 5}`}
</button>
)}
</div>
</div>
<button onClick={() => setOauthRotateId(client.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.rotateSecret')}>
<RefreshCw className="w-4 h-4" />
</button>
<button onClick={() => setOauthDeleteId(client.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.deleteClient')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
<button onClick={() => setMcpDeleteId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
{/* Token list — deprecated */}
<div className="rounded-lg border" style={{ borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--border-primary)', background: 'rgba(245,158,11,0.06)' }}>
<div className="flex items-center gap-2">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label>
<span className="px-1.5 py-0.5 rounded text-xs font-medium" style={{ background: 'rgba(245,158,11,0.15)', color: '#b45309', border: '1px solid rgba(245,158,11,0.4)' }}>
Deprecated
</span>
</div>
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors opacity-60"
style={{ background: 'var(--bg-tertiary, #e5e7eb)', color: 'var(--text-secondary)' }}>
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
</button>
</div>
<div className="px-4 py-2.5 flex items-start gap-2 border-b" style={{ borderColor: 'var(--border-primary)', background: 'rgba(245,158,11,0.06)' }}>
<span className="text-amber-500 mt-0.5 flex-shrink-0"></span>
<p className="text-xs" style={{ color: '#92400e' }}>{t('settings.mcp.apiTokensDeprecated')}</p>
</div>
<div className="p-4">
{mcpTokens.length === 0 ? (
<p className="text-sm py-2 text-center" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.mcp.noTokens')}
</p>
) : (
<div className="space-y-0 rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{mcpTokens.map((token, i) => (
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{token.token_prefix}...
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
{token.last_used_at && (
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
)}
</p>
</div>
<button onClick={() => setMcpDeleteId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* Active OAuth Sessions */}
{oauthSessions.length > 0 && (
<div>
<label className="text-sm font-medium block mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.activeSessions')}</label>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{oauthSessions.map((session, i) => (
<div key={session.id} className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: i < oauthSessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}
<span className="ml-3">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
</p>
</div>
<button onClick={() => setOauthRevokeId(session.id)}
className="px-2.5 py-1 rounded text-xs border transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
{t('settings.oauth.revoke')}
</button>
</div>
))}
</div>
</div>
)}
</Section>
)}
@@ -248,6 +502,282 @@ export default function IntegrationsTab(): React.ReactElement {
</div>
</div>
)}
{/* Create OAuth Client modal */}
{oauthCreateOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget && !oauthCreatedClient) setOauthCreateOpen(false) }}>
<div className="rounded-xl shadow-xl w-full max-w-lg p-6 space-y-4 overflow-y-auto max-h-[90vh]" style={{ background: 'var(--bg-card)' }}>
{!oauthCreatedClient ? (
<>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.modal.createTitle')}</h3>
<div>
<label className="block text-xs font-medium mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.presets')}</label>
<div className="flex flex-wrap gap-1.5">
{OAUTH_PRESETS.map(preset => (
<button
key={preset.id}
type="button"
onClick={() => {
setOauthNewName(preset.name)
setOauthNewUris(preset.uris)
setOauthNewScopes(preset.scopes)
}}
className="px-2.5 py-1 rounded-md text-xs font-medium border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-secondary)' }}>
{preset.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.clientName')}</label>
<input type="text" value={oauthNewName} onChange={e => setOauthNewName(e.target.value)}
placeholder={t('settings.oauth.modal.clientNamePlaceholder')}
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
autoFocus />
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
rows={3}
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-indigo-300"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
<button type="button"
onClick={() => {
const allScopes = Object.values(scopesByGroup).flat().map(s => s.scope)
const allSelected = allScopes.every(s => oauthNewScopes.includes(s))
setOauthNewScopes(allSelected ? [] : allScopes)
}}
className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{Object.values(scopesByGroup).flat().every(s => oauthNewScopes.includes(s.scope))
? t('settings.oauth.modal.deselectAll')
: t('settings.oauth.modal.selectAll')}
</button>
</div>
<div className="space-y-1 max-h-56 overflow-y-auto pr-1">
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
const groupScopeKeys = groupScopes.map(s => s.scope)
const allGroupSelected = groupScopeKeys.every(s => oauthNewScopes.includes(s))
const someGroupSelected = groupScopeKeys.some(s => oauthNewScopes.includes(s))
return (
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
<div className="flex items-center gap-1 px-3 py-2" style={{ background: 'var(--bg-secondary)' }}>
<button type="button"
onClick={() => setOauthScopesOpen(prev => ({ ...prev, [group]: !prev[group] }))}
className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left"
style={{ color: 'var(--text-secondary)' }}>
{oauthScopesOpen[group] ? <ChevronDown className="w-3 h-3 flex-shrink-0" /> : <ChevronRight className="w-3 h-3 flex-shrink-0" />}
{group}
{someGroupSelected && (
<span className="ml-1.5 text-xs font-normal" style={{ color: 'var(--text-tertiary)' }}>
({groupScopeKeys.filter(s => oauthNewScopes.includes(s)).length}/{groupScopeKeys.length})
</span>
)}
</button>
<input type="checkbox"
checked={allGroupSelected}
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
onChange={e => setOauthNewScopes(prev =>
e.target.checked
? [...new Set([...prev, ...groupScopeKeys])]
: prev.filter(s => !groupScopeKeys.includes(s))
)}
className="rounded" title={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`} />
</div>
{oauthScopesOpen[group] && (
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
{groupScopes.map(({ scope, label, description }) => (
<label key={scope} className="flex items-start gap-2.5 px-3 py-2 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<input type="checkbox"
checked={oauthNewScopes.includes(scope)}
onChange={e => setOauthNewScopes(prev => e.target.checked ? [...prev, scope] : prev.filter(s => s !== scope))}
className="mt-0.5 rounded flex-shrink-0" />
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
</div>
</label>
))}
</div>
)}
</div>
)
})}
</div>
</div>
<div className="flex gap-2 justify-end pt-1">
<button onClick={() => setOauthCreateOpen(false)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={handleCreateOAuthClient}
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthNewScopes.length === 0 || oauthCreating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
</button>
</div>
</>
) : (
<>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.modal.createdTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
<span className="text-amber-500 mt-0.5"></span>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.createdWarning')}</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientId')}</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{oauthCreatedClient.client_id}
</code>
<button onClick={() => handleCopy(oauthCreatedClient.client_id, 'new-client-id')}
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)' }}>
{copiedKey === 'new-client-id' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientSecret')}</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{oauthCreatedClient.client_secret}
</code>
<button onClick={() => handleCopy(oauthCreatedClient.client_secret!, 'new-client-secret')}
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)' }}>
{copiedKey === 'new-client-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
</button>
</div>
</div>
</div>
<div className="flex justify-end">
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
className="px-4 py-2 rounded-lg text-sm font-medium text-white"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{t('settings.mcp.modal.done')}
</button>
</div>
</>
)}
</div>
</div>
)}
{/* Delete OAuth Client confirm */}
{oauthDeleteId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setOauthDeleteId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.deleteClient')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.deleteClientMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setOauthDeleteId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => handleDeleteOAuthClient(oauthDeleteId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('settings.oauth.deleteClient')}
</button>
</div>
</div>
</div>
)}
{/* Rotate OAuth Client Secret confirm */}
{oauthRotateId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setOauthRotateId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecret')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.rotateSecretMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setOauthRotateId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => handleRotateSecret(oauthRotateId)} disabled={oauthRotating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{oauthRotating ? t('settings.oauth.rotateSecretConfirming') : t('settings.oauth.rotateSecretConfirm')}
</button>
</div>
</div>
</div>
)}
{/* Rotated Secret display */}
{oauthRotatedSecret !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}>
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecretDoneTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
<span className="text-amber-500 mt-0.5"></span>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.rotateSecretDoneWarning')}</p>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientSecret')}</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{oauthRotatedSecret}
</code>
<button onClick={() => handleCopy(oauthRotatedSecret, 'rotated-secret')}
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)' }}>
{copiedKey === 'rotated-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
</button>
</div>
</div>
<div className="flex justify-end">
<button onClick={() => setOauthRotatedSecret(null)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{t('settings.mcp.modal.done')}
</button>
</div>
</div>
</div>
)}
{/* Revoke OAuth Session confirm */}
{oauthRevokeId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setOauthRevokeId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.revokeSession')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.revokeSessionMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setOauthRevokeId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => handleRevokeSession(oauthRevokeId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('settings.oauth.revoke')}
</button>
</div>
</div>
</div>
)}
</>
)
}
+41
View File
@@ -270,6 +270,47 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.toast.createError': 'Failed to create token',
'settings.mcp.toast.deleted': 'Token deleted',
'settings.mcp.toast.deleteError': 'Failed to delete token',
'settings.mcp.apiTokensDeprecated': 'API Tokens are deprecated and will be removed in a future release. Please use OAuth 2.1 Clients instead.',
'settings.oauth.clients': 'OAuth 2.1 Clients',
'settings.oauth.clientsHint': 'Register OAuth 2.1 clients to let third-party MCP applications (Claude Web, Cursor, etc.) connect without static tokens.',
'settings.oauth.createClient': 'New Client',
'settings.oauth.noClients': 'No OAuth clients registered.',
'settings.oauth.clientId': 'Client ID',
'settings.oauth.clientSecret': 'Client Secret',
'settings.oauth.deleteClient': 'Delete Client',
'settings.oauth.deleteClientMessage': 'This client and all active sessions will be permanently removed. Any application using it will lose access immediately.',
'settings.oauth.rotateSecret': 'Rotate Secret',
'settings.oauth.rotateSecretMessage': 'A new client secret will be generated and all existing sessions will be invalidated immediately. Update your application before closing this dialog.',
'settings.oauth.rotateSecretConfirm': 'Rotate',
'settings.oauth.rotateSecretConfirming': 'Rotating…',
'settings.oauth.rotateSecretDoneTitle': 'New Secret Generated',
'settings.oauth.rotateSecretDoneWarning': 'This secret is shown only once. Copy it now and update your application — all previous sessions have been invalidated.',
'settings.oauth.activeSessions': 'Active OAuth Sessions',
'settings.oauth.sessionScopes': 'Scopes',
'settings.oauth.sessionExpires': 'Expires',
'settings.oauth.revoke': 'Revoke',
'settings.oauth.revokeSession': 'Revoke Session',
'settings.oauth.revokeSessionMessage': 'This will immediately revoke access for this OAuth session.',
'settings.oauth.modal.createTitle': 'Register OAuth Client',
'settings.oauth.modal.presets': 'Quick presets',
'settings.oauth.modal.clientName': 'Application Name',
'settings.oauth.modal.clientNamePlaceholder': 'e.g. Claude Web, My MCP App',
'settings.oauth.modal.redirectUris': 'Redirect URIs',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': 'One URI per line. HTTPS required (localhost exempt). Exact match enforced.',
'settings.oauth.modal.scopes': 'Allowed Scopes',
'settings.oauth.modal.selectAll': 'Select all',
'settings.oauth.modal.deselectAll': 'Deselect all',
'settings.oauth.modal.creating': 'Registering…',
'settings.oauth.modal.create': 'Register Client',
'settings.oauth.modal.createdTitle': 'Client Registered',
'settings.oauth.modal.createdWarning': 'The client secret is shown only once. Copy it now — it cannot be recovered.',
'settings.oauth.toast.createError': 'Failed to register OAuth client',
'settings.oauth.toast.deleted': 'OAuth client deleted',
'settings.oauth.toast.deleteError': 'Failed to delete OAuth client',
'settings.oauth.toast.revoked': 'Session revoked',
'settings.oauth.toast.revokeError': 'Failed to revoke session',
'settings.oauth.toast.rotateError': 'Failed to rotate client secret',
'settings.account': 'Account',
'settings.about': 'About',
'settings.about.reportBug': 'Report a Bug',
+248
View File
@@ -0,0 +1,248 @@
import React, { useEffect, useState } from 'react'
import { useAuthStore } from '../store/authStore'
import { oauthApi } from '../api/client'
import { SCOPE_GROUPS } from '../api/oauthScopes'
import { Lock, ShieldCheck, AlertTriangle, Loader2, LogIn } from 'lucide-react'
interface ValidateResult {
valid: boolean
error?: string
error_description?: string
client?: { name: string; allowed_scopes: string[] }
scopes?: string[]
consentRequired?: boolean
loginRequired?: boolean
}
type PageState = 'loading' | 'login_required' | 'consent' | 'auto_approving' | 'error' | 'done'
export default function OAuthAuthorizePage(): React.ReactElement {
const { isAuthenticated, isLoading: authLoading, loadUser } = useAuthStore()
const [pageState, setPageState] = useState<PageState>('loading')
const [validation, setValidation] = useState<ValidateResult | null>(null)
const [submitting, setSubmitting] = useState(false)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const params = new URLSearchParams(window.location.search)
const clientId = params.get('client_id') || ''
const redirectUri = params.get('redirect_uri') || ''
const scope = params.get('scope') || ''
const state = params.get('state') || ''
const codeChallenge = params.get('code_challenge') || ''
const ccMethod = params.get('code_challenge_method') || ''
// Load auth state once, then validate
useEffect(() => {
loadUser({ silent: true }).catch(() => {})
}, [loadUser])
useEffect(() => {
if (authLoading) return
validateRequest()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authLoading, isAuthenticated])
async function validateRequest() {
setPageState('loading')
try {
const result = await oauthApi.validate({
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: ccMethod,
response_type: 'code',
})
setValidation(result)
if (!result.valid) {
setPageState('error')
setErrorMsg(result.error_description || result.error || 'Invalid authorization request')
return
}
if (result.loginRequired) {
setPageState('login_required')
return
}
if (!result.consentRequired) {
// Consent already on record — auto-approve silently
setPageState('auto_approving')
await submitConsent(true)
return
}
setPageState('consent')
} catch (err: unknown) {
setPageState('error')
setErrorMsg('Failed to validate authorization request. Please try again.')
}
}
async function submitConsent(approved: boolean) {
setSubmitting(true)
try {
const result = await oauthApi.authorize({
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: ccMethod,
approved,
})
setPageState('done')
window.location.href = result.redirect
} catch {
setPageState('error')
setErrorMsg('Authorization failed. Please try again.')
setSubmitting(false)
}
}
function handleLoginRedirect() {
const next = '/oauth/authorize?' + params.toString()
window.location.href = '/login?redirect=' + encodeURIComponent(next)
}
// Group requested scopes by their human-readable group
const scopesByGroup = React.useMemo(() => {
const requested = validation?.scopes || []
const groups: Record<string, string[]> = {}
for (const s of requested) {
const info = SCOPE_GROUPS[s]
const group = info?.group || 'Other'
if (!groups[group]) groups[group] = []
groups[group].push(s)
}
return groups
}, [validation])
// ---- Render states ----
if (pageState === 'loading' || pageState === 'auto_approving') {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
</p>
</div>
</div>
)
}
if (pageState === 'error') {
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
</div>
</div>
)
}
if (pageState === 'login_required') {
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
<div className="text-center space-y-2">
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
</p>
</div>
<button
onClick={handleLoginRedirect}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
<LogIn className="w-4 h-4" />
Sign in to TREK
</button>
</div>
</div>
)
}
// pageState === 'consent'
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="w-full max-w-sm rounded-xl shadow-lg overflow-hidden" style={{ background: 'var(--bg-card)' }}>
{/* Header */}
<div className="px-8 pt-8 pb-5 text-center space-y-3">
<div className="flex justify-center">
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
</div>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
<h1 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
{validation?.client?.name || clientId}
</h1>
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
This application is requesting access to your TREK account.
</p>
</div>
</div>
{/* Scope list */}
<div className="px-8 pb-5">
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
Permissions requested
</p>
<div className="space-y-3">
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
<div key={group}>
<p className="text-xs font-semibold mb-1.5" style={{ color: 'var(--text-secondary)' }}>{group}</p>
<div className="space-y-1.5">
{groupScopes.map(s => {
const info = SCOPE_GROUPS[s]
return (
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
<span className="mt-0.5 text-base leading-none">
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
</span>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{info?.label || s}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{info?.description || ''}</p>
</div>
</div>
)
})}
</div>
</div>
))}
</div>
</div>
{/* Footer / actions */}
<div className="px-8 pb-8 space-y-2">
<p className="text-xs text-center mb-3" style={{ color: 'var(--text-tertiary)' }}>
Only grant access to applications you trust. Your data stays on your server.
</p>
<button
onClick={() => submitConsent(true)}
disabled={submitting}
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{submitting ? 'Authorizing…' : 'Approve Access'}
</button>
<button
onClick={() => submitConsent(false)}
disabled={submitting}
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
Deny
</button>
</div>
</div>
</div>
)
}
+12 -12
View File
@@ -546,9 +546,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"version": "1.19.13",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz",
"integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -3658,9 +3658,9 @@
}
},
"node_modules/hono": {
"version": "4.12.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
"version": "4.12.12",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -4416,9 +4416,9 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -5992,9 +5992,9 @@
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
+11
View File
@@ -0,0 +1,11 @@
export const ADDON_IDS = {
MCP: 'mcp',
PACKING: 'packing',
BUDGET: 'budget',
DOCUMENTS: 'documents',
VACAY: 'vacay',
ATLAS: 'atlas',
COLLAB: 'collab',
} as const;
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
+6
View File
@@ -32,6 +32,7 @@ import budgetRoutes from './routes/budget';
import collabRoutes from './routes/collab';
import backupRoutes from './routes/backup';
import oidcRoutes from './routes/oidc';
import { oauthPublicRouter, oauthApiRouter } from './routes/oauth';
import vacayRoutes from './routes/vacay';
import atlasRoutes from './routes/atlas';
import memoriesRoutes from './routes/memories/unified';
@@ -264,6 +265,11 @@ export function createApp(): express.Application {
app.use('/api/notifications', notificationRoutes);
app.use('/api', shareRoutes);
// OAuth 2.1 — public endpoints (/.well-known, /oauth/token, /oauth/revoke)
app.use('/', oauthPublicRouter);
// OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*)
app.use('/api/oauth', oauthApiRouter);
// MCP endpoint
app.post('/mcp', mcpHandler);
app.get('/mcp', mcpHandler);
+42
View File
@@ -884,6 +884,48 @@ function runMigrations(db: Database.Database): void {
ins.run(r.trip_id, r.category, idx++);
}
},
// Migration: OAuth 2.1 clients, consents, and tokens for MCP
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS oauth_clients (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
client_id TEXT UNIQUE NOT NULL,
client_secret_hash TEXT NOT NULL,
redirect_uris TEXT NOT NULL DEFAULT '[]',
allowed_scopes TEXT NOT NULL DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id);
CREATE TABLE IF NOT EXISTS oauth_consents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scopes TEXT NOT NULL DEFAULT '[]',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(client_id, user_id)
);
CREATE TABLE IF NOT EXISTS oauth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
access_token_hash TEXT UNIQUE NOT NULL,
refresh_token_hash TEXT UNIQUE NOT NULL,
scopes TEXT NOT NULL DEFAULT '[]',
access_token_expires_at DATETIME NOT NULL,
refresh_token_expires_at DATETIME NOT NULL,
revoked_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_access ON oauth_tokens(access_token_hash);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash);
`);
},
];
if (currentVersion < migrations.length) {
+38 -12
View File
@@ -4,7 +4,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
import { User } from '../types';
import { verifyMcpToken, verifyJwtToken } from '../services/authService';
import { getUserByAccessToken } from '../services/oauthService';
import { isAddonEnabled } from '../services/adminService';
import { ADDON_IDS } from '../addons';
import { registerResources } from './resources';
import { registerTools } from './tools';
@@ -12,6 +14,10 @@ interface McpSession {
server: McpServer;
transport: StreamableHTTPServerTransport;
userId: number;
/** null = static trek_ token or JWT (full access); string[] = OAuth 2.1 scopes */
scopes: string[] | null;
/** true when authenticated via static trek_ token — triggers deprecation prompt */
isStaticToken: boolean;
lastActivity: number;
}
@@ -73,30 +79,49 @@ const sessionSweepInterval = setInterval(() => {
// Prevent the interval from keeping the process alive if nothing else is running
sessionSweepInterval.unref();
function verifyToken(authHeader: string | undefined): User | null {
interface VerifyTokenResult {
user: User;
/** null = full access (static token or JWT); string[] = OAuth 2.1 scoped access */
scopes: string[] | null;
isStaticToken: boolean;
}
function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
const token = authHeader && authHeader.split(' ')[1];
if (!token) return null;
// Long-lived MCP API token (trek_...)
if (token.startsWith('trek_')) {
return verifyMcpToken(token);
// OAuth 2.1 access token (trekoa_...)
if (token.startsWith('trekoa_')) {
const result = getUserByAccessToken(token);
if (!result) return null;
return { user: result.user, scopes: result.scopes, isStaticToken: false };
}
// Short-lived JWT
return verifyJwtToken(token);
// Long-lived static MCP token (trek_...) — full access + deprecation notice
if (token.startsWith('trek_')) {
const user = verifyMcpToken(token);
if (!user) return null;
return { user, scopes: null, isStaticToken: true };
}
// Short-lived JWT (TREK web session used directly) — full access, no notice
const user = verifyJwtToken(token);
if (!user) return null;
return { user, scopes: null, isStaticToken: false };
}
export async function mcpHandler(req: Request, res: Response): Promise<void> {
if (!isAddonEnabled('mcp')) {
if (!isAddonEnabled(ADDON_IDS.MCP)) {
res.status(403).json({ error: 'MCP is not enabled' });
return;
}
const user = verifyToken(req.headers['authorization']);
if (!user) {
const tokenResult = verifyToken(req.headers['authorization']);
if (!tokenResult) {
res.status(401).json({ error: 'Access token required' });
return;
}
const { user, scopes, isStaticToken } = tokenResult;
if (isRateLimited(user.id)) {
res.status(429).json({ error: 'Too many requests. Please slow down.' });
@@ -150,13 +175,14 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
},
});
registerResources(server, user.id);
registerTools(server, user.id);
registerTools(server, user.id, scopes, isStaticToken);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
sessions.set(sid, { server, transport, userId: user.id, lastActivity: Date.now() });
console.log(`[MCP] Session ${sid} created for user ${user.id}. Active sessions: ${sessions.size}`);
sessions.set(sid, { server, transport, userId: user.id, scopes, isStaticToken, lastActivity: Date.now() });
const authMethod = isStaticToken ? 'static-token' : scopes ? `oauth(${scopes.join(',')})` : 'jwt';
console.log(`[MCP] Session ${sid} created for user ${user.id} [${authMethod}]. Active sessions: ${sessions.size}`);
},
onsessionclosed: (sid) => {
sessions.delete(sid);
+13 -26
View File
@@ -9,12 +9,12 @@ import { listReservations } from '../services/reservationService';
import { listNotes as listDayNotes } from '../services/dayNoteService';
import { listNotes as listCollabNotes, listPolls, listMessages } from '../services/collabService';
import { listItems as listTodoItems } from '../services/todoService';
import { listFiles } from '../services/fileService';
import { listCategories } from '../services/categoryService';
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
import { getNotifications } from '../services/inAppNotifications';
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
import { isAddonEnabled } from '../services/adminService';
import { ADDON_IDS } from '../addons';
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
@@ -95,7 +95,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Budget items
server.registerResource(
if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerResource(
'trip-budget',
new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }),
{ description: 'Budget and expense items for a trip', mimeType: 'application/json' },
@@ -108,7 +108,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Packing checklist
server.registerResource(
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerResource(
'trip-packing',
new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }),
{ description: 'Packing checklist for a trip', mimeType: 'application/json' },
@@ -176,7 +176,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Collab notes for a trip
server.registerResource(
if (isAddonEnabled(ADDON_IDS.COLLAB)) server.registerResource(
'trip-collab-notes',
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
{ description: 'Shared collaborative notes for a trip', mimeType: 'application/json' },
@@ -188,21 +188,8 @@ export function registerResources(server: McpServer, userId: number): void {
}
);
// Trip files (active, not trash)
server.registerResource(
'trip-files',
new ResourceTemplate('trek://trips/{tripId}/files', { list: undefined }),
{ description: 'Active files attached to a trip (excludes trash)', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const files = listFiles(id, false);
return jsonContent(uri.href, files);
}
);
// Trip to-do list
server.registerResource(
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerResource(
'trip-todos',
new ResourceTemplate('trek://trips/{tripId}/todos', { list: undefined }),
{ description: 'To-do items for a trip, ordered by position', mimeType: 'application/json' },
@@ -226,7 +213,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// User's bucket list
server.registerResource(
if (isAddonEnabled(ADDON_IDS.ATLAS)) server.registerResource(
'bucket-list',
'trek://bucket-list',
{ description: 'Your personal travel bucket list', mimeType: 'application/json' },
@@ -237,7 +224,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// User's visited countries
server.registerResource(
if (isAddonEnabled(ADDON_IDS.ATLAS)) server.registerResource(
'visited-countries',
'trek://visited-countries',
{ description: 'Countries you have marked as visited in Atlas', mimeType: 'application/json' },
@@ -248,7 +235,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Budget per-person summary
server.registerResource(
if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerResource(
'trip-budget-per-person',
new ResourceTemplate('trek://trips/{tripId}/budget/per-person', { list: undefined }),
{ description: 'Per-person budget summary for a trip (total spent per member, split breakdown)', mimeType: 'application/json' },
@@ -261,7 +248,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Budget settlement
server.registerResource(
if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerResource(
'trip-budget-settlement',
new ResourceTemplate('trek://trips/{tripId}/budget/settlement', { list: undefined }),
{ description: 'Suggested settlement transactions to balance who owes whom', mimeType: 'application/json' },
@@ -274,7 +261,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Packing bags
server.registerResource(
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerResource(
'trip-packing-bags',
new ResourceTemplate('trek://trips/{tripId}/packing/bags', { list: undefined }),
{ description: 'All packing bags for a trip with their members', mimeType: 'application/json' },
@@ -298,7 +285,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Atlas stats and regions (addon-gated)
if (isAddonEnabled('atlas')) {
if (isAddonEnabled(ADDON_IDS.ATLAS)) {
server.registerResource(
'atlas-stats',
'trek://atlas/stats',
@@ -321,7 +308,7 @@ export function registerResources(server: McpServer, userId: number): void {
}
// Collab polls & messages (addon-gated)
if (isAddonEnabled('collab')) {
if (isAddonEnabled(ADDON_IDS.COLLAB)) {
server.registerResource(
'trip-collab-polls',
new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }),
@@ -348,7 +335,7 @@ export function registerResources(server: McpServer, userId: number): void {
}
// Vacay resources (addon-gated)
if (isAddonEnabled('vacay')) {
if (isAddonEnabled(ADDON_IDS.VACAY)) {
server.registerResource(
'vacay-plan',
'trek://vacay/plan',
+89
View File
@@ -0,0 +1,89 @@
// ---------------------------------------------------------------------------
// OAuth 2.1 scope definitions for TREK MCP
// ---------------------------------------------------------------------------
export const SCOPES = {
TRIPS_READ: 'trips:read',
TRIPS_WRITE: 'trips:write',
TRIPS_DELETE: 'trips:delete',
PLACES_READ: 'places:read',
PLACES_WRITE: 'places:write',
PACKING_READ: 'packing:read',
PACKING_WRITE: 'packing:write',
BUDGET_READ: 'budget:read',
BUDGET_WRITE: 'budget:write',
RESERVATIONS_READ: 'reservations:read',
RESERVATIONS_WRITE: 'reservations:write',
COLLAB_READ: 'collab:read',
COLLAB_WRITE: 'collab:write',
NOTIFICATIONS_READ: 'notifications:read',
NOTIFICATIONS_WRITE: 'notifications:write',
VACAY_READ: 'vacay:read',
VACAY_WRITE: 'vacay:write',
MEDIA_READ: 'media:read',
} as const;
export type Scope = typeof SCOPES[keyof typeof SCOPES];
export const ALL_SCOPES: Scope[] = Object.values(SCOPES) as Scope[];
export interface ScopeInfo {
label: string;
description: string;
group: string;
}
export const SCOPE_INFO: Record<Scope, ScopeInfo> = {
'trips:read': { label: 'View trips & itineraries', description: 'Read trips, days, day notes, members, and share links', group: 'Trips' },
'trips:write': { label: 'Edit trips & itineraries', description: 'Create and update trips, days, notes, and manage members', group: 'Trips' },
'trips:delete': { label: 'Delete trips', description: 'Permanently delete entire trips — this action is irreversible', group: 'Trips' },
'places:read': { label: 'View places & map data', description: 'Read places, day assignments, tags, categories, and visited countries', group: 'Places' },
'places:write': { label: 'Manage places', description: 'Create, update, and delete places, assignments, tags, and atlas entries', group: 'Places' },
'packing:read': { label: 'View packing lists', description: 'Read packing items, bags, and category assignees', group: 'Packing' },
'packing:write': { label: 'Manage packing lists', description: 'Add, update, delete, toggle, and reorder packing items and bags', group: 'Packing' },
'budget:read': { label: 'View budget', description: 'Read budget items and expense breakdown', group: 'Budget' },
'budget:write': { label: 'Manage budget', description: 'Create, update, and delete budget items', group: 'Budget' },
'reservations:read': { label: 'View reservations', description: 'Read reservations and accommodation details', group: 'Reservations' },
'reservations:write': { label: 'Manage reservations', description: 'Create, update, delete, and reorder reservations', group: 'Reservations' },
'collab:read': { label: 'View collaboration', description: 'Read collab notes, polls, messages, and to-do items', group: 'Collaboration' },
'collab:write': { label: 'Manage collaboration', description: 'Create, update, and delete collab notes, todos, polls, and messages', group: 'Collaboration' },
'notifications:read': { label: 'View notifications', description: 'Read in-app notifications and unread counts', group: 'Notifications' },
'notifications:write': { label: 'Manage notifications', description: 'Mark notifications as read and respond to them', group: 'Notifications' },
'vacay:read': { label: 'View vacation plans', description: 'Read vacation planning data, entries, and stats', group: 'Vacation' },
'vacay:write': { label: 'Manage vacation plans', description: 'Create and manage vacation entries, holidays, and team plans', group: 'Vacation' },
'media:read': { label: 'Maps & weather data', description: 'Search locations, resolve map URLs, and fetch weather forecasts', group: 'Media' },
};
// ---------------------------------------------------------------------------
// Scope enforcement helpers
// null scopes = static trek_ token = full access
// ---------------------------------------------------------------------------
/** trips:read OR trips:write OR trips:delete all grant read access to trips */
export function canReadTrips(scopes: string[] | null): boolean {
if (!scopes) return true;
return scopes.some(s => s === 'trips:read' || s === 'trips:write' || s === 'trips:delete');
}
/** group:write grants write access; for trips canReadTrips handles read */
export function canWrite(scopes: string[] | null, group: string): boolean {
if (!scopes) return true;
return scopes.includes(`${group}:write`);
}
/** group:read OR group:write grant read access */
export function canRead(scopes: string[] | null, group: string): boolean {
if (!scopes) return true;
return scopes.some(s => s === `${group}:read` || s === `${group}:write`);
}
/** trips:delete is a separate scope from trips:write */
export function canDeleteTrips(scopes: string[] | null): boolean {
if (!scopes) return true;
return scopes.includes('trips:delete');
}
export function validateScopes(requestedScopes: string[]): { valid: boolean; invalid: string[] } {
const invalid = requestedScopes.filter(s => !ALL_SCOPES.includes(s as Scope));
return { valid: invalid.length === 0, invalid };
}
+16 -16
View File
@@ -15,34 +15,34 @@ import { registerTripTools } from './tools/trips';
import { registerVacayTools } from './tools/vacay';
import { registerMcpPrompts } from './tools/prompts';
export function registerTools(server: McpServer, userId: number): void {
registerTripTools(server, userId);
export function registerTools(server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false): void {
registerTripTools(server, userId, scopes);
registerPlaceTools(server, userId);
registerPlaceTools(server, userId, scopes);
registerBudgetTools(server, userId);
registerBudgetTools(server, userId, scopes);
registerPackingTools(server, userId);
registerPackingTools(server, userId, scopes);
registerReservationTools(server, userId);
registerReservationTools(server, userId, scopes);
registerDayTools(server, userId);
registerDayTools(server, userId, scopes);
registerAssignmentTools(server, userId);
registerAssignmentTools(server, userId, scopes);
registerTagTools(server, userId);
registerTagTools(server, userId, scopes);
registerMapsWeatherTools(server, userId);
registerMapsWeatherTools(server, userId, scopes);
registerNotificationTools(server, userId);
registerNotificationTools(server, userId, scopes);
registerAtlasTools(server, userId);
registerAtlasTools(server, userId, scopes);
registerCollabTools(server, userId);
registerCollabTools(server, userId, scopes);
registerVacayTools(server, userId);
registerVacayTools(server, userId, scopes);
registerTodoTools(server, userId);
registerTodoTools(server, userId, scopes);
registerMcpPrompts(server, userId);
registerMcpPrompts(server, userId, isStaticToken);
}
+12 -8
View File
@@ -15,11 +15,15 @@ import {
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerAssignmentTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'places');
const W = canWrite(scopes, 'places');
export function registerAssignmentTools(server: McpServer, userId: number): void {
// --- ASSIGNMENTS ---
server.registerTool(
if (W) server.registerTool(
'assign_place_to_day',
{
description: 'Assign a place to a specific day in a trip.',
@@ -42,7 +46,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
}
);
server.registerTool(
if (W) server.registerTool(
'unassign_place',
{
description: 'Remove a place assignment from a day.',
@@ -64,7 +68,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
}
);
server.registerTool(
if (W) server.registerTool(
'update_assignment_time',
{
description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.',
@@ -91,7 +95,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
}
);
server.registerTool(
if (W) server.registerTool(
'move_assignment',
{
description: 'Move a place assignment to a different day.',
@@ -113,7 +117,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
}
);
server.registerTool(
if (R) server.registerTool(
'get_assignment_participants',
{
description: 'Get the list of users participating in a specific place assignment.',
@@ -130,7 +134,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
}
);
server.registerTool(
if (W) server.registerTool(
'set_assignment_participants',
{
description: 'Set the participants for a place assignment (replaces current list).',
@@ -152,7 +156,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
// --- REORDER ---
server.registerTool(
if (W) server.registerTool(
'reorder_day_assignments',
{
description: 'Reorder places within a day by providing the assignment IDs in the desired order.',
+18 -13
View File
@@ -7,16 +7,23 @@ import {
markRegionVisited, unmarkRegionVisited, getCountryPlaces, updateBucketItem,
} from '../../services/atlasService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY,
demoDenied, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerAtlasTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'places');
const W = canWrite(scopes, 'places');
if (!isAddonEnabled(ADDON_IDS.ATLAS)) return;
export function registerAtlasTools(server: McpServer, userId: number): void {
// --- BUCKET LIST ---
server.registerTool(
if (W) server.registerTool(
'create_bucket_list_item',
{
description: 'Add a destination to your personal travel bucket list.',
@@ -36,7 +43,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_bucket_list_item',
{
description: 'Remove an item from your travel bucket list.',
@@ -55,7 +62,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
// --- ATLAS ---
server.registerTool(
if (W) server.registerTool(
'mark_country_visited',
{
description: 'Mark a country as visited in your Atlas.',
@@ -71,7 +78,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'unmark_country_visited',
{
description: 'Remove a country from your visited countries in Atlas.',
@@ -89,8 +96,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
// --- ATLAS EXPANDED ---
if (isAddonEnabled('atlas')) {
server.registerTool(
if (R) server.registerTool(
'get_atlas_stats',
{
description: 'Get atlas statistics — total visited countries, region counts, continent breakdown.',
@@ -103,7 +109,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_visited_regions',
{
description: 'List all manually visited sub-country regions for the current user.',
@@ -116,7 +122,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'mark_region_visited',
{
description: 'Mark a sub-country region as visited.',
@@ -135,7 +141,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'unmark_region_visited',
{
description: 'Remove a region from the visited list.',
@@ -151,7 +157,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'get_country_atlas_places',
{
description: 'Get places saved in the user\'s atlas for a specific country.',
@@ -166,7 +172,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_bucket_list_item',
{
description: 'Update a bucket list item (notes, name, target date, location).',
@@ -188,5 +194,4 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
return ok({ item });
}
);
}
}
+13 -6
View File
@@ -12,11 +12,17 @@ import {
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
export function registerBudgetTools(server: McpServer, userId: number): void {
export function registerBudgetTools(server: McpServer, userId: number, scopes: string[] | null): void {
const W = canWrite(scopes, 'budget');
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
// --- BUDGET ---
server.registerTool(
if (W) server.registerTool(
'create_budget_item',
{
description: 'Add a budget/expense item to a trip.',
@@ -38,7 +44,7 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_budget_item',
{
description: 'Delete a budget item from a trip.',
@@ -60,7 +66,7 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
// --- BUDGET (update) ---
server.registerTool(
if (W) server.registerTool(
'update_budget_item',
{
description: 'Update an existing budget/expense item in a trip.',
@@ -88,7 +94,7 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
// --- BUDGET ADVANCED ---
server.registerTool(
if (W) server.registerTool(
'set_budget_item_members',
{
description: 'Set which trip members are splitting a budget item (replaces current member list).',
@@ -108,7 +114,7 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'toggle_budget_member_paid',
{
description: 'Mark or unmark a member as having paid their share of a budget item.',
@@ -128,4 +134,5 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
return ok({ member });
}
);
} // isAddonEnabled(BUDGET)
}
+21 -16
View File
@@ -8,16 +8,23 @@ import {
listMessages, createMessage, deleteMessage, addOrRemoveReaction,
} from '../../services/collabService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_READONLY,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerCollabTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'collab');
const W = canWrite(scopes, 'collab');
if (!isAddonEnabled(ADDON_IDS.COLLAB)) return;
export function registerCollabTools(server: McpServer, userId: number): void {
// --- COLLAB NOTES ---
server.registerTool(
if (W) server.registerTool(
'create_collab_note',
{
description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).',
@@ -40,7 +47,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_collab_note',
{
description: 'Edit an existing collaborative note on a trip.',
@@ -65,7 +72,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_collab_note',
{
description: 'Delete a collaborative note from a trip.',
@@ -87,9 +94,8 @@ export function registerCollabTools(server: McpServer, userId: number): void {
// --- COLLAB POLLS & CHAT ---
if (isAddonEnabled('collab')) {
server.registerTool(
'list_collab_polls',
if (R) server.registerTool(
'list_collab_polls',
{
description: 'List all polls for a trip.',
inputSchema: {
@@ -104,7 +110,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'create_collab_poll',
{
description: 'Create a new poll in the collab panel.',
@@ -126,7 +132,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'vote_collab_poll',
{
description: 'Vote on a poll option (or remove vote if already voted for that option).',
@@ -146,7 +152,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'close_collab_poll',
{
description: 'Close a poll so no more votes can be cast.',
@@ -166,7 +172,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_collab_poll',
{
description: 'Delete a poll and all its votes.',
@@ -186,7 +192,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_collab_messages',
{
description: 'List chat messages for a trip (most recent 100, oldest-first).',
@@ -203,7 +209,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'send_collab_message',
{
description: "Send a chat message to a trip's collab channel.",
@@ -224,7 +230,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_collab_message',
{
description: 'Delete a chat message (only the message owner can delete their own messages).',
@@ -244,7 +250,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'react_collab_message',
{
description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).',
@@ -264,5 +270,4 @@ export function registerCollabTools(server: McpServer, userId: number): void {
return ok({ reactions: result.reactions });
}
);
}
}
+4 -1
View File
@@ -16,8 +16,11 @@ import {
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canWrite } from '../scopes';
export function registerDayTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (!canWrite(scopes, 'trips')) return;
export function registerDayTools(server: McpServer, userId: number): void {
// --- DAYS ---
server.registerTool(
+4 -1
View File
@@ -6,8 +6,11 @@ import {
TOOL_ANNOTATIONS_READONLY,
ok,
} from './_shared';
import { canRead } from '../scopes';
export function registerMapsWeatherTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (!canRead(scopes, 'media')) return;
export function registerMapsWeatherTools(server: McpServer, userId: number): void {
// --- MAPS EXTRAS ---
server.registerTool(
+10 -6
View File
@@ -11,11 +11,15 @@ import {
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerNotificationTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'notifications');
const W = canWrite(scopes, 'notifications');
export function registerNotificationTools(server: McpServer, userId: number): void {
// --- NOTIFICATIONS ---
server.registerTool(
if (R) server.registerTool(
'list_notifications',
{
description: 'List in-app notifications for the current user.',
@@ -32,7 +36,7 @@ export function registerNotificationTools(server: McpServer, userId: number): vo
}
);
server.registerTool(
if (R) server.registerTool(
'get_unread_notification_count',
{
description: 'Get the number of unread in-app notifications.',
@@ -45,7 +49,7 @@ export function registerNotificationTools(server: McpServer, userId: number): vo
}
);
server.registerTool(
if (W) server.registerTool(
'mark_notification_read',
{
description: 'Mark a single notification as read.',
@@ -62,7 +66,7 @@ export function registerNotificationTools(server: McpServer, userId: number): vo
}
);
server.registerTool(
if (W) server.registerTool(
'mark_notification_unread',
{
description: 'Mark a single notification as unread.',
@@ -79,7 +83,7 @@ export function registerNotificationTools(server: McpServer, userId: number): vo
}
);
server.registerTool(
if (W) server.registerTool(
'mark_all_notifications_read',
{
description: "Mark all of the current user's notifications as read.",
+24 -16
View File
@@ -16,11 +16,19 @@ import {
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
export function registerPackingTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'packing');
const W = canWrite(scopes, 'packing');
if (!isAddonEnabled(ADDON_IDS.PACKING)) return;
export function registerPackingTools(server: McpServer, userId: number): void {
// --- PACKING ---
server.registerTool(
if (W) server.registerTool(
'create_packing_item',
{
description: 'Add an item to the packing checklist for a trip.',
@@ -40,7 +48,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'toggle_packing_item',
{
description: 'Check or uncheck a packing item.',
@@ -61,7 +69,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_packing_item',
{
description: 'Remove an item from the packing checklist.',
@@ -83,7 +91,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
// --- PACKING (update) ---
server.registerTool(
if (W) server.registerTool(
'update_packing_item',
{
description: 'Rename a packing item or change its category.',
@@ -108,7 +116,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
// --- PACKING ADVANCED ---
server.registerTool(
if (W) server.registerTool(
'reorder_packing_items',
{
description: 'Set the display order of packing items within a trip.',
@@ -127,7 +135,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_packing_bags',
{
description: 'List all packing bags for a trip.',
@@ -143,7 +151,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'create_packing_bag',
{
description: 'Create a new packing bag (e.g. "Carry-on", "Checked bag").',
@@ -163,7 +171,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_packing_bag',
{
description: 'Rename or recolor a packing bag.',
@@ -188,7 +196,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_packing_bag',
{
description: 'Delete a packing bag (items in the bag are unassigned, not deleted).',
@@ -207,7 +215,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'set_bag_members',
{
description: 'Assign trip members to a packing bag (determines who packs what bag).',
@@ -227,7 +235,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'get_packing_category_assignees',
{
description: 'Get which trip members are assigned to each packing category.',
@@ -243,7 +251,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'set_packing_category_assignees',
{
description: 'Assign trip members to a packing category.',
@@ -263,7 +271,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'apply_packing_template',
{
description: 'Apply a packing template to a trip (adds items from the template).',
@@ -283,7 +291,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'save_packing_template',
{
description: 'Save the current packing list as a reusable template.',
@@ -301,7 +309,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'bulk_import_packing',
{
description: 'Import multiple packing items at once from a list.',
+11 -7
View File
@@ -10,11 +10,15 @@ import {
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerPlaceTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'places');
const W = canWrite(scopes, 'places');
export function registerPlaceTools(server: McpServer, userId: number): void {
// --- PLACES ---
server.registerTool(
if (W) server.registerTool(
'create_place',
{
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.',
@@ -43,7 +47,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_place',
{
description: 'Update an existing place in a trip.',
@@ -80,7 +84,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_place',
{
description: 'Delete a place from a trip.',
@@ -100,7 +104,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_places',
{
description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.',
@@ -122,7 +126,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
// --- CATEGORIES ---
server.registerTool(
if (R) server.registerTool(
'list_categories',
{
description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.',
@@ -137,7 +141,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
// --- SEARCH ---
server.registerTool(
if (R) server.registerTool(
'search_place',
{
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id if configured). Use these IDs when calling create_place so the app can display opening hours and ratings.',
+25 -3
View File
@@ -3,8 +3,30 @@ import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { getTripSummary } from '../../services/tripService';
import { listItems as listPackingItems } from '../../services/packingService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
export function registerMcpPrompts(server: McpServer, _userId: number): void {
export function registerMcpPrompts(server: McpServer, _userId: number, isStaticToken = false): void {
if (isStaticToken) {
server.registerPrompt(
'token_auth_notice',
{
title: 'Static Token Auth Notice',
description: 'Notification that this session uses a static API token which will be deprecated',
argsSchema: {},
},
async () => ({
description: 'Static token deprecation notice',
messages: [{
role: 'user' as const,
content: {
type: 'text' as const,
text: '⚠️ This MCP connection is authenticated with a static API token (trek_…). Static token authentication will be deprecated in a future version of TREK. Please inform the user that they should migrate to OAuth 2.1 by going to Settings → Integrations → MCP → OAuth Clients in TREK and registering an OAuth 2.1 application for their MCP client.',
},
}],
})
);
}
const userId = _userId;
server.registerPrompt(
@@ -43,7 +65,7 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
}
);
server.registerPrompt(
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerPrompt(
'packing-list',
{
title: 'Packing List',
@@ -77,7 +99,7 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
}
);
server.registerPrompt(
if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerPrompt(
'budget-overview',
{
title: 'Budget Overview',
+4 -1
View File
@@ -13,8 +13,11 @@ import {
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canWrite } from '../scopes';
export function registerReservationTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (!canWrite(scopes, 'reservations')) return;
export function registerReservationTools(server: McpServer, userId: number): void {
server.registerTool(
'create_reservation',
+9 -5
View File
@@ -7,11 +7,15 @@ import {
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerTagTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'places');
const W = canWrite(scopes, 'places');
export function registerTagTools(server: McpServer, userId: number): void {
// --- TAGS ---
server.registerTool(
if (R) server.registerTool(
'list_tags',
{
description: 'List all tags belonging to the current user.',
@@ -24,7 +28,7 @@ export function registerTagTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'create_tag',
{
description: 'Create a new tag (user-scoped label for places).',
@@ -41,7 +45,7 @@ export function registerTagTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_tag',
{
description: 'Update the name or color of an existing tag.',
@@ -60,7 +64,7 @@ export function registerTagTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_tag',
{
description: 'Delete a tag (removes it from all places it was attached to).',
+17 -9
View File
@@ -12,11 +12,19 @@ import {
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
export function registerTodoTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'collab');
const W = canWrite(scopes, 'collab');
if (!isAddonEnabled(ADDON_IDS.PACKING)) return;
export function registerTodoTools(server: McpServer, userId: number): void {
// --- TODOS ---
server.registerTool(
if (R) server.registerTool(
'list_todos',
{
description: 'List all to-do items for a trip, ordered by position.',
@@ -32,7 +40,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'create_todo',
{
description: 'Create a new to-do item for a trip.',
@@ -56,7 +64,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_todo',
{
description: 'Update an existing to-do item. Only provided fields are changed; omitted fields stay as-is. Pass null to clear a nullable field.',
@@ -88,7 +96,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'toggle_todo',
{
description: 'Mark a to-do item as checked (done) or unchecked.',
@@ -109,7 +117,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_todo',
{
description: 'Delete a to-do item.',
@@ -129,7 +137,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'reorder_todos',
{
description: 'Reorder to-do items within a trip by providing a new ordered list of item IDs.',
@@ -147,7 +155,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'get_todo_category_assignees',
{
description: 'Get the default assignees configured per to-do category for a trip.',
@@ -163,7 +171,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'set_todo_category_assignees',
{
description: 'Set the default assignees for a to-do category on a trip. Pass an empty array to clear.',
+36 -31
View File
@@ -13,22 +13,27 @@ import {
createOrUpdateShareLink, getShareLink, deleteShareLink,
} from '../../services/shareService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { countMessages, listPolls } from '../../services/collabService';
import {
listItems as listTodoItems,
} from '../../services/todoService';
import { listFiles } from '../../services/fileService';
import {
safeBroadcast, MAX_MCP_TRIP_DAYS,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canReadTrips, canWrite, canDeleteTrips } from '../scopes';
export function registerTripTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canReadTrips(scopes);
const W = canWrite(scopes, 'trips');
const D = canDeleteTrips(scopes);
export function registerTripTools(server: McpServer, userId: number): void {
// --- TRIPS ---
server.registerTool(
if (W) server.registerTool(
'create_trip',
{
description: 'Create a new trip. Returns the created trip with its generated days.',
@@ -61,7 +66,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_trip',
{
description: 'Update an existing trip\'s details.',
@@ -94,7 +99,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (D) server.registerTool(
'delete_trip',
{
description: 'Delete a trip. Only the trip owner can delete it.',
@@ -111,7 +116,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_trips',
{
description: 'List all trips the current user owns or is a member of. Use this for trip discovery before calling get_trip_summary.',
@@ -128,10 +133,10 @@ export function registerTripTools(server: McpServer, userId: number): void {
// --- TRIP SUMMARY ---
server.registerTool(
if (R) server.registerTool(
'get_trip_summary',
{
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, full budget line items with totals, full packing list with checked status, reservations, collab notes, to-do items, files, and collab poll/message counts. Use this as a context loader before planning or modifying a trip.',
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
@@ -141,31 +146,31 @@ export function registerTripTools(server: McpServer, userId: number): void {
if (!canAccessTrip(tripId, userId)) return noAccess();
const summary = getTripSummary(tripId);
if (!summary) return noAccess();
const todos = listTodoItems(tripId);
const files = listFiles(tripId, false).map((f: any) => ({
id: f.id,
original_name: f.original_name,
mime_type: f.mime_type,
file_size: f.file_size,
starred: !!f.starred,
deleted: !!f.deleted_at,
created_at: f.created_at,
}));
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
const todos = packingEnabled ? listTodoItems(tripId) : [];
let pollCount = 0;
if (isAddonEnabled('collab')) {
pollCount = listPolls(tripId).length;
}
let messageCount = 0;
if (isAddonEnabled('collab')) {
if (collabEnabled) {
pollCount = listPolls(tripId).length;
messageCount = countMessages(tripId);
}
return ok({ ...summary, todos, files, pollCount, messageCount });
return ok({
...summary,
packing: packingEnabled ? summary.packing : undefined,
budget: budgetEnabled ? summary.budget : undefined,
collab_notes: collabEnabled ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
});
}
);
// --- TRIP MEMBERS, COPY, ICS, SHARE ---
server.registerTool(
if (R) server.registerTool(
'list_trip_members',
{
description: 'List all members of a trip (owner + collaborators).',
@@ -183,7 +188,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'add_trip_member',
{
description: 'Add a user to a trip by their username or email address. Only the trip owner can do this.',
@@ -210,7 +215,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'remove_trip_member',
{
description: 'Remove a member from a trip. Only the trip owner can do this.',
@@ -232,7 +237,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'copy_trip',
{
description: 'Duplicate a trip (all days, places, itinerary, packing, budget, reservations, day notes). Packing items are reset to unchecked. Returns the new trip.',
@@ -255,7 +260,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'export_trip_ics',
{
description: 'Export a trip\'s itinerary and reservations as iCalendar (.ics) format text. Useful for importing into calendar apps.',
@@ -275,7 +280,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'get_share_link',
{
description: 'Get the current public share link for a trip, including its permission flags. Returns null if no share link exists.',
@@ -291,7 +296,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'create_share_link',
{
description: 'Create or update the public share link for a trip. Set permission flags to control what is visible to guests.',
@@ -319,7 +324,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_share_link',
{
description: 'Revoke the public share link for a trip. Guests will no longer be able to access the shared view.',
+29 -24
View File
@@ -13,15 +13,20 @@ import {
getCountries as getHolidayCountries, getHolidays,
} from '../../services/vacayService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerVacayTools(server: McpServer, userId: number): void {
if (isAddonEnabled('vacay')) {
server.registerTool(
export function registerVacayTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'vacay');
const W = canWrite(scopes, 'vacay');
if (isAddonEnabled(ADDON_IDS.VACAY)) {
if (R) server.registerTool(
'get_vacay_plan',
{
description: "Get the current user's active vacation plan (own or joined).",
@@ -34,7 +39,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_vacay_plan',
{
description: 'Update vacation plan settings (weekends blocking, holidays, carry-over).',
@@ -55,7 +60,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'set_vacay_color',
{
description: "Set the current user's color in the vacation plan calendar.",
@@ -72,7 +77,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'get_available_vacay_users',
{
description: 'List users who can be invited to the current vacation plan.',
@@ -86,7 +91,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'send_vacay_invite',
{
description: 'Invite a user to join the vacation plan by their user ID.',
@@ -106,7 +111,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'accept_vacay_invite',
{
description: 'Accept a pending invitation to join another user\'s vacation plan.',
@@ -123,7 +128,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'decline_vacay_invite',
{
description: 'Decline a pending vacation plan invitation.',
@@ -138,7 +143,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'cancel_vacay_invite',
{
description: 'Cancel an outgoing invitation (owner cancels invite they sent).',
@@ -155,7 +160,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'dissolve_vacay_plan',
{
description: 'Dissolve the shared plan — all members are removed and everyone returns to their own individual plan.',
@@ -169,7 +174,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_vacay_years',
{
description: 'List calendar years tracked in the current vacation plan.',
@@ -183,7 +188,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'add_vacay_year',
{
description: 'Add a calendar year to the vacation plan.',
@@ -200,7 +205,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_vacay_year',
{
description: 'Remove a calendar year from the vacation plan.',
@@ -217,7 +222,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'get_vacay_entries',
{
description: 'Get all vacation day entries for a plan and year.',
@@ -233,7 +238,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'toggle_vacay_entry',
{
description: 'Toggle a day on or off as a vacation day for the current user.',
@@ -250,7 +255,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'toggle_company_holiday',
{
description: 'Toggle a date as a company holiday for the whole plan.',
@@ -268,7 +273,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'get_vacay_stats',
{
description: 'Get vacation statistics for a specific year (days used, remaining, carried over).',
@@ -284,7 +289,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_vacay_stats',
{
description: 'Update the vacation day allowance for a specific user and year.',
@@ -302,7 +307,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'add_holiday_calendar',
{
description: 'Add a public holiday calendar (by region code) to the vacation plan.',
@@ -322,7 +327,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_holiday_calendar',
{
description: 'Update label or color for a holiday calendar.',
@@ -342,7 +347,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_holiday_calendar',
{
description: 'Remove a holiday calendar from the vacation plan.',
@@ -359,7 +364,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_holiday_countries',
{
description: 'List countries available for public holiday calendars.',
@@ -373,7 +378,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_holidays',
{
description: 'List public holidays for a country and year.',
+296
View File
@@ -0,0 +1,296 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { isAddonEnabled } from '../services/adminService';
import { ALL_SCOPES, SCOPE_INFO } from '../mcp/scopes';
import { ADDON_IDS } from '../addons';
import {
validateAuthorizeRequest,
createAuthCode,
consumeAuthCode,
saveConsent,
issueTokens,
refreshTokens,
revokeToken,
verifyPKCE,
authenticateClient,
listOAuthClients,
createOAuthClient,
deleteOAuthClient,
rotateOAuthClientSecret,
listOAuthSessions,
revokeSession,
AuthorizeParams,
} from '../services/oauthService';
import { getAppUrl } from '../services/oidcService';
// ---------------------------------------------------------------------------
// Public router: /.well-known, /oauth/token, /oauth/revoke
// ---------------------------------------------------------------------------
export const oauthPublicRouter = express.Router();
// RFC 8414 discovery document
oauthPublicRouter.get('/.well-known/oauth-authorization-server', (_req: Request, res: Response) => {
const base = (getAppUrl() || '').replace(/\/+$/, '');
res.json({
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
revocation_endpoint: `${base}/oauth/revoke`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post'],
scopes_supported: ALL_SCOPES,
scope_descriptions: Object.fromEntries(
ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label])
),
});
});
// Token endpoint — handles authorization_code and refresh_token grants
oauthPublicRouter.post('/oauth/token', (req: Request, res: Response) => {
// Accept both JSON and application/x-www-form-urlencoded
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token } = body;
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return res.status(403).json({ error: 'mcp_disabled', error_description: 'MCP is not enabled' });
}
if (!client_id || !client_secret) {
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id and client_secret are required' });
}
// ---- authorization_code grant ----
if (grant_type === 'authorization_code') {
if (!code || !redirect_uri || !code_verifier) {
return res.status(400).json({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' });
}
const pending = consumeAuthCode(code);
if (!pending) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code is invalid or expired' });
}
if (pending.clientId !== client_id) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'client_id mismatch' });
}
if (pending.redirectUri !== redirect_uri) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' });
}
// Verify client secret
if (!authenticateClient(client_id, client_secret)) {
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
// Verify PKCE
if (!verifyPKCE(code_verifier, pending.codeChallenge)) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
}
const tokens = issueTokens(client_id, pending.userId, pending.scopes);
return res.json(tokens);
}
// ---- refresh_token grant ----
if (grant_type === 'refresh_token') {
if (!refresh_token) {
return res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
}
const result = refreshTokens(refresh_token, client_id, client_secret);
if (result.error) {
return res.status(result.status || 400).json({
error: result.error,
error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired',
});
}
return res.json(result.tokens);
}
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
});
// Token revocation endpoint (RFC 7009)
oauthPublicRouter.post('/oauth/revoke', (req: Request, res: Response) => {
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { token, client_id, client_secret } = body;
if (!token || !client_id || !client_secret) {
return res.status(400).json({ error: 'invalid_request', error_description: 'token, client_id, and client_secret are required' });
}
if (!authenticateClient(client_id, client_secret)) {
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
revokeToken(token, client_id);
// RFC 7009 §2.2: always respond 200 even if token was already revoked or not found
return res.status(200).json({});
});
// ---------------------------------------------------------------------------
// API router: /api/oauth/* — authenticated endpoints used by the SPA
// ---------------------------------------------------------------------------
export const oauthApiRouter = express.Router();
// SPA calls this on page load to validate OAuth params before rendering consent UI
oauthApiRouter.get('/authorize/validate', (req: Request, res: Response) => {
const params = req.query as Partial<AuthorizeParams>;
const userId = (req as any).cookies?.trek_session
? (() => {
try {
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('../config');
const decoded = jwt.verify((req as any).cookies.trek_session, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const userRow = require('../db/database').db.prepare('SELECT id FROM users WHERE id = ?').get(decoded.id) as { id: number } | undefined;
return userRow?.id ?? null;
} catch { return null; }
})()
: null;
const result = validateAuthorizeRequest(
{
response_type: params.response_type || '',
client_id: params.client_id || '',
redirect_uri: params.redirect_uri || '',
scope: params.scope || '',
state: params.state,
code_challenge: params.code_challenge || '',
code_challenge_method: params.code_challenge_method || '',
},
userId,
);
if (!result.valid) {
return res.status(400).json(result);
}
return res.json(result);
});
// User submits consent (approve or deny) — requires cookie auth
oauthApiRouter.post('/authorize', authenticate, (req: Request, res: Response) => {
const { user } = req as AuthRequest;
const {
client_id, redirect_uri, scope, state,
code_challenge, code_challenge_method, approved,
} = req.body as {
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
code_challenge: string;
code_challenge_method: string;
approved: boolean;
};
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return res.status(403).json({ error: 'MCP is not enabled' });
}
if (!approved) {
// User denied — redirect with error
const url = new URL(redirect_uri);
url.searchParams.set('error', 'access_denied');
url.searchParams.set('error_description', 'User denied the authorization request');
if (state) url.searchParams.set('state', state);
return res.json({ redirect: url.toString() });
}
// Re-validate all params (server-side re-check after user action)
const params: AuthorizeParams = {
response_type: 'code',
client_id,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method,
};
const validation = validateAuthorizeRequest(params, user.id);
if (!validation.valid) {
return res.status(400).json({ error: validation.error, error_description: validation.error_description });
}
const scopes = validation.scopes!;
// Store consent so subsequent requests skip the screen
saveConsent(client_id, user.id, scopes);
// Issue auth code
const code = createAuthCode({
clientId: client_id,
userId: user.id,
redirectUri: redirect_uri,
scopes,
codeChallenge: code_challenge,
codeChallengeMethod: 'S256',
});
const url = new URL(redirect_uri);
url.searchParams.set('code', code);
if (state) url.searchParams.set('state', state);
return res.json({ redirect: url.toString() });
});
// ---- OAuth client CRUD ----
oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
return res.json({ clients: listOAuthClients(user.id) });
});
oauthApiRouter.post('/clients', authenticate, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const { name, redirect_uris, allowed_scopes } = req.body as {
name: string;
redirect_uris: string[];
allowed_scopes: string[];
};
const result = createOAuthClient(user.id, name, redirect_uris, allowed_scopes);
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.status(201).json(result);
});
oauthApiRouter.post('/clients/:id/rotate', authenticate, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const result = rotateOAuthClientSecret(user.id, req.params.id);
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ client_secret: result.client_secret });
});
oauthApiRouter.delete('/clients/:id', authenticate, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const result = deleteOAuthClient(user.id, req.params.id);
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ success: true });
});
// ---- Active OAuth sessions ----
oauthApiRouter.get('/sessions', authenticate, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
return res.json({ sessions: listOAuthSessions(user.id) });
});
oauthApiRouter.delete('/sessions/:id', authenticate, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const result = revokeSession(user.id, Number(req.params.id));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ success: true });
});
+471
View File
@@ -0,0 +1,471 @@
import crypto, { randomBytes, createHash, randomUUID } from 'crypto';
import { db } from '../db/database';
import { isAddonEnabled } from './adminService';
import { validateScopes } from '../mcp/scopes';
import { ADDON_IDS } from '../addons';
import { User } from '../types';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ACCESS_TOKEN_TTL_S = 60 * 60; // 1 hour
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days rolling
const AUTH_CODE_TTL_MS = 2 * 60 * 1000; // 2 minutes
// ---------------------------------------------------------------------------
// In-memory auth code store (short-lived, no need for DB persistence)
// ---------------------------------------------------------------------------
interface PendingCode {
clientId: string;
userId: number;
redirectUri: string;
scopes: string[];
codeChallenge: string;
codeChallengeMethod: 'S256';
expiresAt: number;
}
const pendingCodes = new Map<string, PendingCode>();
setInterval(() => {
const now = Date.now();
for (const [key, entry] of pendingCodes) {
if (now > entry.expiresAt) pendingCodes.delete(key);
}
}, 60_000).unref();
// ---------------------------------------------------------------------------
// DB row types
// ---------------------------------------------------------------------------
interface OAuthClientRow {
id: string;
user_id: number;
name: string;
client_id: string;
client_secret_hash: string;
redirect_uris: string; // JSON array
allowed_scopes: string; // JSON array
created_at: string;
}
interface OAuthTokenRow {
id: number;
client_id: string;
user_id: number;
access_token_hash: string;
refresh_token_hash: string;
scopes: string; // JSON array
access_token_expires_at: string;
refresh_token_expires_at: string;
revoked_at: string | null;
}
// ---------------------------------------------------------------------------
// Token helpers
// ---------------------------------------------------------------------------
function hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
function generateAccessToken(): string {
return 'trekoa_' + randomBytes(24).toString('hex');
}
function generateRefreshToken(): string {
return 'trekrf_' + randomBytes(24).toString('hex');
}
// ---------------------------------------------------------------------------
// Client management (self-service, gated by MCP addon)
// ---------------------------------------------------------------------------
export function listOAuthClients(userId: number): Record<string, unknown>[] {
const rows = db.prepare(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
).all(userId) as OAuthClientRow[];
return rows.map(r => ({
...r,
redirect_uris: JSON.parse(r.redirect_uris),
allowed_scopes: JSON.parse(r.allowed_scopes),
}));
}
export function createOAuthClient(
userId: number,
name: string,
redirectUris: string[],
allowedScopes: string[],
): { error?: string; status?: number; client?: Record<string, unknown> } {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
if (!redirectUris || redirectUris.length === 0) return { error: 'At least one redirect URI is required', status: 400 };
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
for (const uri of redirectUris) {
try {
const url = new URL(uri);
if (url.protocol !== 'https:' && url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') {
return { error: `Redirect URI must use HTTPS (localhost exempt): ${uri}`, status: 400 };
}
} catch {
return { error: `Invalid redirect URI: ${uri}`, status: 400 };
}
}
if (!allowedScopes || allowedScopes.length === 0) return { error: 'At least one scope is required', status: 400 };
const { valid, invalid } = validateScopes(allowedScopes);
if (!valid) return { error: `Invalid scopes: ${invalid.join(', ')}`, status: 400 };
const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?').get(userId) as { count: number }).count;
if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', status: 400 };
const id = randomUUID();
const clientId = randomUUID();
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
const secretHash = hashToken(rawSecret);
db.prepare(
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes));
const row = db.prepare(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at FROM oauth_clients WHERE id = ?'
).get(id) as OAuthClientRow;
return {
client: {
id: row.id,
user_id: row.user_id,
name: row.name,
client_id: row.client_id,
redirect_uris: JSON.parse(row.redirect_uris),
allowed_scopes: JSON.parse(row.allowed_scopes),
created_at: row.created_at,
client_secret: rawSecret, // shown once — not stored in plain text
},
};
}
export function rotateOAuthClientSecret(
userId: number,
clientRowId: string,
): { error?: string; status?: number; client_secret?: string } {
const row = db.prepare('SELECT id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
if (!row) return { error: 'Client not found', status: 404 };
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
const secretHash = hashToken(rawSecret);
db.prepare('UPDATE oauth_clients SET client_secret_hash = ? WHERE id = ?').run(secretHash, clientRowId);
// Revoke all existing tokens for this client so old sessions are invalidated
db.prepare("UPDATE oauth_tokens SET revoked_at = datetime('now') WHERE client_id = (SELECT client_id FROM oauth_clients WHERE id = ?) AND revoked_at IS NULL").run(clientRowId);
return { client_secret: rawSecret };
}
export function deleteOAuthClient(
userId: number,
clientRowId: string,
): { error?: string; status?: number; success?: boolean } {
const row = db.prepare('SELECT id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId);
if (!row) return { error: 'Client not found', status: 404 };
db.prepare('DELETE FROM oauth_clients WHERE id = ?').run(clientRowId);
return { success: true };
}
// ---------------------------------------------------------------------------
// Auth code (in-memory, 2-minute TTL)
// ---------------------------------------------------------------------------
export function createAuthCode(params: {
clientId: string;
userId: number;
redirectUri: string;
scopes: string[];
codeChallenge: string;
codeChallengeMethod: 'S256';
}): string {
const rawCode = randomBytes(32).toString('hex');
pendingCodes.set(rawCode, { ...params, expiresAt: Date.now() + AUTH_CODE_TTL_MS });
return rawCode;
}
export function consumeAuthCode(code: string): PendingCode | null {
const entry = pendingCodes.get(code);
if (!entry) return null;
pendingCodes.delete(code);
if (Date.now() > entry.expiresAt) return null;
return entry;
}
// ---------------------------------------------------------------------------
// Consent management
// ---------------------------------------------------------------------------
export function getConsent(clientId: string, userId: number): string[] | null {
const row = db.prepare(
'SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?'
).get(clientId, userId) as { scopes: string } | undefined;
return row ? JSON.parse(row.scopes) : null;
}
export function saveConsent(clientId: string, userId: number, scopes: string[]): void {
db.prepare(
'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)'
).run(clientId, userId, JSON.stringify(scopes));
}
export function isConsentSufficient(existingScopes: string[], requestedScopes: string[]): boolean {
return requestedScopes.every(s => existingScopes.includes(s));
}
// ---------------------------------------------------------------------------
// Token issuance
// ---------------------------------------------------------------------------
export function issueTokens(
clientId: string,
userId: number,
scopes: string[],
): {
access_token: string;
refresh_token: string;
token_type: 'Bearer';
expires_in: number;
scope: string;
} {
const rawAccess = generateAccessToken();
const rawRefresh = generateRefreshToken();
const accessHash = hashToken(rawAccess);
const refreshHash = hashToken(rawRefresh);
const now = new Date();
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
const refreshExpiry = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS);
db.prepare(`
INSERT INTO oauth_tokens
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, access_token_expires_at, refresh_token_expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), accessExpiry.toISOString(), refreshExpiry.toISOString());
return {
access_token: rawAccess,
refresh_token: rawRefresh,
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_TTL_S,
scope: scopes.join(' '),
};
}
// ---------------------------------------------------------------------------
// Token verification (used by MCP handler on every request)
// ---------------------------------------------------------------------------
export interface OAuthTokenInfo {
user: User;
scopes: string[];
}
export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
const hash = hashToken(rawToken);
const row = db.prepare(`
SELECT ot.scopes, ot.revoked_at, ot.access_token_expires_at,
u.id, u.username, u.email, u.role
FROM oauth_tokens ot
JOIN users u ON ot.user_id = u.id
WHERE ot.access_token_hash = ?
`).get(hash) as (OAuthTokenRow & { username: string; email: string; role: string }) | undefined;
if (!row) return null;
if (row.revoked_at) return null;
if (new Date(row.access_token_expires_at) < new Date()) return null;
return {
user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' },
scopes: JSON.parse(row.scopes),
};
}
// ---------------------------------------------------------------------------
// Token refresh (rotation)
// ---------------------------------------------------------------------------
export function refreshTokens(
rawRefreshToken: string,
clientId: string,
clientSecret: string,
): { error?: string; status?: number; tokens?: ReturnType<typeof issueTokens> } {
const client = db.prepare('SELECT client_id, client_secret_hash FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
if (!client) return { error: 'invalid_client', status: 401 };
if (hashToken(clientSecret) !== client.client_secret_hash) return { error: 'invalid_client', status: 401 };
const hash = hashToken(rawRefreshToken);
const row = db.prepare(`
SELECT id, client_id, user_id, scopes, refresh_token_expires_at, revoked_at
FROM oauth_tokens WHERE refresh_token_hash = ?
`).get(hash) as OAuthTokenRow | undefined;
if (!row) return { error: 'invalid_grant', status: 400 };
if (row.client_id !== clientId) return { error: 'invalid_grant', status: 400 };
if (row.revoked_at) return { error: 'invalid_grant', status: 400 };
if (new Date(row.refresh_token_expires_at) < new Date()) return { error: 'invalid_grant', status: 400 };
// Revoke old pair immediately (rotation)
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id);
return { tokens: issueTokens(clientId, row.user_id, JSON.parse(row.scopes)) };
}
// ---------------------------------------------------------------------------
// Token revocation
// ---------------------------------------------------------------------------
export function revokeToken(rawToken: string, clientId: string): void {
const hash = hashToken(rawToken);
db.prepare(`
UPDATE oauth_tokens
SET revoked_at = CURRENT_TIMESTAMP
WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?
`).run(hash, hash, clientId);
}
// ---------------------------------------------------------------------------
// Active session listing (for user settings page)
// ---------------------------------------------------------------------------
export function listOAuthSessions(userId: number): Record<string, unknown>[] {
const rows = db.prepare(`
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.scopes,
ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
FROM oauth_tokens ot
JOIN oauth_clients oc ON ot.client_id = oc.client_id
WHERE ot.user_id = ?
AND ot.revoked_at IS NULL
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
ORDER BY ot.created_at DESC
`).all(userId) as Record<string, unknown>[];
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes as string) }));
}
export function revokeSession(
userId: number,
sessionId: number,
): { error?: string; status?: number; success?: boolean } {
const row = db.prepare('SELECT id FROM oauth_tokens WHERE id = ? AND user_id = ?').get(sessionId, userId);
if (!row) return { error: 'Session not found', status: 404 };
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(sessionId);
return { success: true };
}
// ---------------------------------------------------------------------------
// Authorize request validation (option A: called by SPA via GET /api/oauth/authorize/validate)
// ---------------------------------------------------------------------------
export interface AuthorizeParams {
response_type: string;
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
code_challenge: string;
code_challenge_method: string;
}
export interface ValidateAuthorizeResult {
valid: boolean;
error?: string;
error_description?: string;
client?: { name: string; allowed_scopes: string[] };
scopes?: string[];
/** true when user is logged in but consent UI must be shown */
consentRequired?: boolean;
/** true when the request is valid but user is not authenticated */
loginRequired?: boolean;
}
export function validateAuthorizeRequest(
params: AuthorizeParams,
userId: number | null,
): ValidateAuthorizeResult {
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return { valid: false, error: 'mcp_disabled', error_description: 'MCP is not enabled on this server' };
}
if (params.response_type !== 'code') {
return { valid: false, error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' };
}
if (!params.code_challenge || params.code_challenge_method !== 'S256') {
return { valid: false, error: 'invalid_request', error_description: 'PKCE with code_challenge_method=S256 is required (OAuth 2.1)' };
}
if (!params.client_id) {
return { valid: false, error: 'invalid_request', error_description: 'client_id is required' };
}
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(params.client_id) as OAuthClientRow | undefined;
if (!client) {
return { valid: false, error: 'invalid_client', error_description: 'Unknown client_id' };
}
const allowedUris: string[] = JSON.parse(client.redirect_uris);
if (!params.redirect_uri || !allowedUris.includes(params.redirect_uri)) {
return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' };
}
const requestedScopes = (params.scope || '').split(' ').filter(Boolean);
if (requestedScopes.length === 0) {
return { valid: false, error: 'invalid_scope', error_description: 'At least one scope is required' };
}
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
const disallowed = requestedScopes.filter(s => !allowedScopes.includes(s));
if (disallowed.length > 0) {
return { valid: false, error: 'invalid_scope', error_description: `Scopes not permitted for this client: ${disallowed.join(', ')}` };
}
if (userId === null) {
return {
valid: true,
client: { name: client.name, allowed_scopes: allowedScopes },
scopes: requestedScopes,
loginRequired: true,
};
}
const existingConsent = getConsent(params.client_id, userId);
const consentRequired = !existingConsent || !isConsentSufficient(existingConsent, requestedScopes);
return {
valid: true,
client: { name: client.name, allowed_scopes: allowedScopes },
scopes: requestedScopes,
consentRequired,
};
}
// ---------------------------------------------------------------------------
// PKCE verification
// ---------------------------------------------------------------------------
export function verifyPKCE(codeVerifier: string, codeChallenge: string): boolean {
const expected = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
return expected === codeChallenge;
}
// ---------------------------------------------------------------------------
// Client authentication (for token endpoint)
// ---------------------------------------------------------------------------
export function authenticateClient(clientId: string, clientSecret: string): OAuthClientRow | null {
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
if (!client) return null;
if (hashToken(clientSecret) !== client.client_secret_hash) return null;
return client;
}