diff --git a/.gitignore b/.gitignore index 0c7fac9..d2c82c7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ dist/ .wrangler/ *.pem *.cpuprofile + +public/console/ +public/index.html +.console-types/ diff --git a/biome.json b/biome.json index a0dc9cf..a792964 100644 --- a/biome.json +++ b/biome.json @@ -10,7 +10,10 @@ "!!public/cloudflare/openapi.json", "!!providers/cloudflare/exclusions.json", "!!providers/cloudflare/oauth-scopes.json", - "!!src/providers/cloudflare/operation-permissions.ts" + "!!src/providers/cloudflare/operation-permissions.ts", + "!!public/console", + "!!public/index.html", + "!!.console-types" ] }, "formatter": { @@ -21,7 +24,9 @@ }, "linter": { "enabled": true, - "rules": { "preset": "recommended" } + "rules": { + "preset": "recommended" + } }, "javascript": { "formatter": { diff --git a/docs/console.md b/docs/console.md new file mode 100644 index 0000000..cdb181b --- /dev/null +++ b/docs/console.md @@ -0,0 +1,23 @@ +# Public Adapter directory + +The root page lists supported providers, capabilities, maturity, and deployment availability. +Visitors can search providers and filter by category without signing in. + +The frontend uses Hono JSX DOM and a Hono RPC client for the public +`GET /console/api/providers` route. The route projects public metadata from `src/providers/registry.ts`, the same +registry used by the Worker to load provider modules. Each registration supplies +its factory, display metadata, and required configuration. Adding a registration +and deploying automatically adds it to the directory; no frontend list or icon +map needs updating. Merely creating an unregistered source file does not enable +a provider. + +The route returns public metadata +and configuration availability, never credentials or account information. + +Run `pnpm dev` and open the local Worker URL to review the directory. Run +`pnpm build` to build the page and validate the Worker bundle. `pnpm deploy` +builds and publishes both using the existing Worker assets binding. + +Review the deployed root page on desktop and mobile, search for GitHub, select +Productivity, and verify that each card links to its provider documentation. +This page requires no application registration, secrets, or database migration. diff --git a/package.json b/package.json index 6975517..f10a3a1 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,13 @@ }, "packageManager": "pnpm@10.30.2", "scripts": { - "build": "wrangler deploy --dry-run --outdir dist", - "dev": "wrangler dev", - "deploy": "wrangler deploy", + "build": "pnpm run build:console && wrangler deploy --dry-run --outdir dist", + "dev": "pnpm run build:console && wrangler dev", + "deploy": "pnpm run build:console && wrangler deploy", "db:migrate:production": "wrangler d1 migrations apply DB --remote", "types": "wrangler types --strict-vars=false --env-file .dev.vars.example", "types:check": "wrangler types --strict-vars=false --env-file .dev.vars.example --check", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc -p tsconfig.console.json && tsc -p web/tsconfig.json --noEmit", "test": "vitest run", "test:unit": "vitest run --project unit", "test:integration": "vitest run --project integration", @@ -24,7 +24,8 @@ "lint:fix": "biome check --write .", "docs:check": "ruby scripts/check-docs.rb", "sync:github-openapi": "node scripts/sync-github-openapi.mjs", - "sync:cloudflare-openapi": "node scripts/sync-cloudflare-openapi.mjs" + "sync:cloudflare-openapi": "node scripts/sync-cloudflare-openapi.mjs", + "build:console": "node scripts/build-console.mjs" }, "dependencies": { "graphql": "^17.0.2", @@ -38,6 +39,7 @@ "@cloudflare/workers-types": "5.20260808.1", "@types/node": "^24.13.3", "@vitest/coverage-v8": "^4.1.2", + "esbuild": "^0.28.2", "typescript": "^7.0.2", "vitest": "^4.1.2", "wrangler": "4.120.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16ff344..d84e7ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,12 +36,15 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.2 version: 4.1.10(vitest@4.1.10) + esbuild: + specifier: ^0.28.2 + version: 0.28.2 typescript: specifier: ^7.0.2 version: 7.0.2 vitest: specifier: ^4.1.2 - version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11)) + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(tsx@4.23.11)) wrangler: specifier: 4.120.0 version: 4.120.0(@cloudflare/workers-types@5.20260808.1) @@ -192,156 +195,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -841,6 +1000,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1278,7 +1442,7 @@ snapshots: cjs-module-lexer: 1.2.3 esbuild: 0.28.1 miniflare: 5.20260801.1-alpha - vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11)) + vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(tsx@4.23.11)) wrangler: 4.120.0(@cloudflare/workers-types@5.20260808.1) zod: 4.4.3 transitivePeerDependencies: @@ -1315,81 +1479,159 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.2': @@ -1659,7 +1901,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11)) + vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(tsx@4.23.11)) '@vitest/expect@4.1.10': dependencies: @@ -1670,13 +1912,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11))': + '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(tsx@4.23.11))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(tsx@4.23.11) '@vitest/pretty-format@4.1.10': dependencies: @@ -1755,6 +1997,35 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1972,7 +2243,7 @@ snapshots: tsx@4.23.11: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 optional: true @@ -2008,7 +2279,7 @@ snapshots: dependencies: pathe: 2.0.3 - vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11): + vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(tsx@4.23.11): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -2017,14 +2288,14 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 - esbuild: 0.28.1 + esbuild: 0.28.2 fsevents: 2.3.3 tsx: 4.23.11 - vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11)): + vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(tsx@4.23.11)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11)) + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(tsx@4.23.11)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -2041,7 +2312,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(tsx@4.23.11) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..dcc05be --- /dev/null +++ b/public/_headers @@ -0,0 +1,8 @@ +/ + Cache-Control: no-cache + Content-Security-Policy: default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self'; connect-src 'self' https://cloudflareinsights.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self' + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + +/console/* + X-Content-Type-Options: nosniff diff --git a/public/realmroot-logo.png b/public/realmroot-logo.png new file mode 100644 index 0000000..7b010fe Binary files /dev/null and b/public/realmroot-logo.png differ diff --git a/scripts/build-console.mjs b/scripts/build-console.mjs new file mode 100644 index 0000000..3b308d8 --- /dev/null +++ b/scripts/build-console.mjs @@ -0,0 +1,16 @@ +import { copyFile, mkdir } from 'node:fs/promises' +import { build } from 'esbuild' + +await mkdir('public/console', { recursive: true }) +await build({ + entryPoints: ['web/main.tsx'], + bundle: true, + minify: true, + format: 'esm', + target: ['es2022'], + outdir: 'public/console', + jsx: 'automatic', + jsxImportSource: 'hono/jsx/dom', + tsconfig: 'web/tsconfig.json', +}) +await copyFile('web/index.html', 'public/index.html') diff --git a/src/console/routes.ts b/src/console/routes.ts new file mode 100644 index 0000000..b892466 --- /dev/null +++ b/src/console/routes.ts @@ -0,0 +1,11 @@ +import { Hono } from 'hono' +import { providerDirectory } from '../providers/registry.js' + +export const consoleRoutes = new Hono<{ Bindings: Env }>().get('/api/providers', (c) => { + c.header('Cache-Control', 'no-store') + return c.json({ + items: providerDirectory(c.env), + }) +}) + +export type ConsoleApp = typeof consoleRoutes diff --git a/src/providers/registry.ts b/src/providers/registry.ts new file mode 100644 index 0000000..a359208 --- /dev/null +++ b/src/providers/registry.ts @@ -0,0 +1,131 @@ +import type { AdapterModule } from '../core/adapter.js' +import { context7Definition } from './context7/definition.js' +import { fastioDefinition } from './fastio/definition.js' +import { + createCloudflareModules, + createGitHubModules, + createLinearModules, + createManagedModules, + type ProviderRuntime, +} from './runtime.js' +import { search1ApiDefinition } from './search1api/definition.js' +import { todoistDefinition } from './todoist/definition.js' + +export type ProviderRegistration = { + id: string + name: string + category: string + description: string + capabilities: readonly string[] + maturity: string + color: string + mark: string + requiredSecrets: readonly (keyof Env)[] + create(runtime: ProviderRuntime): Promise +} + +export const providerRegistry: readonly ProviderRegistration[] = [ + { + id: 'github', + name: 'GitHub', + category: 'Development', + description: 'Repositories, pull requests, issues, and code. Give your Agent a place on your development team.', + capabilities: ['Repositories', 'Pull requests', 'Git'], + maturity: 'Alpha', + color: '#d8dee9', + mark: 'GH', + requiredSecrets: ['GITHUB_APP_ID', 'GITHUB_PRIVATE_KEY', 'GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET'], + create: createGitHubModules, + }, + { + id: 'linear', + name: 'Linear', + category: 'Productivity', + description: 'Keep projects moving with issues, teams, and workflows, with visible Agent attribution.', + capabilities: ['Issues', 'Projects', 'Teams'], + maturity: 'Experimental', + color: '#a69cff', + mark: 'L', + requiredSecrets: ['LINEAR_CLIENT_ID', 'LINEAR_CLIENT_SECRET', 'LINEAR_CREDENTIAL_ENCRYPTION_KEY'], + create: createLinearModules, + }, + { + id: 'cloudflare', + name: 'Cloudflare', + category: 'Infrastructure', + description: 'Bring Agent access to your infrastructure, from Workers and DNS to databases and storage.', + capabilities: ['Workers', 'DNS', 'Storage'], + maturity: 'Experimental', + color: '#ffac69', + mark: 'CF', + requiredSecrets: ['CLOUDFLARE_CLIENT_ID', 'CLOUDFLARE_CLIENT_SECRET', 'CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY'], + create: createCloudflareModules, + }, + { + id: context7Definition.id, + name: context7Definition.name, + category: 'Knowledge', + description: 'Put current library documentation and relevant code examples within your Agent’s reach.', + capabilities: ['Documentation', 'Libraries'], + maturity: 'Experimental', + color: '#91ddb0', + mark: 'C7', + requiredSecrets: ['CONTEXT7_CREDENTIAL_ENCRYPTION_KEY'], + create: (runtime) => + createManagedModules(context7Definition, runtime.env.CONTEXT7_CREDENTIAL_ENCRYPTION_KEY, runtime), + }, + { + id: todoistDefinition.id, + name: todoistDefinition.name, + category: 'Productivity', + description: 'Let your Agent read your tasks and projects to understand what needs your attention.', + capabilities: ['Tasks · read only', 'Projects'], + maturity: 'Experimental', + color: '#ff9694', + mark: 'T', + requiredSecrets: ['TODOIST_CREDENTIAL_ENCRYPTION_KEY'], + create: (runtime) => + createManagedModules(todoistDefinition, runtime.env.TODOIST_CREDENTIAL_ENCRYPTION_KEY, runtime), + }, + { + id: search1ApiDefinition.id, + name: search1ApiDefinition.name, + category: 'Knowledge', + description: 'Search the web and retrieve information through one connected account.', + capabilities: ['Web search', 'Information'], + maturity: 'Experimental', + color: '#89b9ff', + mark: 'S1', + requiredSecrets: ['SEARCH1API_CREDENTIAL_ENCRYPTION_KEY'], + create: (runtime) => + createManagedModules(search1ApiDefinition, runtime.env.SEARCH1API_CREDENTIAL_ENCRYPTION_KEY, runtime), + }, + { + id: fastioDefinition.id, + name: fastioDefinition.name, + category: 'Storage', + description: 'Bring shared workspaces and files into your Agent’s workflow.', + capabilities: ['Workspaces', 'Files'], + maturity: 'Experimental', + color: '#f1cb84', + mark: 'F', + requiredSecrets: ['FASTIO_CREDENTIAL_ENCRYPTION_KEY'], + create: (runtime) => createManagedModules(fastioDefinition, runtime.env.FASTIO_CREDENTIAL_ENCRYPTION_KEY, runtime), + }, +] + +export function providerDirectory(env: Env, registrations: readonly ProviderRegistration[] = providerRegistry) { + return registrations.map(({ requiredSecrets, create: _create, ...display }) => ({ + ...display, + enabled: Boolean(env.ADAPTER_OAUTH_SIGNING_PRIVATE_JWK) && requiredSecrets.every((key) => Boolean(env[key])), + })) +} + +export async function createProviderModules( + runtime: ProviderRuntime, + registrations: readonly ProviderRegistration[] = providerRegistry, +) { + const modules: AdapterModule[] = [] + for (const registration of registrations) modules.push(...(await registration.create(runtime))) + return modules +} diff --git a/src/providers/runtime.ts b/src/providers/runtime.ts new file mode 100644 index 0000000..3b25c05 --- /dev/null +++ b/src/providers/runtime.ts @@ -0,0 +1,219 @@ +import type { JWK } from 'jose' +import type { AppConfig } from '../config.js' +import type { AdapterModule } from '../core/adapter.js' +import { type ConfiguredManagedProvider, createConfiguredManagedProvider } from '../core/configured-managed-provider.js' +import { createCredentialCipher } from '../core/credential-cipher.js' +import { createExternalAuthorizationServer } from '../core/external-authorization-server.js' +import type { D1ExternalOAuthStore } from '../core/external-oauth-store.js' +import { createCloudflareAdapter } from '../providers/cloudflare/adapter.js' +import { loadCloudflareConfig } from '../providers/cloudflare/config.js' +import { cloudflareManifest } from '../providers/cloudflare/manifest.js' +import { + createCloudflareExternalAuthorization, + createCloudflareOAuthProvider, + D1CloudflareCredentials, +} from '../providers/cloudflare/oauth.js' +import { createGitHubAdapter } from '../providers/github/adapter.js' +import { createGitHubConnectionProvider, createGitHubProvider } from '../providers/github/client.js' +import { loadGitHubConfig } from '../providers/github/config.js' +import { D1GitHubConnections } from '../providers/github/connections.js' +import { createGitHubExternalAuthorization } from '../providers/github/external-authorization.js' +import { permissionsToScopes } from '../providers/github/permissions.js' +import { createLinearAdapter } from '../providers/linear/adapter.js' +import { createLinearProvider } from '../providers/linear/client.js' +import { loadLinearConfig } from '../providers/linear/config.js' +import { D1LinearConnections } from '../providers/linear/connections.js' +import { createLinearCredentialCipher } from '../providers/linear/credentials.js' +import { createLinearExternalAuthorization } from '../providers/linear/external-authorization.js' +import { linearScopes } from '../providers/linear/scopes.js' +import type { D1RuntimeState } from '../storage/d1-runtime-state.js' + +export type ProviderRuntime = { + env: Env + config: AppConfig + executionContext: ExecutionContext + state: D1RuntimeState + oauthStore: D1ExternalOAuthStore + signingPrivateJwk: JWK | undefined +} + +export async function createGitHubModules({ + env, + config, + executionContext, + state, + oauthStore, + signingPrivateJwk, +}: ProviderRuntime): Promise { + const githubConfig = loadGitHubConfig(env, config) + const adapters: AdapterModule[] = [] + if ( + githubConfig.githubAppId && + githubConfig.githubPrivateKey && + githubConfig.githubClientId && + githubConfig.githubClientSecret && + signingPrivateJwk + ) { + const githubConnections = new D1GitHubConnections(env.DB, state) + const githubProvider = createGitHubProvider({ + appId: githubConfig.githubAppId, + privateKey: githubConfig.githubPrivateKey, + apiOrigin: githubConfig.githubApiOrigin, + cache: caches.default, + waitUntil: (promise) => executionContext.waitUntil(promise), + }) + const githubExternal = createGitHubExternalAuthorization({ + origin: config.origin, + connection: createGitHubConnectionProvider({ + appId: githubConfig.githubAppId, + privateKey: githubConfig.githubPrivateKey, + clientId: githubConfig.githubClientId, + clientSecret: githubConfig.githubClientSecret, + redirectUri: `${config.origin}/github/oauth/callback`, + apiOrigin: githubConfig.githubApiOrigin, + }), + connections: githubConnections, + oauthStore, + scopes: permissionsToScopes(await githubProvider.appPermissions()), + }) + const githubAuthorization = await createExternalAuthorizationServer({ + origin: config.origin, + provider: githubExternal.authorization, + providerCallbackPath: '/github/oauth/callback', + store: oauthStore, + signingPrivateJwk, + replayStore: state, + }) + adapters.push( + githubAuthorization, + githubExternal.installationCallback, + createGitHubAdapter(githubConfig, { + authenticator: githubAuthorization.authenticator, + provider: githubProvider, + audit: (record) => state.recordAudit(record), + connections: githubConnections, + }), + ) + } + return adapters +} + +export async function createLinearModules({ + env, + config, + state, + oauthStore, + signingPrivateJwk, +}: ProviderRuntime): Promise { + const linearConfig = loadLinearConfig(env, config) + const adapters: AdapterModule[] = [] + if ( + linearConfig.linearClientId && + linearConfig.linearClientSecret && + linearConfig.linearCredentialEncryptionKey && + signingPrivateJwk + ) { + const linearConnections = new D1LinearConnections( + env.DB, + createLinearCredentialCipher(linearConfig.linearCredentialEncryptionKey), + state, + ) + const linearProvider = createLinearProvider({ + clientId: linearConfig.linearClientId, + clientSecret: linearConfig.linearClientSecret, + redirectUri: `${config.origin}/linear/oauth/callback`, + apiOrigin: linearConfig.linearApiOrigin, + authorizationOrigin: linearConfig.linearAuthorizationOrigin, + }) + const linearAuthorization = await createExternalAuthorizationServer({ + origin: config.origin, + provider: createLinearExternalAuthorization({ + origin: config.origin, + provider: linearProvider, + connections: linearConnections, + scopes: linearScopes, + }), + providerCallbackPath: '/linear/oauth/callback', + store: oauthStore, + signingPrivateJwk, + replayStore: state, + }) + adapters.push( + linearAuthorization, + createLinearAdapter(linearConfig, { + authenticator: linearAuthorization.authenticator, + provider: linearProvider, + connections: linearConnections, + audit: (record) => state.recordAudit(record), + }), + ) + } + return adapters +} + +export async function createCloudflareModules({ + env, + config, + state, + oauthStore, + signingPrivateJwk, +}: ProviderRuntime): Promise { + const cloudflareConfig = loadCloudflareConfig(env, config) + const adapters: AdapterModule[] = [] + if (cloudflareConfig) { + if (!signingPrivateJwk) throw new Error('Cloudflare external authorization is not configured.') + const cloudflareCredentials = new D1CloudflareCredentials( + env.DB, + createCredentialCipher(cloudflareConfig.credentialEncryptionKey), + ) + const cloudflareProvider = createCloudflareOAuthProvider({ + clientId: cloudflareConfig.clientId, + clientSecret: cloudflareConfig.clientSecret, + redirectUri: `${config.origin}/oauth/cloudflare/provider/callback`, + authorizationOrigin: cloudflareConfig.authorizationOrigin, + }) + const cloudflareAuthorization = await createExternalAuthorizationServer({ + origin: config.origin, + provider: createCloudflareExternalAuthorization({ + origin: config.origin, + provider: cloudflareProvider, + credentials: cloudflareCredentials, + scopes: Object.keys(cloudflareManifest.scopes), + }), + store: oauthStore, + signingPrivateJwk, + replayStore: state, + }) + adapters.push( + cloudflareAuthorization, + createCloudflareAdapter(cloudflareConfig, { + authenticator: cloudflareAuthorization.authenticator, + provider: cloudflareProvider, + credentials: cloudflareCredentials, + audit: (record) => state.recordAudit(record), + fetch, + }), + ) + } + return adapters +} + +export async function createManagedModules( + definition: ConfiguredManagedProvider, + credentialEncryptionKey: string | undefined, + { env, config, state, oauthStore, signingPrivateJwk }: ProviderRuntime, +): Promise { + if (!credentialEncryptionKey) return [] + if (!signingPrivateJwk) throw new Error(`${definition.name} external authorization is not configured.`) + return createConfiguredManagedProvider({ + definition, + origin: config.origin, + db: env.DB, + credentialEncryptionKey, + signingPrivateJwk, + oauthStore, + replayStore: state, + audit: (record) => state.recordAudit(record), + fetcher: fetch, + }) +} diff --git a/src/worker.ts b/src/worker.ts index 00c2a9f..cea5791 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,36 +1,9 @@ import { tracing } from 'cloudflare:workers' import { createApp } from './app.js' import { loadConfig } from './config.js' -import type { AdapterModule } from './core/adapter.js' -import { createConfiguredManagedProvider } from './core/configured-managed-provider.js' -import { createCredentialCipher } from './core/credential-cipher.js' -import { createExternalAuthorizationServer } from './core/external-authorization-server.js' +import { consoleRoutes } from './console/routes.js' import { D1ExternalOAuthStore } from './core/external-oauth-store.js' -import { createCloudflareAdapter } from './providers/cloudflare/adapter.js' -import { loadCloudflareConfig } from './providers/cloudflare/config.js' -import { cloudflareManifest } from './providers/cloudflare/manifest.js' -import { - createCloudflareExternalAuthorization, - createCloudflareOAuthProvider, - D1CloudflareCredentials, -} from './providers/cloudflare/oauth.js' -import { context7Definition } from './providers/context7/definition.js' -import { fastioDefinition } from './providers/fastio/definition.js' -import { createGitHubAdapter } from './providers/github/adapter.js' -import { createGitHubConnectionProvider, createGitHubProvider } from './providers/github/client.js' -import { loadGitHubConfig } from './providers/github/config.js' -import { D1GitHubConnections } from './providers/github/connections.js' -import { createGitHubExternalAuthorization } from './providers/github/external-authorization.js' -import { permissionsToScopes } from './providers/github/permissions.js' -import { createLinearAdapter } from './providers/linear/adapter.js' -import { createLinearProvider } from './providers/linear/client.js' -import { loadLinearConfig } from './providers/linear/config.js' -import { D1LinearConnections } from './providers/linear/connections.js' -import { createLinearCredentialCipher } from './providers/linear/credentials.js' -import { createLinearExternalAuthorization } from './providers/linear/external-authorization.js' -import { linearScopes } from './providers/linear/scopes.js' -import { search1ApiDefinition } from './providers/search1api/definition.js' -import { todoistDefinition } from './providers/todoist/definition.js' +import { createProviderModules } from './providers/registry.js' import { D1RuntimeState } from './storage/d1-runtime-state.js' export default { @@ -38,164 +11,19 @@ export default { return tracing.enterSpan('adapter.request.prepare', async (span) => { span.setAttribute('url.path', new URL(request.url).pathname) const config = loadConfig(env, request.url) - const githubConfig = loadGitHubConfig(env, config) - const cloudflareConfig = loadCloudflareConfig(env, config) - const linearConfig = loadLinearConfig(env, config) - const state = new D1RuntimeState(env.DB) - const oauthStore = new D1ExternalOAuthStore(env.DB) - const signingPrivateJwk = config.oauthSigningPrivateJwk ? JSON.parse(config.oauthSigningPrivateJwk) : undefined - const adapters: AdapterModule[] = [] - - if ( - githubConfig.githubAppId && - githubConfig.githubPrivateKey && - githubConfig.githubClientId && - githubConfig.githubClientSecret && - signingPrivateJwk - ) { - const githubConnections = new D1GitHubConnections(env.DB, state) - const githubProvider = createGitHubProvider({ - appId: githubConfig.githubAppId, - privateKey: githubConfig.githubPrivateKey, - apiOrigin: githubConfig.githubApiOrigin, - cache: caches.default, - waitUntil: (promise) => executionContext.waitUntil(promise), - }) - const githubExternal = createGitHubExternalAuthorization({ - origin: config.origin, - connection: createGitHubConnectionProvider({ - appId: githubConfig.githubAppId, - privateKey: githubConfig.githubPrivateKey, - clientId: githubConfig.githubClientId, - clientSecret: githubConfig.githubClientSecret, - redirectUri: `${config.origin}/github/oauth/callback`, - apiOrigin: githubConfig.githubApiOrigin, - }), - connections: githubConnections, - oauthStore, - scopes: permissionsToScopes(await githubProvider.appPermissions()), - }) - const githubAuthorization = await createExternalAuthorizationServer({ - origin: config.origin, - provider: githubExternal.authorization, - providerCallbackPath: '/github/oauth/callback', - store: oauthStore, - signingPrivateJwk, - replayStore: state, - }) - adapters.push( - githubAuthorization, - githubExternal.installationCallback, - createGitHubAdapter(githubConfig, { - authenticator: githubAuthorization.authenticator, - provider: githubProvider, - audit: (record) => state.recordAudit(record), - connections: githubConnections, - }), - ) - } - - if ( - linearConfig.linearClientId && - linearConfig.linearClientSecret && - linearConfig.linearCredentialEncryptionKey && - signingPrivateJwk - ) { - const linearConnections = new D1LinearConnections( - env.DB, - createLinearCredentialCipher(linearConfig.linearCredentialEncryptionKey), - state, - ) - const linearProvider = createLinearProvider({ - clientId: linearConfig.linearClientId, - clientSecret: linearConfig.linearClientSecret, - redirectUri: `${config.origin}/linear/oauth/callback`, - apiOrigin: linearConfig.linearApiOrigin, - authorizationOrigin: linearConfig.linearAuthorizationOrigin, - }) - const linearAuthorization = await createExternalAuthorizationServer({ - origin: config.origin, - provider: createLinearExternalAuthorization({ - origin: config.origin, - provider: linearProvider, - connections: linearConnections, - scopes: linearScopes, - }), - providerCallbackPath: '/linear/oauth/callback', - store: oauthStore, - signingPrivateJwk, - replayStore: state, - }) - adapters.push( - linearAuthorization, - createLinearAdapter(linearConfig, { - authenticator: linearAuthorization.authenticator, - provider: linearProvider, - connections: linearConnections, - audit: (record) => state.recordAudit(record), - }), - ) - } - if (cloudflareConfig) { - if (!signingPrivateJwk) throw new Error('Cloudflare external authorization is not configured.') - const cloudflareCredentials = new D1CloudflareCredentials( - env.DB, - createCredentialCipher(cloudflareConfig.credentialEncryptionKey), - ) - const cloudflareProvider = createCloudflareOAuthProvider({ - clientId: cloudflareConfig.clientId, - clientSecret: cloudflareConfig.clientSecret, - redirectUri: `${config.origin}/oauth/cloudflare/provider/callback`, - authorizationOrigin: cloudflareConfig.authorizationOrigin, - }) - const cloudflareAuthorization = await createExternalAuthorizationServer({ - origin: config.origin, - provider: createCloudflareExternalAuthorization({ - origin: config.origin, - provider: cloudflareProvider, - credentials: cloudflareCredentials, - scopes: Object.keys(cloudflareManifest.scopes), - }), - store: oauthStore, - signingPrivateJwk, - replayStore: state, - }) - adapters.push( - cloudflareAuthorization, - createCloudflareAdapter(cloudflareConfig, { - authenticator: cloudflareAuthorization.authenticator, - provider: cloudflareProvider, - credentials: cloudflareCredentials, - audit: (record) => state.recordAudit(record), - fetch, - }), - ) - } - const managedProviders = [ - { definition: context7Definition, credentialEncryptionKey: env.CONTEXT7_CREDENTIAL_ENCRYPTION_KEY }, - { definition: todoistDefinition, credentialEncryptionKey: env.TODOIST_CREDENTIAL_ENCRYPTION_KEY }, - { definition: search1ApiDefinition, credentialEncryptionKey: env.SEARCH1API_CREDENTIAL_ENCRYPTION_KEY }, - { definition: fastioDefinition, credentialEncryptionKey: env.FASTIO_CREDENTIAL_ENCRYPTION_KEY }, - ] - for (const managed of managedProviders) { - if (!managed.credentialEncryptionKey) continue - if (!signingPrivateJwk) { - throw new Error(`${managed.definition.name} external authorization is not configured.`) - } - adapters.push( - ...(await createConfiguredManagedProvider({ - definition: managed.definition, - origin: config.origin, - db: env.DB, - credentialEncryptionKey: managed.credentialEncryptionKey, - signingPrivateJwk, - oauthStore, - replayStore: state, - audit: (record) => state.recordAudit(record), - fetcher: fetch, - })), - ) + if (new URL(request.url).pathname.startsWith('/console/')) { + const app = createApp([]) + app.route('/console', consoleRoutes) + return app.fetch(request, env, executionContext) } + const adapters = await createProviderModules({ + env, + config, + executionContext, + state: new D1RuntimeState(env.DB), + oauthStore: new D1ExternalOAuthStore(env.DB), + signingPrivateJwk: config.oauthSigningPrivateJwk ? JSON.parse(config.oauthSigningPrivateJwk) : undefined, + }) const app = createApp(adapters) return tracing.enterSpan('adapter.router.dispatch', () => app.fetch(request, env, executionContext)) }) diff --git a/test/integration/directory.test.ts b/test/integration/directory.test.ts new file mode 100644 index 0000000..466349d --- /dev/null +++ b/test/integration/directory.test.ts @@ -0,0 +1,17 @@ +import { createExecutionContext, env } from 'cloudflare:test' +import { expect, it } from 'vitest' +import { providerRegistry } from '../../src/providers/registry.js' +import worker from '../../src/worker.js' + +it('serves the public registry without provider credentials, login, or upstream requests', async () => { + const response = await worker.fetch( + new Request('https://adapters.example/console/api/providers'), + env, + createExecutionContext(), + ) + expect(response.status).toBe(200) + const body = (await response.json()) as { items: Array<{ id: string; enabled: boolean }> } + expect(body.items.map(({ id }) => id)).toEqual(providerRegistry.map(({ id }) => id)) + expect(body.items.every(({ enabled }) => !enabled)).toBe(true) + expect(response.headers.get('set-cookie')).toBeNull() +}) diff --git a/test/providers/registry.test.ts b/test/providers/registry.test.ts new file mode 100644 index 0000000..186da2e --- /dev/null +++ b/test/providers/registry.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createProviderModules, + type ProviderRegistration, + providerDirectory, + providerRegistry, +} from '../../src/providers/registry.js' +import type { ProviderRuntime } from '../../src/providers/runtime.js' + +describe('shared provider registration', () => { + it('includes a newly registered provider in both the directory and runtime without initializing it for display', async () => { + const module = { id: 'new-provider', register: vi.fn() } + const create = vi.fn(async () => [module]) + const registration: ProviderRegistration = { + id: 'new-provider', + name: 'New Provider', + category: 'Knowledge', + description: 'New provider description', + capabilities: ['Search'], + maturity: 'Experimental', + color: '#ffffff', + mark: 'NP', + requiredSecrets: ['CONTEXT7_CREDENTIAL_ENCRYPTION_KEY'], + create, + } + const env = { + ADAPTER_OAUTH_SIGNING_PRIVATE_JWK: 'private-key', + CONTEXT7_CREDENTIAL_ENCRYPTION_KEY: 'secret-value', + } as Env + const directory = providerDirectory(env, [registration]) + expect(directory).toEqual([ + { + id: 'new-provider', + name: 'New Provider', + category: 'Knowledge', + description: 'New provider description', + capabilities: ['Search'], + maturity: 'Experimental', + color: '#ffffff', + mark: 'NP', + enabled: true, + }, + ]) + expect(create).not.toHaveBeenCalled() + expect(JSON.stringify(directory)).not.toContain('secret-value') + expect(JSON.stringify(directory)).not.toContain('private-key') + const runtime = { env } as ProviderRuntime + expect(await createProviderModules(runtime, [registration])).toEqual([module]) + expect(create).toHaveBeenCalledWith(runtime) + expect(providerDirectory({} as Env, [registration])[0]?.enabled).toBe(false) + expect( + providerDirectory({ CONTEXT7_CREDENTIAL_ENCRYPTION_KEY: 'secret-value' } as Env, [registration])[0]?.enabled, + ).toBe(false) + }) + + it('publishes unique identities for all registered providers', () => { + const ids = providerRegistry.map(({ id }) => id) + expect(new Set(ids).size).toBe(ids.length) + expect(providerDirectory({} as Env).map(({ id }) => id)).toEqual(ids) + }) +}) diff --git a/tsconfig.console.json b/tsconfig.console.json new file mode 100644 index 0000000..2253e50 --- /dev/null +++ b/tsconfig.console.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": ".console-types", + "rootDir": "src" + }, + "include": ["src/**/*.ts", "worker-configuration.d.ts"] +} diff --git a/web/api.ts b/web/api.ts new file mode 100644 index 0000000..cecb05d --- /dev/null +++ b/web/api.ts @@ -0,0 +1,12 @@ +import { hc, type InferResponseType } from 'hono/client' +import type { ConsoleApp } from '../.console-types/console/routes.js' + +export const api = hc('/console', { init: { credentials: 'same-origin' } }).api +export type Provider = InferResponseType['items'][number] + +export async function checked(response: { ok: boolean; status: number; json(): Promise }): Promise { + if (!response.ok) { + throw new Error('We couldn’t load this information. Please try again.') + } + return response.json() +} diff --git a/web/components.tsx b/web/components.tsx new file mode 100644 index 0000000..fce86a3 --- /dev/null +++ b/web/components.tsx @@ -0,0 +1,45 @@ +import type { Provider } from './api' + +export function Brand() { + return ( + + + + realmroot/ + adapters + + + ) +} + +export function ProviderMark({ provider }: { provider: Pick }) { + return ( + + ) +} + +export function ErrorNotice({ message, retry }: { message: string; retry?: () => void }) { + return ( + + ) +} +export function Empty({ title, text }: { title: string; text: string }) { + return ( +
+ +

{title}

+

{text}

+
+ ) +} diff --git a/web/env.d.ts b/web/env.d.ts new file mode 100644 index 0000000..5894ae0 --- /dev/null +++ b/web/env.d.ts @@ -0,0 +1 @@ +declare module '*.css' diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..281677e --- /dev/null +++ b/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + + Adapters — Realmroot + + + +

Loading Adapters…

+ diff --git a/web/main.tsx b/web/main.tsx new file mode 100644 index 0000000..a8933d1 --- /dev/null +++ b/web/main.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'hono/jsx' +import { render } from 'hono/jsx/dom' +import { api, checked, type Provider } from './api' +import { Brand, ErrorNotice } from './components' +import { Platforms } from './platforms' +import './style.css' + +function App() { + const [providers, setProviders] = useState([]) + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + const [revision, setRevision] = useState(0) + useEffect(() => { + let active = true + setLoading(true) + setError('') + api.providers + .$get() + .then(checked) + .then((catalog) => { + if (active) setProviders(catalog.items) + }) + .catch((cause: Error) => { + if (active) setError(cause.message) + }) + .finally(() => { + if (active) setLoading(false) + }) + return () => { + active = false + } + }, [revision]) + return ( + <> + +
+ +
+
+ {error ? ( + setRevision(revision + 1)} /> + ) : loading ? ( +
+ Loading platforms… +
+ ) : ( + + )} +
+ + + ) +} + +const root = document.getElementById('app') +if (!root) throw new Error('Directory root is missing.') +render(, root) diff --git a/web/platforms.tsx b/web/platforms.tsx new file mode 100644 index 0000000..24f606c --- /dev/null +++ b/web/platforms.tsx @@ -0,0 +1,125 @@ +import { useState } from 'hono/jsx' +import type { Provider } from './api' +import { Empty, ProviderMark } from './components' + +export function Platforms({ providers }: { providers: Provider[] }) { + const [search, setSearch] = useState('') + const [category, setCategory] = useState('All platforms') + const categories = ['All platforms', ...new Set(providers.map((provider) => provider.category))] + const filtered = providers.filter( + (provider) => + (category === 'All platforms' || provider.category === category) && + `${provider.name} ${provider.description} ${provider.capabilities.join(' ')}` + .toLowerCase() + .includes(search.toLowerCase()), + ) + return ( + <> +
+
+ THE REALMROOT ECOSYSTEM +
+

+ Your tools. +
+ Your Agents, connected. +

+

+ A familiar workspace, a new kind of collaborator. +
Connect your Agents to the platforms you already use, with an identity you can + trace. +

+
+ {String(providers.length).padStart(2, '0')} + + supported platforms +
+ and growing +
+
+ + One identity. +
+ Access you control. +
+
+ +
+
+
+
+
EXPLORE THE DIRECTORY
+

Built for the way you work.

+
+ +
+
+ Platform categories + {categories.map((item) => ( + + ))} +
+
+ {filtered.map((provider) => ( +
+
+ + {provider.maturity} +
+

{provider.name}

+

{provider.description}

+
+ {provider.capabilities.map((capability) => ( + {capability} + ))} +
+
+ + + {provider.enabled ? 'Available' : 'Not enabled here'} + + + Explore + +
+
+ ))} +
+ {filtered.length === 0 && } +
+ + ) +} diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..b43e8db --- /dev/null +++ b/web/style.css @@ -0,0 +1,730 @@ +@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;450;500;550;600;650;700&family=Manrope:wght@400;500;600;650;700;750&display=swap"); +:root { + font-family: "DM Sans", sans-serif; + color: #e9efeb; + background: #101513; + font-synthesis: none; + --muted: #9ca9a1; + --line: #2c3730; + --accent: #b6edce; + --surface: #171e1a; + --text: #e9efeb; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +input, +select { + font: inherit; +} +button, +a, +input, +select { + -webkit-tap-highlight-color: transparent; +} +button, +a { + touch-action: manipulation; +} +button { + cursor: pointer; +} +a { + color: inherit; + text-decoration: none; +} +button:disabled { + cursor: default; + opacity: 0.4; +} +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 5px; +} +::selection { + background: #456b53; + color: white; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.skip-link { + position: fixed; + top: -80px; + left: 16px; + background: var(--accent); + color: #101513; + padding: 12px; + z-index: 9; +} +.skip-link:focus { + top: 12px; +} +header { + border-bottom: 1px solid var(--line); +} +.header-inner { + max-width: 1280px; + margin: auto; + min-height: 88px; + padding: 0 44px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; +} +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + white-space: nowrap; + font-size: 18px; + letter-spacing: -0.6px; +} +.brand strong { + font-weight: 550; + color: var(--muted); +} +.brand-divider { + color: #627368; + margin: 0 12px; +} +.text-link { + color: var(--muted); + font-size: 13px; +} +.text-link:hover { + color: var(--text); +} +.button { + display: inline-flex; + justify-content: center; + align-items: center; + gap: 24px; + border: 1px solid transparent; + background: var(--accent); + color: #15261c; + border-radius: 7px; + min-height: 48px; + padding: 12px 20px; + font-size: 14px; + font-weight: 600; + transition: + background 0.15s, + border-color 0.15s; + white-space: nowrap; +} +.button:hover { + background: #cef7de; +} +.button.secondary { + background: transparent; + border-color: #475a4d; + color: var(--text); +} +.button.secondary:hover { + background: #25352b; + border-color: #718a79; +} +.button.small { + min-height: 44px; + padding: 10px 15px; + font-size: 13px; +} +main { + max-width: 1280px; + margin: auto; + padding: 0 44px 90px; + min-height: calc(100vh - 210px); +} +.hero { + position: relative; + padding: 76px 0 68px; + overflow: hidden; +} +.eyebrow { + display: flex; + align-items: center; + gap: 10px; + font-size: 10px; + font-weight: 650; + letter-spacing: 2px; + color: var(--accent); +} +.status-dot { + display: inline-block; + width: 6px; + height: 6px; + flex-shrink: 0; + border-radius: 50%; + background: #a9eac4; +} +.status-dot.neutral { + background: #819087; +} +.hero h1 { + font-family: Manrope, sans-serif; + font-size: clamp(38px, 4.6vw, 61px); + letter-spacing: -3px; + line-height: 1.13; + font-weight: 500; + margin: 26px 0 23px; + max-width: 820px; +} +.hero-accent { + color: var(--accent); +} +.hero > p { + color: var(--muted); + font-size: 15px; + line-height: 1.85; + max-width: 605px; + margin: 0; +} +.hero-bottom { + display: flex; + align-items: center; + gap: 18px; + margin-top: 34px; + color: var(--muted); + font-size: 11px; + line-height: 1.55; +} +.hero-bottom strong { + font-weight: 400; + color: #d8e3db; +} +.hero-count { + font-family: Manrope, sans-serif; + font-size: 42px; + letter-spacing: -2px; + font-weight: 400; + color: var(--text); +} +.hero-rule { + height: 32px; + width: 1px; + background: var(--line); + margin: 0 7px; +} +.hero-note { + color: #b9c6bd; +} +.orbit { + position: absolute; + width: 260px; + height: 260px; + top: 106px; + right: 28px; + border: 1px solid #304336; + border-radius: 50%; + background: radial-gradient(circle, #253d2e55, transparent 65%); +} +.orbit:after { + content: ""; + position: absolute; + inset: 30px; + border: 1px dashed #304336; + border-radius: 50%; +} +.orbit-inner { + position: absolute; + inset: 78px; + border: 1px solid #45634f; + display: grid; + place-items: center; + border-radius: 22px; + color: var(--accent); + background: #1b2920; +} +.orbit-label { + position: absolute; + font-size: 8px; + letter-spacing: 2px; + color: #9bbca6; + left: 0; + right: 0; + text-align: center; +} +.ol-top { + top: 48px; +} +.ol-bottom { + bottom: 47px; +} +.orbit-node { + position: absolute; + display: grid; + place-items: center; + width: 43px; + height: 43px; + font-style: normal; + font-size: 13px; + background: #202d25; + border: 1px solid #3e5645; + border-radius: 12px; + z-index: 1; +} +.node-one { + top: -12px; + left: 103px; +} +.node-two { + bottom: -15px; + left: 104px; + color: #ace9c5; +} +.node-three { + right: -15px; + top: 101px; + color: #ffac69; +} +.node-four { + left: -15px; + top: 101px; + color: #b8b1ff; +} +.section-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 28px; + margin: 24px 0; +} +.section-heading .eyebrow { + color: var(--muted); + font-size: 9px; +} +.section-heading h2 { + font: + 500 27px Manrope, + sans-serif; + letter-spacing: -1px; + margin: 11px 0 0; +} +.search { + display: flex; + align-items: center; + gap: 11px; + background: #171e1a; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0 14px; + min-height: 44px; + min-width: 252px; + color: var(--muted); +} +.search > span:first-child { + font-size: 24px; +} +.search input { + width: 100%; + background: none; + border: 0; + color: var(--text); + font-size: 13px; + outline: none; + min-width: 0; +} +.search:focus-within { + outline: 2px solid var(--accent); + outline-offset: 3px; +} +.search input::placeholder { + color: #9ca9a1; +} +.filters { + border: 0; + padding: 0; + min-width: 0; + display: flex; + gap: 6px; + margin: 25px 0 24px; + flex-wrap: wrap; +} +.filters button { + display: flex; + align-items: center; + gap: 10px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--muted); + padding: 10px 14px; + min-height: 44px; + font-size: 12px; +} +.filters button:hover { + background: #202b24; + color: var(--text); +} +.filters button.selected { + border-color: #3a4b40; + background: #25362b; + color: var(--accent); +} +.category-count { + font-size: 10px; + padding: 1px 5px; + border: 1px solid #4a6151; + border-radius: 4px; +} +.platform-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} +.platform-card { + padding: 26px 25px 0; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); + display: flex; + flex-direction: column; + transition: + border-color 0.15s, + background 0.15s; +} +.platform-card:hover { + border-color: #536e5d; + background: #1a241d; +} +.card-top { + display: flex; + align-items: center; + justify-content: space-between; +} +.provider-mark { + width: 45px; + height: 45px; + border-radius: 11px; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--mark) 11%, transparent); + border: 1px solid color-mix(in srgb, var(--mark) 22%, transparent); + color: var(--mark); + font-family: Manrope, sans-serif; + font-weight: 750; + font-size: 18px; + letter-spacing: -1px; + flex-shrink: 0; +} +.mark-linear { + font-style: italic; +} +.mark-context7 { + font-family: monospace; +} +.maturity { + font-size: 10px; + color: #b9c6bd; + border: 1px solid #35443a; + border-radius: 4px; + padding: 4px 7px; +} +.platform-card h3 { + font-family: Manrope, sans-serif; + font-size: 19px; + letter-spacing: -0.5px; + margin: 23px 0 10px; + font-weight: 650; +} +.platform-card p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.8; + min-height: 70px; +} +.capabilities { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 21px 0 25px; +} +.capabilities span { + font-size: 10px; + color: #b7c4bb; + border-radius: 4px; + padding: 4px 7px; + background: #232e27; +} +.card-bottom { + margin-top: auto; + border-top: 1px solid var(--line); + padding: 16px 0; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; +} +.card-bottom a { + display: flex; + gap: 12px; + align-items: center; + min-height: 28px; +} +.card-bottom a:hover { + color: var(--accent); +} +.availability, +.muted { + display: flex; + align-items: center; + gap: 7px; + color: #b2c4b8; +} +.muted { + color: var(--muted); +} +footer { + border-top: 1px solid var(--line); + max-width: 1192px; + margin: auto; + padding: 30px 0; + display: flex; + align-items: center; + gap: 30px; + font-size: 11px; + color: var(--muted); +} +footer .brand { + font-size: 14px; +} +:where(footer) .brand-logo { + width: 22px; +} +footer > a:last-child { + margin-left: auto; +} +.loading, +.boot { + padding: 90px 20px; + text-align: center; + color: var(--muted); +} +.empty { + text-align: center; + padding: 65px 30px; + background: var(--surface); + border: 1px dashed #3b5042; + border-radius: 9px; +} +.empty-symbol { + font-size: 40px; + color: var(--accent); +} +.empty h3 { + font: + 500 20px Manrope, + sans-serif; + margin: 18px 0 10px; +} +.empty p { + font-size: 13px; + color: var(--muted); + line-height: 1.8; + max-width: 480px; + margin: auto; +} +.error-notice { + padding: 18px 22px; + border: 1px solid #87594e; + border-radius: 8px; + background: #36241f; + color: #ffd0be; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-top: 25px; + font-size: 14px; +} +@media (min-width: 1500px) { + .hero { + padding-top: 96px; + padding-bottom: 85px; + } + .orbit { + top: 130px; + } +} +@media (max-width: 1100px) { + .orbit { + width: 210px; + height: 210px; + right: 18px; + top: 126px; + } + .orbit-inner { + inset: 59px; + } + .node-one, + .node-two { + left: 80px; + } + .node-three, + .node-four { + top: 80px; + } + .ol-top { + top: 35px; + } + .ol-bottom { + bottom: 35px; + } + .hero h1 { + font-size: 49px; + } + .hero > p { + max-width: 520px; + } + .platform-card { + padding-left: 20px; + padding-right: 20px; + } + footer { + margin: 0 44px; + } +} +@media (max-width: 900px) { + .orbit { + display: none; + } + .platform-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .header-inner { + padding: 0 28px; + } + main { + padding-left: 28px; + padding-right: 28px; + } + .hero h1 { + font-size: 52px; + } + .section-heading h2 { + font-size: 24px; + } + .search { + min-width: 210px; + } + footer { + margin: 0 28px; + } + .footer-note { + display: none; + } +} +@media (max-width: 600px) { + .header-inner { + padding: 0 20px; + min-height: 76px; + gap: 10px; + } + .brand { + font-size: 15px; + gap: 6px; + } + .brand-divider { + margin: 0 7px; + } + .brand-logo { + width: 23px; + } + main { + padding: 0 20px 50px; + } + .hero { + padding: 49px 0 41px; + } + .hero h1 { + font-size: 40px; + letter-spacing: -2px; + line-height: 1.2; + margin-top: 23px; + } + .hero > p { + font-size: 14px; + line-height: 1.8; + } + .desktop-break { + display: none; + } + .hero-bottom { + margin-top: 26px; + } + .hero-count { + font-size: 36px; + } + .section-heading { + flex-direction: column; + align-items: stretch; + gap: 23px; + } + .section-heading h2 { + font-size: 23px; + line-height: 1.4; + } + .search { + width: 100%; + } + .filters { + border: 0; + padding: 0; + min-width: 0; + gap: 3px; + margin: 18px 0; + } + .filters button { + padding: 9px 11px; + font-size: 11px; + } + .platform-grid { + grid-template-columns: 1fr; + gap: 14px; + } + .platform-card { + padding: 23px 23px 0; + } + .platform-card p { + min-height: 0; + font-size: 14px; + } + .capabilities { + margin-top: 19px; + margin-bottom: 23px; + } + footer { + margin: 0 20px; + padding: 24px 0; + gap: 12px; + } + .error-notice { + flex-direction: column; + align-items: stretch; + } + .hero .eyebrow { + font-size: 9px; + } +} +@media (prefers-reduced-motion: reduce) { + .button, + .platform-card { + transition: none; + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..ba4a8bc --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx/dom", + "types": [] + }, + "include": ["./**/*.ts", "./**/*.tsx"] +}