veryfront 0.1.1190 → 0.1.1192
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm/cli/commands/serve/command.d.ts.map +1 -1
- package/esm/cli/commands/serve/command.js +5 -24
- package/esm/cli/commands/serve/proxy-extension-composition.d.ts +6 -5
- package/esm/cli/commands/serve/proxy-extension-composition.d.ts.map +1 -1
- package/esm/cli/commands/serve/proxy-extension-composition.js +57 -29
- package/esm/cli/commands/serve/proxy-runtime.d.ts +17 -0
- package/esm/cli/commands/serve/proxy-runtime.d.ts.map +1 -0
- package/esm/cli/commands/serve/proxy-runtime.js +54 -0
- package/esm/cli/mcp/dev-server-client.d.ts +6 -0
- package/esm/cli/mcp/dev-server-client.d.ts.map +1 -1
- package/esm/cli/mcp/dev-server-client.js +117 -5
- package/esm/cli/shared/config.d.ts +15 -1
- package/esm/cli/shared/config.d.ts.map +1 -1
- package/esm/cli/shared/config.js +24 -15
- package/esm/cli/shared/deployment/control-plane.d.ts +9 -2
- package/esm/cli/shared/deployment/control-plane.d.ts.map +1 -1
- package/esm/cli/shared/deployment/control-plane.js +7 -2
- package/esm/cli/shared/deployment/deploy-project.d.ts +2 -2
- package/esm/cli/shared/deployment/deploy-project.d.ts.map +1 -1
- package/esm/cli/shared/deployment/deploy-project.js +97 -28
- package/esm/cli/templates/manifest.d.ts +2 -0
- package/esm/cli/templates/manifest.js +7 -5
- package/esm/deno.d.ts +6 -0
- package/esm/deno.js +13 -7
- package/esm/extensions/ext-llm-anthropic/src/anthropic-native-content.d.ts.map +1 -1
- package/esm/extensions/ext-llm-anthropic/src/anthropic-native-content.js +2 -2
- package/esm/extensions/ext-llm-google/src/google-thought-signatures.d.ts.map +1 -1
- package/esm/extensions/ext-llm-google/src/google-thought-signatures.js +2 -2
- package/esm/extensions/ext-llm-openai/src/openai-web-search.d.ts.map +1 -1
- package/esm/extensions/ext-llm-openai/src/openai-web-search.js +2 -2
- package/esm/src/agent/hosted/child-fork-run-context.d.ts.map +1 -1
- package/esm/src/agent/hosted/child-fork-run-context.js +4 -1
- package/esm/src/agent/hosted/child-pending-tool-lifecycle.d.ts +1 -0
- package/esm/src/agent/hosted/child-pending-tool-lifecycle.d.ts.map +1 -1
- package/esm/src/agent/hosted/child-pending-tool-lifecycle.js +8 -2
- package/esm/src/agent/hosted/child-tool-input.d.ts +1 -0
- package/esm/src/agent/hosted/child-tool-input.d.ts.map +1 -1
- package/esm/src/agent/hosted/child-tool-input.js +14 -1
- package/esm/src/agent/hosted/cloud-agent-chat-execution.d.ts.map +1 -1
- package/esm/src/agent/hosted/cloud-agent-chat-execution.js +3 -3
- package/esm/src/agent/hosted/cloud-agent-child-tools.d.ts +2 -1
- package/esm/src/agent/hosted/cloud-agent-child-tools.d.ts.map +1 -1
- package/esm/src/agent/hosted/cloud-agent-child-tools.js +12 -6
- package/esm/src/agent/hosted/cloud-agent-config.d.ts +3 -0
- package/esm/src/agent/hosted/cloud-agent-config.d.ts.map +1 -1
- package/esm/src/agent/hosted/cloud-agent-config.js +5 -0
- package/esm/src/agent/hosted/cloud-agent-provider-bootstrap.d.ts +2 -0
- package/esm/src/agent/hosted/cloud-agent-provider-bootstrap.d.ts.map +1 -1
- package/esm/src/agent/hosted/project-reference-resolver.d.ts +5 -0
- package/esm/src/agent/hosted/project-reference-resolver.d.ts.map +1 -1
- package/esm/src/agent/hosted/project-reference-resolver.js +63 -8
- package/esm/src/agent/hosted/veryfront-cloud-agent-service.d.ts +10 -0
- package/esm/src/agent/hosted/veryfront-cloud-agent-service.d.ts.map +1 -1
- package/esm/src/agent/runtime/agent-invocation-contract.d.ts +9 -2
- package/esm/src/agent/runtime/agent-invocation-contract.d.ts.map +1 -1
- package/esm/src/agent/runtime/agent-invocation-contract.js +38 -3
- package/esm/src/agent/runtime/error-utils.d.ts +2 -0
- package/esm/src/agent/runtime/error-utils.d.ts.map +1 -1
- package/esm/src/agent/runtime/error-utils.js +300 -5
- package/esm/src/agent/runtime/model-resolution.js +36 -36
- package/esm/src/agent/schemas/agent.schema.d.ts +4 -4
- package/esm/src/agent/service/config.d.ts.map +1 -1
- package/esm/src/agent/service/config.js +4 -1
- package/esm/src/build/production-build/templates.d.ts.map +1 -1
- package/esm/src/build/production-build/templates.js +1 -1
- package/esm/src/cache/config-hash.d.ts.map +1 -1
- package/esm/src/cache/config-hash.js +39 -26
- package/esm/src/cache/keys/builders/render.d.ts +4 -1
- package/esm/src/cache/keys/builders/render.d.ts.map +1 -1
- package/esm/src/cache/keys/builders/render.js +13 -3
- package/esm/src/channels/control-plane.d.ts +19 -1
- package/esm/src/channels/control-plane.d.ts.map +1 -1
- package/esm/src/channels/control-plane.js +32 -0
- package/esm/src/discovery/discovery-utils.d.ts +6 -0
- package/esm/src/discovery/discovery-utils.d.ts.map +1 -1
- package/esm/src/discovery/discovery-utils.js +19 -0
- package/esm/src/discovery/index.d.ts +1 -1
- package/esm/src/discovery/index.d.ts.map +1 -1
- package/esm/src/discovery/index.js +1 -1
- package/esm/src/errors/catalog/module-errors.d.ts.map +1 -1
- package/esm/src/errors/catalog/module-errors.js +10 -0
- package/esm/src/errors/error-registry/module.d.ts +4 -0
- package/esm/src/errors/error-registry/module.d.ts.map +1 -1
- package/esm/src/errors/error-registry/module.js +16 -0
- package/esm/src/errors/error-registry.d.ts +2 -0
- package/esm/src/errors/error-registry.d.ts.map +1 -1
- package/esm/src/errors/index.d.ts +1 -1
- package/esm/src/errors/index.d.ts.map +1 -1
- package/esm/src/errors/index.js +1 -1
- package/esm/src/errors/types.d.ts.map +1 -1
- package/esm/src/errors/types.js +5 -2
- package/esm/src/eval/studio.d.ts +1 -1
- package/esm/src/extensions/bundler/bundler.d.ts +8 -0
- package/esm/src/extensions/bundler/bundler.d.ts.map +1 -1
- package/esm/src/extensions/distributed/rate-limit-support.d.ts +3 -1
- package/esm/src/extensions/distributed/rate-limit-support.d.ts.map +1 -1
- package/esm/src/extensions/distributed/rate-limit-support.js +3 -1
- package/esm/src/html/dev-scripts.d.ts.map +1 -1
- package/esm/src/html/dev-scripts.js +2 -1
- package/esm/src/html/html-injection.d.ts.map +1 -1
- package/esm/src/html/html-injection.js +37 -3
- package/esm/src/html/hydration-script-builder/hydration-runtime.generated.js +1 -1
- package/esm/src/html/index.d.ts +1 -0
- package/esm/src/html/index.d.ts.map +1 -1
- package/esm/src/html/index.js +1 -0
- package/esm/src/html/project-stylesheet-ids.d.ts +4 -0
- package/esm/src/html/project-stylesheet-ids.d.ts.map +1 -0
- package/esm/src/html/project-stylesheet-ids.js +6 -0
- package/esm/src/internal-agents/schema.d.ts +2 -0
- package/esm/src/internal-agents/schema.d.ts.map +1 -1
- package/esm/src/internal-agents/schema.js +4 -1
- package/esm/src/middleware/builtin/security/rate-limit-validation.d.ts +1 -0
- package/esm/src/middleware/builtin/security/rate-limit-validation.d.ts.map +1 -1
- package/esm/src/middleware/builtin/security/rate-limit-validation.js +1 -0
- package/esm/src/middleware/builtin/security/rate-limit.d.ts +31 -1
- package/esm/src/middleware/builtin/security/rate-limit.d.ts.map +1 -1
- package/esm/src/middleware/builtin/security/rate-limit.js +164 -36
- package/esm/src/middleware/builtin/security/redis-rate-limit-script.d.ts +3 -0
- package/esm/src/middleware/builtin/security/redis-rate-limit-script.d.ts.map +1 -0
- package/esm/src/middleware/builtin/security/redis-rate-limit-script.js +10 -0
- package/esm/src/middleware/builtin/security/redis-rate-limit.d.ts +13 -2
- package/esm/src/middleware/builtin/security/redis-rate-limit.d.ts.map +1 -1
- package/esm/src/middleware/builtin/security/redis-rate-limit.js +88 -20
- package/esm/src/middleware/index.d.ts +1 -1
- package/esm/src/middleware/index.d.ts.map +1 -1
- package/esm/src/modules/import-map/loader.d.ts +2 -1
- package/esm/src/modules/import-map/loader.d.ts.map +1 -1
- package/esm/src/modules/import-map/loader.js +210 -81
- package/esm/src/modules/import-map/merger.d.ts.map +1 -1
- package/esm/src/modules/import-map/merger.js +57 -11
- package/esm/src/modules/import-map/preloader.d.ts +83 -2
- package/esm/src/modules/import-map/preloader.d.ts.map +1 -1
- package/esm/src/modules/import-map/preloader.js +824 -25
- package/esm/src/modules/react-loader/ssr-module-loader/loader.d.ts.map +1 -1
- package/esm/src/modules/react-loader/ssr-module-loader/loader.js +28 -1
- package/esm/src/modules/server/browser-module-admission.d.ts +27 -0
- package/esm/src/modules/server/browser-module-admission.d.ts.map +1 -0
- package/esm/src/modules/server/browser-module-admission.js +164 -0
- package/esm/src/modules/server/classify.d.ts.map +1 -1
- package/esm/src/modules/server/classify.js +18 -2
- package/esm/src/modules/server/module-server.d.ts +14 -0
- package/esm/src/modules/server/module-server.d.ts.map +1 -1
- package/esm/src/modules/server/module-server.js +500 -132
- package/esm/src/modules/server/rate-limiter.d.ts +7 -2
- package/esm/src/modules/server/rate-limiter.d.ts.map +1 -1
- package/esm/src/modules/server/rate-limiter.js +30 -6
- package/esm/src/modules/server/websocket-handler.d.ts.map +1 -1
- package/esm/src/modules/server/websocket-handler.js +4 -5
- package/esm/src/observability/tracing/service-tracer.d.ts.map +1 -1
- package/esm/src/observability/tracing/service-tracer.js +2 -10
- package/esm/src/platform/adapters/fs/cache/file-cache.d.ts.map +1 -1
- package/esm/src/platform/adapters/fs/cache/file-cache.js +11 -1
- package/esm/src/platform/adapters/fs/github/adapter.d.ts.map +1 -1
- package/esm/src/platform/adapters/fs/github/adapter.js +0 -5
- package/esm/src/platform/adapters/fs/github/github-api-client.d.ts +1 -0
- package/esm/src/platform/adapters/fs/github/github-api-client.d.ts.map +1 -1
- package/esm/src/platform/adapters/fs/github/github-api-client.js +81 -6
- package/esm/src/platform/adapters/fs/github/path-utils.d.ts.map +1 -1
- package/esm/src/platform/adapters/fs/github/path-utils.js +22 -1
- package/esm/src/platform/adapters/fs/github/types.d.ts.map +1 -1
- package/esm/src/platform/adapters/fs/github/types.js +12 -11
- package/esm/src/platform/adapters/fs/integration.d.ts.map +1 -1
- package/esm/src/platform/adapters/fs/integration.js +25 -36
- package/esm/src/platform/adapters/fs/veryfront/path-normalizer.d.ts.map +1 -1
- package/esm/src/platform/adapters/fs/veryfront/path-normalizer.js +17 -4
- package/esm/src/platform/adapters/fs/veryfront/proxy-manager.d.ts +6 -2
- package/esm/src/platform/adapters/fs/veryfront/proxy-manager.d.ts.map +1 -1
- package/esm/src/platform/adapters/fs/veryfront/proxy-manager.js +207 -59
- package/esm/src/platform/adapters/fs/veryfront/schemas/proxy-manager.schema.js +1 -1
- package/esm/src/platform/adapters/veryfront-api-transport.d.ts +4 -0
- package/esm/src/platform/adapters/veryfront-api-transport.d.ts.map +1 -1
- package/esm/src/platform/adapters/veryfront-api-transport.js +20 -6
- package/esm/src/platform/compat/error-introspection.d.ts +12 -1
- package/esm/src/platform/compat/error-introspection.d.ts.map +1 -1
- package/esm/src/platform/compat/error-introspection.js +40 -5
- package/esm/src/platform/compat/framework-source-resolver.d.ts.map +1 -1
- package/esm/src/platform/compat/framework-source-resolver.js +6 -1
- package/esm/src/platform/compat/path/basic-operations.d.ts.map +1 -1
- package/esm/src/platform/compat/path/basic-operations.js +6 -2
- package/esm/src/platform/compat/path/portable.d.ts.map +1 -1
- package/esm/src/platform/compat/path/portable.js +98 -54
- package/esm/src/platform/compat/path/resolution.d.ts.map +1 -1
- package/esm/src/platform/compat/path/resolution.js +14 -5
- package/esm/src/platform/compat/primordials/array.d.ts +8 -0
- package/esm/src/platform/compat/primordials/array.d.ts.map +1 -0
- package/esm/src/platform/compat/primordials/array.js +33 -0
- package/esm/src/platform/compat/published-runtime-helpers.d.ts +2 -0
- package/esm/src/platform/compat/published-runtime-helpers.d.ts.map +1 -0
- package/esm/src/platform/compat/published-runtime-helpers.js +5 -0
- package/esm/src/platform/index.d.ts +1 -1
- package/esm/src/platform/index.d.ts.map +1 -1
- package/esm/src/platform/index.js +1 -1
- package/esm/src/provider/runtime-loader/json-snapshot.d.ts +12 -0
- package/esm/src/provider/runtime-loader/json-snapshot.d.ts.map +1 -1
- package/esm/src/provider/runtime-loader/json-snapshot.js +203 -57
- package/esm/src/provider/runtime-loader.d.ts +1 -1
- package/esm/src/provider/runtime-loader.d.ts.map +1 -1
- package/esm/src/provider/runtime-loader.js +3 -3
- package/esm/src/provider/shared/index.d.ts +1 -1
- package/esm/src/provider/shared/index.d.ts.map +1 -1
- package/esm/src/provider/shared/index.js +1 -1
- package/esm/src/proxy/control-plane-signature.d.ts +14 -0
- package/esm/src/proxy/control-plane-signature.d.ts.map +1 -1
- package/esm/src/proxy/control-plane-signature.js +131 -1
- package/esm/src/proxy/handler.d.ts +3 -1
- package/esm/src/proxy/handler.d.ts.map +1 -1
- package/esm/src/proxy/handler.js +56 -3
- package/esm/src/react/components/optimized-image/OptimizedBackgroundImage.d.ts +12 -5
- package/esm/src/react/components/optimized-image/OptimizedBackgroundImage.d.ts.map +1 -1
- package/esm/src/react/components/optimized-image/OptimizedBackgroundImage.js +5 -4
- package/esm/src/react/components/optimized-image/OptimizedImage.d.ts +8 -2
- package/esm/src/react/components/optimized-image/OptimizedImage.d.ts.map +1 -1
- package/esm/src/react/components/optimized-image/OptimizedImage.js +7 -7
- package/esm/src/react/components/optimized-image/SimpleOptimizedImage.d.ts +4 -3
- package/esm/src/react/components/optimized-image/SimpleOptimizedImage.d.ts.map +1 -1
- package/esm/src/react/components/optimized-image/SimpleOptimizedImage.js +10 -7
- package/esm/src/react/components/optimized-image/helpers.d.ts +20 -1
- package/esm/src/react/components/optimized-image/helpers.d.ts.map +1 -1
- package/esm/src/react/components/optimized-image/helpers.js +168 -6
- package/esm/src/react/components/optimized-image/index.d.ts +3 -2
- package/esm/src/react/components/optimized-image/index.d.ts.map +1 -1
- package/esm/src/react/components/optimized-image/useOptimizedImage.d.ts +11 -6
- package/esm/src/react/components/optimized-image/useOptimizedImage.d.ts.map +1 -1
- package/esm/src/react/components/optimized-image/useOptimizedImage.js +6 -8
- package/esm/src/react/index.d.ts +1 -1
- package/esm/src/react/index.d.ts.map +1 -1
- package/esm/src/release-assets/build-executor.d.ts.map +1 -1
- package/esm/src/release-assets/build-executor.js +43 -4
- package/esm/src/release-assets/index.d.ts +1 -1
- package/esm/src/release-assets/index.d.ts.map +1 -1
- package/esm/src/release-assets/index.js +1 -1
- package/esm/src/release-assets/manifest-cache.d.ts +8 -6
- package/esm/src/release-assets/manifest-cache.d.ts.map +1 -1
- package/esm/src/release-assets/manifest-cache.js +20 -5
- package/esm/src/release-assets/manifest-schema.d.ts +14 -2
- package/esm/src/release-assets/manifest-schema.d.ts.map +1 -1
- package/esm/src/release-assets/manifest-schema.js +21 -1
- package/esm/src/rendering/factories/service-factories.d.ts.map +1 -1
- package/esm/src/rendering/factories/service-factories.js +1 -0
- package/esm/src/rendering/layouts/layout-applicator.d.ts +3 -0
- package/esm/src/rendering/layouts/layout-applicator.d.ts.map +1 -1
- package/esm/src/rendering/layouts/layout-applicator.js +3 -1
- package/esm/src/rendering/layouts/utils/applicator.d.ts +2 -1
- package/esm/src/rendering/layouts/utils/applicator.d.ts.map +1 -1
- package/esm/src/rendering/layouts/utils/applicator.js +3 -3
- package/esm/src/rendering/layouts/utils/component-loader.d.ts +4 -3
- package/esm/src/rendering/layouts/utils/component-loader.d.ts.map +1 -1
- package/esm/src/rendering/layouts/utils/component-loader.js +23 -7
- package/esm/src/rendering/orchestrator/layout.d.ts +2 -0
- package/esm/src/rendering/orchestrator/layout.d.ts.map +1 -1
- package/esm/src/rendering/orchestrator/layout.js +7 -2
- package/esm/src/rendering/orchestrator/lifecycle.d.ts +3 -0
- package/esm/src/rendering/orchestrator/lifecycle.d.ts.map +1 -1
- package/esm/src/rendering/orchestrator/lifecycle.js +3 -0
- package/esm/src/rendering/orchestrator/ssr.d.ts.map +1 -1
- package/esm/src/rendering/orchestrator/ssr.js +2 -0
- package/esm/src/rendering/page-renderer.d.ts +3 -0
- package/esm/src/rendering/page-renderer.d.ts.map +1 -1
- package/esm/src/rendering/page-renderer.js +3 -0
- package/esm/src/rendering/page-rendering.d.ts +2 -0
- package/esm/src/rendering/page-rendering.d.ts.map +1 -1
- package/esm/src/rendering/page-rendering.js +13 -1
- package/esm/src/rendering/renderer.d.ts.map +1 -1
- package/esm/src/rendering/renderer.js +1 -0
- package/esm/src/routing/api/module-loader/esbuild-plugin.d.ts.map +1 -1
- package/esm/src/routing/api/module-loader/esbuild-plugin.js +9 -3
- package/esm/src/runs/schemas.d.ts +11 -11
- package/esm/src/security/http/base-handler.d.ts.map +1 -1
- package/esm/src/security/http/base-handler.js +5 -3
- package/esm/src/security/http/local-control-request.d.ts +39 -0
- package/esm/src/security/http/local-control-request.d.ts.map +1 -0
- package/esm/src/security/http/local-control-request.js +137 -0
- package/esm/src/security/sandbox/worker-egress-guard.d.ts +15 -0
- package/esm/src/security/sandbox/worker-egress-guard.d.ts.map +1 -1
- package/esm/src/security/sandbox/worker-egress-guard.js +48 -3
- package/esm/src/server/bootstrap.d.ts.map +1 -1
- package/esm/src/server/bootstrap.js +11 -0
- package/esm/src/server/context/request-context.d.ts +6 -0
- package/esm/src/server/context/request-context.d.ts.map +1 -1
- package/esm/src/server/context/request-context.js +2 -1
- package/esm/src/server/handlers/dev/dashboard/access-policy.d.ts +20 -0
- package/esm/src/server/handlers/dev/dashboard/access-policy.d.ts.map +1 -0
- package/esm/src/server/handlers/dev/dashboard/access-policy.js +106 -0
- package/esm/src/server/handlers/dev/dashboard/html-shell.d.ts +2 -1
- package/esm/src/server/handlers/dev/dashboard/html-shell.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/dashboard/html-shell.js +8 -32
- package/esm/src/server/handlers/dev/dashboard/index.d.ts +3 -0
- package/esm/src/server/handlers/dev/dashboard/index.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/dashboard/index.js +73 -8
- package/esm/src/server/handlers/dev/dashboard/ui-handler.d.ts +2 -1
- package/esm/src/server/handlers/dev/dashboard/ui-handler.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/dashboard/ui-handler.js +5 -84
- package/esm/src/server/handlers/dev/debug-context.handler.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/debug-context.handler.js +4 -0
- package/esm/src/server/handlers/dev/files/dev-file.handler.d.ts +0 -1
- package/esm/src/server/handlers/dev/files/dev-file.handler.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/files/dev-file.handler.js +21 -30
- package/esm/src/server/handlers/dev/files/esbuild-plugins.d.ts +5 -0
- package/esm/src/server/handlers/dev/files/esbuild-plugins.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/files/esbuild-plugins.js +56 -14
- package/esm/src/server/handlers/dev/files/path-validator.d.ts +8 -1
- package/esm/src/server/handlers/dev/files/path-validator.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/files/path-validator.js +21 -8
- package/esm/src/server/handlers/dev/projects/api.js +1 -1
- package/esm/src/server/handlers/dev/projects/html-shell.d.ts +1 -1
- package/esm/src/server/handlers/dev/projects/html-shell.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/projects/html-shell.js +2 -36
- package/esm/src/server/handlers/dev/projects/index.d.ts +3 -0
- package/esm/src/server/handlers/dev/projects/index.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/projects/index.js +37 -7
- package/esm/src/server/handlers/dev/projects/ui-handler.d.ts +2 -1
- package/esm/src/server/handlers/dev/projects/ui-handler.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/projects/ui-handler.js +5 -103
- package/esm/src/server/handlers/dev/scripts/hmr-scripts.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/scripts/hmr-scripts.js +37 -13
- package/esm/src/server/handlers/dev/shared/dev-ui-bundle-response.d.ts +10 -0
- package/esm/src/server/handlers/dev/shared/dev-ui-bundle-response.d.ts.map +1 -0
- package/esm/src/server/handlers/dev/shared/dev-ui-bundle-response.js +50 -0
- package/esm/src/server/handlers/monitoring/client-log.handler.d.ts.map +1 -1
- package/esm/src/server/handlers/monitoring/client-log.handler.js +4 -0
- package/esm/src/server/handlers/monitoring/memory.handler.d.ts.map +1 -1
- package/esm/src/server/handlers/monitoring/memory.handler.js +4 -0
- package/esm/src/server/handlers/monitoring/metrics.handler.d.ts.map +1 -1
- package/esm/src/server/handlers/monitoring/metrics.handler.js +4 -0
- package/esm/src/server/handlers/preview/hmr-client-message.d.ts +2 -1
- package/esm/src/server/handlers/preview/hmr-client-message.d.ts.map +1 -1
- package/esm/src/server/handlers/preview/hmr-client-message.js +4 -13
- package/esm/src/server/handlers/preview/hmr.handler.d.ts.map +1 -1
- package/esm/src/server/handlers/preview/hmr.handler.js +3 -24
- package/esm/src/server/handlers/request/agent-stream.handler.d.ts +5 -0
- package/esm/src/server/handlers/request/agent-stream.handler.d.ts.map +1 -1
- package/esm/src/server/handlers/request/agent-stream.handler.js +78 -60
- package/esm/src/server/handlers/request/module/module-server-handler.d.ts.map +1 -1
- package/esm/src/server/handlers/request/module/module-server-handler.js +2 -0
- package/esm/src/server/project-env/cache.d.ts +31 -8
- package/esm/src/server/project-env/cache.d.ts.map +1 -1
- package/esm/src/server/project-env/cache.js +222 -42
- package/esm/src/server/project-env/fetcher.d.ts +27 -3
- package/esm/src/server/project-env/fetcher.d.ts.map +1 -1
- package/esm/src/server/project-env/fetcher.js +156 -31
- package/esm/src/server/project-env/index.d.ts +2 -1
- package/esm/src/server/project-env/index.d.ts.map +1 -1
- package/esm/src/server/project-env/index.js +2 -1
- package/esm/src/server/project-env/production-environment-resolver.d.ts +40 -0
- package/esm/src/server/project-env/production-environment-resolver.d.ts.map +1 -0
- package/esm/src/server/project-env/production-environment-resolver.js +237 -0
- package/esm/src/server/runtime-handler/adapter-factory.d.ts.map +1 -1
- package/esm/src/server/runtime-handler/adapter-factory.js +4 -9
- package/esm/src/server/runtime-handler/environment-resolution.d.ts.map +1 -1
- package/esm/src/server/runtime-handler/environment-resolution.js +7 -2
- package/esm/src/server/runtime-handler/handler-context-builder.d.ts +9 -1
- package/esm/src/server/runtime-handler/handler-context-builder.d.ts.map +1 -1
- package/esm/src/server/runtime-handler/handler-context-builder.js +4 -0
- package/esm/src/server/runtime-handler/index.d.ts.map +1 -1
- package/esm/src/server/runtime-handler/index.js +45 -25
- package/esm/src/server/runtime-handler/project-middleware.d.ts.map +1 -1
- package/esm/src/server/runtime-handler/project-middleware.js +15 -8
- package/esm/src/server/runtime-handler/project-resolution.d.ts +5 -1
- package/esm/src/server/runtime-handler/project-resolution.d.ts.map +1 -1
- package/esm/src/server/runtime-handler/project-resolution.js +30 -16
- package/esm/src/server/runtime-handler/project-runtime-context.d.ts +9 -8
- package/esm/src/server/runtime-handler/project-runtime-context.d.ts.map +1 -1
- package/esm/src/server/runtime-handler/project-runtime-context.js +86 -28
- package/esm/src/server/runtime-handler/timeout-manager.d.ts.map +1 -1
- package/esm/src/server/runtime-handler/timeout-manager.js +11 -4
- package/esm/src/server/services/rsc/endpoints/endpoint-router.d.ts.map +1 -1
- package/esm/src/server/services/rsc/endpoints/endpoint-router.js +67 -99
- package/esm/src/server/services/rsc/endpoints/rsc-bundles.generated.d.ts.map +1 -1
- package/esm/src/server/services/rsc/endpoints/rsc-bundles.generated.js +2 -2
- package/esm/src/server/services/rsc/orchestrators/handler.d.ts.map +1 -1
- package/esm/src/server/services/rsc/orchestrators/handler.js +1 -0
- package/esm/src/server/services/rsc/orchestrators/render-handler.d.ts +2 -0
- package/esm/src/server/services/rsc/orchestrators/render-handler.d.ts.map +1 -1
- package/esm/src/server/services/rsc/orchestrators/render-handler.js +13 -1
- package/esm/src/server/shared/browser-module-bundler.d.ts +56 -1
- package/esm/src/server/shared/browser-module-bundler.d.ts.map +1 -1
- package/esm/src/server/shared/browser-module-bundler.js +626 -83
- package/esm/src/server/utils/proxy-trust.d.ts +9 -16
- package/esm/src/server/utils/proxy-trust.d.ts.map +1 -1
- package/esm/src/server/utils/proxy-trust.js +8 -29
- package/esm/src/testing/assert.d.ts +1 -1
- package/esm/src/testing/assert.d.ts.map +1 -1
- package/esm/src/testing/assert.js +3 -1
- package/esm/src/tool/factory.d.ts.map +1 -1
- package/esm/src/tool/factory.js +42 -10
- package/esm/src/tool/index.d.ts +1 -1
- package/esm/src/tool/index.d.ts.map +1 -1
- package/esm/src/tool/index.js +1 -1
- package/esm/src/tool/remote-mcp.d.ts +16 -1
- package/esm/src/tool/remote-mcp.d.ts.map +1 -1
- package/esm/src/tool/remote-mcp.js +48 -6
- package/esm/src/transforms/esm/http-cache-helpers.d.ts +4 -0
- package/esm/src/transforms/esm/http-cache-helpers.d.ts.map +1 -1
- package/esm/src/transforms/esm/http-cache-helpers.js +184 -62
- package/esm/src/transforms/esm/package-registry.d.ts +6 -0
- package/esm/src/transforms/esm/package-registry.d.ts.map +1 -1
- package/esm/src/transforms/esm/package-registry.js +9 -0
- package/esm/src/transforms/esm/specifier-resolver.d.ts.map +1 -1
- package/esm/src/transforms/esm/specifier-resolver.js +52 -9
- package/esm/src/transforms/esm/types.d.ts +8 -0
- package/esm/src/transforms/esm/types.d.ts.map +1 -1
- package/esm/src/transforms/import-rewriter/core.js +2 -2
- package/esm/src/transforms/import-rewriter/url-builder.d.ts +6 -0
- package/esm/src/transforms/import-rewriter/url-builder.d.ts.map +1 -1
- package/esm/src/transforms/import-rewriter/url-builder.js +96 -29
- package/esm/src/transforms/mdx/esm-module-loader/import-transformer.js +2 -2
- package/esm/src/transforms/mdx/esm-module-loader/loader-helpers.d.ts.map +1 -1
- package/esm/src/transforms/mdx/esm-module-loader/loader-helpers.js +1 -0
- package/esm/src/transforms/mdx/esm-module-loader/module-fetcher/http-fallback.d.ts.map +1 -1
- package/esm/src/transforms/mdx/esm-module-loader/module-fetcher/http-fallback.js +1 -1
- package/esm/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.d.ts +1 -0
- package/esm/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.d.ts.map +1 -1
- package/esm/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.js +8 -4
- package/esm/src/transforms/mdx/esm-module-loader/module-fetcher/index.d.ts.map +1 -1
- package/esm/src/transforms/mdx/esm-module-loader/module-fetcher/index.js +44 -1
- package/esm/src/transforms/mdx/esm-module-loader/module-writer.d.ts +15 -0
- package/esm/src/transforms/mdx/esm-module-loader/module-writer.d.ts.map +1 -1
- package/esm/src/transforms/mdx/esm-module-loader/module-writer.js +32 -2
- package/esm/src/transforms/mdx/esm-module-loader/types.d.ts +2 -0
- package/esm/src/transforms/mdx/esm-module-loader/types.d.ts.map +1 -1
- package/esm/src/transforms/mdx/index.d.ts +17 -1
- package/esm/src/transforms/mdx/index.d.ts.map +1 -1
- package/esm/src/transforms/mdx/index.js +23 -1
- package/esm/src/transforms/pipeline/cache-identity.d.ts +38 -0
- package/esm/src/transforms/pipeline/cache-identity.d.ts.map +1 -0
- package/esm/src/transforms/pipeline/cache-identity.js +381 -0
- package/esm/src/transforms/pipeline/index.d.ts.map +1 -1
- package/esm/src/transforms/pipeline/index.js +71 -27
- package/esm/src/transforms/pipeline/stages/ssr-http-cache.d.ts.map +1 -1
- package/esm/src/transforms/pipeline/stages/ssr-http-cache.js +2 -0
- package/esm/src/transforms/pipeline/stages/ssr-vf-modules/constants.d.ts +4 -1
- package/esm/src/transforms/pipeline/stages/ssr-vf-modules/constants.d.ts.map +1 -1
- package/esm/src/transforms/pipeline/stages/ssr-vf-modules/constants.js +17 -2
- package/esm/src/transforms/pipeline/stages/ssr-vf-modules/index.d.ts.map +1 -1
- package/esm/src/transforms/pipeline/stages/ssr-vf-modules/index.js +4 -2
- package/esm/src/transforms/pipeline/stages/ssr-vf-modules/transform.d.ts +2 -2
- package/esm/src/transforms/pipeline/stages/ssr-vf-modules/transform.d.ts.map +1 -1
- package/esm/src/transforms/pipeline/stages/ssr-vf-modules/transform.js +10 -10
- package/esm/src/transforms/pipeline/types.d.ts +19 -1
- package/esm/src/transforms/pipeline/types.d.ts.map +1 -1
- package/esm/src/transforms/shared/server-only-packages.d.ts.map +1 -1
- package/esm/src/transforms/shared/server-only-packages.js +17 -2
- package/esm/src/types/server.d.ts +8 -0
- package/esm/src/types/server.d.ts.map +1 -1
- package/esm/src/utils/base64url.d.ts.map +1 -1
- package/esm/src/utils/base64url.js +10 -4
- package/esm/src/utils/bundle-manifest.d.ts +19 -1
- package/esm/src/utils/bundle-manifest.d.ts.map +1 -1
- package/esm/src/utils/bundle-manifest.js +124 -27
- package/esm/src/utils/cache/eviction/eviction-manager.js +2 -2
- package/esm/src/utils/cache/stores/memory/entry-manager.d.ts +2 -1
- package/esm/src/utils/cache/stores/memory/entry-manager.d.ts.map +1 -1
- package/esm/src/utils/cache/stores/memory/entry-manager.js +7 -5
- package/esm/src/utils/cache/stores/memory/lru-cache-adapter.d.ts +7 -0
- package/esm/src/utils/cache/stores/memory/lru-cache-adapter.d.ts.map +1 -1
- package/esm/src/utils/cache/stores/memory/lru-cache-adapter.js +74 -17
- package/esm/src/utils/cache/stores/memory/lru-list-manager.d.ts +2 -0
- package/esm/src/utils/cache/stores/memory/lru-list-manager.d.ts.map +1 -1
- package/esm/src/utils/cache/stores/memory/lru-list-manager.js +6 -2
- package/esm/src/utils/cache/stores/memory/types.d.ts +2 -0
- package/esm/src/utils/cache/stores/memory/types.d.ts.map +1 -1
- package/esm/src/utils/cache-dir.d.ts +22 -1
- package/esm/src/utils/cache-dir.d.ts.map +1 -1
- package/esm/src/utils/cache-dir.js +171 -18
- package/esm/src/utils/cache-file-ops.d.ts +7 -3
- package/esm/src/utils/cache-file-ops.d.ts.map +1 -1
- package/esm/src/utils/cache-file-ops.js +26 -9
- package/esm/src/utils/cache-namespace.d.ts.map +1 -1
- package/esm/src/utils/cache-namespace.js +3 -1
- package/esm/src/utils/constants/limits.d.ts +1 -1
- package/esm/src/utils/constants/limits.d.ts.map +1 -1
- package/esm/src/utils/constants/limits.js +1 -1
- package/esm/src/utils/cookie-utils.d.ts.map +1 -1
- package/esm/src/utils/cookie-utils.js +15 -2
- package/esm/src/utils/env-loader.d.ts.map +1 -1
- package/esm/src/utils/env-loader.js +7 -2
- package/esm/src/utils/file-discovery.d.ts.map +1 -1
- package/esm/src/utils/file-discovery.js +88 -4
- package/esm/src/utils/hash-utils.d.ts.map +1 -1
- package/esm/src/utils/hash-utils.js +32 -6
- package/esm/src/utils/header-identity.d.ts +3 -0
- package/esm/src/utils/header-identity.d.ts.map +1 -0
- package/esm/src/utils/header-identity.js +25 -0
- package/esm/src/utils/id.d.ts.map +1 -1
- package/esm/src/utils/id.js +26 -4
- package/esm/src/utils/image-variant-widths.d.ts +13 -0
- package/esm/src/utils/image-variant-widths.d.ts.map +1 -0
- package/esm/src/utils/image-variant-widths.js +75 -0
- package/esm/src/utils/import-lockfile.d.ts +52 -1
- package/esm/src/utils/import-lockfile.d.ts.map +1 -1
- package/esm/src/utils/import-lockfile.js +1041 -58
- package/esm/src/utils/index.d.ts +1 -1
- package/esm/src/utils/index.d.ts.map +1 -1
- package/esm/src/utils/index.js +1 -1
- package/esm/src/utils/logger/logger.d.ts +1 -0
- package/esm/src/utils/logger/logger.d.ts.map +1 -1
- package/esm/src/utils/logger/logger.js +359 -78
- package/esm/src/utils/logger/redact.d.ts +4 -2
- package/esm/src/utils/logger/redact.d.ts.map +1 -1
- package/esm/src/utils/logger/redact.js +300 -112
- package/esm/src/utils/logger/serialization.d.ts +3 -0
- package/esm/src/utils/logger/serialization.d.ts.map +1 -0
- package/esm/src/utils/logger/serialization.js +81 -0
- package/esm/src/utils/lru-wrapper.js +2 -2
- package/esm/src/utils/memoize.d.ts +1 -1
- package/esm/src/utils/memoize.d.ts.map +1 -1
- package/esm/src/utils/memoize.js +16 -5
- package/esm/src/utils/memory/profiler.d.ts +11 -0
- package/esm/src/utils/memory/profiler.d.ts.map +1 -1
- package/esm/src/utils/memory/profiler.js +20 -5
- package/esm/src/utils/path-utils.d.ts.map +1 -1
- package/esm/src/utils/path-utils.js +4 -2
- package/esm/src/utils/sleep.d.ts +8 -1
- package/esm/src/utils/sleep.d.ts.map +1 -1
- package/esm/src/utils/sleep.js +11 -2
- package/esm/src/utils/version-constant.d.ts +1 -1
- package/esm/src/utils/version-constant.js +1 -1
- package/esm/src/utils/websocket-message-size.d.ts +22 -0
- package/esm/src/utils/websocket-message-size.d.ts.map +1 -0
- package/esm/src/utils/websocket-message-size.js +41 -0
- package/esm/src/workflow/schemas/workflow.schema.d.ts +10 -10
- package/esm/src/workflow/worker/shared.d.ts.map +1 -1
- package/esm/src/workflow/worker/shared.js +16 -5
- package/package.json +8 -8
- package/esm/src/server/dev-ui/manifest.d.ts +0 -33
- package/esm/src/server/dev-ui/manifest.d.ts.map +0 -1
- package/esm/src/server/dev-ui/manifest.js +0 -31
- package/esm/src/server/handlers/dev/shared/ui-module-transform.d.ts +0 -8
- package/esm/src/server/handlers/dev/shared/ui-module-transform.d.ts.map +0 -1
- package/esm/src/server/handlers/dev/shared/ui-module-transform.js +0 -32
|
@@ -115,8 +115,10 @@ export default {
|
|
|
115
115
|
"app/page.tsx": "'use client'\n\nimport { useEffect, useState } from 'react'\nimport { Chat, useChat } from 'veryfront/chat'\n\ninterface Integration {\n id: string\n name: string\n connected: boolean\n connectUrl: string\n}\n\nexport default function ChatPage(): React.ReactElement {\n const chat = useChat({ api: '/api/ag-ui' })\n\n return (\n <div className=\"flex flex-col h-screen bg-white dark:bg-neutral-900\">\n <header className=\"sticky top-0 z-10 flex-shrink-0 border-b border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900\">\n <div className=\"px-4 py-3 flex items-center justify-between\">\n <h1 className=\"font-medium text-neutral-900 dark:text-white\">AI Agent</h1>\n <div className=\"flex items-center gap-4\">\n <ServiceStatusFromAPI />\n <a\n href=\"/setup\"\n className=\"text-sm text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200\"\n >\n Setup\n </a>\n </div>\n </div>\n </header>\n\n <Chat chat={chat} className=\"flex-1 min-h-0\" placeholder=\"Message\" />\n </div>\n )\n}\n\nfunction ServiceStatusFromAPI(): React.ReactElement | null {\n const [integrations, setIntegrations] = useState<Integration[]>([])\n const [loading, setLoading] = useState<boolean>(true)\n\n useEffect((): void => {\n async function fetchStatus(): Promise<void> {\n try {\n const res = await fetch('/api/integrations/status')\n if (!res.ok) return\n\n const data = await res.json()\n setIntegrations(data.integrations ?? [])\n } catch (error) {\n console.error('Failed to fetch integration status:', error)\n } finally {\n setLoading(false)\n }\n }\n\n void fetchStatus()\n }, [])\n\n if (loading) {\n return (\n <div className=\"flex items-center gap-2\">\n <div className=\"animate-pulse h-6 w-24 bg-neutral-200 dark:bg-neutral-700 rounded-full\" />\n </div>\n )\n }\n\n if (integrations.length === 0) return null\n\n const connected: Integration[] = []\n const disconnected: Integration[] = []\n\n for (const integration of integrations) {\n if (integration.connected) connected.push(integration)\n else disconnected.push(integration)\n }\n\n return (\n <div className=\"flex items-center gap-2\">\n {connected.map(service => (\n <span\n key={service.id}\n className=\"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400\"\n title={`${service.name} connected`}\n >\n <span className=\"w-1.5 h-1.5 rounded-full bg-green-500\" />\n {service.name}\n </span>\n ))}\n\n {disconnected.map(service => (\n <a\n key={service.id}\n href={service.connectUrl}\n className=\"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700 transition-colors\"\n title={`Connect ${service.name}`}\n >\n <span className=\"w-1.5 h-1.5 rounded-full bg-neutral-400\" />\n {service.name}\n </a>\n ))}\n\n {disconnected.length > 0 && (\n <span className=\"text-xs text-neutral-500 dark:text-neutral-400\">\n {connected.length}/{integrations.length}\n </span>\n )}\n </div>\n )\n}\n",
|
|
116
116
|
"app/setup/page-helpers.tsx": "import type { JSX } from \"react\";\n\nexport interface Integration {\n id: string;\n name: string;\n icon: string;\n connected: boolean;\n connectUrl: string;\n}\n\nexport interface SetupStep {\n id: string;\n title: string;\n description: string;\n completed: boolean;\n action?: () => void;\n link?: string;\n}\n\ninterface SetupGuide {\n title: string;\n steps: string[];\n link: string;\n envVars: string[];\n category: string;\n}\n\nexport interface TokenStorageStatus {\n mode: \"memory\" | \"database\" | \"kv\" | \"redis\" | \"custom\";\n encrypted: boolean;\n autoGenerated?: boolean;\n}\n\nexport type TokenStorageStyles = {\n container: string;\n iconWrapper: string;\n title: string;\n text: string;\n isMemory: boolean;\n};\n\nexport const CATEGORIES = [\n { id: \"google\", name: \"Google Services\", icon: \"google\" },\n { id: \"microsoft\", name: \"Microsoft Services\", icon: \"microsoft\" },\n { id: \"atlassian\", name: \"Atlassian\", icon: \"atlassian\" },\n { id: \"communication\", name: \"Communication\", icon: \"chat\" },\n { id: \"development\", name: \"Development\", icon: \"code\" },\n { id: \"productivity\", name: \"Productivity\", icon: \"tasks\" },\n { id: \"storage\", name: \"Storage\", icon: \"folder\" },\n { id: \"infrastructure\", name: \"Infrastructure\", icon: \"server\" },\n { id: \"sales\", name: \"Sales & CRM\", icon: \"users\" },\n { id: \"support\", name: \"Support\", icon: \"headset\" },\n { id: \"finance\", name: \"Finance\", icon: \"dollar\" },\n { id: \"marketing\", name: \"Marketing\", icon: \"megaphone\" },\n { id: \"design\", name: \"Design\", icon: \"palette\" },\n { id: \"ai\", name: \"AI Providers\", icon: \"brain\" },\n] as const;\n\nexport const OAUTH_SETUP_GUIDES: Record<string, SetupGuide> = {\n gmail: {\n title: \"Google OAuth Setup (Gmail)\",\n category: \"google\",\n steps: [\n \"Go to Google Cloud Console\",\n \"Create a new project or select existing one\",\n \"Enable Gmail API in APIs & Services > Library\",\n \"Go to APIs & Services > Credentials\",\n \"Create OAuth 2.0 credentials (Web application)\",\n \"Add redirect URI: http://localhost:3000/api/auth/gmail/callback\",\n \"Copy Client ID and Secret to your .env file\",\n ],\n link: \"https://console.cloud.google.com/apis/credentials\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n calendar: {\n title: \"Google Calendar Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials as Gmail\",\n \"Enable Calendar API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/calendar/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/calendar-json.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n drive: {\n title: \"Google Drive Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials\",\n \"Enable Drive API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/drive/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/drive.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n sheets: {\n title: \"Google Sheets Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials\",\n \"Enable Sheets API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/sheets/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/sheets.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n \"docs-google\": {\n title: \"Google Docs Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials\",\n \"Enable Docs API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/docs-google/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/docs.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n outlook: {\n title: \"Microsoft Outlook Setup\",\n category: \"microsoft\",\n steps: [\n \"Go to Azure Portal > Azure Active Directory\",\n \"Click App registrations > New registration\",\n \"Set redirect URI: http://localhost:3000/api/auth/outlook/callback\",\n \"Go to API permissions > Add Microsoft Graph permissions\",\n \"Add: Mail.Read, Mail.Send, Mail.ReadWrite\",\n \"Go to Certificates & secrets > New client secret\",\n \"Copy Application ID and Secret to .env\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n teams: {\n title: \"Microsoft Teams Setup\",\n category: \"microsoft\",\n steps: [\n \"Uses same Microsoft OAuth credentials as Outlook\",\n \"Add Teams permissions: Chat.Read, Chat.ReadWrite, Channel.ReadBasic.All\",\n \"Add redirect URI: http://localhost:3000/api/auth/teams/callback\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n onedrive: {\n title: \"Microsoft OneDrive Setup\",\n category: \"microsoft\",\n steps: [\n \"Uses same Microsoft OAuth credentials\",\n \"Add permissions: Files.Read, Files.ReadWrite\",\n \"Add redirect URI: http://localhost:3000/api/auth/onedrive/callback\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n sharepoint: {\n title: \"Microsoft SharePoint Setup\",\n category: \"microsoft\",\n steps: [\n \"Uses same Microsoft OAuth credentials\",\n \"Add permissions: Sites.Read.All, Sites.ReadWrite.All\",\n \"Add redirect URI: http://localhost:3000/api/auth/sharepoint/callback\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n jira: {\n title: \"Atlassian Jira Setup\",\n category: \"atlassian\",\n steps: [\n \"Go to Atlassian Developer Console\",\n \"Click Create > OAuth 2.0 integration\",\n \"Add Jira API scopes: read:jira-work, write:jira-work\",\n \"Set callback URL: http://localhost:3000/api/auth/jira/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.atlassian.com/console/myapps/\",\n envVars: [\"ATLASSIAN_CLIENT_ID\", \"ATLASSIAN_CLIENT_SECRET\"],\n },\n confluence: {\n title: \"Atlassian Confluence Setup\",\n category: \"atlassian\",\n steps: [\n \"Uses same Atlassian OAuth credentials as Jira\",\n \"Add Confluence scopes: read:confluence-content.all, write:confluence-content\",\n \"Add callback URL: http://localhost:3000/api/auth/confluence/callback\",\n ],\n link: \"https://developer.atlassian.com/console/myapps/\",\n envVars: [\"ATLASSIAN_CLIENT_ID\", \"ATLASSIAN_CLIENT_SECRET\"],\n },\n bitbucket: {\n title: \"Atlassian Bitbucket Setup\",\n category: \"atlassian\",\n steps: [\n \"Go to Bitbucket Settings > OAuth consumers\",\n \"Click Add consumer\",\n \"Set callback URL: http://localhost:3000/api/auth/bitbucket/callback\",\n \"Add permissions: repository:read, repository:write\",\n \"Copy Key and Secret to .env\",\n ],\n link: \"https://bitbucket.org/account/settings/app-passwords/\",\n envVars: [\"BITBUCKET_CLIENT_ID\", \"BITBUCKET_CLIENT_SECRET\"],\n },\n slack: {\n title: \"Slack App Setup\",\n category: \"communication\",\n steps: [\n \"Go to Slack API Apps page\",\n \"Click Create New App > From scratch\",\n \"Go to OAuth & Permissions\",\n \"Add scopes: channels:history, channels:read, chat:write, groups:history, groups:read, im:history, im:read, mpim:history, mpim:read, users:read\",\n \"Add redirect URL: http://localhost:3000/api/auth/slack/callback\",\n \"Install to Workspace\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://api.slack.com/apps\",\n envVars: [\"SLACK_CLIENT_ID\", \"SLACK_CLIENT_SECRET\"],\n },\n zoom: {\n title: \"Zoom App Setup\",\n category: \"communication\",\n steps: [\n \"Go to Zoom App Marketplace\",\n \"Click Develop > Build App\",\n \"Choose OAuth app type\",\n \"Add redirect URL: http://localhost:3000/api/auth/zoom/callback\",\n \"Add scopes: meeting:read, meeting:write, user:read\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://marketplace.zoom.us/develop/create\",\n envVars: [\"ZOOM_CLIENT_ID\", \"ZOOM_CLIENT_SECRET\"],\n },\n webex: {\n title: \"Webex Integration Setup\",\n category: \"communication\",\n steps: [\n \"Go to Webex Developer Portal\",\n \"Create a new integration\",\n \"Add redirect URI: http://localhost:3000/api/auth/webex/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.webex.com/my-apps\",\n envVars: [\"WEBEX_CLIENT_ID\", \"WEBEX_CLIENT_SECRET\"],\n },\n twilio: {\n title: \"Twilio Setup\",\n category: \"communication\",\n steps: [\n \"Go to Twilio Console\",\n \"Copy Account SID and Auth Token\",\n \"Get a phone number for SMS\",\n \"Add credentials to .env\",\n ],\n link: \"https://console.twilio.com/\",\n envVars: [\"TWILIO_ACCOUNT_SID\", \"TWILIO_AUTH_TOKEN\", \"TWILIO_PHONE_NUMBER\"],\n },\n github: {\n title: \"GitHub OAuth App Setup\",\n category: \"development\",\n steps: [\n \"Go to GitHub Developer Settings\",\n \"Click OAuth Apps > New OAuth App\",\n \"Set Homepage URL: http://localhost:3000\",\n \"Set callback URL: http://localhost:3000/api/auth/github/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://github.com/settings/developers\",\n envVars: [\"GITHUB_CLIENT_ID\", \"GITHUB_CLIENT_SECRET\"],\n },\n gitlab: {\n title: \"GitLab OAuth Setup\",\n category: \"development\",\n steps: [\n \"Go to GitLab User Settings > Applications\",\n \"Create new application\",\n \"Add redirect URI: http://localhost:3000/api/auth/gitlab/callback\",\n \"Select scopes: api, read_user, read_repository\",\n \"Copy Application ID and Secret to .env\",\n ],\n link: \"https://gitlab.com/-/profile/applications\",\n envVars: [\"GITLAB_CLIENT_ID\", \"GITLAB_CLIENT_SECRET\"],\n },\n sentry: {\n title: \"Sentry Setup\",\n category: \"development\",\n steps: [\n \"Go to Sentry Settings > Developer Settings\",\n \"Create new integration\",\n \"Add redirect URL: http://localhost:3000/api/auth/sentry/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://sentry.io/settings/developer-settings/\",\n envVars: [\"SENTRY_CLIENT_ID\", \"SENTRY_CLIENT_SECRET\"],\n },\n posthog: {\n title: \"PostHog Setup\",\n category: \"development\",\n steps: [\"Go to PostHog Project Settings\", \"Copy your Project API Key\", \"Add to .env file\"],\n link: \"https://app.posthog.com/project/settings\",\n envVars: [\"POSTHOG_API_KEY\", \"POSTHOG_HOST\"],\n },\n mixpanel: {\n title: \"Mixpanel Setup\",\n category: \"development\",\n steps: [\n \"Go to Mixpanel Project Settings\",\n \"Copy your Project Token\",\n \"For API access, create a Service Account\",\n \"Add credentials to .env\",\n ],\n link: \"https://mixpanel.com/settings/project\",\n envVars: [\"MIXPANEL_TOKEN\", \"MIXPANEL_API_SECRET\"],\n },\n notion: {\n title: \"Notion Integration Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Notion Integrations page\",\n \"Click New integration\",\n \"Name your integration and select workspace\",\n \"Copy the Internal Integration Token\",\n \"Share desired pages/databases with your integration\",\n \"Add token to .env\",\n ],\n link: \"https://www.notion.so/my-integrations\",\n envVars: [\"NOTION_API_KEY\"],\n },\n linear: {\n title: \"Linear OAuth Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Linear Settings > API\",\n \"Create new OAuth application\",\n \"Add redirect URI: http://localhost:3000/api/auth/linear/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://linear.app/settings/api\",\n envVars: [\"LINEAR_CLIENT_ID\", \"LINEAR_CLIENT_SECRET\"],\n },\n asana: {\n title: \"Asana OAuth Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Asana Developer Console\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/asana/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://app.asana.com/0/developer-console\",\n envVars: [\"ASANA_CLIENT_ID\", \"ASANA_CLIENT_SECRET\"],\n },\n trello: {\n title: \"Trello Power-Up Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Trello Power-Ups Admin\",\n \"Create new Power-Up\",\n \"Add redirect URI: http://localhost:3000/api/auth/trello/callback\",\n \"Copy API Key and Secret to .env\",\n ],\n link: \"https://trello.com/power-ups/admin\",\n envVars: [\"TRELLO_API_KEY\", \"TRELLO_API_SECRET\"],\n },\n monday: {\n title: \"Monday.com App Setup\",\n category: \"productivity\",\n steps: [\n \"Go to monday.com Developers\",\n \"Create new app\",\n \"Add OAuth redirect: http://localhost:3000/api/auth/monday/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://monday.com/developers/apps\",\n envVars: [\"MONDAY_CLIENT_ID\", \"MONDAY_CLIENT_SECRET\"],\n },\n clickup: {\n title: \"ClickUp OAuth Setup\",\n category: \"productivity\",\n steps: [\n \"Go to ClickUp Settings > Apps\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/clickup/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://app.clickup.com/settings/apps\",\n envVars: [\"CLICKUP_CLIENT_ID\", \"CLICKUP_CLIENT_SECRET\"],\n },\n box: {\n title: \"Box App Setup\",\n category: \"storage\",\n steps: [\n \"Go to Box Developer Console\",\n \"Create new app with OAuth 2.0\",\n \"Add redirect URI: http://localhost:3000/api/auth/box/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://app.box.com/developers/console\",\n envVars: [\"BOX_CLIENT_ID\", \"BOX_CLIENT_SECRET\"],\n },\n airtable: {\n title: \"Airtable OAuth Setup\",\n category: \"storage\",\n steps: [\n \"Go to Airtable Developer Hub\",\n \"Create new OAuth integration\",\n \"Add redirect URI: http://localhost:3000/api/auth/airtable/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://airtable.com/create/oauth\",\n envVars: [\"AIRTABLE_CLIENT_ID\", \"AIRTABLE_CLIENT_SECRET\"],\n },\n supabase: {\n title: \"Supabase Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to Supabase Dashboard\",\n \"Create new project or select existing\",\n \"Go to Settings > API\",\n \"Copy Project URL and anon/service_role keys\",\n \"Add to .env file\",\n ],\n link: \"https://supabase.com/dashboard\",\n envVars: [\"SUPABASE_URL\", \"SUPABASE_ANON_KEY\", \"SUPABASE_SERVICE_ROLE_KEY\"],\n },\n neon: {\n title: \"Neon Database Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to Neon Console\",\n \"Create new project\",\n \"Copy connection string from Dashboard\",\n \"Add to .env file\",\n ],\n link: \"https://console.neon.tech/\",\n envVars: [\"DATABASE_URL\"],\n },\n snowflake: {\n title: \"Snowflake Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to Snowflake Console\",\n \"Create a service account or use existing credentials\",\n \"Note your account identifier, warehouse, database\",\n \"Add credentials to .env\",\n ],\n link: \"https://app.snowflake.com/\",\n envVars: [\"SNOWFLAKE_ACCOUNT\", \"SNOWFLAKE_USER\", \"SNOWFLAKE_PASSWORD\", \"SNOWFLAKE_WAREHOUSE\"],\n },\n aws: {\n title: \"AWS Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to AWS IAM Console\",\n \"Create new IAM user with programmatic access\",\n \"Attach required policies (S3, Lambda, DynamoDB)\",\n \"Copy Access Key ID and Secret\",\n \"Add to .env file\",\n ],\n link: \"https://console.aws.amazon.com/iam/\",\n envVars: [\"AWS_ACCESS_KEY_ID\", \"AWS_SECRET_ACCESS_KEY\", \"AWS_REGION\"],\n },\n salesforce: {\n title: \"Salesforce Connected App Setup\",\n category: \"sales\",\n steps: [\n \"Go to Salesforce Setup > App Manager\",\n \"Create new Connected App\",\n \"Enable OAuth Settings\",\n \"Add callback URL: http://localhost:3000/api/auth/salesforce/callback\",\n \"Select OAuth scopes: api, refresh_token\",\n \"Copy Consumer Key and Secret to .env\",\n ],\n link: \"https://login.salesforce.com/\",\n envVars: [\"SALESFORCE_CLIENT_ID\", \"SALESFORCE_CLIENT_SECRET\"],\n },\n pipedrive: {\n title: \"Pipedrive OAuth Setup\",\n category: \"sales\",\n steps: [\n \"Go to Pipedrive Developer Hub\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/pipedrive/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.pipedrive.com/\",\n envVars: [\"PIPEDRIVE_CLIENT_ID\", \"PIPEDRIVE_CLIENT_SECRET\"],\n },\n zendesk: {\n title: \"Zendesk OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to Zendesk Admin > API > OAuth Clients\",\n \"Add new OAuth client\",\n \"Set redirect URI: http://localhost:3000/api/auth/zendesk/callback\",\n \"Copy Client ID and Secret to .env\",\n \"Add your Zendesk subdomain\",\n ],\n link: \"https://support.zendesk.com/hc/en-us/articles/4408845965210\",\n envVars: [\"ZENDESK_CLIENT_ID\", \"ZENDESK_CLIENT_SECRET\", \"ZENDESK_SUBDOMAIN\"],\n },\n intercom: {\n title: \"Intercom OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to Intercom Developer Hub\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/intercom/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.intercom.com/\",\n envVars: [\"INTERCOM_CLIENT_ID\", \"INTERCOM_CLIENT_SECRET\"],\n },\n freshdesk: {\n title: \"Freshdesk OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to Freshdesk Admin > Apps > Custom Apps\",\n \"Create new OAuth application\",\n \"Add redirect URI: http://localhost:3000/api/auth/freshdesk/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.freshdesk.com/\",\n envVars: [\"FRESHDESK_CLIENT_ID\", \"FRESHDESK_CLIENT_SECRET\", \"FRESHDESK_DOMAIN\"],\n },\n servicenow: {\n title: \"ServiceNow OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to ServiceNow System OAuth > Application Registry\",\n \"Create OAuth API endpoint for external clients\",\n \"Add redirect URL: http://localhost:3000/api/auth/servicenow/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://docs.servicenow.com/\",\n envVars: [\"SERVICENOW_CLIENT_ID\", \"SERVICENOW_CLIENT_SECRET\", \"SERVICENOW_INSTANCE\"],\n },\n stripe: {\n title: \"Stripe Setup\",\n category: \"finance\",\n steps: [\n \"Go to Stripe Dashboard\",\n \"Go to Developers > API keys\",\n \"Copy Publishable and Secret keys\",\n \"For Connect, set up OAuth in Connect settings\",\n \"Add to .env file\",\n ],\n link: \"https://dashboard.stripe.com/apikeys\",\n envVars: [\"STRIPE_SECRET_KEY\", \"STRIPE_PUBLISHABLE_KEY\"],\n },\n quickbooks: {\n title: \"QuickBooks OAuth Setup\",\n category: \"finance\",\n steps: [\n \"Go to Intuit Developer Portal\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/quickbooks/callback\",\n \"Select Accounting scope\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.intuit.com/app/developer/dashboard\",\n envVars: [\"QUICKBOOKS_CLIENT_ID\", \"QUICKBOOKS_CLIENT_SECRET\"],\n },\n xero: {\n title: \"Xero OAuth Setup\",\n category: \"finance\",\n steps: [\n \"Go to Xero Developer Portal\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/xero/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.xero.com/app/manage\",\n envVars: [\"XERO_CLIENT_ID\", \"XERO_CLIENT_SECRET\"],\n },\n mailchimp: {\n title: \"Mailchimp OAuth Setup\",\n category: \"marketing\",\n steps: [\n \"Go to Mailchimp Developer Portal\",\n \"Register new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/mailchimp/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://admin.mailchimp.com/account/oauth2/\",\n envVars: [\"MAILCHIMP_CLIENT_ID\", \"MAILCHIMP_CLIENT_SECRET\"],\n },\n shopify: {\n title: \"Shopify App Setup\",\n category: \"marketing\",\n steps: [\n \"Go to Shopify Partners Dashboard\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/shopify/callback\",\n \"Copy API Key and Secret to .env\",\n ],\n link: \"https://partners.shopify.com/\",\n envVars: [\"SHOPIFY_API_KEY\", \"SHOPIFY_API_SECRET\"],\n },\n twitter: {\n title: \"Twitter/X OAuth Setup\",\n category: \"marketing\",\n steps: [\n \"Go to Twitter Developer Portal\",\n \"Create new project and app\",\n \"Enable OAuth 2.0\",\n \"Add redirect URI: http://localhost:3000/api/auth/twitter/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.twitter.com/en/portal/dashboard\",\n envVars: [\"TWITTER_CLIENT_ID\", \"TWITTER_CLIENT_SECRET\"],\n },\n figma: {\n title: \"Figma OAuth Setup\",\n category: \"design\",\n steps: [\n \"Go to Figma Developer Settings\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/figma/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://www.figma.com/developers/apps\",\n envVars: [\"FIGMA_CLIENT_ID\", \"FIGMA_CLIENT_SECRET\"],\n },\n anthropic: {\n title: \"Anthropic API Setup\",\n category: \"ai\",\n steps: [\"Go to Anthropic Console\", \"Create new API key\", \"Copy API key to .env\"],\n link: \"https://console.anthropic.com/\",\n envVars: [\"ANTHROPIC_API_KEY\"],\n },\n};\n\nexport function filterIntegrations(\n integrations: Integration[],\n searchQuery: string,\n selectedCategory: string | null,\n): Integration[] {\n const query = searchQuery.toLowerCase();\n\n return integrations.filter((integration) => {\n const guide = OAUTH_SETUP_GUIDES[integration.id];\n\n const matchesSearch =\n query === \"\" ||\n integration.name.toLowerCase().includes(query) ||\n integration.id.toLowerCase().includes(query);\n\n const matchesCategory = selectedCategory === null || guide?.category === selectedCategory;\n\n return matchesSearch && matchesCategory;\n });\n}\n\nexport function groupIntegrationsByCategory(\n integrations: Integration[],\n): Record<string, Integration[]> {\n const groups: Record<string, Integration[]> = {};\n\n for (const integration of integrations) {\n const category = OAUTH_SETUP_GUIDES[integration.id]?.category ?? \"other\";\n (groups[category] ??= []).push(integration);\n }\n\n return groups;\n}\n\nexport function buildSetupSteps(\n envChecked: boolean,\n allConnected: boolean,\n markEnvChecked: () => void,\n): SetupStep[] {\n return [\n {\n id: \"env\",\n title: \"Configure Environment Variables\",\n description: \"Add your OAuth credentials to the .env file\",\n completed: envChecked,\n action: markEnvChecked,\n },\n {\n id: \"oauth\",\n title: \"Create OAuth Apps\",\n description: \"Set up OAuth applications for each service\",\n completed: false,\n },\n {\n id: \"connect\",\n title: \"Connect Services\",\n description: \"Authorize your app to access each service\",\n completed: allConnected,\n },\n ];\n}\n\nexport function getTokenStorageStyles(\n tokenStorage: TokenStorageStatus | null,\n): TokenStorageStyles | null {\n if (!tokenStorage) return null;\n\n const isMemory = tokenStorage.mode === \"memory\";\n\n return {\n container: `rounded-2xl p-6 shadow-sm border mb-8 ${\n isMemory\n ? \"bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800\"\n : \"bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800\"\n }`,\n iconWrapper: `w-10 h-10 rounded-full flex items-center justify-center ${\n isMemory ? \"bg-amber-100 dark:bg-amber-900\" : \"bg-green-100 dark:bg-green-900\"\n }`,\n title: `font-semibold ${\n isMemory ? \"text-amber-800 dark:text-amber-200\" : \"text-green-800 dark:text-green-200\"\n }`,\n text: `text-sm mt-1 ${\n isMemory ? \"text-amber-700 dark:text-amber-300\" : \"text-green-700 dark:text-green-300\"\n }`,\n isMemory,\n };\n}\n\nexport function ServiceIcon({ name }: { name: string }): React.JSX.Element {\n const iconMap: Record<string, React.JSX.Element> = {\n mail: (\n <svg className=\"w-6 h-6 text-red-500\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n d=\"M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n fill=\"none\"\n />\n </svg>\n ),\n slack: (\n <svg className=\"w-6 h-6\" viewBox=\"0 0 24 24\" fill=\"none\">\n <path\n d=\"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z\"\n fill=\"#E01E5A\"\n />\n <path\n d=\"M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z\"\n fill=\"#36C5F0\"\n />\n <path\n d=\"M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312z\"\n fill=\"#2EB67D\"\n />\n <path\n d=\"M15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z\"\n fill=\"#ECB22E\"\n />\n </svg>\n ),\n calendar: (\n <svg\n className=\"w-6 h-6 text-blue-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"\n />\n </svg>\n ),\n github: (\n <svg className=\"w-6 h-6\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n fillRule=\"evenodd\"\n d=\"M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z\"\n clipRule=\"evenodd\"\n />\n </svg>\n ),\n jira: (\n <svg className=\"w-6 h-6\" viewBox=\"0 0 24 24\">\n <defs>\n <linearGradient id=\"jira-gradient\" x1=\"98.031%\" x2=\"58.888%\" y1=\".161%\" y2=\"40.766%\">\n <stop offset=\"0%\" stopColor=\"#0052CC\" />\n <stop offset=\"100%\" stopColor=\"#2684FF\" />\n </linearGradient>\n </defs>\n <path\n fill=\"url(#jira-gradient)\"\n d=\"M11.571 11.513H0a5.218 5.218 0 005.232 5.215h2.13v2.057A5.215 5.215 0 0012.575 24V12.518a1.005 1.005 0 00-1.005-1.005z\"\n />\n <path\n fill=\"#2684FF\"\n d=\"M17.151 5.97H5.58a5.215 5.215 0 005.215 5.214h2.129v2.058a5.218 5.218 0 005.232 5.215V6.975a1.005 1.005 0 00-1.005-1.005z\"\n />\n <path\n fill=\"#2684FF\"\n d=\"M22.723.426H11.152a5.215 5.215 0 005.215 5.215h2.129v2.057a5.218 5.218 0 005.232 5.215V1.431a1.005 1.005 0 00-1.005-1.005z\"\n />\n </svg>\n ),\n notion: (\n <svg className=\"w-6 h-6\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.98-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466l1.823 1.447zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.84-.046.933-.56.933-1.167V6.354c0-.606-.233-.933-.746-.886l-15.177.887c-.56.046-.747.326-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.746 0-.933-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.14c-.093-.514.28-.886.747-.933l3.222-.186zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.933.653.933 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.448-1.632z\" />\n </svg>\n ),\n default: (\n <svg\n className=\"w-6 h-6 text-neutral-400\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M13 10V3L4 14h7v7l9-11h-7z\"\n />\n </svg>\n ),\n };\n\n return iconMap[name] ?? iconMap.default;\n}\n",
|
|
117
117
|
"app/setup/page.tsx": "\"use client\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport {\n buildSetupSteps,\n CATEGORIES,\n filterIntegrations,\n getTokenStorageStyles,\n groupIntegrationsByCategory,\n type Integration,\n OAUTH_SETUP_GUIDES,\n ServiceIcon,\n type TokenStorageStatus,\n} from \"./page-helpers\";\n\nexport default function SetupPage(): React.JSX.Element {\n const [integrations, setIntegrations] = useState<Integration[]>([]);\n const [loading, setLoading] = useState(true);\n const [expandedGuide, setExpandedGuide] = useState<string | null>(null);\n const [envChecked, setEnvChecked] = useState(false);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const [selectedCategory, setSelectedCategory] = useState<string | null>(null);\n const [tokenStorage, setTokenStorage] = useState<TokenStorageStatus | null>(null);\n\n useEffect(() => {\n void fetchStatus();\n void fetchTokenStorage();\n }, []);\n\n async function fetchStatus(): Promise<void> {\n try {\n const res = await fetch(\"/api/integrations/status\");\n if (!res.ok) {\n console.error(\"Failed to fetch integration status:\", res.status);\n setIntegrations([]);\n return;\n }\n\n const data = await res.json();\n setIntegrations(data.integrations ?? []);\n } catch (error) {\n console.error(\"Failed to fetch integration status:\", error);\n setIntegrations([]);\n } finally {\n setLoading(false);\n }\n }\n\n async function fetchTokenStorage(): Promise<void> {\n const fallback: TokenStorageStatus = { mode: \"memory\", encrypted: false };\n\n try {\n const res = await fetch(\"/api/integrations/token-storage\");\n if (!res.ok) {\n setTokenStorage(fallback);\n return;\n }\n const data = await res.json();\n setTokenStorage(data);\n } catch {\n setTokenStorage(fallback);\n }\n }\n\n const filteredIntegrations = useMemo(\n () => filterIntegrations(integrations, searchQuery, selectedCategory),\n [integrations, searchQuery, selectedCategory],\n );\n\n const groupedIntegrations = useMemo(\n () => groupIntegrationsByCategory(filteredIntegrations),\n [filteredIntegrations],\n );\n\n const connectedCount = integrations.filter((i) => i.connected).length;\n const totalCount = integrations.length;\n const progress = totalCount > 0 ? (connectedCount / totalCount) * 100 : 0;\n\n const allConnected = connectedCount === totalCount && totalCount > 0;\n\n const setupSteps = useMemo(\n () => buildSetupSteps(envChecked, allConnected, () => setEnvChecked(true)),\n [allConnected, envChecked],\n );\n\n const tokenStorageStyles = useMemo(() => getTokenStorageStyles(tokenStorage), [tokenStorage]);\n\n return (\n <div className=\"min-h-screen bg-neutral-50 dark:bg-neutral-900\">\n <div className=\"max-w-4xl mx-auto px-4 py-12\">\n <div className=\"text-center mb-12\">\n <h1 className=\"text-4xl font-bold text-neutral-900 dark:text-white mb-4\">\n Setup Your AI Agent\n </h1>\n <p className=\"text-lg text-neutral-600 dark:text-neutral-400\">\n Connect your services to enable AI-powered automation\n </p>\n </div>\n\n <div className=\"bg-white dark:bg-neutral-800 rounded-2xl p-6 shadow-sm border border-neutral-200 dark:border-neutral-700 mb-8\">\n <div className=\"flex items-center justify-between mb-2\">\n <span className=\"text-sm font-medium text-neutral-600 dark:text-neutral-400\">\n Setup Progress\n </span>\n <span className=\"text-sm font-medium text-neutral-900 dark:text-white\">\n {connectedCount} / {totalCount} services connected\n </span>\n </div>\n <div className=\"w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-3\">\n <div\n className=\"bg-gradient-to-r from-green-500 to-emerald-500 h-3 rounded-full transition-all duration-500\"\n style={{ width: `${progress}%` }}\n />\n </div>\n </div>\n\n {tokenStorage && tokenStorageStyles && (\n <div className={tokenStorageStyles.container}>\n <div className=\"flex items-start gap-4\">\n <div className={tokenStorageStyles.iconWrapper}>\n {tokenStorageStyles.isMemory ? (\n <svg\n className=\"w-5 h-5 text-amber-600 dark:text-amber-400\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\"\n />\n </svg>\n ) : (\n <svg\n className=\"w-5 h-5 text-green-600 dark:text-green-400\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z\"\n />\n </svg>\n )}\n </div>\n\n <div className=\"flex-1\">\n <h3 className={tokenStorageStyles.title}>\n Token Storage:{\" \"}\n {tokenStorageStyles.isMemory\n ? \"Development Mode\"\n : `${tokenStorage.mode.charAt(0).toUpperCase()}${tokenStorage.mode.slice(\n 1,\n )} Storage`}\n </h3>\n\n <p className={tokenStorageStyles.text}>\n {tokenStorageStyles.isMemory ? (\n <>Tokens are stored in memory and will be lost on restart.</>\n ) : (\n <>Tokens are persisted to {tokenStorage.mode} storage.</>\n )}\n </p>\n\n <div className=\"mt-2 flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400\">\n <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z\"\n />\n </svg>\n <span>Encryption enabled {tokenStorage.autoGenerated && \"(auto-generated key)\"}</span>\n </div>\n\n {tokenStorageStyles.isMemory && (\n <div className=\"mt-4 pt-4 border-t border-amber-200 dark:border-amber-800\">\n <p className=\"text-sm font-medium text-amber-800 dark:text-amber-200 mb-3\">\n For production, add one of these to your{\" \"}\n <code className=\"px-1 py-0.5 bg-amber-100 dark:bg-amber-900 rounded text-xs\">\n .env\n </code>\n :\n </p>\n <div className=\"grid gap-2\">\n <a\n href=\"https://upstash.com/docs/redis/overall/getstarted\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-green-200 dark:border-green-700 hover:border-green-400 dark:hover:border-green-500 transition-colors group\"\n >\n <div>\n <span className=\"font-medium text-neutral-900 dark:text-white\">\n Upstash\n </span>\n <span className=\"text-green-600 dark:text-green-400 text-xs ml-2 font-medium\">\n Recommended\n </span>\n <span className=\"text-neutral-500 dark:text-neutral-400 text-sm ml-2\">\n Serverless Redis, scales horizontally\n </span>\n </div>\n <code className=\"text-xs bg-neutral-100 dark:bg-neutral-700 px-2 py-1 rounded text-neutral-600 dark:text-neutral-300\">\n REDIS_URL\n </code>\n </a>\n\n <a\n href=\"https://docs.turso.tech/quickstart\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-amber-200 dark:border-amber-700 hover:border-amber-400 dark:hover:border-amber-500 transition-colors group\"\n >\n <div>\n <span className=\"font-medium text-neutral-900 dark:text-white\">\n Turso / libSQL\n </span>\n <span className=\"text-neutral-500 dark:text-neutral-400 text-sm ml-2\">\n Edge SQLite, fast reads globally\n </span>\n </div>\n <code className=\"text-xs bg-neutral-100 dark:bg-neutral-700 px-2 py-1 rounded text-neutral-600 dark:text-neutral-300\">\n DATABASE_URL\n </code>\n </a>\n\n <a\n href=\"https://vercel.com/docs/storage/vercel-kv/quickstart\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-amber-200 dark:border-amber-700 hover:border-amber-400 dark:hover:border-amber-500 transition-colors group\"\n >\n <div>\n <span className=\"font-medium text-neutral-900 dark:text-white\">\n Vercel KV\n </span>\n <span className=\"text-neutral-500 dark:text-neutral-400 text-sm ml-2\">\n Built-in if using Vercel\n </span>\n </div>\n <code className=\"text-xs bg-neutral-100 dark:bg-neutral-700 px-2 py-1 rounded text-neutral-600 dark:text-neutral-300\">\n KV_REST_API_URL\n </code>\n </a>\n\n <a\n href=\"https://neon.tech/docs/get-started-with-neon/connect-neon\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-amber-200 dark:border-amber-700 hover:border-amber-400 dark:hover:border-amber-500 transition-colors group\"\n >\n <div>\n <span className=\"font-medium text-neutral-900 dark:text-white\">Neon</span>\n <span className=\"text-neutral-500 dark:text-neutral-400 text-sm ml-2\">\n Serverless Postgres\n </span>\n </div>\n <code className=\"text-xs bg-neutral-100 dark:bg-neutral-700 px-2 py-1 rounded text-neutral-600 dark:text-neutral-300\">\n DATABASE_URL\n </code>\n </a>\n\n <a\n href=\"https://www.sqlite.org/index.html\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-amber-200 dark:border-amber-700 hover:border-amber-400 dark:hover:border-amber-500 transition-colors group\"\n >\n <div>\n <span className=\"font-medium text-neutral-900 dark:text-white\">\n SQLite\n </span>\n <span className=\"text-neutral-500 dark:text-neutral-400 text-sm ml-2\">\n Local file, single instance only\n </span>\n </div>\n <code className=\"text-xs bg-neutral-100 dark:bg-neutral-700 px-2 py-1 rounded text-neutral-600 dark:text-neutral-300\">\n DATABASE_URL=file:./data.db\n </code>\n </a>\n </div>\n </div>\n )}\n </div>\n </div>\n </div>\n )}\n\n <div className=\"bg-white dark:bg-neutral-800 rounded-2xl shadow-sm border border-neutral-200 dark:border-neutral-700 mb-8 overflow-hidden\">\n <div className=\"p-6 border-b border-neutral-200 dark:border-neutral-700\">\n <h2 className=\"text-xl font-semibold text-neutral-900 dark:text-white\">\n Quick Start Guide\n </h2>\n </div>\n <div className=\"divide-y divide-neutral-200 dark:divide-neutral-700\">\n {setupSteps.map((step, index) => (\n <div key={step.id} className=\"p-6 flex items-start gap-4\">\n <div\n className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${\n step.completed\n ? \"bg-green-500 text-white\"\n : \"bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400\"\n }`}\n >\n {step.completed ? (\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M5 13l4 4L19 7\"\n />\n </svg>\n ) : (\n <span className=\"font-semibold\">{index + 1}</span>\n )}\n </div>\n <div className=\"flex-1\">\n <h3 className=\"font-semibold text-neutral-900 dark:text-white\">{step.title}</h3>\n <p className=\"text-sm text-neutral-600 dark:text-neutral-400 mt-1\">\n {step.description}\n </p>\n </div>\n </div>\n ))}\n </div>\n </div>\n\n <div className=\"bg-white dark:bg-neutral-800 rounded-2xl shadow-sm border border-neutral-200 dark:border-neutral-700 overflow-hidden\">\n <div className=\"p-6 border-b border-neutral-200 dark:border-neutral-700\">\n <h2 className=\"text-xl font-semibold text-neutral-900 dark:text-white\">\n Service Connections\n </h2>\n <p className=\"text-sm text-neutral-600 dark:text-neutral-400 mt-1\">\n Click on a service to see setup instructions or connect\n </p>\n\n <div className=\"mt-4\">\n <input\n type=\"text\"\n placeholder=\"Search services...\"\n value={searchQuery}\n onChange={(e) => setSearchQuery(e.target.value)}\n className=\"w-full px-4 py-2 bg-neutral-100 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl text-neutral-900 dark:text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500\"\n />\n </div>\n\n <div className=\"mt-4 flex flex-wrap gap-2\">\n <button\n type=\"button\"\n onClick={() => setSelectedCategory(null)}\n className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${\n selectedCategory === null\n ? \"bg-neutral-900 dark:bg-white text-white dark:text-neutral-900\"\n : \"bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600\"\n }`}\n >\n All\n </button>\n\n {CATEGORIES.map((category) => (\n <button\n key={category.id}\n type=\"button\"\n onClick={() =>\n setSelectedCategory(selectedCategory === category.id ? null : category.id)\n }\n className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${\n selectedCategory === category.id\n ? \"bg-neutral-900 dark:bg-white text-white dark:text-neutral-900\"\n : \"bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600\"\n }`}\n >\n {category.name}\n </button>\n ))}\n </div>\n </div>\n\n {loading ? (\n <div className=\"p-12 text-center text-neutral-500\">Loading...</div>\n ) : filteredIntegrations.length === 0 ? (\n <div className=\"p-12 text-center text-neutral-500\">\n No services found matching your search\n </div>\n ) : (\n <div>\n {CATEGORIES.filter((cat) => groupedIntegrations[cat.id]?.length > 0).map(\n (category) => (\n <div key={category.id}>\n <div className=\"px-6 py-3 bg-neutral-50 dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-700\">\n <h3 className=\"text-sm font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider\">\n {category.name}\n </h3>\n </div>\n\n <div className=\"divide-y divide-neutral-200 dark:divide-neutral-700\">\n {groupedIntegrations[category.id]?.map((integration) => {\n const guide = OAUTH_SETUP_GUIDES[integration.id];\n const isExpanded = expandedGuide === integration.id;\n\n return (\n <div key={integration.id}>\n <div className=\"p-6 flex items-center justify-between\">\n <div className=\"flex items-center gap-4\">\n <div className=\"w-12 h-12 bg-neutral-100 dark:bg-neutral-700 rounded-xl flex items-center justify-center\">\n <ServiceIcon name={integration.icon} />\n </div>\n <div>\n <h3 className=\"font-semibold text-neutral-900 dark:text-white\">\n {integration.name}\n </h3>\n <p\n className={`text-sm ${\n integration.connected\n ? \"text-green-600 dark:text-green-400\"\n : \"text-neutral-500\"\n }`}\n >\n {integration.connected ? \"Connected\" : \"Not connected\"}\n </p>\n </div>\n </div>\n\n <div className=\"flex items-center gap-3\">\n {guide && (\n <button\n type=\"button\"\n onClick={() =>\n setExpandedGuide(isExpanded ? null : integration.id)\n }\n className=\"px-4 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white\"\n >\n {isExpanded ? \"Hide Guide\" : \"Setup Guide\"}\n </button>\n )}\n\n {integration.connected ? (\n <span className=\"inline-flex items-center gap-1.5 px-4 py-2 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 rounded-xl text-sm font-medium\">\n <span className=\"w-2 h-2 bg-green-500 rounded-full\" />\n Connected\n </span>\n ) : (\n <a\n href={integration.connectUrl}\n className=\"px-4 py-2 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl text-sm font-medium hover:opacity-90 transition-opacity\"\n >\n Connect\n </a>\n )}\n </div>\n </div>\n\n {isExpanded && guide && (\n <div className=\"px-6 pb-6\">\n <div className=\"bg-neutral-50 dark:bg-neutral-900 rounded-xl p-6 border border-neutral-200 dark:border-neutral-700\">\n <h4 className=\"font-semibold text-neutral-900 dark:text-white mb-4\">\n {guide.title}\n </h4>\n\n <ol className=\"space-y-3 mb-6\">\n {guide.steps.map((step, i) => (\n <li key={i} className=\"flex items-start gap-3\">\n <span className=\"w-6 h-6 bg-neutral-200 dark:bg-neutral-700 rounded-full flex items-center justify-center text-sm font-medium text-neutral-600 dark:text-neutral-400 flex-shrink-0\">\n {i + 1}\n </span>\n <span className=\"text-neutral-700 dark:text-neutral-300\">\n {step}\n </span>\n </li>\n ))}\n </ol>\n\n <div className=\"mb-4 p-4 bg-neutral-100 dark:bg-neutral-800 rounded-lg\">\n <h5 className=\"text-sm font-semibold text-neutral-700 dark:text-neutral-300 mb-2\">\n Required Environment Variables:\n </h5>\n <pre className=\"text-sm text-neutral-600 dark:text-neutral-400 font-mono whitespace-pre-wrap\">\n {guide.envVars.map((v) => `${v}=your_value`).join(\"\\n\")}\n </pre>\n </div>\n\n <a\n href={guide.link}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 text-sm font-medium hover:underline\"\n >\n Open Developer Console\n <svg\n className=\"w-4 h-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14\"\n />\n </svg>\n </a>\n </div>\n </div>\n )}\n </div>\n );\n })}\n </div>\n </div>\n ),\n )}\n </div>\n )}\n </div>\n\n {allConnected && (\n <div className=\"mt-8 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-2xl p-6 border border-green-200 dark:border-green-800 text-center\">\n <div className=\"text-4xl mb-4\">🎉</div>\n <h3 className=\"text-xl font-semibold text-green-800 dark:text-green-200 mb-2\">\n All Services Connected!\n </h3>\n <p className=\"text-green-700 dark:text-green-300 mb-4\">\n Your AI agent is ready to use. Start chatting to automate your workflows.\n </p>\n <a\n href=\"/\"\n className=\"inline-flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-xl font-medium hover:bg-green-700 transition-colors\"\n >\n Start Using Your Agent\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M13 7l5 5m0 0l-5 5m5-5H6\"\n />\n </svg>\n </a>\n </div>\n )}\n </div>\n </div>\n );\n}\n",
|
|
118
|
-
"lib/oauth.ts": "import { type OAuthToken, tokenStore } from \"./token-store.ts\";\n\nexport interface OAuthProvider {\n name: string;\n authorizationUrl: string;\n tokenUrl: string;\n clientId: string;\n clientSecret: string;\n scopes: string[];\n callbackPath: string;\n}\n\nfunction getExpiresAt(expiresIn: unknown): number | undefined {\n if (typeof expiresIn !== \"number\" || expiresIn <= 0) return undefined;\n return Date.now() + expiresIn * 1000;\n}\n\nasync function postTokenRequest(\n provider: OAuthProvider,\n body: Record<string, string>,\n errorPrefix: string,\n): Promise<any> {\n const response = await fetch(provider.tokenUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams(body),\n });\n\n if (response.ok) return response.json();\n\n const error = await response.text();\n throw new Error(`${errorPrefix}: ${response.status} - ${error}`);\n}\n\nexport function getAuthorizationUrl(\n provider: OAuthProvider,\n state: string,\n redirectUri: string,\n): string {\n const params = new URLSearchParams({\n client_id: provider.clientId,\n redirect_uri: redirectUri,\n response_type: \"code\",\n scope: provider.scopes.join(\" \"),\n state,\n access_type: \"offline\",\n prompt: \"consent\",\n });\n\n return `${provider.authorizationUrl}?${params.toString()}`;\n}\n\nexport async function exchangeCodeForTokens(\n provider: OAuthProvider,\n code: string,\n redirectUri: string,\n): Promise<OAuthToken> {\n const data = await postTokenRequest(\n provider,\n {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n code,\n grant_type: \"authorization_code\",\n redirect_uri: redirectUri,\n },\n \"Token exchange failed\",\n );\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function refreshAccessToken(\n provider: OAuthProvider,\n refreshToken: string,\n): Promise<OAuthToken> {\n const data = await postTokenRequest(\n provider,\n {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n refresh_token: refreshToken,\n grant_type: \"refresh_token\",\n },\n \"Token refresh failed\",\n );\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token ?? refreshToken,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function getValidToken(\n provider: OAuthProvider,\n userId: string,\n service: string,\n): Promise<string | null> {\n const token = await tokenStore.getToken(userId, service);\n if (!token) return null;\n\n const isExpired = token.expiresAt\n ? token.expiresAt < Date.now() + 5 * 60 * 1000\n : false;\n\n if (!isExpired || !token.refreshToken) return token.accessToken;\n\n try {\n const newToken = await refreshAccessToken(provider, token.refreshToken);\n await tokenStore.setToken(userId, service, newToken);\n return newToken.accessToken;\n } catch {\n await tokenStore.revokeToken(userId, service);\n return null;\n }\n}\n",
|
|
119
|
-
"lib/
|
|
118
|
+
"lib/encrypted-token-store.ts": "/**\n * Encrypted key-value OAuth token store for generated integrations.\n *\n * Wraps any durable key-value service (Redis, Postgres, Deno KV, a cloud KV\n * API) in the `RefreshCapableTokenStore` contract that `configureTokenStore`\n * in `token-store.ts` expects. Every value is encrypted at rest with\n * AES-256-GCM via the Web Crypto API before it reaches the backend:\n *\n * - A fresh random 96-bit IV is generated for every encryption.\n * - The storage key is bound as AES-GCM additional authenticated data, so a\n * ciphertext copied between storage slots fails authentication.\n * - The key comes from the `TOKEN_ENCRYPTION_KEY` environment variable\n * (64 hex characters = 256 bits). There is NO plaintext fallback: creating\n * the store without a valid key throws, and values that are not in the\n * expected encrypted envelope are refused on read.\n *\n * Key rotation: set `TOKEN_ENCRYPTION_KEY` to the new key and move the old\n * key to `TOKEN_ENCRYPTION_KEY_PREVIOUS`. New writes are sealed with the new\n * key (the v2 envelope records a key id derived from the key), while rows\n * sealed with the previous key stay readable. Every token row decrypted with\n * a non-current key is transparently re-sealed with the current key: always\n * on the next write, and best-effort on every read, so rotation converges\n * even for rows that are read but never rewritten. Use\n * `checkEncryptedTokenStoreRotation` to confirm no rows still need the\n * previous key, then remove `TOKEN_ENCRYPTION_KEY_PREVIOUS`; stragglers\n * degrade to \"disconnected\" and recover on reconnect.\n *\n * Legacy v1 envelopes (no key id) were written only by earlier revisions of\n * this template; they are decrypted by trying every configured key and are\n * upgraded to v2 by the same re-seal-on-read path, so the compatibility\n * branch retires itself as rows are read.\n *\n * Undecryptable token rows (unknown key, tampering, legacy plaintext) never\n * fail a whole request: the token read paths log a warning and report the\n * integration as disconnected, so the recovery is simply reconnecting (a\n * fresh `setTokens` overwrites the row; `clearTokens` removes it).\n *\n * Generate a key once per deployment and set it before startup:\n *\n * ```sh\n * openssl rand -hex 32\n * ```\n *\n * Concurrency (compare-and-swap, refresh locking) is delegated to the\n * backend so the guarantees hold across workers; this module never emulates\n * distributed behavior in process memory. See `token-store-examples.ts` for\n * reference backends and wiring.\n */\n\nimport type {\n OAuthTokens,\n OAuthTokenSnapshot,\n RefreshCapableTokenStore,\n StoredOAuthState,\n} from \"veryfront/oauth\";\n\n/**\n * Minimal durable backend contract. All five operations are required; the\n * atomic ones are what make token refresh and one-shot OAuth state safe\n * across workers.\n */\nexport interface EncryptedKvBackend {\n /** Read the raw stored value for a key, or null when absent. */\n get(key: string): Promise<string | null>;\n /**\n * Durably write a value, replacing any existing one. `expiresInMs`, when\n * provided, is a TTL after which the backend may drop the row.\n */\n set(key: string, value: string, options?: { expiresInMs?: number }): Promise<void>;\n /** Remove a key. Deleting an absent key is not an error. */\n delete(key: string): Promise<void>;\n /**\n * Atomically replace `expected` with `next`. `expected === null` requires\n * the key to be absent; `next === null` deletes the key. Returns false\n * (without writing) when the current value does not match `expected`.\n */\n compareAndSwap(\n key: string,\n expected: string | null,\n next: string | null,\n options?: { expiresInMs?: number },\n ): Promise<boolean>;\n /**\n * Run `operation` while holding a mutual-exclusion lease for `key` that is\n * visible to every worker (for example a Redis lock or an advisory lock).\n * The lease must be bounded so a crashed holder cannot block refresh\n * forever.\n */\n withLock<T>(key: string, operation: () => Promise<T>): Promise<T>;\n}\n\nexport interface EncryptedKvRotationScanBackend extends EncryptedKvBackend {\n /**\n * Iterate stored rows whose key starts with `prefix`. Use a backend-native\n * bounded cursor or paginated scan; do not load an unbounded keyspace into\n * memory before yielding.\n */\n scan(prefix: string): AsyncIterable<{ key: string; value: string }>;\n}\n\nexport interface EncryptedTokenStoreRotationReport {\n scannedRows: number;\n currentKeyRows: number;\n previousKeyRows: number;\n unreadableRows: number;\n complete: boolean;\n}\n\nconst ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY\";\nconst PREVIOUS_ENCRYPTION_KEY_ENV_VAR = \"TOKEN_ENCRYPTION_KEY_PREVIOUS\";\nconst ENVELOPE_PREFIX = \"vf-aes-gcm.v2:\";\nconst LEGACY_ENVELOPE_PREFIX = \"vf-aes-gcm.v1:\";\nconst KEY_ID_HEX_LENGTH = 16;\nconst KEY_ID_PATTERN = /^[0-9a-f]{16}$/;\nconst AES_GCM_IV_BYTES = 12;\nconst AES_GCM_TAG_BYTES = 16;\nconst AES_KEY_BYTES = 32;\nconst MAX_PLAINTEXT_BYTES = 64 * 1024;\nconst MAX_ENCRYPTED_BYTES = MAX_PLAINTEXT_BYTES + AES_GCM_IV_BYTES + AES_GCM_TAG_BYTES;\nconst MAX_ENCODED_LENGTH = Math.ceil(MAX_ENCRYPTED_BYTES / 3) * 4 + ENVELOPE_PREFIX.length +\n KEY_ID_HEX_LENGTH + 1;\nconst BASE64_CHUNK_BYTES = 0x8000;\nconst MAX_KEY_COMPONENT_LENGTH = 1_024;\nconst MAX_STATE_KEY_LENGTH = 1_024;\nconst STATE_TTL_MS = 10 * 60 * 1_000;\nconst STATE_CLOCK_SKEW_MS = 60 * 1_000;\nconst MAX_SERVICE_ID_LENGTH = 128;\nconst MAX_SCOPE_COUNT = 100;\nconst MAX_REDIRECT_URI_LENGTH = 8_192;\nconst MAX_TOKEN_VALUE_LENGTH = 65_536;\nconst MAX_TOKEN_TYPE_LENGTH = 256;\nconst MAX_SCOPE_WIRE_LENGTH = 4_096;\n// A JSON array containing one-character values needs two bytes per value once\n// separators are included. Bounding the traversal before cloning therefore\n// prevents sparse arrays or deeply nested metadata from consuming memory\n// before the final plaintext-size check can run.\nconst MAX_JSON_VALUE_COUNT = Math.floor((MAX_PLAINTEXT_BYTES + 1) / 2);\nconst MAX_JSON_NESTING_DEPTH = 64;\n\nconst SERVICE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;\nconst SCOPE_TOKEN_PATTERN = /^[\\x21\\x23-\\x5B\\x5D-\\x7E]+$/;\nconst PKCE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;\n\nconst TOKENS_KEY_PREFIX = \"veryfront:oauth:v1:tokens:\";\nconst STATE_KEY_PREFIX = \"veryfront:oauth:v1:state:\";\nconst REFRESH_LOCK_KEY_PREFIX = \"veryfront:oauth:v1:refresh-lock:\";\n\nconst REQUIRED_BACKEND_METHODS = [\n \"get\",\n \"set\",\n \"delete\",\n \"compareAndSwap\",\n \"withLock\",\n] as const;\n\nfunction readEnvironmentVariable(name: string): string | undefined {\n try {\n if (typeof process !== \"undefined\" && process.env) return process.env[name];\n } catch {\n // Deno exposes the Node-compatible `process` global even when env access\n // is denied. Treat that denial as an unavailable value; never bypass it\n // through a second environment API.\n return undefined;\n }\n try {\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(name);\n } catch {\n return undefined;\n }\n}\n\n/** Generate a fresh 256-bit key encoded as 64 hex characters. */\nexport function generateEncryptionKey(): string {\n return Array.from(crypto.getRandomValues(new Uint8Array(AES_KEY_BYTES)))\n .map((byte) => byte.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\nfunction parseEncryptionKeyHex(keyHex: string, envVar: string): Uint8Array<ArrayBuffer> {\n if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {\n throw new TypeError(\n `${envVar} must be exactly 64 hexadecimal characters ` +\n \"(a 256-bit AES key). Generate one with `openssl rand -hex 32`.\",\n );\n }\n const key = new Uint8Array(AES_KEY_BYTES);\n for (let index = 0; index < key.length; index++) {\n key[index] = Number.parseInt(keyHex.slice(index * 2, index * 2 + 2), 16);\n }\n return key;\n}\n\n/**\n * Resolve the configured encryption key or fail closed. This store never\n * writes plaintext credentials, so a missing key is a hard error rather than\n * a downgrade.\n */\nfunction requireEncryptionKeyBytes(): Uint8Array<ArrayBuffer> {\n const configured = readEnvironmentVariable(ENCRYPTION_KEY_ENV_VAR);\n if (configured === undefined || configured === \"\") {\n throw new Error(\n `${ENCRYPTION_KEY_ENV_VAR} is not set. The encrypted token store refuses ` +\n \"to persist plaintext OAuth credentials. Generate a key with \" +\n \"`openssl rand -hex 32` (or generateEncryptionKey()) and set \" +\n `${ENCRYPTION_KEY_ENV_VAR} before starting the app.`,\n );\n }\n return parseEncryptionKeyHex(configured, ENCRYPTION_KEY_ENV_VAR);\n}\n\n/**\n * Resolve the decryption key ring: the current key first (used for every\n * new write), then the optional previous key kept readable during rotation.\n */\nfunction resolveEncryptionKeyRing(): Uint8Array<ArrayBuffer>[] {\n const ring = [requireEncryptionKeyBytes()];\n const previous = readEnvironmentVariable(PREVIOUS_ENCRYPTION_KEY_ENV_VAR);\n if (previous !== undefined && previous !== \"\") {\n ring.push(parseEncryptionKeyHex(previous, PREVIOUS_ENCRYPTION_KEY_ENV_VAR));\n }\n return ring;\n}\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n let binary = \"\";\n for (let offset = 0; offset < bytes.byteLength; offset += BASE64_CHUNK_BYTES) {\n const chunk = bytes.subarray(offset, offset + BASE64_CHUNK_BYTES);\n binary += String.fromCharCode(...chunk);\n }\n return btoa(binary);\n}\n\nfunction base64ToBytes(encoded: string): Uint8Array<ArrayBuffer> {\n if (\n encoded.length === 0 || encoded.length % 4 !== 0 ||\n !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)\n ) {\n throw new TypeError(\"Encrypted OAuth value has invalid base64 encoding\");\n }\n const binary = atob(encoded);\n if (\n binary.length < AES_GCM_IV_BYTES + AES_GCM_TAG_BYTES ||\n binary.length > MAX_ENCRYPTED_BYTES\n ) {\n throw new RangeError(\"Encrypted OAuth value has an invalid size\");\n }\n const bytes = new Uint8Array(binary.length);\n for (let index = 0; index < binary.length; index++) {\n bytes[index] = binary.charCodeAt(index);\n }\n return bytes;\n}\n\nfunction requireKeyComponent(value: string, label: string): string {\n if (\n typeof value !== \"string\" || value.length === 0 ||\n value.length > MAX_KEY_COMPONENT_LENGTH || value.trim() !== value ||\n hasAsciiControlCharacter(value)\n ) {\n throw new TypeError(\n `${label} must be a trimmed, non-empty string of at most ${MAX_KEY_COMPONENT_LENGTH} characters without control characters`,\n );\n }\n return value;\n}\n\nfunction quoteJsonString(value: string): string {\n let quoted = '\"';\n for (let index = 0; index < value.length; index++) {\n const code = value.charCodeAt(index);\n switch (code) {\n case 0x08:\n quoted += \"\\\\b\";\n break;\n case 0x09:\n quoted += \"\\\\t\";\n break;\n case 0x0a:\n quoted += \"\\\\n\";\n break;\n case 0x0c:\n quoted += \"\\\\f\";\n break;\n case 0x0d:\n quoted += \"\\\\r\";\n break;\n case 0x22:\n quoted += '\\\\\"';\n break;\n case 0x5c:\n quoted += \"\\\\\\\\\";\n break;\n default:\n if (code <= 0x1f) {\n quoted += \"\\\\u\" + code.toString(16).padStart(4, \"0\");\n } else if (code >= 0xd800 && code <= 0xdbff) {\n const next = value.charCodeAt(index + 1);\n if (next >= 0xdc00 && next <= 0xdfff) {\n quoted += value[index]! + value[index + 1]!;\n index++;\n } else {\n quoted += \"\\\\u\" + code.toString(16).padStart(4, \"0\");\n }\n } else if (code >= 0xdc00 && code <= 0xdfff) {\n quoted += \"\\\\u\" + code.toString(16).padStart(4, \"0\");\n } else {\n quoted += value[index];\n }\n }\n }\n return quoted + '\"';\n}\n\nfunction jsonArrayFrame(values: readonly string[]): string {\n return \"[\" + values.map(quoteJsonString).join(\",\") + \"]\";\n}\n\nfunction tokensStorageKey(serviceId: string, userId: string): string {\n return TOKENS_KEY_PREFIX + jsonArrayFrame([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction refreshLockKey(serviceId: string, userId: string): string {\n return REFRESH_LOCK_KEY_PREFIX + jsonArrayFrame([\n requireKeyComponent(serviceId, \"serviceId\"),\n requireKeyComponent(userId, \"userId\"),\n ]);\n}\n\nfunction stateStorageKey(state: string): string {\n if (typeof state !== \"string\") {\n throw new TypeError(\"state must be a string\");\n }\n if (state.length === 0 || state.length > MAX_STATE_KEY_LENGTH) {\n throw new RangeError(\n `state must contain between 1 and ${MAX_STATE_KEY_LENGTH} characters`,\n );\n }\n if (state.trim() !== state || hasAsciiControlCharacter(state)) {\n throw new TypeError(\"state must not contain surrounding whitespace or control characters\");\n }\n return STATE_KEY_PREFIX + jsonArrayFrame([state]);\n}\n\ninterface StoredTokenEntry {\n revision: string;\n tokens: OAuthTokens;\n}\n\nfunction ownDataValue(record: object, key: string): unknown {\n const descriptor = Object.getOwnPropertyDescriptor(record, key);\n return descriptor && \"value\" in descriptor ? descriptor.value : undefined;\n}\n\nfunction hasAsciiControlCharacter(value: string): boolean {\n for (let index = 0; index < value.length; index++) {\n const code = value.charCodeAt(index);\n if (code <= 0x1f || code === 0x7f) return true;\n }\n return false;\n}\n\ninterface JsonTraversalState {\n ancestors: WeakSet<object>;\n depth: number;\n remainingStringCodeUnits: number;\n remainingValues: number;\n}\n\nfunction createJsonTraversalState(): JsonTraversalState {\n return {\n ancestors: new WeakSet(),\n depth: 0,\n remainingStringCodeUnits: MAX_PLAINTEXT_BYTES,\n remainingValues: MAX_JSON_VALUE_COUNT,\n };\n}\n\nfunction consumeJsonStringBudget(\n state: JsonTraversalState,\n value: string,\n label: string,\n): void {\n if (value.length > state.remainingStringCodeUnits) {\n throw new RangeError(`${label} contains too much JSON string data`);\n }\n state.remainingStringCodeUnits -= value.length;\n}\n\nfunction snapshotJsonData(\n value: unknown,\n label: string,\n state = createJsonTraversalState(),\n): unknown {\n if (state.remainingValues === 0) {\n throw new RangeError(`${label} contains too many JSON values`);\n }\n state.remainingValues--;\n\n if (value === null || typeof value === \"boolean\") {\n return value;\n }\n if (typeof value === \"string\") {\n consumeJsonStringBudget(state, value, label);\n return value;\n }\n if (typeof value === \"number\") {\n if (!Number.isFinite(value)) {\n throw new TypeError(`${label} must contain only finite JSON numbers`);\n }\n return value;\n }\n if (!value || typeof value !== \"object\") {\n throw new TypeError(`${label} must contain only JSON data values`);\n }\n if (state.depth >= MAX_JSON_NESTING_DEPTH) {\n throw new RangeError(\n `${label} exceeds the maximum JSON nesting depth of ${MAX_JSON_NESTING_DEPTH}`,\n );\n }\n if (state.ancestors.has(value)) {\n throw new TypeError(`${label} must not contain cyclic JSON data`);\n }\n state.ancestors.add(value);\n state.depth++;\n\n try {\n if (Array.isArray(value)) {\n if (value.length > state.remainingValues) {\n throw new RangeError(`${label} contains too many JSON values`);\n }\n const snapshot: unknown[] = [];\n snapshot.length = value.length;\n for (let index = 0; index < value.length; index++) {\n const descriptor = Object.getOwnPropertyDescriptor(value, String(index));\n if (!descriptor || !(\"value\" in descriptor)) {\n throw new TypeError(`${label} must contain only own data values`);\n }\n Object.defineProperty(snapshot, String(index), {\n configurable: true,\n enumerable: true,\n value: snapshotJsonData(descriptor.value, label, state),\n writable: true,\n });\n }\n Object.defineProperty(snapshot, \"toJSON\", {\n configurable: true,\n enumerable: false,\n value: undefined,\n });\n return snapshot;\n }\n\n const prototype = Object.getPrototypeOf(value);\n if (prototype !== Object.prototype && prototype !== null) {\n throw new TypeError(`${label} must contain only plain JSON data objects`);\n }\n const keys = Object.keys(value);\n if (keys.length > state.remainingValues) {\n throw new RangeError(`${label} contains too many JSON values`);\n }\n const snapshot: Record<string, unknown> = Object.create(null);\n for (const key of keys) {\n consumeJsonStringBudget(state, key, label);\n const descriptor = Object.getOwnPropertyDescriptor(value, key);\n if (!descriptor || !(\"value\" in descriptor)) {\n throw new TypeError(`${label} must contain only own data values`);\n }\n snapshot[key] = snapshotJsonData(descriptor.value, label, state);\n }\n return snapshot;\n } finally {\n state.depth--;\n state.ancestors.delete(value);\n }\n}\n\nfunction stringifyJsonData(value: unknown): string {\n return JSON.stringify(snapshotJsonData(value, \"Stored OAuth value\"));\n}\n\nfunction requireMetadata(value: unknown): Record<string, unknown> | undefined {\n if (value === undefined) return undefined;\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n throw new TypeError(\"Stored OAuth state metadata must be a plain object\");\n }\n return snapshotJsonData(value, \"Stored OAuth state metadata\") as Record<string, unknown>;\n}\n\nfunction requireOptionalTokenString(\n record: object,\n key: string,\n maxLength: number,\n): string | undefined {\n const value = ownDataValue(record, key);\n if (value === undefined) return undefined;\n if (\n typeof value !== \"string\" || value.length === 0 || value.length > maxLength ||\n value.trim() !== value || hasAsciiControlCharacter(value)\n ) {\n throw new TypeError(`OAuth token row ${key} must be a safe bounded string`);\n }\n return value;\n}\n\nfunction requireTokenRow(value: unknown): OAuthTokens {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n throw new TypeError(\"OAuth token row must be an object\");\n }\n const accessToken = requireOptionalTokenString(value, \"accessToken\", MAX_TOKEN_VALUE_LENGTH);\n if (accessToken === undefined) {\n throw new TypeError(\"OAuth token row must contain a non-empty accessToken\");\n }\n const refreshToken = requireOptionalTokenString(value, \"refreshToken\", MAX_TOKEN_VALUE_LENGTH);\n const tokenType = requireOptionalTokenString(value, \"tokenType\", MAX_TOKEN_TYPE_LENGTH);\n const scopeValue = ownDataValue(value, \"scope\");\n let scope: string | undefined;\n if (scopeValue !== undefined) {\n if (\n typeof scopeValue !== \"string\" || hasAsciiControlCharacter(scopeValue) ||\n scopeValue.length > MAX_SCOPE_WIRE_LENGTH\n ) {\n throw new TypeError(\"OAuth token row scope must be a safe bounded string\");\n }\n scope = scopeValue.trim();\n if (scope.length === 0) {\n throw new TypeError(\"OAuth token row scope must be a safe bounded string\");\n }\n }\n const idToken = requireOptionalTokenString(value, \"idToken\", MAX_TOKEN_VALUE_LENGTH);\n const expiresAt = ownDataValue(value, \"expiresAt\");\n if (\n expiresAt !== undefined &&\n (typeof expiresAt !== \"number\" || !Number.isSafeInteger(expiresAt) || expiresAt < 0)\n ) {\n throw new TypeError(\"OAuth token expiresAt must be a non-negative safe integer\");\n }\n return {\n accessToken,\n ...(refreshToken === undefined ? {} : { refreshToken }),\n ...(expiresAt === undefined ? {} : { expiresAt }),\n ...(tokenType === undefined ? {} : { tokenType }),\n ...(scope === undefined ? {} : { scope }),\n ...(idToken === undefined ? {} : { idToken }),\n };\n}\n\nfunction requireTokenEntry(value: unknown): StoredTokenEntry {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n throw new TypeError(\"Stored OAuth token entry must be an object\");\n }\n const revision = ownDataValue(value, \"revision\");\n if (typeof revision !== \"string\" || revision.length === 0) {\n throw new TypeError(\"Stored OAuth token entry must contain a revision\");\n }\n return { revision, tokens: requireTokenRow(ownDataValue(value, \"tokens\")) };\n}\n\nfunction requireStateRow(value: unknown): StoredOAuthState {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n throw new TypeError(\"Stored OAuth state row must be an object\");\n }\n const userId = ownDataValue(value, \"userId\");\n const serviceId = ownDataValue(value, \"serviceId\");\n const redirectUri = ownDataValue(value, \"redirectUri\");\n const scopes = ownDataValue(value, \"scopes\");\n const createdAt = ownDataValue(value, \"createdAt\");\n const codeVerifier = ownDataValue(value, \"codeVerifier\");\n const metadata = requireMetadata(ownDataValue(value, \"metadata\"));\n if (\n typeof userId !== \"string\" || userId.length === 0 ||\n userId.length > MAX_KEY_COMPONENT_LENGTH || userId.trim() !== userId ||\n hasAsciiControlCharacter(userId)\n ) {\n throw new TypeError(\"Stored OAuth state row must contain a userId\");\n }\n if (\n typeof serviceId !== \"string\" || serviceId.length > MAX_SERVICE_ID_LENGTH ||\n !SERVICE_ID_PATTERN.test(serviceId)\n ) {\n throw new TypeError(\"Stored OAuth state row must contain a serviceId\");\n }\n let parsedRedirectUri: URL;\n try {\n if (\n typeof redirectUri !== \"string\" || redirectUri.length > MAX_REDIRECT_URI_LENGTH ||\n redirectUri.trim() !== redirectUri || hasAsciiControlCharacter(redirectUri) ||\n redirectUri.includes(\"\\\\\")\n ) {\n throw new TypeError();\n }\n parsedRedirectUri = new URL(redirectUri);\n } catch {\n throw new TypeError(\"Stored OAuth state row must contain a valid redirectUri\");\n }\n const isLoopback = parsedRedirectUri.hostname === \"localhost\" ||\n parsedRedirectUri.hostname === \"127.0.0.1\" ||\n parsedRedirectUri.hostname === \"[::1]\" || parsedRedirectUri.hostname === \"::1\";\n if (\n parsedRedirectUri.username || parsedRedirectUri.password || parsedRedirectUri.hash ||\n (parsedRedirectUri.protocol !== \"https:\" &&\n !(parsedRedirectUri.protocol === \"http:\" && isLoopback))\n ) {\n throw new TypeError(\"Stored OAuth state row must contain a valid redirectUri\");\n }\n if (!Array.isArray(scopes) || scopes.length > MAX_SCOPE_COUNT) {\n throw new TypeError(\"Stored OAuth state row must contain valid scopes\");\n }\n const scopeSnapshot: string[] = [];\n for (let index = 0; index < scopes.length; index++) {\n const descriptor = Object.getOwnPropertyDescriptor(scopes, String(index));\n if (\n !descriptor || !(\"value\" in descriptor) || typeof descriptor.value !== \"string\" ||\n !SCOPE_TOKEN_PATTERN.test(descriptor.value)\n ) {\n throw new TypeError(\"Stored OAuth state row must contain valid scopes\");\n }\n scopeSnapshot.push(descriptor.value);\n }\n if (scopeSnapshot.join(\" \").length > MAX_SCOPE_WIRE_LENGTH) {\n throw new TypeError(\"Stored OAuth state row must contain valid scopes\");\n }\n if (typeof createdAt !== \"number\" || !Number.isSafeInteger(createdAt) || createdAt <= 0) {\n throw new TypeError(\"Stored OAuth state row must contain a createdAt timestamp\");\n }\n if (\n codeVerifier !== undefined &&\n (typeof codeVerifier !== \"string\" || !PKCE_VERIFIER_PATTERN.test(codeVerifier))\n ) {\n throw new TypeError(\"Stored OAuth state row has an invalid codeVerifier\");\n }\n return {\n userId,\n serviceId,\n redirectUri,\n scopes: scopeSnapshot,\n createdAt,\n ...(codeVerifier === undefined ? {} : { codeVerifier }),\n ...(metadata === undefined ? {} : { metadata }),\n };\n}\n\nfunction isFreshState(createdAt: number, now: number): boolean {\n if (createdAt > now) {\n return createdAt - now <= STATE_CLOCK_SKEW_MS;\n }\n return now - createdAt <= STATE_TTL_MS;\n}\n\nfunction assertBackend(backend: EncryptedKvBackend): void {\n if (!backend || typeof backend !== \"object\") {\n throw new TypeError(\"Encrypted token store backend must be an object\");\n }\n for (const method of REQUIRED_BACKEND_METHODS) {\n if (typeof backend[method] !== \"function\") {\n throw new TypeError(`Encrypted token store backend must implement ${method}()`);\n }\n }\n}\n\ninterface EnvelopeKey {\n /** First 8 bytes of SHA-256 over the raw key, hex-encoded. */\n keyId: string;\n key: CryptoKey;\n}\n\nasync function importEnvelopeKey(keyBytes: Uint8Array<ArrayBuffer>): Promise<EnvelopeKey> {\n const digest = new Uint8Array(await crypto.subtle.digest(\"SHA-256\", keyBytes));\n const key = await crypto.subtle.importKey(\"raw\", keyBytes, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n keyBytes.fill(0);\n const keyId = Array.from(digest.subarray(0, KEY_ID_HEX_LENGTH / 2))\n .map((byte) => byte.toString(16).padStart(2, \"0\"))\n .join(\"\");\n return { keyId, key };\n}\n\ninterface OpenedEnvelope {\n value: unknown;\n /**\n * False when the row was decrypted with a retiring key or arrived in a\n * legacy v1 envelope, i.e. re-sealing it with the current key lets\n * `TOKEN_ENCRYPTION_KEY_PREVIOUS` be dropped sooner.\n */\n sealedWithCurrentKey: boolean;\n}\n\nclass EnvelopeCipher {\n /** The first entry is the current key; every entry may decrypt. */\n readonly #keys: Promise<EnvelopeKey[]>;\n\n constructor(keyRing: readonly Uint8Array<ArrayBuffer>[]) {\n this.#keys = Promise.all(keyRing.map(importEnvelopeKey));\n }\n\n async seal(storageKey: string, value: unknown): Promise<string> {\n const plaintext = new TextEncoder().encode(stringifyJsonData(value));\n if (plaintext.byteLength > MAX_PLAINTEXT_BYTES) {\n throw new RangeError(`Stored OAuth value exceeds ${MAX_PLAINTEXT_BYTES} bytes`);\n }\n const [current] = await this.#keys;\n if (!current) {\n throw new Error(\"Encrypted token store has no encryption key configured\");\n }\n const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));\n const ciphertext = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: \"AES-GCM\", iv, additionalData: new TextEncoder().encode(storageKey) },\n current.key,\n plaintext,\n ),\n );\n const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength);\n combined.set(iv);\n combined.set(ciphertext, iv.byteLength);\n return ENVELOPE_PREFIX + current.keyId + \":\" + bytesToBase64(combined);\n }\n\n async open(storageKey: string, stored: string): Promise<OpenedEnvelope> {\n if (typeof stored !== \"string\" || stored.length > MAX_ENCODED_LENGTH) {\n throw new TypeError(\"Stored OAuth value must be a bounded string\");\n }\n const keys = await this.#keys;\n if (stored.startsWith(ENVELOPE_PREFIX)) {\n const body = stored.slice(ENVELOPE_PREFIX.length);\n const keyId = body.slice(0, KEY_ID_HEX_LENGTH);\n if (!KEY_ID_PATTERN.test(keyId) || body[KEY_ID_HEX_LENGTH] !== \":\") {\n throw new TypeError(\"Encrypted OAuth value has a malformed key id\");\n }\n const match = keys.find((entry) => entry.keyId === keyId);\n if (!match) {\n throw new Error(\n `Encrypted OAuth value was sealed with an unknown encryption key (id ${keyId}). ` +\n `Set ${PREVIOUS_ENCRYPTION_KEY_ENV_VAR} to the retiring key during rotation, ` +\n \"or re-authenticate affected users.\",\n );\n }\n return {\n value: await this.#decrypt(\n storageKey,\n base64ToBytes(body.slice(KEY_ID_HEX_LENGTH + 1)),\n match.key,\n ),\n sealedWithCurrentKey: match === keys[0],\n };\n }\n if (stored.startsWith(LEGACY_ENVELOPE_PREFIX)) {\n // v1 envelopes carry no key id, so try every configured key. They are\n // never reported as current: re-sealing upgrades them to v2.\n const combined = base64ToBytes(stored.slice(LEGACY_ENVELOPE_PREFIX.length));\n let lastFailure: unknown;\n for (const entry of keys) {\n try {\n return {\n value: await this.#decrypt(storageKey, combined, entry.key),\n sealedWithCurrentKey: false,\n };\n } catch (failure) {\n lastFailure = failure;\n }\n }\n throw lastFailure;\n }\n throw new Error(\n \"Stored OAuth value is not in a vf-aes-gcm envelope format. This store \" +\n \"never reads plaintext credentials; re-authenticate affected users to \" +\n \"replace legacy rows.\",\n );\n }\n\n async #decrypt(\n storageKey: string,\n combined: Uint8Array<ArrayBuffer>,\n key: CryptoKey,\n ): Promise<unknown> {\n const iv = combined.subarray(0, AES_GCM_IV_BYTES);\n const ciphertext = combined.subarray(AES_GCM_IV_BYTES);\n let plaintext: ArrayBuffer;\n try {\n plaintext = await crypto.subtle.decrypt(\n { name: \"AES-GCM\", iv, additionalData: new TextEncoder().encode(storageKey) },\n key,\n ciphertext,\n );\n } catch (cause) {\n throw new Error(\n \"Encrypted OAuth value failed authentication (wrong key, corrupted \" +\n \"data, or a value moved between storage slots)\",\n { cause },\n );\n }\n try {\n return JSON.parse(new TextDecoder().decode(plaintext));\n } catch (cause) {\n throw new Error(\"Encrypted OAuth value contains invalid JSON\", { cause });\n }\n }\n}\n\nfunction unreadableTokenRowReason(failure: unknown): string {\n if (!(failure instanceof Error)) return \"malformed encrypted row\";\n if (failure.message.includes(\"unknown encryption key\")) return \"unknown encryption key\";\n if (failure.message.includes(\"failed authentication\")) return \"failed authentication\";\n return \"malformed encrypted row\";\n}\n\nfunction unreadableStateRowReason(failure: unknown): string {\n if (!(failure instanceof Error)) return \"malformed encrypted state row\";\n if (failure.message.includes(\"unknown encryption key\")) return \"unknown encryption key\";\n if (failure.message.includes(\"failed authentication\")) return \"failed authentication\";\n return \"malformed encrypted state row\";\n}\n\nfunction assertRotationScanBackend(\n backend: EncryptedKvRotationScanBackend,\n): asserts backend is EncryptedKvRotationScanBackend {\n assertBackend(backend);\n if (typeof backend.scan !== \"function\") {\n throw new TypeError(\"Encrypted token store rotation checks require backend.scan()\");\n }\n}\n\n/**\n * Count token and OAuth state rows that still require\n * `TOKEN_ENCRYPTION_KEY_PREVIOUS`.\n *\n * Run this after rotating keys and after normal reconnect/refresh traffic has\n * had a chance to rewrite rows. When `complete` is true, the scanned rows no\n * longer require the previous key. Unreadable rows are counted separately and\n * should be cleared or replaced before removing the previous key.\n * Expired OAuth state rows are ignored after authenticated decrypt and schema\n * validation because they can no longer be consumed.\n * `complete` describes only rows yielded by the backend; an empty scan reports\n * `complete: true` with `scannedRows: 0`. Confirm the scan covered the expected\n * rows before removing the previous key.\n */\nexport async function checkEncryptedTokenStoreRotation(\n backend: EncryptedKvRotationScanBackend,\n): Promise<EncryptedTokenStoreRotationReport> {\n assertRotationScanBackend(backend);\n const cipher = new EnvelopeCipher(resolveEncryptionKeyRing());\n const report: EncryptedTokenStoreRotationReport = {\n scannedRows: 0,\n currentKeyRows: 0,\n previousKeyRows: 0,\n unreadableRows: 0,\n complete: false,\n };\n const scanNow = Date.now();\n\n for (const prefix of [TOKENS_KEY_PREFIX, STATE_KEY_PREFIX]) {\n for await (const row of backend.scan(prefix)) {\n report.scannedRows++;\n if (\n !row || typeof row !== \"object\" || typeof row.key !== \"string\" ||\n typeof row.value !== \"string\" || !row.key.startsWith(prefix)\n ) {\n report.unreadableRows++;\n continue;\n }\n try {\n const opened = await cipher.open(row.key, row.value);\n if (row.key.startsWith(TOKENS_KEY_PREFIX)) {\n requireTokenEntry(opened.value);\n } else {\n const state = requireStateRow(opened.value);\n if (!isFreshState(state.createdAt, scanNow)) continue;\n }\n if (opened.sealedWithCurrentKey) report.currentKeyRows++;\n else report.previousKeyRows++;\n } catch {\n report.unreadableRows++;\n }\n }\n }\n\n report.complete = report.previousKeyRows === 0 && report.unreadableRows === 0;\n return report;\n}\n\n/**\n * Build a `RefreshCapableTokenStore` over a durable key-value backend with\n * AES-256-GCM encryption at rest.\n *\n * Fails closed at creation time when `TOKEN_ENCRYPTION_KEY` is missing or\n * malformed, and when the backend does not provide the atomic operations\n * that safe multi-worker refresh requires.\n *\n * Wire it once during startup through an explicit configuration boundary:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import {\n * createEncryptedTokenStore,\n * type EncryptedKvBackend,\n * } from \"./encrypted-token-store.ts\";\n *\n * export function configureOAuthStorage(backend: EncryptedKvBackend): void {\n * configureTokenStore(createEncryptedTokenStore(backend));\n * }\n * ```\n */\nexport function createEncryptedTokenStore(\n backend: EncryptedKvBackend,\n): RefreshCapableTokenStore {\n assertBackend(backend);\n const cipher = new EnvelopeCipher(resolveEncryptionKeyRing());\n\n // Undecryptable or malformed rows degrade to \"absent\" instead of failing\n // the caller: a single bad row must not take down an integrations page.\n // The integration shows as disconnected and reconnecting (setTokens)\n // overwrites the row; clearTokens removes it explicitly. The warning never\n // includes token material.\n async function readTokenEntry(\n serviceId: string,\n userId: string,\n ): Promise<{ key: string; raw: string; entry: StoredTokenEntry } | null> {\n const key = tokensStorageKey(serviceId, userId);\n const raw = await backend.get(key);\n if (raw === null) return null;\n try {\n const opened = await cipher.open(key, raw);\n const entry = requireTokenEntry(opened.value);\n if (opened.sealedWithCurrentKey) return { key, raw, entry };\n return { key, raw: (await resealTokenRow(key, raw, entry)) ?? raw, entry };\n } catch (failure) {\n console.warn(\n \"[Encrypted Token Store] Ignoring unreadable OAuth token row \" +\n `(${unreadableTokenRowReason(failure)}). ` +\n \"The integration is reported as disconnected; reconnecting overwrites the row.\",\n );\n return null;\n }\n }\n\n // Best-effort transparent re-seal so rotation also converges for rows that\n // are read but never rewritten. The revision is preserved (this is a\n // re-encryption, not a logical write), the swap is ABA-safe because every\n // seal uses a fresh IV, and any failure is ignored: the next read simply\n // tries again, and losing the swap to a concurrent writer is fine because\n // that writer already sealed with the current key.\n async function resealTokenRow(\n key: string,\n raw: string,\n entry: StoredTokenEntry,\n ): Promise<string | null> {\n try {\n const resealed = await cipher.seal(key, entry);\n return (await backend.compareAndSwap(key, raw, resealed)) ? resealed : null;\n } catch {\n return null;\n }\n }\n\n return {\n async getTokens(serviceId: string, userId: string): Promise<OAuthTokens | null> {\n return (await readTokenEntry(serviceId, userId))?.entry.tokens ?? null;\n },\n\n async getTokenSnapshot(\n serviceId: string,\n userId: string,\n ): Promise<OAuthTokenSnapshot | null> {\n return (await readTokenEntry(serviceId, userId))?.entry ?? null;\n },\n\n async setTokens(serviceId: string, userId: string, tokens: OAuthTokens): Promise<void> {\n const key = tokensStorageKey(serviceId, userId);\n const entry: StoredTokenEntry = {\n revision: crypto.randomUUID(),\n tokens: requireTokenRow(tokens),\n };\n await backend.set(key, await cipher.seal(key, entry));\n },\n\n async compareAndSetTokens(\n serviceId: string,\n userId: string,\n expectedRevision: string,\n tokens: OAuthTokens,\n ): Promise<boolean> {\n if (typeof expectedRevision !== \"string\" || expectedRevision.length === 0) {\n throw new TypeError(\"Expected OAuth token revision must be a non-empty string\");\n }\n const current = await readTokenEntry(serviceId, userId);\n if (!current || current.entry.revision !== expectedRevision) return false;\n const next: StoredTokenEntry = {\n revision: crypto.randomUUID(),\n tokens: requireTokenRow(tokens),\n };\n return backend.compareAndSwap(\n current.key,\n current.raw,\n await cipher.seal(current.key, next),\n );\n },\n\n withTokenRefreshLock<T>(\n serviceId: string,\n userId: string,\n operation: () => Promise<T>,\n ): Promise<T> {\n return backend.withLock(refreshLockKey(serviceId, userId), operation);\n },\n\n async clearTokens(serviceId: string, userId: string): Promise<void> {\n await backend.delete(tokensStorageKey(serviceId, userId));\n },\n\n async setState(state: string, metadata: StoredOAuthState): Promise<void> {\n const key = stateStorageKey(state);\n const row = requireStateRow(metadata);\n if (!isFreshState(row.createdAt, Date.now())) {\n throw new RangeError(\"OAuth state createdAt is outside the acceptance window\");\n }\n const inserted = await backend.compareAndSwap(\n key,\n null,\n await cipher.seal(key, row),\n { expiresInMs: STATE_TTL_MS + STATE_CLOCK_SKEW_MS },\n );\n if (!inserted) throw new Error(\"OAuth state already exists\");\n },\n\n async consumeState(state: string): Promise<StoredOAuthState | null> {\n const key = stateStorageKey(state);\n const raw = await backend.get(key);\n if (raw === null) return null;\n // One-shot semantics: only the caller that atomically removes the row\n // may redeem it, so a replayed callback cannot reuse the state.\n const consumed = await backend.compareAndSwap(key, raw, null);\n if (!consumed) return null;\n try {\n const row = requireStateRow((await cipher.open(key, raw)).value);\n return isFreshState(row.createdAt, Date.now()) ? row : null;\n } catch (failure) {\n console.warn(\n \"[Encrypted Token Store] Ignoring unreadable OAuth state row \" +\n `(${unreadableStateRowReason(failure)}). ` +\n \"The OAuth callback state is rejected.\",\n );\n return null;\n }\n },\n };\n}\n",
|
|
119
|
+
"lib/oauth.ts": "import {\n getRefreshableAccessToken,\n type OAuthToken,\n tokenStore,\n} from \"./token-store.ts\";\n\nexport interface OAuthProvider {\n name: string;\n authorizationUrl: string;\n tokenUrl: string;\n clientId: string;\n clientSecret: string;\n scopes: string[];\n callbackPath: string;\n}\n\nfunction getExpiresAt(expiresIn: unknown): number | undefined {\n if (typeof expiresIn !== \"number\" || expiresIn <= 0) return undefined;\n return Date.now() + expiresIn * 1000;\n}\n\nasync function postTokenRequest(\n provider: OAuthProvider,\n body: Record<string, string>,\n errorPrefix: string,\n): Promise<any> {\n const response = await fetch(provider.tokenUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams(body),\n });\n\n if (response.ok) return response.json();\n\n const error = await response.text();\n throw new Error(`${errorPrefix}: ${response.status} - ${error}`);\n}\n\nexport function getAuthorizationUrl(\n provider: OAuthProvider,\n state: string,\n redirectUri: string,\n): string {\n const params = new URLSearchParams({\n client_id: provider.clientId,\n redirect_uri: redirectUri,\n response_type: \"code\",\n scope: provider.scopes.join(\" \"),\n state,\n access_type: \"offline\",\n prompt: \"consent\",\n });\n\n return `${provider.authorizationUrl}?${params.toString()}`;\n}\n\nexport async function exchangeCodeForTokens(\n provider: OAuthProvider,\n code: string,\n redirectUri: string,\n): Promise<OAuthToken> {\n const data = await postTokenRequest(\n provider,\n {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n code,\n grant_type: \"authorization_code\",\n redirect_uri: redirectUri,\n },\n \"Token exchange failed\",\n );\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function refreshAccessToken(\n provider: OAuthProvider,\n refreshToken: string,\n): Promise<OAuthToken> {\n const data = await postTokenRequest(\n provider,\n {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n refresh_token: refreshToken,\n grant_type: \"refresh_token\",\n },\n \"Token refresh failed\",\n );\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token ?? refreshToken,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function getValidToken(\n provider: OAuthProvider,\n userId: string,\n service: string,\n): Promise<string | null> {\n return await getRefreshableAccessToken(\n tokenStore,\n service,\n userId,\n (refreshToken) => refreshAccessToken(provider, refreshToken),\n );\n}\n",
|
|
120
|
+
"lib/token-store-examples.ts": "/**\n * Reference backends for `createEncryptedTokenStore` in\n * `encrypted-token-store.ts`.\n *\n * The in-memory backend below is for local development and tests only: it is\n * process-local, so tokens vanish on restart and are not shared across\n * workers. For production, implement `EncryptedKvBackend` over a durable\n * service and pass it into startup through an explicit configuration\n * boundary. This example is complete and does not rely on module globals:\n *\n * ```ts\n * import { configureTokenStore } from \"./token-store.ts\";\n * import {\n * createEncryptedTokenStore,\n * type EncryptedKvBackend,\n * } from \"./encrypted-token-store.ts\";\n *\n * export function configureOAuthStorage(backend: EncryptedKvBackend): void {\n * configureTokenStore(createEncryptedTokenStore(backend));\n * }\n * ```\n *\n * Redis adapter sketch (pseudocode, not a paste-ready client): replace every\n * angle-bracketed operation with the equivalent atomic operation from your\n * initialized Redis client.\n *\n * ```text\n * const redisBackend: EncryptedKvBackend = {\n * get: (key) => <redis client get>(key),\n * set: async (key, value, options) => {\n * await <redis client set>(key, value, options?.expiresInMs);\n * },\n * delete: (key) => <redis client delete>(key),\n * compareAndSwap: (key, expected, next, options) =>\n * <atomic WATCH/MULTI or Lua CAS>(key, expected, next, options?.expiresInMs),\n * withLock: (key, operation) =>\n * <bounded, renewable, fenced Redis lease>(key, operation),\n * };\n * ```\n */\n\nimport type { EncryptedKvBackend } from \"./encrypted-token-store.ts\";\n\nfunction runtimeMode(): string | undefined {\n try {\n if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n } catch {\n // Deno exposes the Node-compatible `process` global even when env access\n // is denied. Preserve the fail-closed mode decision in that runtime.\n return undefined;\n }\n try {\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(\"NODE_ENV\");\n } catch {\n return undefined;\n }\n}\n\ninterface MemoryRow {\n value: string;\n expiresAt: number | null;\n}\n\n/**\n * Development/test in-memory backend. Values are still encrypted (the store\n * requires `TOKEN_ENCRYPTION_KEY` in every mode) but nothing is durable and\n * nothing is shared across workers, so creation is refused in production.\n */\nexport function createMemoryKvBackend(): EncryptedKvBackend {\n const mode = runtimeMode();\n if (mode !== \"development\" && mode !== \"test\") {\n throw new Error(\n mode === \"production\"\n ? \"The in-memory example backend is not allowed in production. Implement \" +\n \"EncryptedKvBackend over a durable service (Redis, Postgres, Deno KV).\"\n : \"The in-memory example backend requires an explicit development or test \" +\n \"runtime. Set NODE_ENV accordingly, or implement EncryptedKvBackend over \" +\n \"a durable service (Redis, Postgres, Deno KV).\",\n );\n }\n\n const rows = new Map<string, MemoryRow>();\n const lockTails = new Map<string, Promise<void>>();\n\n function readRow(key: string): string | null {\n const row = rows.get(key);\n if (!row) return null;\n if (row.expiresAt !== null && Date.now() >= row.expiresAt) {\n rows.delete(key);\n return null;\n }\n return row.value;\n }\n\n function writeRow(key: string, value: string, expiresInMs?: number): void {\n rows.set(key, {\n value,\n expiresAt: expiresInMs === undefined ? null : Date.now() + expiresInMs,\n });\n }\n\n return {\n get(key) {\n return Promise.resolve(readRow(key));\n },\n set(key, value, options) {\n writeRow(key, value, options?.expiresInMs);\n return Promise.resolve();\n },\n delete(key) {\n rows.delete(key);\n return Promise.resolve();\n },\n compareAndSwap(key, expected, next, options) {\n // No await between comparison and write: within one process this block\n // is indivisible, which is exactly the guarantee the contract asks a\n // distributed backend to provide server-side.\n if (readRow(key) !== expected) return Promise.resolve(false);\n if (next === null) rows.delete(key);\n else writeRow(key, next, options?.expiresInMs);\n return Promise.resolve(true);\n },\n async withLock(key, operation) {\n const prior = lockTails.get(key) ?? Promise.resolve();\n let release!: () => void;\n const current = new Promise<void>((resolve) => {\n release = resolve;\n });\n const tail = prior.catch(() => undefined).then(() => current);\n lockTails.set(key, tail);\n\n await prior.catch(() => undefined);\n try {\n return await operation();\n } finally {\n release();\n if (lockTails.get(key) === tail) lockTails.delete(key);\n }\n },\n };\n}\n",
|
|
121
|
+
"lib/token-store.ts": "/**\n * Shared OAuth token store for generated integrations.\n *\n * The same store owns authorization state and tokens. This is required for\n * callbacks and token refresh to work across production workers. Configure a\n * durable, extension-owned RefreshCapableTokenStore before the first OAuth\n * request in production. The built-in memory store is for development and test.\n *\n * To build that durable store on top of a plain key-value service with\n * AES-256-GCM encryption at rest, see `encrypted-token-store.ts` (reference\n * backends live in `token-store-examples.ts`).\n */\n\nimport {\n MemoryTokenStore,\n type OAuthTokens,\n type OAuthTokenSnapshot,\n type RefreshCapableTokenStore,\n type StoredOAuthState,\n} from \"veryfront/oauth\";\n\nexport type OAuthToken = OAuthTokens;\n\n/**\n * Application-facing store used by both Veryfront OAuth handlers and the\n * generated integration clients.\n */\nexport interface TokenStore extends RefreshCapableTokenStore {\n getToken(userId: string, serviceId: string): Promise<OAuthToken | null>;\n setToken(userId: string, serviceId: string, token: OAuthToken): Promise<void>;\n revokeToken(userId: string, serviceId: string): Promise<void>;\n isConnected(userId: string, serviceId: string): Promise<boolean>;\n}\n\nconst REQUIRED_STORE_METHODS = [\n \"getTokens\",\n \"getTokenSnapshot\",\n \"setTokens\",\n \"compareAndSetTokens\",\n \"withTokenRefreshLock\",\n \"clearTokens\",\n \"setState\",\n \"consumeState\",\n] as const;\nconst TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1_000;\n\nfunction runtimeMode(): string | undefined {\n try {\n if (typeof process !== \"undefined\" && process.env) return process.env.NODE_ENV;\n } catch {\n // Deno exposes the Node-compatible `process` global even when env access\n // is denied. Preserve the fail-closed mode decision in that runtime.\n return undefined;\n }\n try {\n return (globalThis as { Deno?: { env?: { get?: (name: string) => string | undefined } } })\n .Deno?.env?.get?.(\"NODE_ENV\");\n } catch {\n return undefined;\n }\n}\n\nfunction allowsProcessLocalStorage(): boolean {\n const mode = runtimeMode();\n return mode === \"development\" || mode === \"test\";\n}\n\nfunction assertRefreshCapableStore(\n store: RefreshCapableTokenStore,\n): asserts store is RefreshCapableTokenStore {\n if (!store || typeof store !== \"object\") {\n throw new TypeError(\"OAuth token store must be an object\");\n }\n\n for (const method of REQUIRED_STORE_METHODS) {\n if (typeof store[method] !== \"function\") {\n throw new TypeError(`OAuth token store must implement ${method}()`);\n }\n }\n}\n\n/**\n * Add the generated client aliases to a production-grade Veryfront OAuth\n * store. The adapter delegates every concurrency and state operation to the\n * supplied store; it never emulates distributed behavior in process memory.\n */\nexport function createTokenStore(store: RefreshCapableTokenStore): TokenStore {\n assertRefreshCapableStore(store);\n\n return {\n getTokens(serviceId: string, userId: string): Promise<OAuthTokens | null> {\n return store.getTokens(serviceId, userId);\n },\n\n getTokenSnapshot(\n serviceId: string,\n userId: string,\n ): Promise<OAuthTokenSnapshot | null> {\n return store.getTokenSnapshot(serviceId, userId);\n },\n\n setTokens(serviceId: string, userId: string, tokens: OAuthTokens): Promise<void> {\n return store.setTokens(serviceId, userId, tokens);\n },\n\n compareAndSetTokens(\n serviceId: string,\n userId: string,\n expectedRevision: string,\n tokens: OAuthTokens,\n ): Promise<boolean> {\n return store.compareAndSetTokens(serviceId, userId, expectedRevision, tokens);\n },\n\n withTokenRefreshLock<T>(\n serviceId: string,\n userId: string,\n operation: () => Promise<T>,\n ): Promise<T> {\n return store.withTokenRefreshLock(serviceId, userId, operation);\n },\n\n clearTokens(serviceId: string, userId: string): Promise<void> {\n return store.clearTokens(serviceId, userId);\n },\n\n setState(state: string, metadata: StoredOAuthState): Promise<void> {\n return store.setState(state, metadata);\n },\n\n consumeState(state: string): Promise<StoredOAuthState | null> {\n return store.consumeState(state);\n },\n\n getToken(userId: string, serviceId: string): Promise<OAuthToken | null> {\n return store.getTokens(serviceId, userId);\n },\n\n setToken(userId: string, serviceId: string, token: OAuthToken): Promise<void> {\n return store.setTokens(serviceId, userId, token);\n },\n\n revokeToken(userId: string, serviceId: string): Promise<void> {\n return store.clearTokens(serviceId, userId);\n },\n\n async isConnected(userId: string, serviceId: string): Promise<boolean> {\n const token = await store.getTokens(serviceId, userId);\n return !!token && (token.expiresAt === undefined || token.expiresAt > Date.now());\n },\n };\n}\n\nfunction unexpiredAccessToken(token: OAuthToken, now = Date.now()): string | null {\n return token.expiresAt === undefined || now < token.expiresAt ? token.accessToken : null;\n}\n\n/**\n * Resolve an access token, refreshing it under the store's distributed lock.\n * Revisioned compare-and-set prevents a refresh from overwriting a concurrent\n * reconnect or revocation that did not participate in the refresh lock.\n */\nexport async function getRefreshableAccessToken(\n store: TokenStore,\n serviceId: string,\n userId: string,\n refresh: (refreshToken: string) => Promise<OAuthToken>,\n): Promise<string | null> {\n const initial = await store.getTokenSnapshot(serviceId, userId);\n if (!initial) return null;\n\n const initialToken = initial.tokens;\n const now = Date.now();\n if (\n initialToken.expiresAt === undefined ||\n now < initialToken.expiresAt - TOKEN_REFRESH_BUFFER_MS\n ) {\n return initialToken.accessToken;\n }\n if (!initialToken.refreshToken) return unexpiredAccessToken(initialToken, now);\n\n return await store.withTokenRefreshLock(serviceId, userId, async () => {\n // Another worker may have refreshed this slot while this caller waited.\n const current = await store.getTokenSnapshot(serviceId, userId);\n if (!current) return null;\n\n const token = current.tokens;\n const lockedNow = Date.now();\n if (\n token.expiresAt === undefined ||\n lockedNow < token.expiresAt - TOKEN_REFRESH_BUFFER_MS\n ) {\n return token.accessToken;\n }\n if (!token.refreshToken) return unexpiredAccessToken(token, lockedNow);\n\n let refreshed: OAuthToken;\n try {\n refreshed = await refresh(token.refreshToken);\n } catch {\n // A provider failure must not unconditionally delete a row that may\n // have been replaced by a concurrent reconnect outside the lock.\n const latest = await store.getTokens(serviceId, userId);\n return latest ? unexpiredAccessToken(latest) : null;\n }\n\n if (refreshed.refreshToken === undefined) {\n refreshed = { ...refreshed, refreshToken: token.refreshToken };\n }\n\n const replaced = await store.compareAndSetTokens(\n serviceId,\n userId,\n current.revision,\n refreshed,\n );\n if (replaced) return refreshed.accessToken;\n\n const latest = await store.getTokens(serviceId, userId);\n return latest ? unexpiredAccessToken(latest) : null;\n });\n}\n\nlet configuredTokenStore: TokenStore | null = null;\nlet defaultTokenStore: TokenStore | null = null;\n\n/**\n * Configure the shared production store before the first OAuth request.\n *\n * Production stores must persist state and tokens across workers, implement\n * atomic compare-and-set, and use a bounded, crash-recoverable distributed\n * lease for withTokenRefreshLock(). Storage extensions are responsible for\n * encryption and backend-specific concurrency guarantees.\n */\nexport function configureTokenStore(store: RefreshCapableTokenStore): void {\n if (configuredTokenStore || defaultTokenStore) {\n throw new Error(\"OAuth token store must be configured exactly once before first use\");\n }\n if (!allowsProcessLocalStorage() && store instanceof MemoryTokenStore) {\n throw new Error(\n \"MemoryTokenStore is allowed only when NODE_ENV is explicitly development or test\",\n );\n }\n configuredTokenStore = createTokenStore(store);\n}\n\n/** Resolve the development default without doing work during module import. */\nexport function createDefaultTokenStore(): TokenStore {\n if (!allowsProcessLocalStorage()) {\n throw new Error(\n \"OAuth token storage is not configured. The in-memory default is allowed \" +\n \"only when NODE_ENV is explicitly development or test. \" +\n \"Configure an extension-owned RefreshCapableTokenStore with \" +\n \"configureTokenStore() before the first OAuth request.\",\n );\n }\n\n console.warn(\n \"[Token Store] Using development-only in-memory OAuth storage. \" +\n \"State and tokens will be lost on restart.\",\n );\n return createTokenStore(new MemoryTokenStore());\n}\n\nfunction getDefaultTokenStore(): TokenStore {\n defaultTokenStore ??= configuredTokenStore ?? createDefaultTokenStore();\n return defaultTokenStore;\n}\n\n/**\n * Lazy proxy shared by every generated OAuth route and integration client.\n * Importing a route never initializes storage or throws.\n */\nexport const tokenStore: TokenStore = {\n getTokens(serviceId, userId) {\n return getDefaultTokenStore().getTokens(serviceId, userId);\n },\n getTokenSnapshot(serviceId, userId) {\n return getDefaultTokenStore().getTokenSnapshot(serviceId, userId);\n },\n setTokens(serviceId, userId, tokens) {\n return getDefaultTokenStore().setTokens(serviceId, userId, tokens);\n },\n compareAndSetTokens(serviceId, userId, expectedRevision, tokens) {\n return getDefaultTokenStore().compareAndSetTokens(\n serviceId,\n userId,\n expectedRevision,\n tokens,\n );\n },\n withTokenRefreshLock(serviceId, userId, operation) {\n return getDefaultTokenStore().withTokenRefreshLock(serviceId, userId, operation);\n },\n clearTokens(serviceId, userId) {\n return getDefaultTokenStore().clearTokens(serviceId, userId);\n },\n setState(state, metadata) {\n return getDefaultTokenStore().setState(state, metadata);\n },\n consumeState(state) {\n return getDefaultTokenStore().consumeState(state);\n },\n getToken(userId, serviceId) {\n return getDefaultTokenStore().getToken(userId, serviceId);\n },\n setToken(userId, serviceId, token) {\n return getDefaultTokenStore().setToken(userId, serviceId, token);\n },\n revokeToken(userId, serviceId) {\n return getDefaultTokenStore().revokeToken(userId, serviceId);\n },\n isConnected(userId, serviceId) {\n return getDefaultTokenStore().isConnected(userId, serviceId);\n },\n};\n",
|
|
120
122
|
"lib/user-id.ts": "import type { ToolExecutionContext } from \"veryfront/tool\";\n\nfunction normalizeUserId(value: unknown): string | null {\n if (typeof value !== \"string\" || value.length === 0 || value.length > 1_024) {\n return null;\n }\n return value.trim() === value ? value : null;\n}\n\n/**\n * Application-owned session/JWT lookup.\n *\n * Replace this implementation with a server-side session lookup or verified\n * JWT check. There is deliberately no environment-gated default, because an\n * ambient identity collapses every visitor onto a single OAuth token owner.\n */\nexport async function resolveAuthenticatedUserId(\n _request: Request,\n): Promise<string | null> {\n throw new Error(\n \"Authenticated request identity is not configured. Implement \" +\n \"resolveAuthenticatedUserId in lib/user-id.ts using a verified session or JWT.\",\n );\n}\n\n/** Resolve and validate the authenticated user for an OAuth request. */\nexport async function requireUserIdFromRequest(\n request: Request,\n): Promise<string | null> {\n return normalizeUserId(await resolveAuthenticatedUserId(request));\n}\n\nexport function requireUserIdFromContext(\n context?: ToolExecutionContext,\n): string {\n const userId = normalizeUserId(context?.userId);\n if (userId) return userId;\n throw new Error(\"Authenticated tool context userId is required\");\n}\n",
|
|
121
123
|
"SETUP.md": "# Integration Setup Guide\n\nThis guide helps you set up credentials for all 50+ service integrations available in Veryfront.\n\n## Quick Start\n\n```bash\n# Create a new project with integrations\nveryfront init my-app --with ai --integrations slack,github,notion\n\n# Start development\ncd my-app\nveryfront dev\n```\n\nVisit `http://localhost:3000/api/auth/{service}` to connect each service.\n\n---\n\n## Table of Contents\n\n- [Google Services](#google-services) (Gmail, Calendar, Drive, Docs, Sheets)\n- [Microsoft Services](#microsoft-services) (Outlook, Teams, SharePoint, OneDrive)\n- [Atlassian Services](#atlassian-services) (Jira, Confluence)\n- [Communication](#communication) (Slack, Twilio, Zoom, Webex)\n- [Project Management](#project-management) (Asana, Monday, Trello, ClickUp, Linear, Notion)\n- [Developer Tools](#developer-tools) (GitHub, GitLab, Bitbucket, Figma, Sentry, PostHog)\n- [CRM & Sales](#crm--sales) (Salesforce, Pipedrive, Intercom, Zendesk, Freshdesk)\n- [Databases](#databases) (Supabase, Neon, Airtable, Snowflake)\n- [Cloud & Storage](#cloud--storage) (AWS, Box)\n- [Finance](#finance) (Stripe, QuickBooks, Xero)\n- [Marketing](#marketing) (Mailchimp, Twitter)\n- [E-commerce](#e-commerce) (Shopify)\n- [AI & Analytics](#ai--analytics) (Anthropic, Mixpanel)\n\n---\n\n## Google Services\n\n**Gmail, Calendar, Drive, Docs, Sheets** all use the same Google OAuth credentials.\n\n### Setup Steps\n\n1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)\n2. Create a new project or select existing\n3. Enable required APIs:\n - Gmail API\n - Google Calendar API\n - Google Drive API\n - Google Docs API\n - Google Sheets API\n4. Go to **OAuth consent screen**:\n - User Type: External (or Internal for Workspace)\n - Add scopes for each API you need\n5. Go to **Credentials** > **Create Credentials** > **OAuth client ID**:\n - Application type: Web application\n - Authorized redirect URIs:\n ```\n http://localhost:3000/api/auth/gmail/callback\n http://localhost:3000/api/auth/calendar/callback\n http://localhost:3000/api/auth/drive/callback\n http://localhost:3000/api/auth/docs-google/callback\n http://localhost:3000/api/auth/sheets/callback\n ```\n\n### Environment Variables\n\n```env\nGOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com\nGOOGLE_CLIENT_SECRET=your-client-secret\n```\n\n### Required Scopes by Service\n\n| Service | Scopes |\n| -------- | -------------------------------------------------------------------------------------------------------------------------------- |\n| Gmail | `gmail.readonly`, `gmail.send`, `gmail.modify`, `gmail.labels`, `gmail.compose`, `https://mail.google.com/` for permanent delete |\n| Calendar | `calendar.readonly`, `calendar.events` |\n| Drive | `drive.readonly`, `drive.file` |\n| Docs | `documents.readonly`, `documents` |\n| Sheets | `spreadsheets.readonly`, `spreadsheets` |\n\n---\n\n## Microsoft Services\n\n**Outlook, Teams, SharePoint, OneDrive** use Microsoft OAuth (Azure AD).\n\n### Setup Steps\n\n1. Go to [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade)\n2. Click **New registration**:\n - Name: Your app name\n - Supported account types: Accounts in any organizational directory\n - Redirect URI: Web, `http://localhost:3000/api/auth/outlook/callback`\n3. After creation, go to **Certificates & secrets**:\n - Create a new client secret\n4. Go to **API permissions**:\n - Add Microsoft Graph permissions\n\n### Environment Variables\n\n```env\nMICROSOFT_CLIENT_ID=your-application-client-id\nMICROSOFT_CLIENT_SECRET=your-client-secret\nMICROSOFT_TENANT_ID=common\n```\n\n### Required Scopes by Service\n\n| Service | Scopes |\n| ---------- | ------------------------------------------------------------- |\n| Outlook | `Mail.Read`, `Mail.Send`, `Calendars.ReadWrite` |\n| Teams | `Team.ReadBasic.All`, `Chat.ReadWrite`, `ChannelMessage.Send` |\n| SharePoint | `Sites.Read.All`, `Files.ReadWrite.All` |\n| OneDrive | `Files.Read`, `Files.ReadWrite` |\n\n---\n\n## Atlassian Services\n\n**Jira and Confluence** use Atlassian OAuth 2.0 (3LO).\n\n### Setup Steps\n\n1. Go to [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/)\n2. Click **Create** > **OAuth 2.0 integration**\n3. Configure:\n - Name: Your app name\n - Callback URL: `http://localhost:3000/api/auth/jira/callback`\n4. Add required scopes in **Permissions**\n5. Get your Cloud ID: Visit `https://your-domain.atlassian.net/_edge/tenant_info`\n\n### Environment Variables\n\n```env\nATLASSIAN_CLIENT_ID=your-client-id\nATLASSIAN_CLIENT_SECRET=your-client-secret\nATLASSIAN_CLOUD_ID=your-cloud-id\n```\n\n### Required Scopes\n\n| Service | Scopes |\n| ---------- | --------------------------------------------------------- |\n| Jira | `read:jira-work`, `write:jira-work`, `read:jira-user` |\n| Confluence | `read:confluence-content.all`, `write:confluence-content` |\n\n---\n\n## Communication\n\n### Slack\n\n1. Go to [Slack API Apps](https://api.slack.com/apps)\n2. Click **Create New App** > **From scratch**\n3. Go to **OAuth & Permissions**:\n - Add redirect URL: `http://localhost:3000/api/auth/slack/callback`\n - Add scopes: `channels:history`, `channels:read`, `chat:write`, `groups:history`, `groups:read`, `im:history`, `im:read`, `mpim:history`, `mpim:read`, `users:read`\n4. **Install to Workspace**\n\n```env\nSLACK_CLIENT_ID=your-client-id\nSLACK_CLIENT_SECRET=your-client-secret\n```\n\n### Twilio (SMS/WhatsApp)\n\n1. Go to [Twilio Console](https://console.twilio.com/)\n2. Get Account SID and Auth Token from dashboard\n3. Get or buy a phone number for sending\n\n```env\nTWILIO_ACCOUNT_SID=your-account-sid\nTWILIO_AUTH_TOKEN=your-auth-token\nTWILIO_PHONE_NUMBER=+1234567890\n```\n\n### Zoom\n\n1. Go to [Zoom App Marketplace](https://marketplace.zoom.us/develop/create)\n2. Create **OAuth App**\n3. Configure redirect: `http://localhost:3000/api/auth/zoom/callback`\n4. Add scopes: `meeting:read`, `meeting:write`, `user:read`\n\n```env\nZOOM_CLIENT_ID=your-client-id\nZOOM_CLIENT_SECRET=your-client-secret\n```\n\n### Webex\n\n1. Go to [Webex for Developers](https://developer.webex.com/my-apps)\n2. Create new integration\n3. Redirect URI: `http://localhost:3000/api/auth/webex/callback`\n4. Scopes: `spark:messages_read`, `spark:messages_write`, `spark:rooms_read`\n\n```env\nWEBEX_CLIENT_ID=your-client-id\nWEBEX_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## Project Management\n\n### Asana\n\n1. Go to [Asana Developer Console](https://app.asana.com/0/developer-console)\n2. Create new app\n3. Set redirect URL: `http://localhost:3000/api/auth/asana/callback`\n\n```env\nASANA_CLIENT_ID=your-client-id\nASANA_CLIENT_SECRET=your-client-secret\n```\n\n### Monday.com\n\n1. Go to [Monday Apps](https://auth.monday.com/oauth2/authorize)\n2. Create new app in your account's Developer section\n3. Configure OAuth with redirect: `http://localhost:3000/api/auth/monday/callback`\n\n```env\nMONDAY_CLIENT_ID=your-client-id\nMONDAY_CLIENT_SECRET=your-client-secret\n```\n\n### Trello\n\n1. Go to [Trello Power-Ups Admin](https://trello.com/power-ups/admin)\n2. Create new Power-Up\n3. Configure OAuth redirect: `http://localhost:3000/api/auth/trello/callback`\n\n```env\nTRELLO_API_KEY=your-api-key\nTRELLO_API_SECRET=your-api-secret\n```\n\n### ClickUp\n\n1. Go to [ClickUp API Settings](https://app.clickup.com/settings/apps)\n2. Create new app\n3. Redirect URL: `http://localhost:3000/api/auth/clickup/callback`\n\n```env\nCLICKUP_CLIENT_ID=your-client-id\nCLICKUP_CLIENT_SECRET=your-client-secret\n```\n\n### Linear\n\n1. Go to [Linear Settings > API](https://linear.app/settings/api)\n2. Create OAuth application\n3. Callback URL: `http://localhost:3000/api/auth/linear/callback`\n\n```env\nLINEAR_CLIENT_ID=your-client-id\nLINEAR_CLIENT_SECRET=your-client-secret\n```\n\n### Notion\n\n1. Go to [Notion Integrations](https://www.notion.so/my-integrations)\n2. Create new **public** integration (for OAuth)\n3. Set redirect URI: `http://localhost:3000/api/auth/notion/callback`\n4. **Important**: Share pages with your integration\n\n```env\nNOTION_CLIENT_ID=your-oauth-client-id\nNOTION_CLIENT_SECRET=your-oauth-client-secret\n```\n\n---\n\n## Developer Tools\n\n### GitHub\n\n1. Go to [GitHub Developer Settings](https://github.com/settings/developers)\n2. Create **New OAuth App**\n3. Authorization callback: `http://localhost:3000/api/auth/github/callback`\n\n```env\nGITHUB_CLIENT_ID=your-client-id\nGITHUB_CLIENT_SECRET=your-client-secret\n```\n\n### GitLab\n\n1. Go to [GitLab Applications](https://gitlab.com/-/profile/applications)\n2. Create new application\n3. Redirect URI: `http://localhost:3000/api/auth/gitlab/callback`\n4. Scopes: `read_user`, `read_api`, `read_repository`\n\n```env\nGITLAB_CLIENT_ID=your-application-id\nGITLAB_CLIENT_SECRET=your-secret\n```\n\n### Bitbucket\n\n1. Go to [Bitbucket App Passwords](https://bitbucket.org/account/settings/app-passwords/) or create OAuth consumer\n2. For OAuth: Workspace settings > OAuth consumers\n3. Callback URL: `http://localhost:3000/api/auth/bitbucket/callback`\n\n```env\nBITBUCKET_CLIENT_ID=your-client-id\nBITBUCKET_CLIENT_SECRET=your-client-secret\n```\n\n### Figma\n\n1. Go to [Figma Developers](https://www.figma.com/developers/apps)\n2. Create new app\n3. Callback URL: `http://localhost:3000/api/auth/figma/callback`\n\n```env\nFIGMA_CLIENT_ID=your-client-id\nFIGMA_CLIENT_SECRET=your-client-secret\n```\n\n### Sentry\n\n1. Go to [Sentry Developer Settings](https://sentry.io/settings/developer-settings/)\n2. Create new public integration\n3. Redirect URL: `http://localhost:3000/api/auth/sentry/callback`\n\n```env\nSENTRY_CLIENT_ID=your-client-id\nSENTRY_CLIENT_SECRET=your-client-secret\n```\n\n### PostHog\n\nUses API key authentication (no OAuth).\n\n1. Go to your PostHog project settings\n2. Create a personal API key\n\n```env\nPOSTHOG_API_KEY=phx_your-api-key\nPOSTHOG_HOST=https://app.posthog.com\n```\n\n---\n\n## CRM & Sales\n\n### Salesforce\n\n1. Go to [Salesforce Setup](https://login.salesforce.com/) > App Manager\n2. Create **New Connected App**\n3. Enable OAuth, add callback: `http://localhost:3000/api/auth/salesforce/callback`\n4. Required scopes: `api`, `refresh_token`\n\n```env\nSALESFORCE_CLIENT_ID=your-consumer-key\nSALESFORCE_CLIENT_SECRET=your-consumer-secret\n```\n\n### Pipedrive\n\n1. Go to [Pipedrive Marketplace Manager](https://developers.pipedrive.com/)\n2. Create new app\n3. OAuth redirect: `http://localhost:3000/api/auth/pipedrive/callback`\n\n```env\nPIPEDRIVE_CLIENT_ID=your-client-id\nPIPEDRIVE_CLIENT_SECRET=your-client-secret\n```\n\n### Intercom\n\n1. Go to [Intercom Developer Hub](https://developers.intercom.com/)\n2. Create new app\n3. Configure OAuth: `http://localhost:3000/api/auth/intercom/callback`\n\n```env\nINTERCOM_CLIENT_ID=your-client-id\nINTERCOM_CLIENT_SECRET=your-client-secret\n```\n\n### Zendesk\n\n1. Go to Admin Center > Apps and integrations > APIs > Zendesk API\n2. Create OAuth client\n3. Redirect URL: `http://localhost:3000/api/auth/zendesk/callback`\n\n```env\nZENDESK_CLIENT_ID=your-client-id\nZENDESK_CLIENT_SECRET=your-client-secret\nZENDESK_SUBDOMAIN=your-subdomain\n```\n\n### Freshdesk\n\nUses API key authentication.\n\n1. Go to Profile Settings in Freshdesk\n2. Find your API Key\n\n```env\nFRESHDESK_API_KEY=your-api-key\nFRESHDESK_DOMAIN=your-domain.freshdesk.com\n```\n\n---\n\n## Databases\n\n### Supabase\n\nUses API key (no OAuth needed).\n\n1. Go to your Supabase project dashboard\n2. Go to Settings > API\n3. Copy the `anon` or `service_role` key\n\n```env\nSUPABASE_URL=https://your-project.supabase.co\nSUPABASE_ANON_KEY=your-anon-key\nSUPABASE_SERVICE_ROLE_KEY=your-service-role-key\n```\n\n### Neon\n\nUses API key authentication.\n\n1. Go to [Neon Console](https://console.neon.tech/)\n2. Create API key in Account Settings\n\n```env\nNEON_API_KEY=your-api-key\nNEON_PROJECT_ID=your-project-id\n```\n\n### Airtable\n\n1. Go to [Airtable Account](https://airtable.com/account)\n2. Create personal access token or OAuth app\n3. For OAuth: [Airtable OAuth](https://airtable.com/create/oauth)\n\n```env\nAIRTABLE_API_KEY=your-api-key\n# Or for OAuth:\nAIRTABLE_CLIENT_ID=your-client-id\nAIRTABLE_CLIENT_SECRET=your-client-secret\n```\n\n### Snowflake\n\nUses account credentials (key-pair or password).\n\n1. Get your Snowflake account identifier\n2. Create a user with appropriate permissions\n3. (Optional) Set up key-pair authentication\n\n```env\nSNOWFLAKE_ACCOUNT=your-account-identifier\nSNOWFLAKE_USERNAME=your-username\nSNOWFLAKE_PASSWORD=your-password\nSNOWFLAKE_WAREHOUSE=your-warehouse\nSNOWFLAKE_DATABASE=your-database\n```\n\n---\n\n## Cloud & Storage\n\n### AWS\n\nUses IAM credentials.\n\n1. Go to [AWS IAM Console](https://console.aws.amazon.com/iam/)\n2. Create a new IAM user with programmatic access\n3. Attach policies for services you need (S3, EC2, Lambda, etc.)\n\n```env\nAWS_ACCESS_KEY_ID=your-access-key\nAWS_SECRET_ACCESS_KEY=your-secret-key\nAWS_REGION=us-east-1\n```\n\n### Box\n\n1. Go to [Box Developer Console](https://app.box.com/developers/console)\n2. Create new app with OAuth 2.0\n3. Redirect URI: `http://localhost:3000/api/auth/box/callback`\n\n```env\nBOX_CLIENT_ID=your-client-id\nBOX_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## Finance\n\n### Stripe\n\nUses API key (no OAuth for basic usage).\n\n1. Go to [Stripe Dashboard](https://dashboard.stripe.com/apikeys)\n2. Get your secret key (use test key for development)\n\n```env\nSTRIPE_SECRET_KEY=sk_test_your-secret-key\nSTRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key\n```\n\n### QuickBooks\n\n1. Go to [Intuit Developer](https://developer.intuit.com/)\n2. Create app and get OAuth credentials\n3. Redirect URI: `http://localhost:3000/api/auth/quickbooks/callback`\n\n```env\nQUICKBOOKS_CLIENT_ID=your-client-id\nQUICKBOOKS_CLIENT_SECRET=your-client-secret\n```\n\n### Xero\n\n1. Go to [Xero Developer](https://developer.xero.com/app/manage)\n2. Create app\n3. Redirect URI: `http://localhost:3000/api/auth/xero/callback`\n\n```env\nXERO_CLIENT_ID=your-client-id\nXERO_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## Marketing\n\n### Mailchimp\n\n1. Go to [Mailchimp Account API Keys](https://us1.admin.mailchimp.com/account/api/)\n2. For OAuth: Register app at [Mailchimp OAuth](https://admin.mailchimp.com/account/oauth2/)\n3. Redirect: `http://localhost:3000/api/auth/mailchimp/callback`\n\n```env\nMAILCHIMP_CLIENT_ID=your-client-id\nMAILCHIMP_CLIENT_SECRET=your-client-secret\n# Or API key:\nMAILCHIMP_API_KEY=your-api-key-us1\n```\n\n### Twitter/X\n\n1. Go to [Twitter Developer Portal](https://developer.twitter.com/en/portal/dashboard)\n2. Create project and app\n3. Enable OAuth 2.0\n4. Callback URL: `http://localhost:3000/api/auth/twitter/callback`\n\n```env\nTWITTER_CLIENT_ID=your-client-id\nTWITTER_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## E-commerce\n\n### Shopify\n\n1. Go to [Shopify Partners](https://partners.shopify.com/)\n2. Create new app\n3. App URL and redirect: `http://localhost:3000/api/auth/shopify/callback`\n\n```env\nSHOPIFY_CLIENT_ID=your-api-key\nSHOPIFY_CLIENT_SECRET=your-api-secret\nSHOPIFY_SHOP_NAME=your-store.myshopify.com\n```\n\n---\n\n## AI & Analytics\n\n### Anthropic (Admin API)\n\nFor organization management and usage tracking.\n\n1. Go to [Anthropic Console](https://console.anthropic.com/)\n2. Create Admin API key (requires admin access)\n\n```env\nANTHROPIC_ADMIN_API_KEY=your-admin-api-key\n```\n\n### Mixpanel\n\nUses API key/secret for data export.\n\n1. Go to [Mixpanel Project Settings](https://mixpanel.com/settings/project)\n2. Get Project Token for tracking\n3. Get API Secret for data export\n\n```env\nMIXPANEL_PROJECT_TOKEN=your-project-token\nMIXPANEL_API_SECRET=your-api-secret\n```\n\n---\n\n## Testing Your Setup\n\nAfter configuring credentials:\n\n```bash\n# Start the dev server\nveryfront dev\n\n# Test each integration by visiting:\n# http://localhost:3000/api/auth/{service}\n\n# Check connection status\ncurl http://localhost:3000/api/connections\n```\n\n## Troubleshooting\n\n### Common Issues\n\n| Error | Solution |\n| ---------------------- | -------------------------------------------------------------- |\n| \"Invalid redirect URI\" | Ensure callback URL matches exactly (including trailing slash) |\n| \"Invalid client\" | Check CLIENT_ID is correct and app is published |\n| \"Access denied\" | Verify all required scopes are added |\n| \"Token expired\" | Implement refresh token flow or re-authenticate |\n\n### Debug Mode\n\nEnable debug logging:\n\n```bash\nDEBUG=veryfront:oauth veryfront dev\n```\n\n### Token storage\n\nThe generated token-store proxy uses in-memory storage only in an explicit\ndevelopment or test environment. For production:\n\n1. Select a storage extension that provides a durable\n `RefreshCapableTokenStore`.\n2. Configure it with `configureTokenStore()` before the first OAuth request.\n3. Ensure the extension encrypts tokens, atomically consumes OAuth state,\n implements compare-and-set, and uses a bounded, crash-recoverable\n distributed lease for refresh locking.\n\n```typescript\n// lib/configure-oauth-storage.ts (import once during application startup)\nimport type { RefreshCapableTokenStore } from \"veryfront/oauth\";\nimport { configureTokenStore } from \"./token-store.ts\";\nimport { createApplicationOAuthTokenStore } from \"./storage/oauth.ts\";\n\nconst oauthStore: RefreshCapableTokenStore = createApplicationOAuthTokenStore();\nconfigureTokenStore(oauthStore);\n```\n\n`createApplicationOAuthTokenStore` is the factory exported by your selected\nstorage extension. Veryfront core does not select or import the backend.\n\n### Authenticated OAuth routes\n\nGenerated OAuth routes call `requireUserIdFromRequest` in `lib/user-id.ts`.\nThe generated `resolveAuthenticatedUserId` seam deliberately throws in every\nruntime mode until you replace it with a resolver backed by your server-side\nsession or verified JWT. The surrounding request boundary validates the\nreturned id. There is no development escape hatch because an ambient default\nidentity makes all visitors share one token owner.\n\n```ts\n// lib/user-id.ts\nexport async function resolveAuthenticatedUserId(request: Request) {\n const session = await verifySession(request); // your session/JWT check\n return session?.userId ?? null; // null => anonymous => routes answer 401\n}\n```\n\nRaw request headers are not an authentication boundary. Never return a value\ncopied from an untrusted header such as `x-user-id`.\n\n### Authenticated tool context\n\nGenerated integration tools resolve the token owner from `context.userId`.\nPass the authenticated user through `ToolExecutionContext` whenever an agent or\nworkflow invokes a tool. Execution fails closed when the context has no\nauthenticated user id.\n\n## Production Checklist\n\n- [ ] Update all redirect URIs to production domain\n- [ ] Configure an extension-owned `RefreshCapableTokenStore`\n- [ ] Implement `resolveAuthenticatedUserId` using a verified session or JWT\n- [ ] Verify token encryption and distributed refresh locking\n- [ ] Pass authenticated user ids through every tool execution context\n- [ ] Configure rate limiting\n- [ ] Add error monitoring (Sentry)\n- [ ] Test OAuth flows end-to-end\n- [ ] Review and minimize required scopes\n\n## Need Help?\n\n- Run `veryfront doctor` to diagnose issues\n- Check the [Veryfront Documentation](https://veryfront.com/docs)\n- Join our [Discord community](https://discord.gg/xWuRjafrtV)\n"
|
|
122
124
|
}
|
|
@@ -223,7 +225,7 @@ export default {
|
|
|
223
225
|
"app/api/auth/docs-google/callback/route.ts": "import { createOAuthCallbackHandler, docsGoogleConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\n\nexport const GET = createOAuthCallbackHandler(docsGoogleConfig, { tokenStore });\n",
|
|
224
226
|
"app/api/auth/docs-google/route.ts": "import { createOAuthInitHandler, docsGoogleConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../lib/token-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../lib/user-id.ts\";\n\nexport const GET = createOAuthInitHandler(docsGoogleConfig, {\n tokenStore,\n getUserId: requireUserIdFromRequest,\n});\n",
|
|
225
227
|
"lib/docs-client.ts": "/**\n * Google Docs API Client\n *\n * Provides a type-safe interface to Google Docs API operations.\n */\n\nimport { getValidToken } from \"./docs-google-oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n return undefined;\n}\n\nconst DOCS_API_BASE = \"https://docs.googleapis.com/v1\";\nconst DRIVE_API_BASE = \"https://www.googleapis.com/drive/v3\";\n\nexport interface Document {\n documentId: string;\n title: string;\n body: {\n content: StructuralElement[];\n };\n revisionId: string;\n suggestionsViewMode: string;\n documentStyle: DocumentStyle;\n}\n\nexport interface StructuralElement {\n startIndex: number;\n endIndex: number;\n paragraph?: Paragraph;\n table?: Table;\n sectionBreak?: SectionBreak;\n}\n\nexport interface Paragraph {\n elements: ParagraphElement[];\n paragraphStyle?: ParagraphStyle;\n bullet?: Bullet;\n}\n\nexport interface ParagraphElement {\n startIndex: number;\n endIndex: number;\n textRun?: TextRun;\n inlineObjectElement?: InlineObjectElement;\n}\n\nexport interface TextRun {\n content: string;\n textStyle?: TextStyle;\n}\n\nexport interface TextStyle {\n bold?: boolean;\n italic?: boolean;\n underline?: boolean;\n strikethrough?: boolean;\n fontSize?: Dimension;\n foregroundColor?: Color;\n backgroundColor?: Color;\n fontFamily?: string;\n link?: Link;\n}\n\nexport interface Link {\n url?: string;\n bookmarkId?: string;\n headingId?: string;\n}\n\nexport interface Dimension {\n magnitude: number;\n unit: string;\n}\n\nexport interface Color {\n rgbColor?: RgbColor;\n}\n\nexport interface RgbColor {\n red: number;\n green: number;\n blue: number;\n}\n\nexport interface ParagraphStyle {\n headingId?: string;\n namedStyleType?: string;\n alignment?: string;\n lineSpacing?: number;\n direction?: string;\n spacingMode?: string;\n spaceAbove?: Dimension;\n spaceBelow?: Dimension;\n indentFirstLine?: Dimension;\n indentStart?: Dimension;\n indentEnd?: Dimension;\n}\n\nexport interface Bullet {\n listId: string;\n nestingLevel?: number;\n textStyle?: TextStyle;\n}\n\nexport interface Table {\n rows: number;\n columns: number;\n tableRows: TableRow[];\n tableStyle?: TableStyle;\n}\n\nexport interface TableRow {\n startIndex: number;\n endIndex: number;\n tableCells: TableCell[];\n}\n\nexport interface TableCell {\n startIndex: number;\n endIndex: number;\n content: StructuralElement[];\n tableCellStyle?: TableCellStyle;\n}\n\nexport interface TableCellStyle {\n rowSpan?: number;\n columnSpan?: number;\n backgroundColor?: Color;\n borderLeft?: TableCellBorder;\n borderRight?: TableCellBorder;\n borderTop?: TableCellBorder;\n borderBottom?: TableCellBorder;\n paddingLeft?: Dimension;\n paddingRight?: Dimension;\n paddingTop?: Dimension;\n paddingBottom?: Dimension;\n}\n\nexport interface TableCellBorder {\n color?: Color;\n width?: Dimension;\n dashStyle?: string;\n}\n\nexport interface TableStyle {\n tableColumnProperties?: TableColumnProperties[];\n}\n\nexport interface TableColumnProperties {\n width?: Dimension;\n widthType?: string;\n}\n\nexport interface SectionBreak {\n sectionStyle?: SectionStyle;\n}\n\nexport interface SectionStyle {\n columnSeparatorStyle?: string;\n contentDirection?: string;\n marginTop?: Dimension;\n marginBottom?: Dimension;\n marginRight?: Dimension;\n marginLeft?: Dimension;\n pageNumberStart?: number;\n}\n\nexport interface DocumentStyle {\n background?: Background;\n pageNumberStart?: number;\n marginTop?: Dimension;\n marginBottom?: Dimension;\n marginRight?: Dimension;\n marginLeft?: Dimension;\n pageSize?: Size;\n marginHeader?: Dimension;\n marginFooter?: Dimension;\n useFirstPageHeaderFooter?: boolean;\n}\n\nexport interface Background {\n color?: Color;\n}\n\nexport interface Size {\n height?: Dimension;\n width?: Dimension;\n}\n\nexport interface InlineObjectElement {\n inlineObjectId: string;\n textStyle?: TextStyle;\n}\n\nexport interface DocumentFile {\n id: string;\n name: string;\n mimeType: string;\n createdTime: string;\n modifiedTime: string;\n webViewLink: string;\n iconLink?: string;\n thumbnailLink?: string;\n}\n\nexport interface CreateDocumentOptions {\n title: string;\n}\n\nexport interface BatchUpdateRequest {\n requests: Request[];\n}\n\nexport interface Request {\n insertText?: InsertTextRequest;\n deleteContentRange?: DeleteContentRangeRequest;\n replaceAllText?: ReplaceAllTextRequest;\n updateTextStyle?: UpdateTextStyleRequest;\n updateParagraphStyle?: UpdateParagraphStyleRequest;\n insertPageBreak?: InsertPageBreakRequest;\n insertTable?: InsertTableRequest;\n deleteTableRow?: DeleteTableRowRequest;\n deleteTableColumn?: DeleteTableColumnRequest;\n createParagraphBullets?: CreateParagraphBulletsRequest;\n deleteParagraphBullets?: DeleteParagraphBulletsRequest;\n}\n\nexport interface InsertTextRequest {\n text: string;\n location: Location;\n}\n\nexport interface DeleteContentRangeRequest {\n range: Range;\n}\n\nexport interface ReplaceAllTextRequest {\n containsText: ContainsText;\n replaceText: string;\n}\n\nexport interface UpdateTextStyleRequest {\n range: Range;\n textStyle: TextStyle;\n fields: string;\n}\n\nexport interface UpdateParagraphStyleRequest {\n range: Range;\n paragraphStyle: ParagraphStyle;\n fields: string;\n}\n\nexport interface InsertPageBreakRequest {\n location: Location;\n}\n\nexport interface InsertTableRequest {\n rows: number;\n columns: number;\n location: Location;\n}\n\nexport interface DeleteTableRowRequest {\n tableCellLocation: TableCellLocation;\n}\n\nexport interface DeleteTableColumnRequest {\n tableCellLocation: TableCellLocation;\n}\n\nexport interface CreateParagraphBulletsRequest {\n range: Range;\n bulletPreset: string;\n}\n\nexport interface DeleteParagraphBulletsRequest {\n range: Range;\n}\n\nexport interface Location {\n index: number;\n segmentId?: string;\n}\n\nexport interface Range {\n startIndex: number;\n endIndex: number;\n segmentId?: string;\n}\n\nexport interface ContainsText {\n text: string;\n matchCase: boolean;\n}\n\nexport interface TableCellLocation {\n tableStartLocation: Location;\n rowIndex: number;\n columnIndex: number;\n}\n\nexport interface BatchUpdateResponse {\n documentId: string;\n replies: Reply[];\n writeControl?: WriteControl;\n}\n\nexport interface Reply {\n [key: string]: unknown;\n}\n\nexport interface WriteControl {\n requiredRevisionId: string;\n targetRevisionId: string;\n}\n\n/**\n * Google Docs OAuth provider configuration\n */\nexport const docsOAuthProvider = {\n name: \"docs-google\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/documents.readonly\",\n \"https://www.googleapis.com/auth/documents\",\n \"https://www.googleapis.com/auth/docs\",\n \"https://www.googleapis.com/auth/drive.readonly\",\n ],\n callbackPath: \"/api/auth/docs-google/callback\",\n};\n\nexport function createDocsClient(userId: string): {\n listDocuments(options?: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n }): Promise<DocumentFile[]>;\n getDocument(documentId: string): Promise<Document>;\n createDocument(options: CreateDocumentOptions): Promise<Document>;\n updateDocument(documentId: string, requests: Request[]): Promise<BatchUpdateResponse>;\n insertText(documentId: string, text: string, index: number): Promise<BatchUpdateResponse>;\n deleteContent(documentId: string, startIndex: number, endIndex: number): Promise<BatchUpdateResponse>;\n replaceAllText(\n documentId: string,\n searchText: string,\n replaceText: string,\n matchCase?: boolean,\n ): Promise<BatchUpdateResponse>;\n searchDocuments(query: string, maxResults?: number): Promise<DocumentFile[]>;\n extractText(document: Document): string;\n createDocumentWithContent(title: string, content: string): Promise<Document>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(docsOAuthProvider, userId, \"docs-google\");\n if (!token) throw new Error(\"Google Docs not connected. Please connect your Google account first.\");\n return token;\n }\n\n async function apiRequest<T>(\n baseUrl: string,\n label: string,\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${baseUrl}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`${label} API error: ${response.status} - ${error}`);\n }\n\n return response.json();\n }\n\n function docsApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiRequest<T>(DOCS_API_BASE, \"Docs\", endpoint, options);\n }\n\n function driveApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiRequest<T>(DRIVE_API_BASE, \"Drive\", endpoint, options);\n }\n\n function extractText(document: Document): string {\n const textParts: string[] = [];\n\n function processElement(element: StructuralElement): void {\n if (element.paragraph) {\n for (const el of element.paragraph.elements) {\n if (el.textRun) textParts.push(el.textRun.content);\n }\n return;\n }\n\n if (!element.table) return;\n\n for (const row of element.table.tableRows) {\n for (const cell of row.tableCells) {\n for (const child of cell.content) processElement(child);\n }\n }\n }\n\n for (const element of document.body.content) processElement(element);\n return textParts.join(\"\");\n }\n\n async function listDocuments(options: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n } = {}): Promise<DocumentFile[]> {\n const params = new URLSearchParams({\n q: \"mimeType='application/vnd.google-apps.document' and trashed=false\",\n fields: \"files(id,name,mimeType,createdTime,modifiedTime,webViewLink,iconLink,thumbnailLink)\",\n pageSize: String(options.maxResults ?? 20),\n orderBy: `${options.orderBy ?? \"modifiedTime\"} desc`,\n });\n\n const result = await driveApiRequest<{ files: DocumentFile[] }>(`/files?${params.toString()}`);\n return result.files ?? [];\n }\n\n async function searchDocuments(query: string, maxResults = 20): Promise<DocumentFile[]> {\n const params = new URLSearchParams({\n q: `mimeType='application/vnd.google-apps.document' and trashed=false and fullText contains '${query}'`,\n fields: \"files(id,name,mimeType,createdTime,modifiedTime,webViewLink,iconLink,thumbnailLink)\",\n pageSize: String(maxResults),\n orderBy: \"modifiedTime desc\",\n });\n\n const result = await driveApiRequest<{ files: DocumentFile[] }>(`/files?${params.toString()}`);\n return result.files ?? [];\n }\n\n function getDocument(documentId: string): Promise<Document> {\n return docsApiRequest<Document>(`/documents/${documentId}`);\n }\n\n function createDocument(options: CreateDocumentOptions): Promise<Document> {\n return docsApiRequest<Document>(\"/documents\", {\n method: \"POST\",\n body: JSON.stringify({ title: options.title }),\n });\n }\n\n function updateDocument(documentId: string, requests: Request[]): Promise<BatchUpdateResponse> {\n return docsApiRequest<BatchUpdateResponse>(`/documents/${documentId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({ requests }),\n });\n }\n\n function insertText(documentId: string, text: string, index: number): Promise<BatchUpdateResponse> {\n return updateDocument(documentId, [\n {\n insertText: {\n text,\n location: { index },\n },\n },\n ]);\n }\n\n function deleteContent(documentId: string, startIndex: number, endIndex: number): Promise<BatchUpdateResponse> {\n return updateDocument(documentId, [\n {\n deleteContentRange: {\n range: { startIndex, endIndex },\n },\n },\n ]);\n }\n\n function replaceAllText(\n documentId: string,\n searchText: string,\n replaceText: string,\n matchCase = false,\n ): Promise<BatchUpdateResponse> {\n return updateDocument(documentId, [\n {\n replaceAllText: {\n containsText: {\n text: searchText,\n matchCase,\n },\n replaceText,\n },\n },\n ]);\n }\n\n async function createDocumentWithContent(title: string, content: string): Promise<Document> {\n const doc = await createDocument({ title });\n await insertText(doc.documentId, content, 1);\n return getDocument(doc.documentId);\n }\n\n return {\n listDocuments,\n getDocument,\n createDocument,\n updateDocument,\n insertText,\n deleteContent,\n replaceAllText,\n searchDocuments,\n extractText,\n createDocumentWithContent,\n };\n}\n\nexport type DocsClient = ReturnType<typeof createDocsClient>;\n",
|
|
226
|
-
"lib/docs-google-oauth.ts": "import {
|
|
228
|
+
"lib/docs-google-oauth.ts": "import {\n getRefreshableAccessToken,\n type OAuthToken,\n tokenStore,\n} from \"./token-store.ts\";\n\nexport interface OAuthProvider {\n name: string;\n authorizationUrl: string;\n tokenUrl: string;\n clientId: string;\n clientSecret: string;\n scopes: string[];\n callbackPath: string;\n}\n\nfunction getExpiresAt(expiresIn: unknown): number | undefined {\n if (typeof expiresIn !== \"number\") return undefined;\n return Date.now() + expiresIn * 1000;\n}\n\nasync function postForm(url: string, body: Record<string, string>): Promise<any> {\n const response = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams(body),\n });\n\n if (response.ok) return response.json();\n\n throw new Error(\n `Token request failed: ${response.status} - ${await response.text()}`,\n );\n}\n\nexport function getAuthorizationUrl(\n provider: OAuthProvider,\n state: string,\n redirectUri: string,\n): string {\n const params = new URLSearchParams({\n client_id: provider.clientId,\n redirect_uri: redirectUri,\n response_type: \"code\",\n scope: provider.scopes.join(\" \"),\n state,\n access_type: \"offline\",\n prompt: \"consent\",\n });\n\n return `${provider.authorizationUrl}?${params.toString()}`;\n}\n\nexport async function exchangeCodeForTokens(\n provider: OAuthProvider,\n code: string,\n redirectUri: string,\n): Promise<OAuthToken> {\n const data = await postForm(provider.tokenUrl, {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n code,\n grant_type: \"authorization_code\",\n redirect_uri: redirectUri,\n });\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function refreshAccessToken(\n provider: OAuthProvider,\n refreshToken: string,\n): Promise<OAuthToken> {\n const data = await postForm(provider.tokenUrl, {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n refresh_token: refreshToken,\n grant_type: \"refresh_token\",\n });\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token ?? refreshToken,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function getValidToken(\n provider: OAuthProvider,\n userId: string,\n service: string,\n): Promise<string | null> {\n return await getRefreshableAccessToken(\n tokenStore,\n service,\n userId,\n (refreshToken) => refreshAccessToken(provider, refreshToken),\n );\n}\n",
|
|
227
229
|
"tools/create-document.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createDocsClient } from \"../lib/docs-client.ts\";\nimport { requireUserIdFromContext } from \"../lib/user-id.ts\";\n\nexport default tool({\n id: \"docs-google-create-document\",\n description:\n \"Create a new Google Docs document with optional initial content. Returns the new document ID and URL.\",\n inputSchema: defineSchema((v) =>\n v.object({\n title: v.string().describe(\"Title of the new document\"),\n content: v\n .string()\n .optional()\n .describe(\"Optional initial text content to insert into the document\"),\n })\n )(),\n async execute({ title, content }, context) {\n const userId = requireUserIdFromContext(context);\n const client = createDocsClient(userId);\n\n const document = content\n ? await client.createDocumentWithContent(title, content)\n : await client.createDocument({ title });\n\n const [docMeta] = await client.listDocuments({ maxResults: 1 });\n const webViewLink = docMeta?.id === document.documentId ? docMeta.webViewLink : undefined;\n\n return {\n documentId: document.documentId,\n title: document.title,\n url: webViewLink ?? `https://docs.google.com/document/d/${document.documentId}/edit`,\n revisionId: document.revisionId,\n };\n },\n});\n",
|
|
228
230
|
"tools/get-document.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createDocsClient } from \"../lib/docs-client.ts\";\nimport { requireUserIdFromContext } from \"../lib/user-id.ts\";\n\nexport default tool({\n id: \"docs-google-get-document\",\n description:\n \"Get a Google Docs document's content and metadata. Returns the full document structure including text, formatting, and styles.\",\n inputSchema: defineSchema((v) =>\n v.object({\n documentId: v.string().describe(\"The ID of the document to retrieve\"),\n extractTextOnly: v\n .boolean()\n .default(false)\n .describe(\"If true, only return plain text content without formatting\"),\n })\n )(),\n async execute({ documentId, extractTextOnly }, context) {\n const userId = requireUserIdFromContext(context);\n const client = createDocsClient(userId);\n const document = await client.getDocument(documentId);\n\n const { documentId: id, title, revisionId } = document;\n\n if (extractTextOnly) {\n return {\n documentId: id,\n title,\n revisionId,\n text: client.extractText(document),\n };\n }\n\n return {\n documentId: id,\n title,\n revisionId,\n body: document.body,\n documentStyle: document.documentStyle,\n };\n },\n});\n",
|
|
229
231
|
"tools/list-documents.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createDocsClient } from \"../lib/docs-client.ts\";\nimport { requireUserIdFromContext } from \"../lib/user-id.ts\";\n\nexport default tool({\n id: \"docs-google-list-documents\",\n description:\n \"List recent Google Docs documents from Google Drive. Returns document names, IDs, and metadata.\",\n inputSchema: defineSchema((v) =>\n v.object({\n maxResults: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of documents to return\"),\n orderBy: v\n .enum([\"createdTime\", \"modifiedTime\", \"name\"] as const)\n .default(\"modifiedTime\")\n .describe(\"Sort order for results\"),\n })\n )(),\n async execute({ maxResults, orderBy }, context) {\n const userId = requireUserIdFromContext(context);\n const client = createDocsClient(userId);\n const documents = await client.listDocuments({ maxResults, orderBy });\n\n return documents.map((doc) => ({\n id: doc.id,\n name: doc.name,\n url: doc.webViewLink,\n createdTime: doc.createdTime,\n modifiedTime: doc.modifiedTime,\n thumbnail: doc.thumbnailLink,\n }));\n },\n});\n",
|
|
@@ -237,7 +239,7 @@ export default {
|
|
|
237
239
|
"app/api/auth/drive/callback/route.ts": "import { createOAuthCallbackHandler, driveConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\n\nexport const GET = createOAuthCallbackHandler(driveConfig, { tokenStore });\n",
|
|
238
240
|
"app/api/auth/drive/route.ts": "import { createOAuthInitHandler, driveConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../lib/token-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../lib/user-id.ts\";\n\nexport const GET = createOAuthInitHandler(driveConfig, {\n tokenStore,\n getUserId: requireUserIdFromRequest,\n});\n",
|
|
239
241
|
"lib/drive-client.ts": "import { getValidToken } from \"./drive-oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n return undefined;\n}\n\nconst DRIVE_API_BASE = \"https://www.googleapis.com/drive/v3\";\n\nexport interface DriveFile {\n id: string;\n name: string;\n mimeType: string;\n kind: string;\n createdTime: string;\n modifiedTime: string;\n size?: string;\n webViewLink?: string;\n webContentLink?: string;\n iconLink?: string;\n thumbnailLink?: string;\n parents?: string[];\n starred?: boolean;\n trashed?: boolean;\n shared?: boolean;\n owners?: Array<{\n displayName: string;\n emailAddress: string;\n photoLink?: string;\n }>;\n lastModifyingUser?: {\n displayName: string;\n emailAddress: string;\n photoLink?: string;\n };\n capabilities?: {\n canEdit?: boolean;\n canComment?: boolean;\n canShare?: boolean;\n canDelete?: boolean;\n canDownload?: boolean;\n };\n}\n\nexport interface DriveFileList {\n files: DriveFile[];\n nextPageToken?: string;\n incompleteSearch?: boolean;\n}\n\nexport interface CreateFolderOptions {\n name: string;\n parentId?: string;\n description?: string;\n}\n\nexport interface UploadFileOptions {\n name: string;\n content: string;\n mimeType: string;\n parentId?: string;\n description?: string;\n}\n\nexport interface ListFilesOptions {\n folderId?: string;\n pageSize?: number;\n pageToken?: string;\n orderBy?: string;\n query?: string;\n}\n\nexport interface SearchFilesOptions {\n query: string;\n pageSize?: number;\n pageToken?: string;\n orderBy?: string;\n}\n\nexport const driveOAuthProvider = {\n name: \"drive\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/drive\",\n ],\n callbackPath: \"/api/auth/drive/callback\",\n};\n\nexport function createDriveClient(userId: string): {\n listFiles(options?: ListFilesOptions): Promise<DriveFileList>;\n getFile(fileId: string): Promise<DriveFile>;\n searchFiles(options: SearchFilesOptions): Promise<DriveFileList>;\n createFolder(options: CreateFolderOptions): Promise<DriveFile>;\n uploadFile(options: UploadFileOptions): Promise<DriveFile>;\n downloadFile(fileId: string): Promise<string>;\n deleteFile(fileId: string): Promise<void>;\n copyFile(fileId: string, name: string, parentId?: string): Promise<DriveFile>;\n updateFile(\n fileId: string,\n updates: { name?: string; description?: string; starred?: boolean },\n ): Promise<DriveFile>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(driveOAuthProvider, userId, \"drive\");\n if (!token) {\n throw new Error(\"Google Drive not connected. Please connect your Google account first.\");\n }\n return token;\n }\n\n async function driveApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${DRIVE_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Drive API error: ${response.status} - ${error}`);\n }\n\n if (response.status === 204) return undefined as T;\n return response.json();\n }\n\n function buildMetadata(options: {\n name: string;\n mimeType: string;\n parentId?: string;\n description?: string;\n }): Record<string, unknown> {\n const metadata: Record<string, unknown> = {\n name: options.name,\n mimeType: options.mimeType,\n };\n\n if (options.parentId) metadata.parents = [options.parentId];\n if (options.description) metadata.description = options.description;\n\n return metadata;\n }\n\n const fileFields =\n \"id,name,mimeType,kind,createdTime,modifiedTime,size,webViewLink,webContentLink,iconLink,thumbnailLink,parents,starred,trashed,shared,owners,lastModifyingUser,capabilities\";\n\n return {\n async listFiles(options: ListFilesOptions = {}): Promise<DriveFileList> {\n const params = new URLSearchParams({\n fields: `nextPageToken,incompleteSearch,files(${fileFields})`,\n pageSize: String(options.pageSize ?? 100),\n orderBy: options.orderBy ?? \"modifiedTime desc\",\n });\n\n let query = \"trashed=false\";\n if (options.folderId) query += ` and '${options.folderId}' in parents`;\n if (options.query) query += ` and ${options.query}`;\n\n params.append(\"q\", query);\n if (options.pageToken) params.append(\"pageToken\", options.pageToken);\n\n return driveApiRequest<DriveFileList>(`/files?${params.toString()}`);\n },\n\n async getFile(fileId: string): Promise<DriveFile> {\n const params = new URLSearchParams({ fields: fileFields });\n return driveApiRequest<DriveFile>(`/files/${fileId}?${params.toString()}`);\n },\n\n async searchFiles(options: SearchFilesOptions): Promise<DriveFileList> {\n const params = new URLSearchParams({\n fields:\n \"nextPageToken,incompleteSearch,files(id,name,mimeType,kind,createdTime,modifiedTime,size,webViewLink,webContentLink,iconLink,thumbnailLink,parents,starred,trashed)\",\n pageSize: String(options.pageSize ?? 100),\n q: `${options.query} and trashed=false`,\n orderBy: options.orderBy ?? \"modifiedTime desc\",\n });\n\n if (options.pageToken) params.append(\"pageToken\", options.pageToken);\n\n return driveApiRequest<DriveFileList>(`/files?${params.toString()}`);\n },\n\n async createFolder(options: CreateFolderOptions): Promise<DriveFile> {\n const metadata = buildMetadata({\n name: options.name,\n mimeType: \"application/vnd.google-apps.folder\",\n parentId: options.parentId,\n description: options.description,\n });\n\n return driveApiRequest<DriveFile>(\"/files\", {\n method: \"POST\",\n body: JSON.stringify(metadata),\n });\n },\n\n async uploadFile(options: UploadFileOptions): Promise<DriveFile> {\n const accessToken = await getAccessToken();\n\n const boundary = \"-------314159265358979323846\";\n const delimiter = `\\r\\n--${boundary}\\r\\n`;\n const closeDelimiter = `\\r\\n--${boundary}--`;\n\n const metadata = buildMetadata({\n name: options.name,\n mimeType: options.mimeType,\n parentId: options.parentId,\n description: options.description,\n });\n\n const multipartRequestBody =\n delimiter +\n \"Content-Type: application/json\\r\\n\\r\\n\" +\n JSON.stringify(metadata) +\n delimiter +\n `Content-Type: ${options.mimeType}\\r\\n\\r\\n` +\n options.content +\n closeDelimiter;\n\n const response = await fetch(\n \"https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,mimeType,kind,createdTime,modifiedTime,size,webViewLink,webContentLink\",\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": `multipart/related; boundary=${boundary}`,\n },\n body: multipartRequestBody,\n },\n );\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Drive upload error: ${response.status} - ${error}`);\n }\n\n return response.json();\n },\n\n async downloadFile(fileId: string): Promise<string> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${DRIVE_API_BASE}/files/${fileId}?alt=media`, {\n headers: { Authorization: `Bearer ${accessToken}` },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Drive download error: ${response.status} - ${error}`);\n }\n\n return response.text();\n },\n\n async deleteFile(fileId: string): Promise<void> {\n await driveApiRequest(`/files/${fileId}`, { method: \"DELETE\" });\n },\n\n async copyFile(fileId: string, name: string, parentId?: string): Promise<DriveFile> {\n const metadata: Record<string, unknown> = { name };\n if (parentId) metadata.parents = [parentId];\n\n return driveApiRequest<DriveFile>(`/files/${fileId}/copy`, {\n method: \"POST\",\n body: JSON.stringify(metadata),\n });\n },\n\n async updateFile(\n fileId: string,\n updates: { name?: string; description?: string; starred?: boolean },\n ): Promise<DriveFile> {\n return driveApiRequest<DriveFile>(`/files/${fileId}`, {\n method: \"PATCH\",\n body: JSON.stringify(updates),\n });\n },\n };\n}\n\nexport type DriveClient = ReturnType<typeof createDriveClient>;\n",
|
|
240
|
-
"lib/drive-oauth.ts": "import {
|
|
242
|
+
"lib/drive-oauth.ts": "import {\n getRefreshableAccessToken,\n type OAuthToken,\n tokenStore,\n} from \"./token-store.ts\";\n\nexport interface OAuthProvider {\n name: string;\n authorizationUrl: string;\n tokenUrl: string;\n clientId: string;\n clientSecret: string;\n scopes: string[];\n callbackPath: string;\n}\n\nfunction buildTokenRequest(\n provider: OAuthProvider,\n body: Record<string, string>,\n): RequestInit {\n return {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n ...body,\n }),\n };\n}\n\nasync function fetchToken(\n provider: OAuthProvider,\n body: Record<string, string>,\n errorPrefix: string,\n): Promise<any> {\n const response = await fetch(\n provider.tokenUrl,\n buildTokenRequest(provider, body),\n );\n\n if (response.ok) return response.json();\n\n const error = await response.text();\n throw new Error(`${errorPrefix}: ${response.status} - ${error}`);\n}\n\nfunction toOAuthToken(data: any, fallbackRefreshToken?: string): OAuthToken {\n const expiresIn = data.expires_in;\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token ?? fallbackRefreshToken,\n expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined,\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport function getAuthorizationUrl(\n provider: OAuthProvider,\n state: string,\n redirectUri: string,\n): string {\n const params = new URLSearchParams({\n client_id: provider.clientId,\n redirect_uri: redirectUri,\n response_type: \"code\",\n scope: provider.scopes.join(\" \"),\n state,\n access_type: \"offline\",\n prompt: \"consent\",\n });\n\n return `${provider.authorizationUrl}?${params.toString()}`;\n}\n\nexport async function exchangeCodeForTokens(\n provider: OAuthProvider,\n code: string,\n redirectUri: string,\n): Promise<OAuthToken> {\n const data = await fetchToken(\n provider,\n {\n code,\n grant_type: \"authorization_code\",\n redirect_uri: redirectUri,\n },\n \"Token exchange failed\",\n );\n\n return toOAuthToken(data);\n}\n\nexport async function refreshAccessToken(\n provider: OAuthProvider,\n refreshToken: string,\n): Promise<OAuthToken> {\n const data = await fetchToken(\n provider,\n {\n refresh_token: refreshToken,\n grant_type: \"refresh_token\",\n },\n \"Token refresh failed\",\n );\n\n return toOAuthToken(data, refreshToken);\n}\n\nexport async function getValidToken(\n provider: OAuthProvider,\n userId: string,\n service: string,\n): Promise<string | null> {\n return await getRefreshableAccessToken(\n tokenStore,\n service,\n userId,\n (refreshToken) => refreshAccessToken(provider, refreshToken),\n );\n}\n",
|
|
241
243
|
"tools/create-folder.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createDriveClient } from \"../lib/drive-client.ts\";\nimport { requireUserIdFromContext } from \"../lib/user-id.ts\";\n\nexport default tool({\n id: \"drive-create-folder\",\n description:\n \"Create a new folder in Google Drive. Can optionally specify a parent folder. Returns the new folder ID and details.\",\n inputSchema: defineSchema((v) =>\n v.object({\n name: v.string().describe(\"Name of the folder to create\"),\n parentId: v\n .string()\n .optional()\n .describe(\"ID of the parent folder. If not provided, creates in root.\"),\n description: v\n .string()\n .optional()\n .describe(\"Optional description for the folder\"),\n })\n )(),\n async execute({ name, parentId, description }, context) {\n const userId = requireUserIdFromContext(context);\n const client = createDriveClient(userId);\n const folder = await client.createFolder({ name, parentId, description });\n\n return {\n id: folder.id,\n name: folder.name,\n mimeType: folder.mimeType,\n createdTime: folder.createdTime,\n modifiedTime: folder.modifiedTime,\n webViewLink: folder.webViewLink,\n parents: folder.parents,\n };\n },\n});\n",
|
|
242
244
|
"tools/get-file.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createDriveClient } from \"../lib/drive-client.ts\";\nimport { requireUserIdFromContext } from \"../lib/user-id.ts\";\nconst FOLDER_MIME_TYPE = \"application/vnd.google-apps.folder\";\n\nexport default tool({\n id: \"drive-get-file\",\n description:\n \"Get detailed metadata about a specific file or folder in Google Drive. Returns detailed information including sharing settings, owners, and capabilities.\",\n inputSchema: defineSchema((v) =>\n v.object({\n fileId: v.string().describe(\"The ID of the file or folder to retrieve\"),\n })\n )(),\n async execute({ fileId }, context) {\n const userId = requireUserIdFromContext(context);\n const client = createDriveClient(userId);\n const file = await client.getFile(fileId);\n\n const lastModifyingUser = file.lastModifyingUser\n ? {\n name: file.lastModifyingUser.displayName,\n email: file.lastModifyingUser.emailAddress,\n photoLink: file.lastModifyingUser.photoLink,\n }\n : undefined;\n\n return {\n id: file.id,\n name: file.name,\n mimeType: file.mimeType,\n isFolder: file.mimeType === FOLDER_MIME_TYPE,\n size: file.size,\n createdTime: file.createdTime,\n modifiedTime: file.modifiedTime,\n webViewLink: file.webViewLink,\n webContentLink: file.webContentLink,\n iconLink: file.iconLink,\n thumbnailLink: file.thumbnailLink,\n parents: file.parents,\n starred: file.starred,\n trashed: file.trashed,\n shared: file.shared,\n owners: file.owners?.map((owner) => ({\n name: owner.displayName,\n email: owner.emailAddress,\n photoLink: owner.photoLink,\n })),\n lastModifyingUser,\n capabilities: file.capabilities,\n };\n },\n});\n",
|
|
243
245
|
"tools/list-files.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createDriveClient } from \"../lib/drive-client.ts\";\nimport { requireUserIdFromContext } from \"../lib/user-id.ts\";\nconst FOLDER_MIME_TYPE = \"application/vnd.google-apps.folder\";\n\nexport default tool({\n id: \"drive-list-files\",\n description:\n \"List files and folders in Google Drive. Can list from a specific folder or root. Returns file names, IDs, types, and metadata.\",\n inputSchema: defineSchema((v) =>\n v.object({\n folderId: v\n .string()\n .optional()\n .describe(\n \"ID of the folder to list files from. If not provided, lists from root.\",\n ),\n pageSize: v\n .number()\n .min(1)\n .max(1000)\n .default(100)\n .describe(\"Maximum number of files to return\"),\n pageToken: v\n .string()\n .optional()\n .describe(\"Token for pagination to get next page of results\"),\n orderBy: v\n .enum([\n \"createdTime\",\n \"folder\",\n \"modifiedByMeTime\",\n \"modifiedTime\",\n \"name\",\n \"quotaBytesUsed\",\n \"recency\",\n \"sharedWithMeTime\",\n \"starred\",\n \"viewedByMeTime\",\n ])\n .optional()\n .describe(\"Field to sort results by\"),\n })\n )(),\n async execute({ folderId, pageSize, pageToken, orderBy }, context) {\n const userId = requireUserIdFromContext(context);\n const client = createDriveClient(userId);\n\n const result = await client.listFiles({\n folderId,\n pageSize,\n pageToken,\n orderBy: orderBy ? `${orderBy} desc` : undefined,\n });\n\n const nextPageToken = result.nextPageToken;\n\n return {\n files: result.files.map((file) => ({\n id: file.id,\n name: file.name,\n mimeType: file.mimeType,\n isFolder: file.mimeType === FOLDER_MIME_TYPE,\n size: file.size,\n createdTime: file.createdTime,\n modifiedTime: file.modifiedTime,\n webViewLink: file.webViewLink,\n iconLink: file.iconLink,\n thumbnailLink: file.thumbnailLink,\n starred: file.starred,\n shared: file.shared,\n })),\n nextPageToken,\n hasMore: Boolean(nextPageToken),\n };\n },\n});\n",
|
|
@@ -303,7 +305,7 @@ export default {
|
|
|
303
305
|
"app/api/auth/gmail/callback/route.ts": "import { createOAuthCallbackHandler, gmailConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\n\nexport const GET = createOAuthCallbackHandler(gmailConfig, { tokenStore });\n",
|
|
304
306
|
"app/api/auth/gmail/route.ts": "import { createOAuthInitHandler, gmailConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../lib/token-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../lib/user-id.ts\";\n\nexport const GET = createOAuthInitHandler(gmailConfig, {\n tokenStore,\n getUserId: requireUserIdFromRequest,\n});\n",
|
|
305
307
|
"lib/context.ts": "import type { ToolExecutionContext } from \"veryfront/tool\";\nimport { requireUserIdFromContext } from \"./user-id.ts\";\n\nexport function resolveUserId(context?: ToolExecutionContext): string {\n return requireUserIdFromContext(context);\n}\n",
|
|
306
|
-
"lib/gmail-client.ts": "/**\n * Gmail API Client\n *\n * Provides a type-safe interface to Gmail API operations\n * using the veryfront/oauth module for authentication.\n */\n\nimport { gmailConfig, OAuthService } from \"veryfront/oauth\";\nimport { tokenStore } from \"./token-store.ts\";\nimport type { OAuthToken } from \"./token-store.ts\";\n\nexport type GmailMessageFormat = \"full\" | \"metadata\" | \"minimal\" | \"raw\";\nexport type GmailThreadFormat = Exclude<GmailMessageFormat, \"raw\">;\nexport type GmailLabelVisibility = \"labelShow\" | \"labelShowIfUnread\" | \"labelHide\";\nexport type GmailMessageListVisibility = \"show\" | \"hide\";\nexport type GmailHistoryType = \"messageAdded\" | \"messageDeleted\" | \"labelAdded\" | \"labelRemoved\";\n\nexport interface GmailMessagePartBody {\n attachmentId?: string;\n data?: string;\n size: number;\n}\n\nexport interface GmailMessagePart {\n partId?: string;\n mimeType: string;\n filename?: string;\n headers?: Array<{ name: string; value: string }>;\n body?: GmailMessagePartBody;\n parts?: GmailMessagePart[];\n}\n\nexport interface GmailMessage {\n id: string;\n threadId: string;\n labelIds?: string[];\n snippet?: string;\n payload?: GmailMessagePart;\n internalDate?: string;\n historyId?: string;\n sizeEstimate?: number;\n raw?: string;\n}\n\nexport interface GmailMessageList {\n messages?: Array<{ id: string; threadId: string }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface GmailLabel {\n id: string;\n name: string;\n messageListVisibility?: GmailMessageListVisibility;\n labelListVisibility?: GmailLabelVisibility;\n type?: \"system\" | \"user\";\n messagesTotal?: number;\n messagesUnread?: number;\n threadsTotal?: number;\n threadsUnread?: number;\n color?: {\n textColor: string;\n backgroundColor: string;\n };\n}\n\nexport interface GmailLabelList {\n labels: GmailLabel[];\n}\n\nexport interface GmailThread {\n id: string;\n snippet?: string;\n historyId?: string;\n messages?: GmailMessage[];\n}\n\nexport interface GmailThreadList {\n threads?: Array<{ id: string; historyId?: string; snippet?: string }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface GmailDraft {\n id: string;\n message: GmailMessage;\n}\n\nexport interface GmailDraftList {\n drafts?: Array<{ id: string; message: { id: string; threadId: string } }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface GmailAttachment {\n attachmentId?: string;\n size: number;\n data: string;\n}\n\nexport interface GmailProfile {\n emailAddress: string;\n messagesTotal: number;\n threadsTotal: number;\n historyId: string;\n}\n\nexport interface GmailHistoryList {\n history?: Array<{\n id: string;\n messages?: GmailMessage[];\n messagesAdded?: Array<{ message: GmailMessage }>;\n messagesDeleted?: Array<{ message: GmailMessage }>;\n labelsAdded?: Array<{ message: GmailMessage; labelIds: string[] }>;\n labelsRemoved?: Array<{ message: GmailMessage; labelIds: string[] }>;\n }>;\n nextPageToken?: string;\n historyId: string;\n}\n\nexport interface GmailWatchResponse {\n historyId: string;\n expiration: string;\n}\n\nexport interface SendEmailOptions {\n to: string | string[];\n subject: string;\n body: string;\n cc?: string | string[];\n bcc?: string | string[];\n replyTo?: string;\n isHtml?: boolean;\n threadId?: string;\n}\n\nexport type DraftEmailOptions = SendEmailOptions;\n\nexport interface ModifyLabelsOptions {\n addLabelIds?: string[];\n removeLabelIds?: string[];\n}\n\nexport interface ListOptions {\n maxResults?: number;\n pageToken?: string;\n}\n\nexport interface ListMessagesOptions extends ListOptions {\n query?: string;\n labelIds?: string[];\n}\n\nexport interface ListHistoryOptions extends ListOptions {\n startHistoryId: string;\n labelId?: string;\n historyTypes?: GmailHistoryType[];\n}\n\nexport interface WatchMailboxOptions {\n topicName: string;\n labelIds?: string[];\n labelFilterBehavior?: \"include\" | \"exclude\";\n}\n\nexport interface GmailClient {\n isConnected(): Promise<boolean>;\n listMessages(options?: ListMessagesOptions): Promise<GmailMessageList>;\n getMessage(messageId: string, format?: GmailMessageFormat): Promise<GmailMessage>;\n sendEmail(options: SendEmailOptions): Promise<{ id: string; threadId: string }>;\n searchEmails(query: string, maxResults?: number): Promise<GmailMessage[]>;\n getUnreadEmails(maxResults?: number): Promise<GmailMessage[]>;\n markAsRead(messageId: string): Promise<void>;\n archiveEmail(messageId: string): Promise<void>;\n listLabels(): Promise<GmailLabelList>;\n getLabel(labelId: string): Promise<GmailLabel>;\n createLabel(label: Partial<GmailLabel> & { name: string }): Promise<GmailLabel>;\n updateLabel(labelId: string, label: Partial<GmailLabel> & { name: string }): Promise<GmailLabel>;\n patchLabel(labelId: string, label: Partial<GmailLabel>): Promise<GmailLabel>;\n deleteLabel(labelId: string): Promise<void>;\n modifyMessageLabels(messageId: string, labels: ModifyLabelsOptions): Promise<GmailMessage>;\n trashMessage(messageId: string): Promise<GmailMessage>;\n untrashMessage(messageId: string): Promise<GmailMessage>;\n deleteMessage(messageId: string): Promise<void>;\n batchModifyMessages(messageIds: string[], labels: ModifyLabelsOptions): Promise<void>;\n batchDeleteMessages(messageIds: string[]): Promise<void>;\n listThreads(options?: ListMessagesOptions): Promise<GmailThreadList>;\n getThread(threadId: string, format?: GmailThreadFormat): Promise<GmailThread>;\n modifyThreadLabels(threadId: string, labels: ModifyLabelsOptions): Promise<GmailThread>;\n trashThread(threadId: string): Promise<GmailThread>;\n untrashThread(threadId: string): Promise<GmailThread>;\n deleteThread(threadId: string): Promise<void>;\n createDraft(options: DraftEmailOptions): Promise<GmailDraft>;\n listDrafts(options?: ListMessagesOptions): Promise<GmailDraftList>;\n getDraft(draftId: string, format?: GmailMessageFormat): Promise<GmailDraft>;\n updateDraft(draftId: string, options: DraftEmailOptions): Promise<GmailDraft>;\n sendDraft(draftId: string): Promise<{ id: string; threadId: string }>;\n deleteDraft(draftId: string): Promise<void>;\n getAttachment(messageId: string, attachmentId: string): Promise<GmailAttachment>;\n getProfile(): Promise<GmailProfile>;\n listHistory(options: ListHistoryOptions): Promise<GmailHistoryList>;\n watchMailbox(options: WatchMailboxOptions): Promise<GmailWatchResponse>;\n stopMailboxWatch(): Promise<void>;\n}\n\n// TokenStore adapter keyed by (serviceId, userId). All API calls must pass\n// the authenticated user's id. Never use a shared development user id\n// in production; that re-introduces VULN-AUTH-2.\nconst tokenStoreAdapter = {\n async getTokens(serviceId: string, userId: string): Promise<OAuthToken | null> {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ): Promise<void> {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string): Promise<void> {\n await tokenStore.revokeToken(userId, serviceId);\n },\n async setState(): Promise<void> {},\n async consumeState(): Promise<null> {\n return null;\n },\n};\n\nconst gmailService = new OAuthService(gmailConfig, tokenStoreAdapter);\n\nfunction formatAddresses(addresses: string | string[] | undefined): string {\n if (!addresses) return \"\";\n return Array.isArray(addresses) ? addresses.join(\", \") : addresses;\n}\n\nfunction encodeEmail(options: SendEmailOptions): string {\n const toAddresses = formatAddresses(options.to);\n const ccAddresses = formatAddresses(options.cc);\n const bccAddresses = formatAddresses(options.bcc);\n\n const headers = [\n `To: ${toAddresses}`,\n `Subject: ${options.subject}`,\n options.isHtml\n ? \"Content-Type: text/html; charset=utf-8\"\n : \"Content-Type: text/plain; charset=utf-8\",\n ];\n\n if (ccAddresses) headers.push(`Cc: ${ccAddresses}`);\n if (bccAddresses) headers.push(`Bcc: ${bccAddresses}`);\n if (options.replyTo) headers.push(`Reply-To: ${options.replyTo}`);\n\n const email = `${headers.join(\"\\r\\n\")}\\r\\n\\r\\n${options.body}`;\n return btoa(email).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction addListParams(params: URLSearchParams, options: ListMessagesOptions = {}): void {\n if (options.maxResults != null) params.set(\"maxResults\", String(options.maxResults));\n if (options.query) params.set(\"q\", options.query);\n if (options.labelIds?.length) {\n for (const labelId of options.labelIds) params.append(\"labelIds\", labelId);\n }\n if (options.pageToken) params.set(\"pageToken\", options.pageToken);\n}\n\nfunction withQuery(path: string, params: URLSearchParams): string {\n const query = params.toString();\n return query ? `${path}?${query}` : path;\n}\n\nfunction encodedMessage(options: SendEmailOptions): { raw: string; threadId?: string } {\n return {\n raw: encodeEmail(options),\n ...(options.threadId ? { threadId: options.threadId } : {}),\n };\n}\n\n/**\n * Create a Gmail client scoped to a specific user. Pass the authenticated\n * user's id (from your session). Tokens are looked up and stored per-user.\n */\nexport function createGmailClient(userId: string): GmailClient {\n async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await gmailService.getAccessToken(userId);\n if (!token) {\n throw new Error(\"Gmail not connected\");\n }\n\n const url = endpoint.startsWith(\"http\") ? endpoint : `${gmailConfig.apiBaseUrl}${endpoint}`;\n const response = await fetch(url, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const detail = await response.text();\n throw new Error(`Gmail API error: ${response.status} ${detail}`);\n }\n\n const text = await response.text();\n return (text ? JSON.parse(text) : undefined) as T;\n }\n\n return {\n async isConnected(): Promise<boolean> {\n const token = await gmailService.getAccessToken(userId);\n return token !== null;\n },\n\n listMessages(options: ListMessagesOptions = {}): Promise<GmailMessageList> {\n const params = new URLSearchParams();\n addListParams(params, options);\n return apiRequest<GmailMessageList>(withQuery(\"/users/me/messages\", params));\n },\n\n getMessage(messageId: string, format: GmailMessageFormat = \"full\"): Promise<GmailMessage> {\n return apiRequest<GmailMessage>(`/users/me/messages/${messageId}?format=${format}`);\n },\n\n sendEmail(options: SendEmailOptions): Promise<{ id: string; threadId: string }> {\n return apiRequest<{ id: string; threadId: string }>(\"/users/me/messages/send\", {\n method: \"POST\",\n body: JSON.stringify(encodedMessage(options)),\n });\n },\n\n async searchEmails(query: string, maxResults = 10): Promise<GmailMessage[]> {\n const list = await this.listMessages({ query, maxResults });\n if (!list.messages?.length) return [];\n return Promise.all(list.messages.map((m) => this.getMessage(m.id, \"metadata\")));\n },\n\n getUnreadEmails(maxResults = 10): Promise<GmailMessage[]> {\n return this.searchEmails(\"is:unread\", maxResults);\n },\n\n async markAsRead(messageId: string): Promise<void> {\n await this.modifyMessageLabels(messageId, { removeLabelIds: [\"UNREAD\"] });\n },\n\n async archiveEmail(messageId: string): Promise<void> {\n await this.modifyMessageLabels(messageId, { removeLabelIds: [\"INBOX\"] });\n },\n\n listLabels(): Promise<GmailLabelList> {\n return apiRequest<GmailLabelList>(\"/users/me/labels\");\n },\n\n getLabel(labelId: string): Promise<GmailLabel> {\n return apiRequest<GmailLabel>(`/users/me/labels/${labelId}`);\n },\n\n createLabel(label: Partial<GmailLabel> & { name: string }): Promise<GmailLabel> {\n return apiRequest<GmailLabel>(\"/users/me/labels\", {\n method: \"POST\",\n body: JSON.stringify(label),\n });\n },\n\n updateLabel(\n labelId: string,\n label: Partial<GmailLabel> & { name: string },\n ): Promise<GmailLabel> {\n return apiRequest<GmailLabel>(`/users/me/labels/${labelId}`, {\n method: \"PUT\",\n body: JSON.stringify(label),\n });\n },\n\n patchLabel(labelId: string, label: Partial<GmailLabel>): Promise<GmailLabel> {\n return apiRequest<GmailLabel>(`/users/me/labels/${labelId}`, {\n method: \"PATCH\",\n body: JSON.stringify(label),\n });\n },\n\n async deleteLabel(labelId: string): Promise<void> {\n await apiRequest<void>(`/users/me/labels/${labelId}`, { method: \"DELETE\" });\n },\n\n modifyMessageLabels(messageId: string, labels: ModifyLabelsOptions): Promise<GmailMessage> {\n return apiRequest<GmailMessage>(`/users/me/messages/${messageId}/modify`, {\n method: \"POST\",\n body: JSON.stringify(labels),\n });\n },\n\n trashMessage(messageId: string): Promise<GmailMessage> {\n return apiRequest<GmailMessage>(`/users/me/messages/${messageId}/trash`, { method: \"POST\" });\n },\n\n untrashMessage(messageId: string): Promise<GmailMessage> {\n return apiRequest<GmailMessage>(`/users/me/messages/${messageId}/untrash`, {\n method: \"POST\",\n });\n },\n\n async deleteMessage(messageId: string): Promise<void> {\n await apiRequest<void>(`/users/me/messages/${messageId}`, { method: \"DELETE\" });\n },\n\n async batchModifyMessages(messageIds: string[], labels: ModifyLabelsOptions): Promise<void> {\n await apiRequest<void>(\"/users/me/messages/batchModify\", {\n method: \"POST\",\n body: JSON.stringify({ ids: messageIds, ...labels }),\n });\n },\n\n async batchDeleteMessages(messageIds: string[]): Promise<void> {\n await apiRequest<void>(\"/users/me/messages/batchDelete\", {\n method: \"POST\",\n body: JSON.stringify({ ids: messageIds }),\n });\n },\n\n listThreads(options: ListMessagesOptions = {}): Promise<GmailThreadList> {\n const params = new URLSearchParams();\n addListParams(params, options);\n return apiRequest<GmailThreadList>(withQuery(\"/users/me/threads\", params));\n },\n\n getThread(threadId: string, format: GmailThreadFormat = \"full\"): Promise<GmailThread> {\n return apiRequest<GmailThread>(`/users/me/threads/${threadId}?format=${format}`);\n },\n\n modifyThreadLabels(threadId: string, labels: ModifyLabelsOptions): Promise<GmailThread> {\n return apiRequest<GmailThread>(`/users/me/threads/${threadId}/modify`, {\n method: \"POST\",\n body: JSON.stringify(labels),\n });\n },\n\n trashThread(threadId: string): Promise<GmailThread> {\n return apiRequest<GmailThread>(`/users/me/threads/${threadId}/trash`, { method: \"POST\" });\n },\n\n untrashThread(threadId: string): Promise<GmailThread> {\n return apiRequest<GmailThread>(`/users/me/threads/${threadId}/untrash`, { method: \"POST\" });\n },\n\n async deleteThread(threadId: string): Promise<void> {\n await apiRequest<void>(`/users/me/threads/${threadId}`, { method: \"DELETE\" });\n },\n\n createDraft(options: DraftEmailOptions): Promise<GmailDraft> {\n return apiRequest<GmailDraft>(\"/users/me/drafts\", {\n method: \"POST\",\n body: JSON.stringify({ message: encodedMessage(options) }),\n });\n },\n\n listDrafts(options: ListMessagesOptions = {}): Promise<GmailDraftList> {\n const params = new URLSearchParams();\n addListParams(params, options);\n return apiRequest<GmailDraftList>(withQuery(\"/users/me/drafts\", params));\n },\n\n getDraft(draftId: string, format: GmailMessageFormat = \"full\"): Promise<GmailDraft> {\n return apiRequest<GmailDraft>(`/users/me/drafts/${draftId}?format=${format}`);\n },\n\n updateDraft(draftId: string, options: DraftEmailOptions): Promise<GmailDraft> {\n return apiRequest<GmailDraft>(`/users/me/drafts/${draftId}`, {\n method: \"PUT\",\n body: JSON.stringify({ id: draftId, message: encodedMessage(options) }),\n });\n },\n\n sendDraft(draftId: string): Promise<{ id: string; threadId: string }> {\n return apiRequest<{ id: string; threadId: string }>(\"/users/me/drafts/send\", {\n method: \"POST\",\n body: JSON.stringify({ id: draftId }),\n });\n },\n\n async deleteDraft(draftId: string): Promise<void> {\n await apiRequest<void>(`/users/me/drafts/${draftId}`, { method: \"DELETE\" });\n },\n\n getAttachment(messageId: string, attachmentId: string): Promise<GmailAttachment> {\n return apiRequest<GmailAttachment>(\n `/users/me/messages/${messageId}/attachments/${attachmentId}`,\n );\n },\n\n getProfile(): Promise<GmailProfile> {\n return apiRequest<GmailProfile>(\"/users/me/profile\");\n },\n\n listHistory(options: ListHistoryOptions): Promise<GmailHistoryList> {\n const params = new URLSearchParams();\n params.set(\"startHistoryId\", options.startHistoryId);\n if (options.maxResults != null) params.set(\"maxResults\", String(options.maxResults));\n if (options.pageToken) params.set(\"pageToken\", options.pageToken);\n if (options.labelId) params.set(\"labelId\", options.labelId);\n if (options.historyTypes?.length) {\n for (const historyType of options.historyTypes) params.append(\"historyTypes\", historyType);\n }\n return apiRequest<GmailHistoryList>(withQuery(\"/users/me/history\", params));\n },\n\n watchMailbox(options: WatchMailboxOptions): Promise<GmailWatchResponse> {\n return apiRequest<GmailWatchResponse>(\"/users/me/watch\", {\n method: \"POST\",\n body: JSON.stringify(options),\n });\n },\n\n async stopMailboxWatch(): Promise<void> {\n await apiRequest<void>(\"/users/me/stop\", { method: \"POST\" });\n },\n };\n}\n\nexport function parseEmailHeaders(\n headers: Array<{ name: string; value: string }>,\n): { from: string; to: string; subject: string; date: string } {\n function getHeader(name: string): string {\n return headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? \"\";\n }\n\n return {\n from: getHeader(\"From\"),\n to: getHeader(\"To\"),\n subject: getHeader(\"Subject\"),\n date: getHeader(\"Date\"),\n };\n}\n",
|
|
308
|
+
"lib/gmail-client.ts": "/**\n * Gmail API Client\n *\n * Provides a type-safe interface to Gmail API operations\n * using the veryfront/oauth module for authentication.\n */\n\nimport { gmailConfig, OAuthService } from \"veryfront/oauth\";\nimport { tokenStore } from \"./token-store.ts\";\n\nexport type GmailMessageFormat = \"full\" | \"metadata\" | \"minimal\" | \"raw\";\nexport type GmailThreadFormat = Exclude<GmailMessageFormat, \"raw\">;\nexport type GmailLabelVisibility = \"labelShow\" | \"labelShowIfUnread\" | \"labelHide\";\nexport type GmailMessageListVisibility = \"show\" | \"hide\";\nexport type GmailHistoryType = \"messageAdded\" | \"messageDeleted\" | \"labelAdded\" | \"labelRemoved\";\n\nexport interface GmailMessagePartBody {\n attachmentId?: string;\n data?: string;\n size: number;\n}\n\nexport interface GmailMessagePart {\n partId?: string;\n mimeType: string;\n filename?: string;\n headers?: Array<{ name: string; value: string }>;\n body?: GmailMessagePartBody;\n parts?: GmailMessagePart[];\n}\n\nexport interface GmailMessage {\n id: string;\n threadId: string;\n labelIds?: string[];\n snippet?: string;\n payload?: GmailMessagePart;\n internalDate?: string;\n historyId?: string;\n sizeEstimate?: number;\n raw?: string;\n}\n\nexport interface GmailMessageList {\n messages?: Array<{ id: string; threadId: string }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface GmailLabel {\n id: string;\n name: string;\n messageListVisibility?: GmailMessageListVisibility;\n labelListVisibility?: GmailLabelVisibility;\n type?: \"system\" | \"user\";\n messagesTotal?: number;\n messagesUnread?: number;\n threadsTotal?: number;\n threadsUnread?: number;\n color?: {\n textColor: string;\n backgroundColor: string;\n };\n}\n\nexport interface GmailLabelList {\n labels: GmailLabel[];\n}\n\nexport interface GmailThread {\n id: string;\n snippet?: string;\n historyId?: string;\n messages?: GmailMessage[];\n}\n\nexport interface GmailThreadList {\n threads?: Array<{ id: string; historyId?: string; snippet?: string }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface GmailDraft {\n id: string;\n message: GmailMessage;\n}\n\nexport interface GmailDraftList {\n drafts?: Array<{ id: string; message: { id: string; threadId: string } }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface GmailAttachment {\n attachmentId?: string;\n size: number;\n data: string;\n}\n\nexport interface GmailProfile {\n emailAddress: string;\n messagesTotal: number;\n threadsTotal: number;\n historyId: string;\n}\n\nexport interface GmailHistoryList {\n history?: Array<{\n id: string;\n messages?: GmailMessage[];\n messagesAdded?: Array<{ message: GmailMessage }>;\n messagesDeleted?: Array<{ message: GmailMessage }>;\n labelsAdded?: Array<{ message: GmailMessage; labelIds: string[] }>;\n labelsRemoved?: Array<{ message: GmailMessage; labelIds: string[] }>;\n }>;\n nextPageToken?: string;\n historyId: string;\n}\n\nexport interface GmailWatchResponse {\n historyId: string;\n expiration: string;\n}\n\nexport interface SendEmailOptions {\n to: string | string[];\n subject: string;\n body: string;\n cc?: string | string[];\n bcc?: string | string[];\n replyTo?: string;\n isHtml?: boolean;\n threadId?: string;\n}\n\nexport type DraftEmailOptions = SendEmailOptions;\n\nexport interface ModifyLabelsOptions {\n addLabelIds?: string[];\n removeLabelIds?: string[];\n}\n\nexport interface ListOptions {\n maxResults?: number;\n pageToken?: string;\n}\n\nexport interface ListMessagesOptions extends ListOptions {\n query?: string;\n labelIds?: string[];\n}\n\nexport interface ListHistoryOptions extends ListOptions {\n startHistoryId: string;\n labelId?: string;\n historyTypes?: GmailHistoryType[];\n}\n\nexport interface WatchMailboxOptions {\n topicName: string;\n labelIds?: string[];\n labelFilterBehavior?: \"include\" | \"exclude\";\n}\n\nexport interface GmailClient {\n isConnected(): Promise<boolean>;\n listMessages(options?: ListMessagesOptions): Promise<GmailMessageList>;\n getMessage(messageId: string, format?: GmailMessageFormat): Promise<GmailMessage>;\n sendEmail(options: SendEmailOptions): Promise<{ id: string; threadId: string }>;\n searchEmails(query: string, maxResults?: number): Promise<GmailMessage[]>;\n getUnreadEmails(maxResults?: number): Promise<GmailMessage[]>;\n markAsRead(messageId: string): Promise<void>;\n archiveEmail(messageId: string): Promise<void>;\n listLabels(): Promise<GmailLabelList>;\n getLabel(labelId: string): Promise<GmailLabel>;\n createLabel(label: Partial<GmailLabel> & { name: string }): Promise<GmailLabel>;\n updateLabel(labelId: string, label: Partial<GmailLabel> & { name: string }): Promise<GmailLabel>;\n patchLabel(labelId: string, label: Partial<GmailLabel>): Promise<GmailLabel>;\n deleteLabel(labelId: string): Promise<void>;\n modifyMessageLabels(messageId: string, labels: ModifyLabelsOptions): Promise<GmailMessage>;\n trashMessage(messageId: string): Promise<GmailMessage>;\n untrashMessage(messageId: string): Promise<GmailMessage>;\n deleteMessage(messageId: string): Promise<void>;\n batchModifyMessages(messageIds: string[], labels: ModifyLabelsOptions): Promise<void>;\n batchDeleteMessages(messageIds: string[]): Promise<void>;\n listThreads(options?: ListMessagesOptions): Promise<GmailThreadList>;\n getThread(threadId: string, format?: GmailThreadFormat): Promise<GmailThread>;\n modifyThreadLabels(threadId: string, labels: ModifyLabelsOptions): Promise<GmailThread>;\n trashThread(threadId: string): Promise<GmailThread>;\n untrashThread(threadId: string): Promise<GmailThread>;\n deleteThread(threadId: string): Promise<void>;\n createDraft(options: DraftEmailOptions): Promise<GmailDraft>;\n listDrafts(options?: ListMessagesOptions): Promise<GmailDraftList>;\n getDraft(draftId: string, format?: GmailMessageFormat): Promise<GmailDraft>;\n updateDraft(draftId: string, options: DraftEmailOptions): Promise<GmailDraft>;\n sendDraft(draftId: string): Promise<{ id: string; threadId: string }>;\n deleteDraft(draftId: string): Promise<void>;\n getAttachment(messageId: string, attachmentId: string): Promise<GmailAttachment>;\n getProfile(): Promise<GmailProfile>;\n listHistory(options: ListHistoryOptions): Promise<GmailHistoryList>;\n watchMailbox(options: WatchMailboxOptions): Promise<GmailWatchResponse>;\n stopMailboxWatch(): Promise<void>;\n}\n\n// Keep the full refresh-capable contract: OAuthService uses the store's\n// distributed lock and revisioned compare-and-set when access tokens expire.\nconst gmailService = new OAuthService(gmailConfig, tokenStore);\n\nfunction formatAddresses(addresses: string | string[] | undefined): string {\n if (!addresses) return \"\";\n return Array.isArray(addresses) ? addresses.join(\", \") : addresses;\n}\n\nfunction encodeEmail(options: SendEmailOptions): string {\n const toAddresses = formatAddresses(options.to);\n const ccAddresses = formatAddresses(options.cc);\n const bccAddresses = formatAddresses(options.bcc);\n\n const headers = [\n `To: ${toAddresses}`,\n `Subject: ${options.subject}`,\n options.isHtml\n ? \"Content-Type: text/html; charset=utf-8\"\n : \"Content-Type: text/plain; charset=utf-8\",\n ];\n\n if (ccAddresses) headers.push(`Cc: ${ccAddresses}`);\n if (bccAddresses) headers.push(`Bcc: ${bccAddresses}`);\n if (options.replyTo) headers.push(`Reply-To: ${options.replyTo}`);\n\n const email = `${headers.join(\"\\r\\n\")}\\r\\n\\r\\n${options.body}`;\n return btoa(email).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction addListParams(params: URLSearchParams, options: ListMessagesOptions = {}): void {\n if (options.maxResults != null) params.set(\"maxResults\", String(options.maxResults));\n if (options.query) params.set(\"q\", options.query);\n if (options.labelIds?.length) {\n for (const labelId of options.labelIds) params.append(\"labelIds\", labelId);\n }\n if (options.pageToken) params.set(\"pageToken\", options.pageToken);\n}\n\nfunction withQuery(path: string, params: URLSearchParams): string {\n const query = params.toString();\n return query ? `${path}?${query}` : path;\n}\n\nfunction encodedMessage(options: SendEmailOptions): { raw: string; threadId?: string } {\n return {\n raw: encodeEmail(options),\n ...(options.threadId ? { threadId: options.threadId } : {}),\n };\n}\n\n/**\n * Create a Gmail client scoped to a specific user. Pass the authenticated\n * user's id (from your session). Tokens are looked up and stored per-user.\n */\nexport function createGmailClient(userId: string): GmailClient {\n async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await gmailService.getAccessToken(userId);\n if (!token) {\n throw new Error(\"Gmail not connected\");\n }\n\n const url = endpoint.startsWith(\"http\") ? endpoint : `${gmailConfig.apiBaseUrl}${endpoint}`;\n const response = await fetch(url, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const detail = await response.text();\n throw new Error(`Gmail API error: ${response.status} ${detail}`);\n }\n\n const text = await response.text();\n return (text ? JSON.parse(text) : undefined) as T;\n }\n\n return {\n async isConnected(): Promise<boolean> {\n const token = await gmailService.getAccessToken(userId);\n return token !== null;\n },\n\n listMessages(options: ListMessagesOptions = {}): Promise<GmailMessageList> {\n const params = new URLSearchParams();\n addListParams(params, options);\n return apiRequest<GmailMessageList>(withQuery(\"/users/me/messages\", params));\n },\n\n getMessage(messageId: string, format: GmailMessageFormat = \"full\"): Promise<GmailMessage> {\n return apiRequest<GmailMessage>(`/users/me/messages/${messageId}?format=${format}`);\n },\n\n sendEmail(options: SendEmailOptions): Promise<{ id: string; threadId: string }> {\n return apiRequest<{ id: string; threadId: string }>(\"/users/me/messages/send\", {\n method: \"POST\",\n body: JSON.stringify(encodedMessage(options)),\n });\n },\n\n async searchEmails(query: string, maxResults = 10): Promise<GmailMessage[]> {\n const list = await this.listMessages({ query, maxResults });\n if (!list.messages?.length) return [];\n return Promise.all(list.messages.map((m) => this.getMessage(m.id, \"metadata\")));\n },\n\n getUnreadEmails(maxResults = 10): Promise<GmailMessage[]> {\n return this.searchEmails(\"is:unread\", maxResults);\n },\n\n async markAsRead(messageId: string): Promise<void> {\n await this.modifyMessageLabels(messageId, { removeLabelIds: [\"UNREAD\"] });\n },\n\n async archiveEmail(messageId: string): Promise<void> {\n await this.modifyMessageLabels(messageId, { removeLabelIds: [\"INBOX\"] });\n },\n\n listLabels(): Promise<GmailLabelList> {\n return apiRequest<GmailLabelList>(\"/users/me/labels\");\n },\n\n getLabel(labelId: string): Promise<GmailLabel> {\n return apiRequest<GmailLabel>(`/users/me/labels/${labelId}`);\n },\n\n createLabel(label: Partial<GmailLabel> & { name: string }): Promise<GmailLabel> {\n return apiRequest<GmailLabel>(\"/users/me/labels\", {\n method: \"POST\",\n body: JSON.stringify(label),\n });\n },\n\n updateLabel(\n labelId: string,\n label: Partial<GmailLabel> & { name: string },\n ): Promise<GmailLabel> {\n return apiRequest<GmailLabel>(`/users/me/labels/${labelId}`, {\n method: \"PUT\",\n body: JSON.stringify(label),\n });\n },\n\n patchLabel(labelId: string, label: Partial<GmailLabel>): Promise<GmailLabel> {\n return apiRequest<GmailLabel>(`/users/me/labels/${labelId}`, {\n method: \"PATCH\",\n body: JSON.stringify(label),\n });\n },\n\n async deleteLabel(labelId: string): Promise<void> {\n await apiRequest<void>(`/users/me/labels/${labelId}`, { method: \"DELETE\" });\n },\n\n modifyMessageLabels(messageId: string, labels: ModifyLabelsOptions): Promise<GmailMessage> {\n return apiRequest<GmailMessage>(`/users/me/messages/${messageId}/modify`, {\n method: \"POST\",\n body: JSON.stringify(labels),\n });\n },\n\n trashMessage(messageId: string): Promise<GmailMessage> {\n return apiRequest<GmailMessage>(`/users/me/messages/${messageId}/trash`, { method: \"POST\" });\n },\n\n untrashMessage(messageId: string): Promise<GmailMessage> {\n return apiRequest<GmailMessage>(`/users/me/messages/${messageId}/untrash`, {\n method: \"POST\",\n });\n },\n\n async deleteMessage(messageId: string): Promise<void> {\n await apiRequest<void>(`/users/me/messages/${messageId}`, { method: \"DELETE\" });\n },\n\n async batchModifyMessages(messageIds: string[], labels: ModifyLabelsOptions): Promise<void> {\n await apiRequest<void>(\"/users/me/messages/batchModify\", {\n method: \"POST\",\n body: JSON.stringify({ ids: messageIds, ...labels }),\n });\n },\n\n async batchDeleteMessages(messageIds: string[]): Promise<void> {\n await apiRequest<void>(\"/users/me/messages/batchDelete\", {\n method: \"POST\",\n body: JSON.stringify({ ids: messageIds }),\n });\n },\n\n listThreads(options: ListMessagesOptions = {}): Promise<GmailThreadList> {\n const params = new URLSearchParams();\n addListParams(params, options);\n return apiRequest<GmailThreadList>(withQuery(\"/users/me/threads\", params));\n },\n\n getThread(threadId: string, format: GmailThreadFormat = \"full\"): Promise<GmailThread> {\n return apiRequest<GmailThread>(`/users/me/threads/${threadId}?format=${format}`);\n },\n\n modifyThreadLabels(threadId: string, labels: ModifyLabelsOptions): Promise<GmailThread> {\n return apiRequest<GmailThread>(`/users/me/threads/${threadId}/modify`, {\n method: \"POST\",\n body: JSON.stringify(labels),\n });\n },\n\n trashThread(threadId: string): Promise<GmailThread> {\n return apiRequest<GmailThread>(`/users/me/threads/${threadId}/trash`, { method: \"POST\" });\n },\n\n untrashThread(threadId: string): Promise<GmailThread> {\n return apiRequest<GmailThread>(`/users/me/threads/${threadId}/untrash`, { method: \"POST\" });\n },\n\n async deleteThread(threadId: string): Promise<void> {\n await apiRequest<void>(`/users/me/threads/${threadId}`, { method: \"DELETE\" });\n },\n\n createDraft(options: DraftEmailOptions): Promise<GmailDraft> {\n return apiRequest<GmailDraft>(\"/users/me/drafts\", {\n method: \"POST\",\n body: JSON.stringify({ message: encodedMessage(options) }),\n });\n },\n\n listDrafts(options: ListMessagesOptions = {}): Promise<GmailDraftList> {\n const params = new URLSearchParams();\n addListParams(params, options);\n return apiRequest<GmailDraftList>(withQuery(\"/users/me/drafts\", params));\n },\n\n getDraft(draftId: string, format: GmailMessageFormat = \"full\"): Promise<GmailDraft> {\n return apiRequest<GmailDraft>(`/users/me/drafts/${draftId}?format=${format}`);\n },\n\n updateDraft(draftId: string, options: DraftEmailOptions): Promise<GmailDraft> {\n return apiRequest<GmailDraft>(`/users/me/drafts/${draftId}`, {\n method: \"PUT\",\n body: JSON.stringify({ id: draftId, message: encodedMessage(options) }),\n });\n },\n\n sendDraft(draftId: string): Promise<{ id: string; threadId: string }> {\n return apiRequest<{ id: string; threadId: string }>(\"/users/me/drafts/send\", {\n method: \"POST\",\n body: JSON.stringify({ id: draftId }),\n });\n },\n\n async deleteDraft(draftId: string): Promise<void> {\n await apiRequest<void>(`/users/me/drafts/${draftId}`, { method: \"DELETE\" });\n },\n\n getAttachment(messageId: string, attachmentId: string): Promise<GmailAttachment> {\n return apiRequest<GmailAttachment>(\n `/users/me/messages/${messageId}/attachments/${attachmentId}`,\n );\n },\n\n getProfile(): Promise<GmailProfile> {\n return apiRequest<GmailProfile>(\"/users/me/profile\");\n },\n\n listHistory(options: ListHistoryOptions): Promise<GmailHistoryList> {\n const params = new URLSearchParams();\n params.set(\"startHistoryId\", options.startHistoryId);\n if (options.maxResults != null) params.set(\"maxResults\", String(options.maxResults));\n if (options.pageToken) params.set(\"pageToken\", options.pageToken);\n if (options.labelId) params.set(\"labelId\", options.labelId);\n if (options.historyTypes?.length) {\n for (const historyType of options.historyTypes) params.append(\"historyTypes\", historyType);\n }\n return apiRequest<GmailHistoryList>(withQuery(\"/users/me/history\", params));\n },\n\n watchMailbox(options: WatchMailboxOptions): Promise<GmailWatchResponse> {\n return apiRequest<GmailWatchResponse>(\"/users/me/watch\", {\n method: \"POST\",\n body: JSON.stringify(options),\n });\n },\n\n async stopMailboxWatch(): Promise<void> {\n await apiRequest<void>(\"/users/me/stop\", { method: \"POST\" });\n },\n };\n}\n\nexport function parseEmailHeaders(\n headers: Array<{ name: string; value: string }>,\n): { from: string; to: string; subject: string; date: string } {\n function getHeader(name: string): string {\n return headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? \"\";\n }\n\n return {\n from: getHeader(\"From\"),\n to: getHeader(\"To\"),\n subject: getHeader(\"Subject\"),\n date: getHeader(\"Date\"),\n };\n}\n",
|
|
307
309
|
"tools/apply-labels.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nconst getLabelChangeInput = defineSchema((v) => v\n .object({\n messageId: v.string().min(1).describe(\"Gmail message ID\"),\n addLabelIds: v.array(v.string().min(1)).optional().describe(\"Label IDs to add\"),\n removeLabelIds: v.array(v.string().min(1)).optional().describe(\"Label IDs to remove\"),\n })\n .refine((value) => value.addLabelIds?.length || value.removeLabelIds?.length, {\n message: \"At least one label must be added or removed\",\n }));\n\nexport default tool({\n id: \"gmail-apply-labels\",\n description: \"Apply or remove Gmail labels on a message.\",\n inputSchema: getLabelChangeInput(),\n execute: async ({ messageId, addLabelIds, removeLabelIds }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const message = await gmail.modifyMessageLabels(messageId, { addLabelIds, removeLabelIds });\n\n return {\n success: true,\n message,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
308
310
|
"tools/archive-email.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"gmail-archive-email\",\n description: \"Archive a Gmail message by removing the INBOX label.\",\n inputSchema: defineSchema((v) => v.object({\n messageId: v.string().min(1).describe(\"Gmail message ID\"),\n }))(),\n execute: async ({ messageId }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n await gmail.archiveEmail(messageId);\n\n return {\n success: true,\n messageId,\n message: \"Email archived.\",\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
309
311
|
"tools/batch-delete-emails.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"gmail-batch-delete-emails\",\n description: \"Permanently delete multiple Gmail messages.\",\n inputSchema: defineSchema((v) => v.object({\n messageIds: v.array(v.string().min(1)).min(1).describe(\"Gmail message IDs\"),\n }))(),\n execute: async ({ messageIds }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n await gmail.batchDeleteMessages(messageIds);\n\n return {\n success: true,\n count: messageIds.length,\n message: `Permanently deleted ${messageIds.length} email(s).`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|