palaryn 0.1.0 → 0.3.2
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/README.md +243 -588
- package/dist/sdk/typescript/src/client.js +2 -2
- package/dist/sdk/typescript/src/client.js.map +1 -1
- package/dist/src/anomaly/detector.d.ts +7 -4
- package/dist/src/anomaly/detector.d.ts.map +1 -1
- package/dist/src/anomaly/detector.js +22 -12
- package/dist/src/anomaly/detector.js.map +1 -1
- package/dist/src/audit/logger.d.ts +10 -0
- package/dist/src/audit/logger.d.ts.map +1 -1
- package/dist/src/audit/logger.js +52 -38
- package/dist/src/audit/logger.js.map +1 -1
- package/dist/src/auth/routes.d.ts.map +1 -1
- package/dist/src/auth/routes.js +35 -0
- package/dist/src/auth/routes.js.map +1 -1
- package/dist/src/budget/manager.d.ts +5 -0
- package/dist/src/budget/manager.d.ts.map +1 -1
- package/dist/src/budget/manager.js +32 -0
- package/dist/src/budget/manager.js.map +1 -1
- package/dist/src/budget/model-pricing.d.ts +20 -0
- package/dist/src/budget/model-pricing.d.ts.map +1 -0
- package/dist/src/budget/model-pricing.js +107 -0
- package/dist/src/budget/model-pricing.js.map +1 -0
- package/dist/src/budget/usage-extractor.d.ts +3 -1
- package/dist/src/budget/usage-extractor.d.ts.map +1 -1
- package/dist/src/budget/usage-extractor.js +47 -3
- package/dist/src/budget/usage-extractor.js.map +1 -1
- package/dist/src/config/defaults.d.ts.map +1 -1
- package/dist/src/config/defaults.js +65 -13
- package/dist/src/config/defaults.js.map +1 -1
- package/dist/src/dlp/tool-patterns.d.ts +7 -0
- package/dist/src/dlp/tool-patterns.d.ts.map +1 -0
- package/dist/src/dlp/tool-patterns.js +34 -0
- package/dist/src/dlp/tool-patterns.js.map +1 -0
- package/dist/src/executor/filesystem-executor.d.ts +28 -0
- package/dist/src/executor/filesystem-executor.d.ts.map +1 -0
- package/dist/src/executor/filesystem-executor.js +192 -0
- package/dist/src/executor/filesystem-executor.js.map +1 -0
- package/dist/src/executor/http-executor.d.ts.map +1 -1
- package/dist/src/executor/http-executor.js +22 -2
- package/dist/src/executor/http-executor.js.map +1 -1
- package/dist/src/executor/index.d.ts +4 -0
- package/dist/src/executor/index.d.ts.map +1 -1
- package/dist/src/executor/index.js +9 -1
- package/dist/src/executor/index.js.map +1 -1
- package/dist/src/executor/shell-executor.d.ts +22 -0
- package/dist/src/executor/shell-executor.d.ts.map +1 -0
- package/dist/src/executor/shell-executor.js +119 -0
- package/dist/src/executor/shell-executor.js.map +1 -0
- package/dist/src/executor/sql-executor.d.ts +29 -0
- package/dist/src/executor/sql-executor.d.ts.map +1 -0
- package/dist/src/executor/sql-executor.js +114 -0
- package/dist/src/executor/sql-executor.js.map +1 -0
- package/dist/src/executor/websocket-executor.d.ts +26 -0
- package/dist/src/executor/websocket-executor.d.ts.map +1 -0
- package/dist/src/executor/websocket-executor.js +205 -0
- package/dist/src/executor/websocket-executor.js.map +1 -0
- package/dist/src/interceptor/index.d.ts +2 -0
- package/dist/src/interceptor/index.d.ts.map +1 -0
- package/dist/src/interceptor/index.js +6 -0
- package/dist/src/interceptor/index.js.map +1 -0
- package/dist/src/interceptor/provider-interceptor.d.ts +36 -0
- package/dist/src/interceptor/provider-interceptor.d.ts.map +1 -0
- package/dist/src/interceptor/provider-interceptor.js +302 -0
- package/dist/src/interceptor/provider-interceptor.js.map +1 -0
- package/dist/src/mcp/auth-verifier.d.ts.map +1 -1
- package/dist/src/mcp/auth-verifier.js +3 -2
- package/dist/src/mcp/auth-verifier.js.map +1 -1
- package/dist/src/mcp/bridge.d.ts +14 -10
- package/dist/src/mcp/bridge.d.ts.map +1 -1
- package/dist/src/mcp/bridge.js +51 -227
- package/dist/src/mcp/bridge.js.map +1 -1
- package/dist/src/mcp/http-transport.d.ts +2 -0
- package/dist/src/mcp/http-transport.d.ts.map +1 -1
- package/dist/src/mcp/http-transport.js +117 -66
- package/dist/src/mcp/http-transport.js.map +1 -1
- package/dist/src/mcp/internal-auth.d.ts +13 -0
- package/dist/src/mcp/internal-auth.d.ts.map +1 -0
- package/dist/src/mcp/internal-auth.js +12 -0
- package/dist/src/mcp/internal-auth.js.map +1 -0
- package/dist/src/mcp/tool-definitions.d.ts +41 -0
- package/dist/src/mcp/tool-definitions.d.ts.map +1 -0
- package/dist/src/mcp/tool-definitions.js +491 -0
- package/dist/src/mcp/tool-definitions.js.map +1 -0
- package/dist/src/middleware/auth.js.map +1 -1
- package/dist/src/middleware/session.js.map +1 -1
- package/dist/src/middleware/validate.d.ts +8 -0
- package/dist/src/middleware/validate.d.ts.map +1 -1
- package/dist/src/middleware/validate.js +45 -0
- package/dist/src/middleware/validate.js.map +1 -1
- package/dist/src/policy/engine.d.ts +4 -0
- package/dist/src/policy/engine.d.ts.map +1 -1
- package/dist/src/policy/engine.js +117 -0
- package/dist/src/policy/engine.js.map +1 -1
- package/dist/src/saas/routes.d.ts.map +1 -1
- package/dist/src/saas/routes.js +355 -22
- package/dist/src/saas/routes.js.map +1 -1
- package/dist/src/server/app.d.ts.map +1 -1
- package/dist/src/server/app.js +24 -3
- package/dist/src/server/app.js.map +1 -1
- package/dist/src/server/gateway.d.ts.map +1 -1
- package/dist/src/server/gateway.js +17 -0
- package/dist/src/server/gateway.js.map +1 -1
- package/dist/src/server/index.d.ts.map +1 -1
- package/dist/src/server/index.js +18 -0
- package/dist/src/server/index.js.map +1 -1
- package/dist/src/storage/interfaces.d.ts +14 -3
- package/dist/src/storage/interfaces.d.ts.map +1 -1
- package/dist/src/storage/memory.d.ts +2 -0
- package/dist/src/storage/memory.d.ts.map +1 -1
- package/dist/src/storage/memory.js +6 -0
- package/dist/src/storage/memory.js.map +1 -1
- package/dist/src/storage/postgres.d.ts +5 -0
- package/dist/src/storage/postgres.d.ts.map +1 -1
- package/dist/src/storage/postgres.js +16 -0
- package/dist/src/storage/postgres.js.map +1 -1
- package/dist/src/storage/redis.d.ts +10 -0
- package/dist/src/storage/redis.d.ts.map +1 -1
- package/dist/src/storage/redis.js +65 -0
- package/dist/src/storage/redis.js.map +1 -1
- package/dist/src/types/budget.d.ts +4 -0
- package/dist/src/types/budget.d.ts.map +1 -1
- package/dist/src/types/config.d.ts +58 -0
- package/dist/src/types/config.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +1 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/policy.d.ts +11 -1
- package/dist/src/types/policy.d.ts.map +1 -1
- package/dist/src/types/tool-result.d.ts +11 -0
- package/dist/src/types/tool-result.d.ts.map +1 -1
- package/dist/tests/unit/app-routes.test.d.ts +2 -0
- package/dist/tests/unit/app-routes.test.d.ts.map +1 -0
- package/dist/tests/unit/app-routes.test.js +715 -0
- package/dist/tests/unit/app-routes.test.js.map +1 -0
- package/dist/tests/unit/audit-logger.test.js +105 -0
- package/dist/tests/unit/audit-logger.test.js.map +1 -1
- package/dist/tests/unit/auth-providers.test.d.ts +2 -0
- package/dist/tests/unit/auth-providers.test.d.ts.map +1 -0
- package/dist/tests/unit/auth-providers.test.js +279 -0
- package/dist/tests/unit/auth-providers.test.js.map +1 -0
- package/dist/tests/unit/auth-routes-extended.test.d.ts +2 -0
- package/dist/tests/unit/auth-routes-extended.test.d.ts.map +1 -0
- package/dist/tests/unit/auth-routes-extended.test.js +993 -0
- package/dist/tests/unit/auth-routes-extended.test.js.map +1 -0
- package/dist/tests/unit/auth-verifier.test.d.ts +2 -0
- package/dist/tests/unit/auth-verifier.test.d.ts.map +1 -0
- package/dist/tests/unit/auth-verifier.test.js +505 -0
- package/dist/tests/unit/auth-verifier.test.js.map +1 -0
- package/dist/tests/unit/billing-routes.test.d.ts +2 -0
- package/dist/tests/unit/billing-routes.test.d.ts.map +1 -0
- package/dist/tests/unit/billing-routes.test.js +432 -0
- package/dist/tests/unit/billing-routes.test.js.map +1 -0
- package/dist/tests/unit/config-defaults.test.d.ts +2 -0
- package/dist/tests/unit/config-defaults.test.d.ts.map +1 -0
- package/dist/tests/unit/config-defaults.test.js +119 -0
- package/dist/tests/unit/config-defaults.test.js.map +1 -0
- package/dist/tests/unit/defaults.test.js +0 -10
- package/dist/tests/unit/defaults.test.js.map +1 -1
- package/dist/tests/unit/filesystem-executor.test.d.ts +2 -0
- package/dist/tests/unit/filesystem-executor.test.d.ts.map +1 -0
- package/dist/tests/unit/filesystem-executor.test.js +280 -0
- package/dist/tests/unit/filesystem-executor.test.js.map +1 -0
- package/dist/tests/unit/gateway-branches.test.d.ts +2 -0
- package/dist/tests/unit/gateway-branches.test.d.ts.map +1 -0
- package/dist/tests/unit/gateway-branches.test.js +1039 -0
- package/dist/tests/unit/gateway-branches.test.js.map +1 -0
- package/dist/tests/unit/http-executor-branches.test.d.ts +2 -0
- package/dist/tests/unit/http-executor-branches.test.d.ts.map +1 -0
- package/dist/tests/unit/http-executor-branches.test.js +495 -0
- package/dist/tests/unit/http-executor-branches.test.js.map +1 -0
- package/dist/tests/unit/logger.test.d.ts +2 -0
- package/dist/tests/unit/logger.test.d.ts.map +1 -0
- package/dist/tests/unit/logger.test.js +97 -0
- package/dist/tests/unit/logger.test.js.map +1 -0
- package/dist/tests/unit/mcp-internal-auth.test.d.ts +2 -0
- package/dist/tests/unit/mcp-internal-auth.test.d.ts.map +1 -0
- package/dist/tests/unit/mcp-internal-auth.test.js +445 -0
- package/dist/tests/unit/mcp-internal-auth.test.js.map +1 -0
- package/dist/tests/unit/metrics.test.js +102 -0
- package/dist/tests/unit/metrics.test.js.map +1 -1
- package/dist/tests/unit/model-pricing.test.d.ts +2 -0
- package/dist/tests/unit/model-pricing.test.d.ts.map +1 -0
- package/dist/tests/unit/model-pricing.test.js +87 -0
- package/dist/tests/unit/model-pricing.test.js.map +1 -0
- package/dist/tests/unit/oauth-stores.test.d.ts +2 -0
- package/dist/tests/unit/oauth-stores.test.d.ts.map +1 -0
- package/dist/tests/unit/oauth-stores.test.js +260 -0
- package/dist/tests/unit/oauth-stores.test.js.map +1 -0
- package/dist/tests/unit/policy-engine.test.js +466 -0
- package/dist/tests/unit/policy-engine.test.js.map +1 -1
- package/dist/tests/unit/provider-interceptor.test.d.ts +2 -0
- package/dist/tests/unit/provider-interceptor.test.d.ts.map +1 -0
- package/dist/tests/unit/provider-interceptor.test.js +472 -0
- package/dist/tests/unit/provider-interceptor.test.js.map +1 -0
- package/dist/tests/unit/saas-routes-branches.test.d.ts +2 -0
- package/dist/tests/unit/saas-routes-branches.test.d.ts.map +1 -0
- package/dist/tests/unit/saas-routes-branches.test.js +2165 -0
- package/dist/tests/unit/saas-routes-branches.test.js.map +1 -0
- package/dist/tests/unit/saas-routes-crud.test.d.ts +2 -0
- package/dist/tests/unit/saas-routes-crud.test.d.ts.map +1 -0
- package/dist/tests/unit/saas-routes-crud.test.js +332 -0
- package/dist/tests/unit/saas-routes-crud.test.js.map +1 -0
- package/dist/tests/unit/saas-routes-data.test.d.ts +2 -0
- package/dist/tests/unit/saas-routes-data.test.d.ts.map +1 -0
- package/dist/tests/unit/saas-routes-data.test.js +405 -0
- package/dist/tests/unit/saas-routes-data.test.js.map +1 -0
- package/dist/tests/unit/saas-routes.test.js +3 -3
- package/dist/tests/unit/saas-routes.test.js.map +1 -1
- package/dist/tests/unit/shell-executor.test.d.ts +2 -0
- package/dist/tests/unit/shell-executor.test.d.ts.map +1 -0
- package/dist/tests/unit/shell-executor.test.js +145 -0
- package/dist/tests/unit/shell-executor.test.js.map +1 -0
- package/dist/tests/unit/sql-executor.test.d.ts +2 -0
- package/dist/tests/unit/sql-executor.test.d.ts.map +1 -0
- package/dist/tests/unit/sql-executor.test.js +177 -0
- package/dist/tests/unit/sql-executor.test.js.map +1 -0
- package/dist/tests/unit/stream-proxy.test.d.ts +2 -0
- package/dist/tests/unit/stream-proxy.test.d.ts.map +1 -0
- package/dist/tests/unit/stream-proxy.test.js +147 -0
- package/dist/tests/unit/stream-proxy.test.js.map +1 -0
- package/dist/tests/unit/tool-definitions.test.d.ts +2 -0
- package/dist/tests/unit/tool-definitions.test.d.ts.map +1 -0
- package/dist/tests/unit/tool-definitions.test.js +184 -0
- package/dist/tests/unit/tool-definitions.test.js.map +1 -0
- package/dist/tests/unit/usage-extractor.test.js +140 -0
- package/dist/tests/unit/usage-extractor.test.js.map +1 -1
- package/dist/tests/unit/webhook-handler.test.d.ts +2 -0
- package/dist/tests/unit/webhook-handler.test.d.ts.map +1 -0
- package/dist/tests/unit/webhook-handler.test.js +453 -0
- package/dist/tests/unit/webhook-handler.test.js.map +1 -0
- package/dist/tests/unit/webhook-routes.test.d.ts +2 -0
- package/dist/tests/unit/webhook-routes.test.d.ts.map +1 -0
- package/dist/tests/unit/webhook-routes.test.js +69 -0
- package/dist/tests/unit/webhook-routes.test.js.map +1 -0
- package/dist/tests/unit/websocket-executor.test.d.ts +2 -0
- package/dist/tests/unit/websocket-executor.test.d.ts.map +1 -0
- package/dist/tests/unit/websocket-executor.test.js +121 -0
- package/dist/tests/unit/websocket-executor.test.js.map +1 -0
- package/package.json +8 -2
- package/policy-packs/demo_fail.yaml +41 -0
- package/policy-packs/full_tools.yaml +136 -0
- package/src/admin/index.ts +1 -0
- package/src/admin/routes.ts +509 -0
- package/src/admin/templates.ts +572 -0
- package/src/anomaly/detector.ts +730 -0
- package/src/anomaly/index.ts +1 -0
- package/src/approval/manager.ts +569 -0
- package/src/approval/webhook.ts +133 -0
- package/src/audit/logger.ts +490 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/password.ts +21 -0
- package/src/auth/pkce.ts +22 -0
- package/src/auth/providers.ts +208 -0
- package/src/auth/routes.ts +561 -0
- package/src/auth/session.ts +84 -0
- package/src/billing/index.ts +6 -0
- package/src/billing/plan-enforcer.ts +135 -0
- package/src/billing/routes.ts +229 -0
- package/src/billing/stripe-client.ts +58 -0
- package/src/billing/webhook-handler.ts +182 -0
- package/src/billing/webhook-routes.ts +28 -0
- package/src/budget/manager.ts +679 -0
- package/src/budget/model-pricing.ts +119 -0
- package/src/budget/usage-extractor.ts +214 -0
- package/src/cli.ts +91 -0
- package/src/config/defaults.ts +261 -0
- package/src/config/validate.ts +88 -0
- package/src/dlp/composite-scanner.ts +213 -0
- package/src/dlp/index.ts +9 -0
- package/src/dlp/interfaces.ts +34 -0
- package/src/dlp/patterns.ts +30 -0
- package/src/dlp/prompt-injection-backend.ts +181 -0
- package/src/dlp/prompt-injection-patterns.ts +302 -0
- package/src/dlp/regex-backend.ts +181 -0
- package/src/dlp/scanner.ts +502 -0
- package/src/dlp/text-normalizer.ts +225 -0
- package/src/dlp/tool-patterns.ts +35 -0
- package/src/dlp/trufflehog-backend.ts +190 -0
- package/src/executor/filesystem-executor.ts +196 -0
- package/src/executor/http-executor.ts +349 -0
- package/src/executor/index.ts +9 -0
- package/src/executor/interfaces.ts +11 -0
- package/src/executor/noop-executor.ts +23 -0
- package/src/executor/registry.ts +64 -0
- package/src/executor/shell-executor.ts +148 -0
- package/src/executor/slack-executor.ts +176 -0
- package/src/executor/sql-executor.ts +146 -0
- package/src/executor/websocket-executor.ts +211 -0
- package/src/index.ts +24 -0
- package/src/interceptor/index.ts +1 -0
- package/src/interceptor/provider-interceptor.ts +315 -0
- package/src/mcp/auth-verifier.ts +152 -0
- package/src/mcp/bridge.ts +703 -0
- package/src/mcp/http-transport.ts +698 -0
- package/src/mcp/index.ts +9 -0
- package/src/mcp/internal-auth.ts +14 -0
- package/src/mcp/oauth-pages.ts +139 -0
- package/src/mcp/oauth-postgres-stores.ts +278 -0
- package/src/mcp/oauth-provider.ts +536 -0
- package/src/mcp/oauth-stores.ts +202 -0
- package/src/mcp/server.ts +55 -0
- package/src/mcp/tool-definitions.ts +562 -0
- package/src/metrics/collector.ts +357 -0
- package/src/metrics/index.ts +1 -0
- package/src/middleware/auth.ts +814 -0
- package/src/middleware/session.ts +85 -0
- package/src/middleware/validate.ts +130 -0
- package/src/policy/engine.ts +815 -0
- package/src/policy/index.ts +2 -0
- package/src/policy/opa-engine.ts +829 -0
- package/src/proxy/forward-proxy.ts +649 -0
- package/src/proxy/index.ts +1 -0
- package/src/ratelimit/limiter.ts +196 -0
- package/src/replay/engine.ts +142 -0
- package/src/replay/index.ts +1 -0
- package/src/saas/index.ts +1 -0
- package/src/saas/routes.ts +2178 -0
- package/src/server/app.ts +985 -0
- package/src/server/errors.ts +49 -0
- package/src/server/gateway.ts +1130 -0
- package/src/server/index.ts +307 -0
- package/src/server/logger.ts +255 -0
- package/src/server/stream-proxy.ts +202 -0
- package/src/storage/file-persistence.ts +315 -0
- package/src/storage/index.ts +4 -0
- package/src/storage/interfaces.ts +287 -0
- package/src/storage/memory.ts +686 -0
- package/src/storage/postgres.ts +1831 -0
- package/src/storage/redis.ts +835 -0
- package/src/tracing/index.ts +1 -0
- package/src/tracing/provider.ts +100 -0
- package/src/trust/calculator.ts +141 -0
- package/src/trust/index.ts +7 -0
- package/src/types/budget.ts +36 -0
- package/src/types/config.ts +278 -0
- package/src/types/events.ts +41 -0
- package/src/types/express.d.ts +14 -0
- package/src/types/index.ts +7 -0
- package/src/types/policy.ts +83 -0
- package/src/types/stripe-config.ts +11 -0
- package/src/types/subscription.ts +59 -0
- package/src/types/tool-call.ts +47 -0
- package/src/types/tool-result.ts +82 -0
- package/src/types/user.ts +125 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,2178 @@
|
|
|
1
|
+
import { Router, Request, Response } from 'express';
|
|
2
|
+
import * as crypto from 'crypto';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import { GatewayConfig } from '../types/config';
|
|
5
|
+
import {
|
|
6
|
+
UserStore, WorkspaceStore, WorkspaceMemberStore,
|
|
7
|
+
UserApiKeyStore, SessionStore, PolicyStore,
|
|
8
|
+
RateLimitConfigStore, BudgetConfigStore,
|
|
9
|
+
WorkspaceRateLimitConfig, WorkspaceBudgetConfig,
|
|
10
|
+
} from '../storage/interfaces';
|
|
11
|
+
import { InMemoryPolicyStore, InMemoryRateLimitConfigStore, InMemoryBudgetConfigStore } from '../storage/memory';
|
|
12
|
+
import { Gateway } from '../server/gateway';
|
|
13
|
+
import { TrustScoreCalculator } from '../trust/calculator';
|
|
14
|
+
import { SessionReplayEngine } from '../replay/engine';
|
|
15
|
+
import { PlanEnforcer } from '../billing/plan-enforcer';
|
|
16
|
+
import { PlanTier } from '../types/subscription';
|
|
17
|
+
import { MODEL_PRICING, resolveModelPricing } from '../budget/model-pricing';
|
|
18
|
+
|
|
19
|
+
export interface SaaSRouteDeps {
|
|
20
|
+
config: GatewayConfig;
|
|
21
|
+
userStore: UserStore;
|
|
22
|
+
workspaceStore: WorkspaceStore;
|
|
23
|
+
workspaceMemberStore: WorkspaceMemberStore;
|
|
24
|
+
userApiKeyStore: UserApiKeyStore;
|
|
25
|
+
sessionStore: SessionStore;
|
|
26
|
+
gateway: Gateway;
|
|
27
|
+
policyStore?: PolicyStore;
|
|
28
|
+
rateLimitConfigStore?: RateLimitConfigStore;
|
|
29
|
+
budgetConfigStore?: BudgetConfigStore;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Extract a route param as string (Express 5 returns string | string[]). */
|
|
33
|
+
function param(req: Request, name: string): string {
|
|
34
|
+
const val = req.params[name];
|
|
35
|
+
return Array.isArray(val) ? val[0] : val;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Require session authentication for all SaaS routes.
|
|
40
|
+
*/
|
|
41
|
+
function requireSession(req: Request, res: Response): boolean {
|
|
42
|
+
if (!(req as any).sessionUser) {
|
|
43
|
+
res.status(401).json({ error: 'Session authentication required' });
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createSaaSRouter(deps: SaaSRouteDeps): Router {
|
|
50
|
+
const router = Router();
|
|
51
|
+
const { config, userStore, workspaceStore, workspaceMemberStore, userApiKeyStore, sessionStore, gateway } = deps;
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// User Profile
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
router.get('/user/profile', (req: Request, res: Response) => {
|
|
58
|
+
if (!requireSession(req, res)) return;
|
|
59
|
+
const user = (req as any).sessionUser;
|
|
60
|
+
res.json({
|
|
61
|
+
id: user.id,
|
|
62
|
+
email: user.email,
|
|
63
|
+
display_name: user.display_name,
|
|
64
|
+
avatar_url: user.avatar_url,
|
|
65
|
+
status: user.status,
|
|
66
|
+
onboarding_completed: user.onboarding_completed,
|
|
67
|
+
created_at: user.created_at,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
router.put('/user/profile', (req: Request, res: Response) => {
|
|
72
|
+
if (!requireSession(req, res)) return;
|
|
73
|
+
const user = (req as any).sessionUser;
|
|
74
|
+
const { display_name } = req.body;
|
|
75
|
+
|
|
76
|
+
if (!display_name || typeof display_name !== 'string' || display_name.trim().length === 0) {
|
|
77
|
+
res.status(400).json({ error: 'display_name is required' });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (display_name.length > 100) {
|
|
81
|
+
res.status(400).json({ error: 'display_name must be 100 characters or less' });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const updated = userStore.update(user.id, {
|
|
86
|
+
display_name: display_name.trim(),
|
|
87
|
+
updated_at: new Date().toISOString(),
|
|
88
|
+
});
|
|
89
|
+
res.json(updated);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
router.put('/user/onboarding', (req: Request, res: Response) => {
|
|
93
|
+
if (!requireSession(req, res)) return;
|
|
94
|
+
const user = (req as any).sessionUser;
|
|
95
|
+
const updated = userStore.update(user.id, {
|
|
96
|
+
onboarding_completed: true,
|
|
97
|
+
updated_at: new Date().toISOString(),
|
|
98
|
+
});
|
|
99
|
+
res.json(updated);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Workspaces
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
router.get('/workspaces', (req: Request, res: Response) => {
|
|
107
|
+
if (!requireSession(req, res)) return;
|
|
108
|
+
const user = (req as any).sessionUser;
|
|
109
|
+
const memberships = workspaceMemberStore.getByUser(user.id);
|
|
110
|
+
const workspaces = memberships.map(m => {
|
|
111
|
+
const ws = workspaceStore.getById(m.workspace_id);
|
|
112
|
+
return ws ? { ...ws, role: m.role } : null;
|
|
113
|
+
}).filter(Boolean);
|
|
114
|
+
res.json({ workspaces });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
router.post('/workspaces', (req: Request, res: Response) => {
|
|
118
|
+
if (!requireSession(req, res)) return;
|
|
119
|
+
const user = (req as any).sessionUser;
|
|
120
|
+
const { name, slug } = req.body;
|
|
121
|
+
|
|
122
|
+
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
|
123
|
+
res.status(400).json({ error: 'name is required' });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Enforce workspace limit based on user's highest plan
|
|
128
|
+
const userWorkspaces = workspaceMemberStore.getByUser(user.id)
|
|
129
|
+
.filter(m => m.role === 'owner')
|
|
130
|
+
.map(m => workspaceStore.getById(m.workspace_id))
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
// Determine the highest plan the user owns
|
|
133
|
+
const planPriority: Record<string, number> = { free: 0, pro: 1, business: 2, enterprise: 3 };
|
|
134
|
+
let highestPlan: PlanTier = 'free';
|
|
135
|
+
for (const ws of userWorkspaces) {
|
|
136
|
+
const p = (ws!.plan || 'free') as PlanTier;
|
|
137
|
+
if ((planPriority[p] || 0) > (planPriority[highestPlan] || 0)) {
|
|
138
|
+
highestPlan = p;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const wsLimitCheck = PlanEnforcer.checkWorkspaceLimit(highestPlan, userWorkspaces.length);
|
|
142
|
+
if (!wsLimitCheck.allowed) {
|
|
143
|
+
res.status(403).json({ error: wsLimitCheck.reason });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Generate slug from name if not provided
|
|
148
|
+
const wsSlug = (slug || name).toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').slice(0, 63);
|
|
149
|
+
|
|
150
|
+
if (workspaceStore.getBySlug(wsSlug)) {
|
|
151
|
+
res.status(409).json({ error: 'Workspace slug already taken' });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const now = new Date().toISOString();
|
|
156
|
+
const workspaceId = randomUUID();
|
|
157
|
+
|
|
158
|
+
workspaceStore.create({
|
|
159
|
+
id: workspaceId,
|
|
160
|
+
name: name.trim(),
|
|
161
|
+
slug: wsSlug,
|
|
162
|
+
owner_user_id: user.id,
|
|
163
|
+
plan: 'free',
|
|
164
|
+
settings: {},
|
|
165
|
+
created_at: now,
|
|
166
|
+
updated_at: now,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
workspaceMemberStore.create({
|
|
170
|
+
id: randomUUID(),
|
|
171
|
+
workspace_id: workspaceId,
|
|
172
|
+
user_id: user.id,
|
|
173
|
+
role: 'owner',
|
|
174
|
+
joined_at: now,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Update session to point to this workspace
|
|
178
|
+
const sessionData = (req as any).sessionData;
|
|
179
|
+
if (sessionData) {
|
|
180
|
+
sessionStore.update(sessionData.id, { workspace_id: workspaceId });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
184
|
+
res.status(201).json(workspace);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
router.get('/workspaces/:id', (req: Request, res: Response) => {
|
|
188
|
+
if (!requireSession(req, res)) return;
|
|
189
|
+
const user = (req as any).sessionUser;
|
|
190
|
+
const workspaceId = param(req, 'id');
|
|
191
|
+
|
|
192
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
193
|
+
if (!membership) {
|
|
194
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
199
|
+
if (!workspace) {
|
|
200
|
+
res.status(404).json({ error: 'Workspace not found' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
res.json({ ...workspace, role: membership.role });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Workspace Stats (Dashboard)
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
router.get('/workspaces/:id/stats', (req: Request, res: Response) => {
|
|
212
|
+
if (!requireSession(req, res)) return;
|
|
213
|
+
const user = (req as any).sessionUser;
|
|
214
|
+
const workspaceId = param(req, 'id');
|
|
215
|
+
|
|
216
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
217
|
+
if (!membership) {
|
|
218
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Gather stats from the gateway's audit store
|
|
223
|
+
// Match events by workspace UUID or slug (Android clients send slug as workspace_id)
|
|
224
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
225
|
+
const wsSlug = workspace?.slug;
|
|
226
|
+
const allEvents = gateway.getAuditLogger().getAllEvents();
|
|
227
|
+
const wsEvents = allEvents.filter(e => e.workspace_id === workspaceId || (wsSlug && e.workspace_id === wsSlug));
|
|
228
|
+
const recentEvents = wsEvents.filter(e => {
|
|
229
|
+
const age = Date.now() - new Date(e.timestamp).getTime();
|
|
230
|
+
return age < 86400_000; // Last 24 hours
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const members = workspaceMemberStore.getByWorkspace(workspaceId);
|
|
234
|
+
const apiKeys = userApiKeyStore.getByWorkspace(workspaceId);
|
|
235
|
+
|
|
236
|
+
res.json({
|
|
237
|
+
total_requests: wsEvents.length,
|
|
238
|
+
requests_24h: recentEvents.length,
|
|
239
|
+
members: members.length,
|
|
240
|
+
api_keys: apiKeys.filter(k => !k.revoked).length,
|
|
241
|
+
recent_events: recentEvents.slice(-10).reverse(),
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Events (filterable, sortable, paginated)
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
router.get('/workspaces/:id/events', (req: Request, res: Response) => {
|
|
250
|
+
if (!requireSession(req, res)) return;
|
|
251
|
+
const user = (req as any).sessionUser;
|
|
252
|
+
const workspaceId = param(req, 'id');
|
|
253
|
+
|
|
254
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
255
|
+
if (!membership) {
|
|
256
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
261
|
+
const wsSlug = workspace?.slug;
|
|
262
|
+
const allEvents = gateway.getAuditLogger().getAllEvents();
|
|
263
|
+
|
|
264
|
+
// Filter to this workspace (strict match — no ws_default fallback)
|
|
265
|
+
let events = allEvents.filter(
|
|
266
|
+
e => e.workspace_id === workspaceId || (wsSlug && e.workspace_id === wsSlug)
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// Query params
|
|
270
|
+
const eventType = req.query.event_type as string | undefined;
|
|
271
|
+
const toolName = req.query.tool as string | undefined;
|
|
272
|
+
const actorId = req.query.actor as string | undefined;
|
|
273
|
+
const status = req.query.status as string | undefined;
|
|
274
|
+
const search = req.query.q as string | undefined;
|
|
275
|
+
const since = req.query.since as string | undefined;
|
|
276
|
+
const sort = (req.query.sort as string) || 'desc';
|
|
277
|
+
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
|
|
278
|
+
const offset = parseInt(req.query.offset as string) || 0;
|
|
279
|
+
|
|
280
|
+
// Apply filters
|
|
281
|
+
if (eventType) {
|
|
282
|
+
const types = eventType.split(',');
|
|
283
|
+
events = events.filter(e => types.includes(e.event_type));
|
|
284
|
+
}
|
|
285
|
+
if (toolName) {
|
|
286
|
+
events = events.filter(e => e.tool_name?.includes(toolName));
|
|
287
|
+
}
|
|
288
|
+
if (actorId) {
|
|
289
|
+
events = events.filter(e => e.actor_id?.includes(actorId));
|
|
290
|
+
}
|
|
291
|
+
if (status) {
|
|
292
|
+
const statuses = status.split(',');
|
|
293
|
+
events = events.filter(e => {
|
|
294
|
+
const s = (e.metadata?.status as string) || (e.metadata?.decision as string) || '';
|
|
295
|
+
return statuses.some(st => s.includes(st));
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (search) {
|
|
299
|
+
const q = search.toLowerCase();
|
|
300
|
+
events = events.filter(e =>
|
|
301
|
+
e.tool_name?.toLowerCase().includes(q) ||
|
|
302
|
+
e.actor_id?.toLowerCase().includes(q) ||
|
|
303
|
+
e.task_id?.toLowerCase().includes(q) ||
|
|
304
|
+
e.event_type?.toLowerCase().includes(q)
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (since) {
|
|
308
|
+
events = events.filter(e => e.timestamp >= since);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Sort
|
|
312
|
+
events.sort((a, b) => {
|
|
313
|
+
const cmp = a.timestamp.localeCompare(b.timestamp);
|
|
314
|
+
return sort === 'asc' ? cmp : -cmp;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const total = events.length;
|
|
318
|
+
const paged = events.slice(offset, offset + limit);
|
|
319
|
+
|
|
320
|
+
res.json({ events: paged, total, offset, limit });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// API Keys
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
router.get('/workspaces/:id/api-keys', (req: Request, res: Response) => {
|
|
328
|
+
if (!requireSession(req, res)) return;
|
|
329
|
+
const user = (req as any).sessionUser;
|
|
330
|
+
const workspaceId = param(req, 'id');
|
|
331
|
+
|
|
332
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
333
|
+
if (!membership) {
|
|
334
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const keys = userApiKeyStore.getByWorkspace(workspaceId);
|
|
339
|
+
// Never return the hash, only metadata
|
|
340
|
+
res.json({
|
|
341
|
+
api_keys: keys.map(k => ({
|
|
342
|
+
id: k.id,
|
|
343
|
+
prefix: k.key_prefix,
|
|
344
|
+
name: k.name,
|
|
345
|
+
roles: k.roles,
|
|
346
|
+
tags: k.tags || [],
|
|
347
|
+
revoked: k.revoked,
|
|
348
|
+
last_used_at: k.last_used_at,
|
|
349
|
+
created_at: k.created_at,
|
|
350
|
+
})),
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
router.post('/workspaces/:id/api-keys', (req: Request, res: Response) => {
|
|
355
|
+
if (!requireSession(req, res)) return;
|
|
356
|
+
const user = (req as any).sessionUser;
|
|
357
|
+
const workspaceId = param(req, 'id');
|
|
358
|
+
|
|
359
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
360
|
+
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
|
|
361
|
+
res.status(403).json({ error: 'Only workspace owners and admins can create API keys' });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const { name, roles, tags } = req.body;
|
|
366
|
+
if (!name || typeof name !== 'string') {
|
|
367
|
+
res.status(400).json({ error: 'name is required' });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Validate tags
|
|
372
|
+
const parsedTags: string[] = Array.isArray(tags)
|
|
373
|
+
? tags.filter((t: unknown) => typeof t === 'string' && t.length > 0).slice(0, 20)
|
|
374
|
+
: [];
|
|
375
|
+
|
|
376
|
+
// Enforce API key limit based on workspace plan
|
|
377
|
+
const wsForKeyLimit = workspaceStore.getById(workspaceId);
|
|
378
|
+
if (wsForKeyLimit) {
|
|
379
|
+
const plan = (wsForKeyLimit.plan || 'free') as PlanTier;
|
|
380
|
+
const currentKeys = userApiKeyStore.getByWorkspace(workspaceId).filter(k => !k.revoked);
|
|
381
|
+
const keyLimitCheck = PlanEnforcer.checkApiKeyLimit(plan, currentKeys.length);
|
|
382
|
+
if (!keyLimitCheck.allowed) {
|
|
383
|
+
res.status(403).json({ error: keyLimitCheck.reason });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Generate a random API key (pn_<32 random hex chars>) with salted hash
|
|
389
|
+
const rawKey = `pn_${crypto.randomBytes(32).toString('hex')}`;
|
|
390
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
391
|
+
const hash = crypto.createHash('sha256').update(salt + rawKey).digest('hex');
|
|
392
|
+
const keyHash = `${salt}:${hash}`; // salted format: "salt:hash"
|
|
393
|
+
const keyPrefix = rawKey.slice(0, 11); // "pn_" + first 8 hex
|
|
394
|
+
|
|
395
|
+
const apiKey = {
|
|
396
|
+
id: randomUUID(),
|
|
397
|
+
key_hash: keyHash,
|
|
398
|
+
key_prefix: keyPrefix,
|
|
399
|
+
user_id: user.id,
|
|
400
|
+
workspace_id: workspaceId,
|
|
401
|
+
name: name.trim(),
|
|
402
|
+
roles: Array.isArray(roles) ? roles : ['agent'],
|
|
403
|
+
tags: parsedTags,
|
|
404
|
+
revoked: false,
|
|
405
|
+
created_at: new Date().toISOString(),
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
userApiKeyStore.create(apiKey);
|
|
409
|
+
|
|
410
|
+
// Return the plaintext key only once
|
|
411
|
+
res.status(201).json({
|
|
412
|
+
id: apiKey.id,
|
|
413
|
+
key: rawKey,
|
|
414
|
+
prefix: keyPrefix,
|
|
415
|
+
name: apiKey.name,
|
|
416
|
+
roles: apiKey.roles,
|
|
417
|
+
tags: apiKey.tags,
|
|
418
|
+
workspace_id: workspaceId,
|
|
419
|
+
created_at: apiKey.created_at,
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
router.delete('/workspaces/:id/api-keys/:keyId', (req: Request, res: Response) => {
|
|
424
|
+
if (!requireSession(req, res)) return;
|
|
425
|
+
const user = (req as any).sessionUser;
|
|
426
|
+
const workspaceId = param(req, 'id');
|
|
427
|
+
const keyId = param(req, 'keyId');
|
|
428
|
+
|
|
429
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
430
|
+
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
|
|
431
|
+
res.status(403).json({ error: 'Only workspace owners and admins can revoke API keys' });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const key = userApiKeyStore.getById(keyId);
|
|
436
|
+
if (!key || key.workspace_id !== workspaceId) {
|
|
437
|
+
res.status(404).json({ error: 'API key not found' });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
userApiKeyStore.update(keyId, { revoked: true });
|
|
442
|
+
res.json({ status: 'ok', id: keyId });
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
router.patch('/workspaces/:id/api-keys/:keyId', (req: Request, res: Response) => {
|
|
446
|
+
if (!requireSession(req, res)) return;
|
|
447
|
+
const user = (req as any).sessionUser;
|
|
448
|
+
const workspaceId = param(req, 'id');
|
|
449
|
+
const keyId = param(req, 'keyId');
|
|
450
|
+
|
|
451
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
452
|
+
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
|
|
453
|
+
res.status(403).json({ error: 'Only workspace owners and admins can update API keys' });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const key = userApiKeyStore.getById(keyId);
|
|
458
|
+
if (!key || key.workspace_id !== workspaceId) {
|
|
459
|
+
res.status(404).json({ error: 'API key not found' });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const updates: Record<string, unknown> = {};
|
|
464
|
+
if (req.body.name !== undefined && typeof req.body.name === 'string') {
|
|
465
|
+
updates.name = req.body.name.trim();
|
|
466
|
+
}
|
|
467
|
+
if (Array.isArray(req.body.tags)) {
|
|
468
|
+
updates.tags = req.body.tags.filter((t: unknown) => typeof t === 'string' && t.length > 0).slice(0, 20);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (Object.keys(updates).length === 0) {
|
|
472
|
+
res.status(400).json({ error: 'No valid fields to update (supported: name, tags)' });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const updated = userApiKeyStore.update(keyId, updates as any);
|
|
477
|
+
if (!updated) {
|
|
478
|
+
res.status(500).json({ error: 'Failed to update API key' });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
res.json({
|
|
483
|
+
id: updated.id,
|
|
484
|
+
key_prefix: updated.key_prefix,
|
|
485
|
+
name: updated.name,
|
|
486
|
+
roles: updated.roles,
|
|
487
|
+
tags: updated.tags || [],
|
|
488
|
+
revoked: updated.revoked,
|
|
489
|
+
last_used_at: updated.last_used_at,
|
|
490
|
+
created_at: updated.created_at,
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Traces
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
router.get('/workspaces/:id/traces', (req: Request, res: Response) => {
|
|
499
|
+
if (!requireSession(req, res)) return;
|
|
500
|
+
const user = (req as any).sessionUser;
|
|
501
|
+
const workspaceId = param(req, 'id');
|
|
502
|
+
|
|
503
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
504
|
+
if (!membership) {
|
|
505
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
|
|
510
|
+
const offset = parseInt(req.query.offset as string) || 0;
|
|
511
|
+
const statusFilter = req.query.status as string | undefined;
|
|
512
|
+
const toolFilter = req.query.tool as string | undefined;
|
|
513
|
+
const eventTypeFilter = req.query.event_type as string | undefined;
|
|
514
|
+
|
|
515
|
+
const allEvents = gateway.getAuditLogger().getAllEvents();
|
|
516
|
+
// Match events by workspace UUID or slug (Android clients send slug as workspace_id)
|
|
517
|
+
const wsForTraces = workspaceStore.getById(workspaceId);
|
|
518
|
+
const slugForTraces = wsForTraces?.slug;
|
|
519
|
+
let wsEvents = allEvents
|
|
520
|
+
.filter(e => e.workspace_id === workspaceId || (slugForTraces && e.workspace_id === slugForTraces));
|
|
521
|
+
|
|
522
|
+
// Apply server-side filters
|
|
523
|
+
if (statusFilter) {
|
|
524
|
+
const sf = statusFilter.toLowerCase();
|
|
525
|
+
wsEvents = wsEvents.filter(e => {
|
|
526
|
+
const decision = (e.metadata?.decision as string || '').toLowerCase();
|
|
527
|
+
const status = (e.metadata?.status as string || '').toLowerCase();
|
|
528
|
+
return decision.includes(sf) || status.includes(sf) || e.event_type.toLowerCase().includes(sf);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
if (toolFilter) {
|
|
532
|
+
const tf = toolFilter.toLowerCase();
|
|
533
|
+
wsEvents = wsEvents.filter(e => (e.tool_name || '').toLowerCase().includes(tf));
|
|
534
|
+
}
|
|
535
|
+
if (eventTypeFilter) {
|
|
536
|
+
const ef = eventTypeFilter.toLowerCase();
|
|
537
|
+
wsEvents = wsEvents.filter(e => e.event_type.toLowerCase().includes(ef));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
wsEvents.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
541
|
+
|
|
542
|
+
res.json({
|
|
543
|
+
traces: wsEvents.slice(offset, offset + limit),
|
|
544
|
+
total: wsEvents.length,
|
|
545
|
+
limit,
|
|
546
|
+
offset,
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
router.delete('/workspaces/:id/traces', (req: Request, res: Response) => {
|
|
551
|
+
if (!requireSession(req, res)) return;
|
|
552
|
+
const user = (req as any).sessionUser;
|
|
553
|
+
const workspaceId = param(req, 'id');
|
|
554
|
+
|
|
555
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
556
|
+
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
|
|
557
|
+
res.status(403).json({ error: 'Admin or owner access required' });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
gateway.getAuditLogger().clear();
|
|
562
|
+
res.json({ status: 'ok', message: 'All traces cleared' });
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
router.delete('/workspaces/:id/traces/:taskId', (req: Request, res: Response) => {
|
|
566
|
+
if (!requireSession(req, res)) return;
|
|
567
|
+
const user = (req as any).sessionUser;
|
|
568
|
+
const workspaceId = param(req, 'id');
|
|
569
|
+
const taskId = param(req, 'taskId');
|
|
570
|
+
|
|
571
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
572
|
+
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
|
|
573
|
+
res.status(403).json({ error: 'Admin or owner access required' });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
gateway.getAuditLogger().deleteByTaskId(taskId);
|
|
578
|
+
res.json({ status: 'ok', message: `Trace ${taskId} deleted` });
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
router.delete('/workspaces/:id/sessions/:sessionId', (req: Request, res: Response) => {
|
|
582
|
+
if (!requireSession(req, res)) return;
|
|
583
|
+
const user = (req as any).sessionUser;
|
|
584
|
+
const workspaceId = param(req, 'id');
|
|
585
|
+
const sessionId = param(req, 'sessionId');
|
|
586
|
+
|
|
587
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
588
|
+
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
|
|
589
|
+
res.status(403).json({ error: 'Admin or owner access required' });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
gateway.getAuditLogger().deleteBySessionId(sessionId);
|
|
594
|
+
res.json({ status: 'ok', message: `Session ${sessionId} traces deleted` });
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
// Sessions (grouped traces)
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
|
|
601
|
+
router.get('/workspaces/:id/sessions', (req: Request, res: Response) => {
|
|
602
|
+
if (!requireSession(req, res)) return;
|
|
603
|
+
const user = (req as any).sessionUser;
|
|
604
|
+
const workspaceId = param(req, 'id');
|
|
605
|
+
|
|
606
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
607
|
+
if (!membership) {
|
|
608
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
|
|
613
|
+
const offset = parseInt(req.query.offset as string) || 0;
|
|
614
|
+
|
|
615
|
+
const allEvents = gateway.getAuditLogger().getAllEvents();
|
|
616
|
+
const wsForSessions = workspaceStore.getById(workspaceId);
|
|
617
|
+
const slugForSessions = wsForSessions?.slug;
|
|
618
|
+
const wsEvents = allEvents.filter(
|
|
619
|
+
e => e.workspace_id === workspaceId || (slugForSessions && e.workspace_id === slugForSessions)
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
// Group events by session_id (falling back to tool_call_id for old events)
|
|
623
|
+
const sessionMap = new Map<string, typeof wsEvents>();
|
|
624
|
+
for (const e of wsEvents) {
|
|
625
|
+
const key = e.session_id || e.tool_call_id || e.task_id;
|
|
626
|
+
if (!sessionMap.has(key)) sessionMap.set(key, []);
|
|
627
|
+
sessionMap.get(key)!.push(e);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Build session summaries
|
|
631
|
+
const sessions: Array<{
|
|
632
|
+
session_id: string;
|
|
633
|
+
tool_call_count: number;
|
|
634
|
+
first_timestamp: string;
|
|
635
|
+
last_timestamp: string;
|
|
636
|
+
duration_ms: number;
|
|
637
|
+
actor_id: string;
|
|
638
|
+
platform: string;
|
|
639
|
+
tools_used: string[];
|
|
640
|
+
overall_status: string;
|
|
641
|
+
status_counts: { ok: number; blocked: number; error: number; approval: number; redacted: number };
|
|
642
|
+
}> = [];
|
|
643
|
+
|
|
644
|
+
for (const [sessionId, events] of sessionMap) {
|
|
645
|
+
events.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
646
|
+
const first = events[0];
|
|
647
|
+
const last = events[events.length - 1];
|
|
648
|
+
|
|
649
|
+
// Count unique tool_call_ids (each represents one tool call)
|
|
650
|
+
const toolCallIds = new Set(events.map(e => e.tool_call_id));
|
|
651
|
+
const receivedEvents = events.filter(e => e.event_type === 'TOOL_CALL_RECEIVED');
|
|
652
|
+
const toolCallCount = Math.max(receivedEvents.length, 1);
|
|
653
|
+
|
|
654
|
+
// Unique tools
|
|
655
|
+
const toolsUsed = [...new Set(events.map(e => e.tool_name).filter(Boolean))];
|
|
656
|
+
|
|
657
|
+
// Derive status counts per tool_call_id
|
|
658
|
+
const statusCounts = { ok: 0, blocked: 0, error: 0, approval: 0, redacted: 0 };
|
|
659
|
+
for (const tcId of toolCallIds) {
|
|
660
|
+
const tcEvents = events.filter(e => e.tool_call_id === tcId);
|
|
661
|
+
const hasDeny = tcEvents.some(e => e.event_type === 'POLICY_DECIDED' && e.metadata?.decision === 'deny');
|
|
662
|
+
const hasApproval = tcEvents.some(e => e.event_type === 'APPROVAL_REQUESTED');
|
|
663
|
+
const hasError = tcEvents.some(e => e.event_type === 'TOOL_EXECUTED' && e.metadata?.status === 'error');
|
|
664
|
+
const hasRedaction = tcEvents.some(e =>
|
|
665
|
+
e.event_type === 'DLP_SCANNED' && Array.isArray(e.metadata?.detected) && (e.metadata!.detected as string[]).length > 0
|
|
666
|
+
);
|
|
667
|
+
if (hasDeny) statusCounts.blocked++;
|
|
668
|
+
else if (hasError) statusCounts.error++;
|
|
669
|
+
else if (hasApproval) statusCounts.approval++;
|
|
670
|
+
else statusCounts.ok++;
|
|
671
|
+
// Redacted is orthogonal — a call can be ok AND redacted
|
|
672
|
+
if (hasRedaction) statusCounts.redacted++;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Overall status: worst status across calls
|
|
676
|
+
let overallStatus = 'ok';
|
|
677
|
+
if (statusCounts.blocked > 0) overallStatus = 'blocked';
|
|
678
|
+
else if (statusCounts.error > 0) overallStatus = 'error';
|
|
679
|
+
else if (statusCounts.approval > 0) overallStatus = 'approval';
|
|
680
|
+
|
|
681
|
+
sessions.push({
|
|
682
|
+
session_id: sessionId,
|
|
683
|
+
tool_call_count: toolCallCount,
|
|
684
|
+
first_timestamp: first.timestamp,
|
|
685
|
+
last_timestamp: last.timestamp,
|
|
686
|
+
duration_ms: new Date(last.timestamp).getTime() - new Date(first.timestamp).getTime(),
|
|
687
|
+
actor_id: first.actor_id || '',
|
|
688
|
+
platform: (first.metadata?.platform as string) || 'unknown',
|
|
689
|
+
tools_used: toolsUsed,
|
|
690
|
+
overall_status: overallStatus,
|
|
691
|
+
status_counts: statusCounts,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Sort newest first
|
|
696
|
+
sessions.sort((a, b) => b.first_timestamp.localeCompare(a.first_timestamp));
|
|
697
|
+
|
|
698
|
+
res.json({
|
|
699
|
+
sessions: sessions.slice(offset, offset + limit),
|
|
700
|
+
total: sessions.length,
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
router.get('/workspaces/:id/sessions/:sessionId', (req: Request, res: Response) => {
|
|
705
|
+
if (!requireSession(req, res)) return;
|
|
706
|
+
const user = (req as any).sessionUser;
|
|
707
|
+
const workspaceId = param(req, 'id');
|
|
708
|
+
const sessionId = param(req, 'sessionId');
|
|
709
|
+
|
|
710
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
711
|
+
if (!membership) {
|
|
712
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const allEvents = gateway.getAuditLogger().getAllEvents();
|
|
717
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
718
|
+
const wsSlug = workspace?.slug;
|
|
719
|
+
|
|
720
|
+
// Find events by session_id, filtered to this workspace
|
|
721
|
+
const sessionEvents = allEvents.filter(
|
|
722
|
+
e => e.session_id === sessionId &&
|
|
723
|
+
(e.workspace_id === workspaceId || (wsSlug && e.workspace_id === wsSlug))
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
// If no events found by session_id, try treating sessionId as a tool_call_id (backward compat)
|
|
727
|
+
const events = sessionEvents.length > 0
|
|
728
|
+
? sessionEvents
|
|
729
|
+
: allEvents.filter(
|
|
730
|
+
e => e.tool_call_id === sessionId &&
|
|
731
|
+
(e.workspace_id === workspaceId || (wsSlug && e.workspace_id === wsSlug))
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
// Group by tool_call_id
|
|
735
|
+
const toolCallMap = new Map<string, typeof events>();
|
|
736
|
+
for (const e of events) {
|
|
737
|
+
const tcId = e.tool_call_id;
|
|
738
|
+
if (!toolCallMap.has(tcId)) toolCallMap.set(tcId, []);
|
|
739
|
+
toolCallMap.get(tcId)!.push(e);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const toolCalls: Array<{
|
|
743
|
+
tool_call_id: string;
|
|
744
|
+
task_id: string;
|
|
745
|
+
tool_name: string;
|
|
746
|
+
timestamp: string;
|
|
747
|
+
duration_ms: number;
|
|
748
|
+
status: string;
|
|
749
|
+
events: typeof events;
|
|
750
|
+
}> = [];
|
|
751
|
+
|
|
752
|
+
for (const [tcId, tcEvents] of toolCallMap) {
|
|
753
|
+
tcEvents.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
754
|
+
const first = tcEvents[0];
|
|
755
|
+
const last = tcEvents[tcEvents.length - 1];
|
|
756
|
+
const resultEvent = tcEvents.find(e => e.event_type === 'TOOL_RESULT_RETURNED');
|
|
757
|
+
const durationMs = (resultEvent?.metadata?.duration_ms as number) ||
|
|
758
|
+
(new Date(last.timestamp).getTime() - new Date(first.timestamp).getTime());
|
|
759
|
+
|
|
760
|
+
// Derive status
|
|
761
|
+
const hasDeny = tcEvents.some(e => e.event_type === 'POLICY_DECIDED' && e.metadata?.decision === 'deny');
|
|
762
|
+
const hasApproval = tcEvents.some(e => e.event_type === 'APPROVAL_REQUESTED');
|
|
763
|
+
const hasError = tcEvents.some(e => e.event_type === 'TOOL_EXECUTED' && e.metadata?.status === 'error');
|
|
764
|
+
let status = 'ok';
|
|
765
|
+
if (hasDeny) status = 'blocked';
|
|
766
|
+
else if (hasError) status = 'error';
|
|
767
|
+
else if (hasApproval) status = 'approval';
|
|
768
|
+
|
|
769
|
+
toolCalls.push({
|
|
770
|
+
tool_call_id: tcId,
|
|
771
|
+
task_id: first.task_id,
|
|
772
|
+
tool_name: first.tool_name || 'unknown',
|
|
773
|
+
timestamp: first.timestamp,
|
|
774
|
+
duration_ms: durationMs,
|
|
775
|
+
status,
|
|
776
|
+
events: tcEvents,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Sort by timestamp
|
|
781
|
+
toolCalls.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
782
|
+
|
|
783
|
+
res.json({
|
|
784
|
+
session_id: sessionId,
|
|
785
|
+
tool_calls: toolCalls,
|
|
786
|
+
total_events: events.length,
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
// Budgets
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
router.get('/workspaces/:id/budgets', (req: Request, res: Response) => {
|
|
795
|
+
if (!requireSession(req, res)) return;
|
|
796
|
+
const user = (req as any).sessionUser;
|
|
797
|
+
const workspaceId = param(req, 'id');
|
|
798
|
+
|
|
799
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
800
|
+
if (!membership) {
|
|
801
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const spending = gateway.getBudgetManager().getSpendingSummary();
|
|
806
|
+
|
|
807
|
+
// Use workspace-specific budget config if available, otherwise global
|
|
808
|
+
const budgetConfigStore = gateway.getBudgetConfigStore();
|
|
809
|
+
const wsConfig = budgetConfigStore?.getByWorkspaceId(workspaceId);
|
|
810
|
+
const effectiveConfig = wsConfig ? { ...config.budget, ...wsConfig } : config.budget;
|
|
811
|
+
const is_custom = !!wsConfig;
|
|
812
|
+
|
|
813
|
+
res.json({
|
|
814
|
+
workspace_id: workspaceId,
|
|
815
|
+
config: effectiveConfig,
|
|
816
|
+
spending,
|
|
817
|
+
is_custom,
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
// ---------------------------------------------------------------------------
|
|
822
|
+
// Approvals (proxy to gateway's ApprovalManager)
|
|
823
|
+
// ---------------------------------------------------------------------------
|
|
824
|
+
|
|
825
|
+
router.get('/workspaces/:id/approvals', (req: Request, res: Response) => {
|
|
826
|
+
if (!requireSession(req, res)) return;
|
|
827
|
+
const user = (req as any).sessionUser;
|
|
828
|
+
const workspaceId = param(req, 'id');
|
|
829
|
+
|
|
830
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
831
|
+
if (!membership) {
|
|
832
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const pending = gateway.getPendingApprovals(workspaceId);
|
|
837
|
+
// Strip the JWT token — frontend only sees approval metadata
|
|
838
|
+
const approvals = pending.map(a => ({
|
|
839
|
+
approval_id: a.approval_id,
|
|
840
|
+
tool_call_id: a.tool_call_id,
|
|
841
|
+
task_id: a.task_id,
|
|
842
|
+
workspace_id: a.workspace_id,
|
|
843
|
+
actor_id: a.actor_id,
|
|
844
|
+
tool_name: a.tool_name,
|
|
845
|
+
tool_capability: a.tool_capability,
|
|
846
|
+
args_summary: a.args_summary,
|
|
847
|
+
scope: a.scope,
|
|
848
|
+
reason: a.reason,
|
|
849
|
+
status: a.status,
|
|
850
|
+
created_at: a.created_at,
|
|
851
|
+
expires_at: a.expires_at,
|
|
852
|
+
}));
|
|
853
|
+
|
|
854
|
+
res.json({ approvals });
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
router.post('/workspaces/:id/approvals/:approvalId/approve', async (req: Request, res: Response) => {
|
|
858
|
+
if (!requireSession(req, res)) return;
|
|
859
|
+
const user = (req as any).sessionUser;
|
|
860
|
+
const workspaceId = param(req, 'id');
|
|
861
|
+
const approvalId = param(req, 'approvalId');
|
|
862
|
+
|
|
863
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
864
|
+
if (!membership) {
|
|
865
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Verify approval exists and belongs to this workspace
|
|
870
|
+
const approval = gateway.getApprovalManager().getApproval(approvalId);
|
|
871
|
+
if (!approval) {
|
|
872
|
+
res.status(404).json({ error: 'Approval not found' });
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
if (approval.workspace_id !== workspaceId) {
|
|
876
|
+
res.status(404).json({ error: 'Approval not found' });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
try {
|
|
881
|
+
await gateway.getApprovalManager().resolveById(approvalId, user.id, true);
|
|
882
|
+
} catch (err) {
|
|
883
|
+
const message = err instanceof Error ? err.message : 'Approval failed';
|
|
884
|
+
res.status(400).json({ error: message });
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
res.json({ status: 'approved', approval_id: approvalId });
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
router.post('/workspaces/:id/approvals/:approvalId/deny', async (req: Request, res: Response) => {
|
|
892
|
+
if (!requireSession(req, res)) return;
|
|
893
|
+
const user = (req as any).sessionUser;
|
|
894
|
+
const workspaceId = param(req, 'id');
|
|
895
|
+
const approvalId = param(req, 'approvalId');
|
|
896
|
+
|
|
897
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
898
|
+
if (!membership) {
|
|
899
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const approvalRecord = gateway.getApprovalManager().getApproval(approvalId);
|
|
904
|
+
if (!approvalRecord) {
|
|
905
|
+
res.status(404).json({ error: 'Approval not found' });
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (approvalRecord.workspace_id !== workspaceId) {
|
|
909
|
+
res.status(404).json({ error: 'Approval not found' });
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const reason = req.body?.reason || 'Denied via SaaS dashboard';
|
|
914
|
+
try {
|
|
915
|
+
await gateway.getApprovalManager().resolveById(approvalId, user.id, false, reason);
|
|
916
|
+
} catch (err) {
|
|
917
|
+
const message = err instanceof Error ? err.message : 'Denial failed';
|
|
918
|
+
res.status(400).json({ error: message });
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
res.json({ status: 'denied', approval_id: approvalId });
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
// ---------------------------------------------------------------------------
|
|
926
|
+
// Policies (workspace-aware CRUD)
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
|
|
929
|
+
// Ensure a PolicyStore is available on the gateway
|
|
930
|
+
if (!gateway.getPolicyStore()) {
|
|
931
|
+
gateway.setStores({ policyStore: deps.policyStore || new InMemoryPolicyStore() });
|
|
932
|
+
}
|
|
933
|
+
const policyStore = gateway.getPolicyStore()!;
|
|
934
|
+
|
|
935
|
+
router.get('/workspaces/:id/policies', (req: Request, res: Response) => {
|
|
936
|
+
if (!requireSession(req, res)) return;
|
|
937
|
+
const user = (req as any).sessionUser;
|
|
938
|
+
const workspaceId = param(req, 'id');
|
|
939
|
+
|
|
940
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
941
|
+
if (!membership) {
|
|
942
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const { policy, is_custom } = gateway.getWorkspacePolicy(workspaceId);
|
|
947
|
+
res.json({ policy, is_custom });
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
router.put('/workspaces/:id/policies', (req: Request, res: Response) => {
|
|
951
|
+
if (!requireSession(req, res)) return;
|
|
952
|
+
const user = (req as any).sessionUser;
|
|
953
|
+
const workspaceId = param(req, 'id');
|
|
954
|
+
|
|
955
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
956
|
+
if (!membership) {
|
|
957
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (!['owner', 'admin'].includes(membership.role)) {
|
|
961
|
+
res.status(403).json({ error: 'Only workspace owners and admins can modify policies' });
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const body = req.body;
|
|
966
|
+
const validation = gateway.validatePolicy(body);
|
|
967
|
+
if (!validation.valid) {
|
|
968
|
+
res.status(400).json({ valid: false, errors: validation.errors });
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
policyStore.set(workspaceId, body);
|
|
973
|
+
|
|
974
|
+
gateway.getAuditLogger().log({
|
|
975
|
+
event_type: 'POLICY_UPDATED',
|
|
976
|
+
tool_call_id: '',
|
|
977
|
+
task_id: '',
|
|
978
|
+
workspace_id: workspaceId,
|
|
979
|
+
actor_id: user.id,
|
|
980
|
+
tool_name: '',
|
|
981
|
+
metadata: { policy_name: body.name, policy_version: body.version, updated_by: user.id },
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
res.json({ policy: body, is_custom: true });
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
router.delete('/workspaces/:id/policies', (req: Request, res: Response) => {
|
|
988
|
+
if (!requireSession(req, res)) return;
|
|
989
|
+
const user = (req as any).sessionUser;
|
|
990
|
+
const workspaceId = param(req, 'id');
|
|
991
|
+
|
|
992
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
993
|
+
if (!membership) {
|
|
994
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (!['owner', 'admin'].includes(membership.role)) {
|
|
998
|
+
res.status(403).json({ error: 'Only workspace owners and admins can modify policies' });
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
policyStore.delete(workspaceId);
|
|
1003
|
+
|
|
1004
|
+
gateway.getAuditLogger().log({
|
|
1005
|
+
event_type: 'POLICY_RESET',
|
|
1006
|
+
tool_call_id: '',
|
|
1007
|
+
task_id: '',
|
|
1008
|
+
workspace_id: workspaceId,
|
|
1009
|
+
actor_id: user.id,
|
|
1010
|
+
tool_name: '',
|
|
1011
|
+
metadata: { reset_by: user.id },
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
const policy = gateway.getCurrentPolicy();
|
|
1015
|
+
res.json({ status: 'reset', policy, is_custom: false });
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
router.post('/workspaces/:id/policies/validate', (req: Request, res: Response) => {
|
|
1019
|
+
if (!requireSession(req, res)) return;
|
|
1020
|
+
const user = (req as any).sessionUser;
|
|
1021
|
+
const workspaceId = param(req, 'id');
|
|
1022
|
+
|
|
1023
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1024
|
+
if (!membership) {
|
|
1025
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const body = req.body;
|
|
1030
|
+
const validation = gateway.validatePolicy(body);
|
|
1031
|
+
res.json(validation);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// ---------------------------------------------------------------------------
|
|
1035
|
+
// Policy Rule Generation (LLM-powered)
|
|
1036
|
+
// ---------------------------------------------------------------------------
|
|
1037
|
+
|
|
1038
|
+
// Per-user rate limiter for LLM rule generation: 10 requests per hour
|
|
1039
|
+
const LLM_RATE_LIMIT_MAX = 10;
|
|
1040
|
+
const LLM_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
1041
|
+
const llmRateLimitHits = new Map<string, number[]>();
|
|
1042
|
+
|
|
1043
|
+
// Periodic cleanup to prevent memory leaks
|
|
1044
|
+
setInterval(() => {
|
|
1045
|
+
const now = Date.now();
|
|
1046
|
+
for (const [userId, timestamps] of llmRateLimitHits) {
|
|
1047
|
+
const valid = timestamps.filter(t => now - t < LLM_RATE_LIMIT_WINDOW_MS);
|
|
1048
|
+
if (valid.length === 0) {
|
|
1049
|
+
llmRateLimitHits.delete(userId);
|
|
1050
|
+
} else {
|
|
1051
|
+
llmRateLimitHits.set(userId, valid);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}, LLM_RATE_LIMIT_WINDOW_MS).unref();
|
|
1055
|
+
|
|
1056
|
+
router.post('/workspaces/:id/policies/generate-rule', async (req: Request, res: Response) => {
|
|
1057
|
+
if (!requireSession(req, res)) return;
|
|
1058
|
+
const user = (req as any).sessionUser;
|
|
1059
|
+
const workspaceId = param(req, 'id');
|
|
1060
|
+
|
|
1061
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1062
|
+
if (!membership) {
|
|
1063
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const apiKey = process.env.PALARYN_LLM_API_KEY;
|
|
1068
|
+
if (!apiKey) {
|
|
1069
|
+
res.status(503).json({ error: 'AI rule generation is not configured. Set PALARYN_LLM_API_KEY environment variable.' });
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const { description } = req.body;
|
|
1074
|
+
if (!description || typeof description !== 'string' || description.trim().length === 0) {
|
|
1075
|
+
res.status(400).json({ error: 'description is required' });
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const trimmed = description.trim().slice(0, 500);
|
|
1080
|
+
|
|
1081
|
+
// Per-user rate limit check (after validation, before LLM call)
|
|
1082
|
+
const now = Date.now();
|
|
1083
|
+
const userTimestamps = llmRateLimitHits.get(user.id) || [];
|
|
1084
|
+
const windowStart = now - LLM_RATE_LIMIT_WINDOW_MS;
|
|
1085
|
+
const validTimestamps = userTimestamps.filter(t => t > windowStart);
|
|
1086
|
+
|
|
1087
|
+
if (validTimestamps.length >= LLM_RATE_LIMIT_MAX) {
|
|
1088
|
+
const retryAfterMs = LLM_RATE_LIMIT_WINDOW_MS - (now - validTimestamps[0]);
|
|
1089
|
+
res.status(429).json({
|
|
1090
|
+
error: `You've reached the limit of ${LLM_RATE_LIMIT_MAX} AI generations per hour. Please try again later.`,
|
|
1091
|
+
retry_after_ms: retryAfterMs,
|
|
1092
|
+
});
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
validTimestamps.push(now);
|
|
1097
|
+
llmRateLimitHits.set(user.id, validTimestamps);
|
|
1098
|
+
|
|
1099
|
+
const systemPrompt = `You are a policy rule generator for Palaryn, an AI agent gateway. Given a natural language description, generate a single JSON PolicyRule object.
|
|
1100
|
+
|
|
1101
|
+
The PolicyRule schema:
|
|
1102
|
+
{
|
|
1103
|
+
"name": string, // Short kebab-case name for the rule
|
|
1104
|
+
"description": string, // Human-readable description
|
|
1105
|
+
"effect": "ALLOW" | "DENY" | "REQUIRE_APPROVAL" | "TRANSFORM",
|
|
1106
|
+
"priority": number, // Lower = higher precedence (1-100)
|
|
1107
|
+
"conditions": {
|
|
1108
|
+
"tools": string[], // Tool names: "http.request", "slack.post_message", etc.
|
|
1109
|
+
"tool_match": string, // Regex pattern for tool name matching
|
|
1110
|
+
"capabilities": string[], // "read", "write", "delete", "admin"
|
|
1111
|
+
"actors": string[], // Actor/agent IDs
|
|
1112
|
+
"actor_types": string[], // "agent", "user", "system"
|
|
1113
|
+
"domains": string[], // URL domains: "api.github.com", "*.googleapis.com"
|
|
1114
|
+
"domain_blocklist": string[],// Blocked domains
|
|
1115
|
+
"methods": string[], // HTTP methods: "GET", "POST", "PUT", "DELETE", "PATCH"
|
|
1116
|
+
"labels": string[], // Context labels
|
|
1117
|
+
"platforms": string[], // Source platforms: "langgraph", "claude_code", "n8n", "custom"
|
|
1118
|
+
"workspace_ids": string[] // Specific workspace IDs
|
|
1119
|
+
},
|
|
1120
|
+
"approval": { // Only when effect is REQUIRE_APPROVAL
|
|
1121
|
+
"scope": string,
|
|
1122
|
+
"ttl_seconds": number,
|
|
1123
|
+
"reason": string
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
Rules:
|
|
1128
|
+
- Only include condition fields that are relevant to the description. Omit empty arrays.
|
|
1129
|
+
- Use "DENY" for blocking, "ALLOW" for permitting, "REQUIRE_APPROVAL" when human approval is needed.
|
|
1130
|
+
- For domain wildcards use "*.example.com" format.
|
|
1131
|
+
- IMPORTANT: Always pair capabilities with matching methods. read → ["GET","HEAD","OPTIONS"], write → ["POST","PUT","PATCH"], delete → ["DELETE"], admin → all methods.
|
|
1132
|
+
- When the description says "read-only", set capabilities to ["read"] AND methods to ["GET","HEAD","OPTIONS"].
|
|
1133
|
+
- Return ONLY the JSON object, no markdown fences, no explanation.
|
|
1134
|
+
|
|
1135
|
+
Examples:
|
|
1136
|
+
Input: "Allow GET requests to GitHub and Google APIs"
|
|
1137
|
+
Output: {"name":"allow-get-github-google","description":"Allow GET requests to GitHub and Google APIs","effect":"ALLOW","priority":10,"conditions":{"tools":["http.request"],"capabilities":["read"],"methods":["GET"],"domains":["api.github.com","*.googleapis.com"]}}
|
|
1138
|
+
|
|
1139
|
+
Input: "Block all delete operations"
|
|
1140
|
+
Output: {"name":"block-delete-ops","description":"Block all delete operations","effect":"DENY","priority":5,"conditions":{"capabilities":["delete"],"methods":["DELETE"]}}
|
|
1141
|
+
|
|
1142
|
+
Input: "Allow read-only access"
|
|
1143
|
+
Output: {"name":"allow-read-only","description":"Allow read-only access","effect":"ALLOW","priority":10,"conditions":{"capabilities":["read"],"methods":["GET","HEAD","OPTIONS"]}}
|
|
1144
|
+
|
|
1145
|
+
Input: "Require approval for write operations to Slack"
|
|
1146
|
+
Output: {"name":"approve-slack-writes","description":"Require approval for write operations to Slack","effect":"REQUIRE_APPROVAL","priority":15,"conditions":{"capabilities":["write"],"methods":["POST","PUT","PATCH"],"domains":["api.slack.com"]},"approval":{"scope":"team_lead","ttl_seconds":3600,"reason":"Write operations to Slack require approval"}}`;
|
|
1147
|
+
|
|
1148
|
+
try {
|
|
1149
|
+
const controller = new AbortController();
|
|
1150
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
1151
|
+
|
|
1152
|
+
// Detect provider from API key prefix
|
|
1153
|
+
const isOpenAI = apiKey.startsWith('sk-proj-') || apiKey.startsWith('sk-');
|
|
1154
|
+
const llmUrl = isOpenAI
|
|
1155
|
+
? 'https://api.openai.com/v1/chat/completions'
|
|
1156
|
+
: 'https://api.anthropic.com/v1/messages';
|
|
1157
|
+
const llmHeaders: Record<string, string> = isOpenAI
|
|
1158
|
+
? { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }
|
|
1159
|
+
: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' };
|
|
1160
|
+
const llmBody = isOpenAI
|
|
1161
|
+
? JSON.stringify({
|
|
1162
|
+
model: 'gpt-4.1-mini',
|
|
1163
|
+
max_tokens: 1024,
|
|
1164
|
+
messages: [
|
|
1165
|
+
{ role: 'system', content: systemPrompt },
|
|
1166
|
+
{ role: 'user', content: trimmed },
|
|
1167
|
+
],
|
|
1168
|
+
})
|
|
1169
|
+
: JSON.stringify({
|
|
1170
|
+
model: 'claude-sonnet-4-5-20241022',
|
|
1171
|
+
max_tokens: 1024,
|
|
1172
|
+
system: systemPrompt,
|
|
1173
|
+
messages: [{ role: 'user', content: trimmed }],
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
const llmRes = await fetch(llmUrl, {
|
|
1177
|
+
method: 'POST',
|
|
1178
|
+
headers: llmHeaders,
|
|
1179
|
+
body: llmBody,
|
|
1180
|
+
signal: controller.signal,
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
clearTimeout(timeout);
|
|
1184
|
+
|
|
1185
|
+
if (!llmRes.ok) {
|
|
1186
|
+
const errBody = await llmRes.text();
|
|
1187
|
+
console.error('[generate-rule] LLM API error:', llmRes.status, errBody);
|
|
1188
|
+
res.status(502).json({ error: 'Failed to generate rule. LLM API returned an error.' });
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const llmData = await llmRes.json() as any;
|
|
1193
|
+
const text = isOpenAI
|
|
1194
|
+
? (llmData.choices?.[0]?.message?.content || '')
|
|
1195
|
+
: (llmData.content?.[0]?.text || '');
|
|
1196
|
+
|
|
1197
|
+
// Strip markdown code fences if present
|
|
1198
|
+
const cleaned = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim();
|
|
1199
|
+
|
|
1200
|
+
let rule;
|
|
1201
|
+
try {
|
|
1202
|
+
rule = JSON.parse(cleaned);
|
|
1203
|
+
} catch {
|
|
1204
|
+
console.error('[generate-rule] Failed to parse LLM output:', cleaned);
|
|
1205
|
+
res.status(500).json({ error: 'Failed to parse generated rule. Please try rephrasing your description.' });
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Normalize effect to uppercase
|
|
1210
|
+
if (rule.effect && typeof rule.effect === 'string') {
|
|
1211
|
+
rule.effect = rule.effect.toUpperCase();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Ensure required fields exist
|
|
1215
|
+
if (!rule.name) rule.name = 'generated-rule';
|
|
1216
|
+
if (!rule.conditions) rule.conditions = {};
|
|
1217
|
+
if (!rule.effect) rule.effect = 'DENY';
|
|
1218
|
+
|
|
1219
|
+
// Strip unknown top-level fields
|
|
1220
|
+
const validKeys = ['name', 'description', 'effect', 'priority', 'conditions', 'transformations', 'approval'];
|
|
1221
|
+
for (const key of Object.keys(rule)) {
|
|
1222
|
+
if (!validKeys.includes(key)) delete rule[key];
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
res.json({ rule });
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
if ((err as Error).name === 'AbortError') {
|
|
1228
|
+
res.status(504).json({ error: 'Rule generation timed out. Please try again.' });
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
console.error('[generate-rule] Unexpected error:', err);
|
|
1232
|
+
res.status(500).json({ error: 'Failed to generate rule. Please try again.' });
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
// ---------------------------------------------------------------------------
|
|
1237
|
+
// Security (DLP detections dashboard)
|
|
1238
|
+
// ---------------------------------------------------------------------------
|
|
1239
|
+
|
|
1240
|
+
router.get('/workspaces/:id/security', (req: Request, res: Response) => {
|
|
1241
|
+
if (!requireSession(req, res)) return;
|
|
1242
|
+
const user = (req as any).sessionUser;
|
|
1243
|
+
const workspaceId = param(req, 'id');
|
|
1244
|
+
|
|
1245
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1246
|
+
if (!membership) {
|
|
1247
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
|
|
1252
|
+
const offset = parseInt(req.query.offset as string) || 0;
|
|
1253
|
+
const severity = req.query.severity as string | undefined;
|
|
1254
|
+
const tool = req.query.tool as string | undefined;
|
|
1255
|
+
const actor = req.query.actor as string | undefined;
|
|
1256
|
+
const q = req.query.q as string | undefined;
|
|
1257
|
+
|
|
1258
|
+
const allDlpEvents = gateway.getAuditLogger().getEventsByType('DLP_SCANNED');
|
|
1259
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
1260
|
+
const wsSlug = workspace?.slug;
|
|
1261
|
+
const wsEvents = allDlpEvents
|
|
1262
|
+
.filter(e => e.workspace_id === workspaceId || (wsSlug && e.workspace_id === wsSlug))
|
|
1263
|
+
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
1264
|
+
|
|
1265
|
+
// Apply optional filters
|
|
1266
|
+
let filtered = wsEvents;
|
|
1267
|
+
if (severity) filtered = filtered.filter(e => (e.metadata?.severity as string) === severity);
|
|
1268
|
+
if (tool) filtered = filtered.filter(e => e.tool_name.toLowerCase().includes(tool.toLowerCase()));
|
|
1269
|
+
if (actor) filtered = filtered.filter(e => e.actor_id.toLowerCase().includes(actor.toLowerCase()));
|
|
1270
|
+
if (q) {
|
|
1271
|
+
const ql = q.toLowerCase();
|
|
1272
|
+
filtered = filtered.filter(e =>
|
|
1273
|
+
e.tool_name.toLowerCase().includes(ql) ||
|
|
1274
|
+
e.actor_id.toLowerCase().includes(ql) ||
|
|
1275
|
+
((e.metadata?.detected as string[]) || []).some(p => p.toLowerCase().includes(ql))
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Compute severity stats over filtered results
|
|
1280
|
+
let high = 0, medium = 0, low = 0;
|
|
1281
|
+
const patternSet = new Set<string>();
|
|
1282
|
+
for (const e of filtered) {
|
|
1283
|
+
const sev = e.metadata?.severity as string;
|
|
1284
|
+
if (sev === 'high') high++;
|
|
1285
|
+
else if (sev === 'medium') medium++;
|
|
1286
|
+
else low++;
|
|
1287
|
+
const detected = e.metadata?.detected as string[];
|
|
1288
|
+
if (Array.isArray(detected)) {
|
|
1289
|
+
for (const p of detected) patternSet.add(p);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
res.json({
|
|
1294
|
+
events: filtered.slice(offset, offset + limit),
|
|
1295
|
+
total: filtered.length,
|
|
1296
|
+
limit,
|
|
1297
|
+
offset,
|
|
1298
|
+
stats: {
|
|
1299
|
+
total: filtered.length,
|
|
1300
|
+
high,
|
|
1301
|
+
medium,
|
|
1302
|
+
low,
|
|
1303
|
+
unique_patterns: patternSet.size,
|
|
1304
|
+
},
|
|
1305
|
+
});
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
// ---------------------------------------------------------------------------
|
|
1309
|
+
// Workspace Switch
|
|
1310
|
+
// ---------------------------------------------------------------------------
|
|
1311
|
+
|
|
1312
|
+
router.post('/workspaces/:id/switch', (req: Request, res: Response) => {
|
|
1313
|
+
if (!requireSession(req, res)) return;
|
|
1314
|
+
const user = (req as any).sessionUser;
|
|
1315
|
+
const workspaceId = param(req, 'id');
|
|
1316
|
+
|
|
1317
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1318
|
+
if (!membership) {
|
|
1319
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
1324
|
+
if (!workspace) {
|
|
1325
|
+
res.status(404).json({ error: 'Workspace not found' });
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Update session to point to this workspace
|
|
1330
|
+
const sessionData = (req as any).sessionData;
|
|
1331
|
+
if (sessionData) {
|
|
1332
|
+
sessionStore.update(sessionData.id, { workspace_id: workspaceId });
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
res.json({ ...workspace, role: membership.role });
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
// ---------------------------------------------------------------------------
|
|
1339
|
+
// Members
|
|
1340
|
+
// ---------------------------------------------------------------------------
|
|
1341
|
+
|
|
1342
|
+
router.get('/workspaces/:id/members', (req: Request, res: Response) => {
|
|
1343
|
+
if (!requireSession(req, res)) return;
|
|
1344
|
+
const user = (req as any).sessionUser;
|
|
1345
|
+
const workspaceId = param(req, 'id');
|
|
1346
|
+
|
|
1347
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1348
|
+
if (!membership) {
|
|
1349
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const members = workspaceMemberStore.getByWorkspace(workspaceId);
|
|
1354
|
+
const enriched = members.map(m => {
|
|
1355
|
+
const u = userStore.getById(m.user_id);
|
|
1356
|
+
return {
|
|
1357
|
+
id: m.id,
|
|
1358
|
+
user_id: m.user_id,
|
|
1359
|
+
role: m.role,
|
|
1360
|
+
joined_at: m.joined_at,
|
|
1361
|
+
email: u?.email || '',
|
|
1362
|
+
display_name: u?.display_name || '',
|
|
1363
|
+
};
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
res.json({ members: enriched, viewer_role: membership.role });
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
router.put('/workspaces/:id/members/:memberId', (req: Request, res: Response) => {
|
|
1370
|
+
if (!requireSession(req, res)) return;
|
|
1371
|
+
const user = (req as any).sessionUser;
|
|
1372
|
+
const workspaceId = param(req, 'id');
|
|
1373
|
+
const memberId = param(req, 'memberId');
|
|
1374
|
+
|
|
1375
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1376
|
+
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
|
|
1377
|
+
res.status(403).json({ error: 'Only workspace owners and admins can change roles' });
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const target = workspaceMemberStore.getById(memberId);
|
|
1382
|
+
if (!target || target.workspace_id !== workspaceId) {
|
|
1383
|
+
res.status(404).json({ error: 'Member not found' });
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// Cannot change own role
|
|
1388
|
+
if (target.user_id === user.id) {
|
|
1389
|
+
res.status(400).json({ error: 'Cannot change your own role' });
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
const { role } = req.body;
|
|
1394
|
+
const validRoles = ['owner', 'admin', 'member', 'viewer'];
|
|
1395
|
+
if (!role || !validRoles.includes(role)) {
|
|
1396
|
+
res.status(400).json({ error: `role must be one of: ${validRoles.join(', ')}` });
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
const updated = workspaceMemberStore.update(memberId, { role });
|
|
1401
|
+
res.json(updated);
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
router.delete('/workspaces/:id/members/:memberId', (req: Request, res: Response) => {
|
|
1405
|
+
if (!requireSession(req, res)) return;
|
|
1406
|
+
const user = (req as any).sessionUser;
|
|
1407
|
+
const workspaceId = param(req, 'id');
|
|
1408
|
+
const memberId = param(req, 'memberId');
|
|
1409
|
+
|
|
1410
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1411
|
+
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
|
|
1412
|
+
res.status(403).json({ error: 'Only workspace owners and admins can remove members' });
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const target = workspaceMemberStore.getById(memberId);
|
|
1417
|
+
if (!target || target.workspace_id !== workspaceId) {
|
|
1418
|
+
res.status(404).json({ error: 'Member not found' });
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Cannot remove yourself
|
|
1423
|
+
if (target.user_id === user.id) {
|
|
1424
|
+
res.status(400).json({ error: 'Cannot remove yourself' });
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// Prevent removing the last owner
|
|
1429
|
+
if (target.role === 'owner') {
|
|
1430
|
+
const owners = workspaceMemberStore.getByWorkspace(workspaceId).filter(m => m.role === 'owner');
|
|
1431
|
+
if (owners.length <= 1) {
|
|
1432
|
+
res.status(400).json({ error: 'Cannot remove the last owner' });
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
workspaceMemberStore.delete(memberId);
|
|
1438
|
+
res.json({ status: 'ok', id: memberId });
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
router.post('/workspaces/:id/members', (req: Request, res: Response) => {
|
|
1442
|
+
if (!requireSession(req, res)) return;
|
|
1443
|
+
const user = (req as any).sessionUser;
|
|
1444
|
+
const workspaceId = param(req, 'id');
|
|
1445
|
+
|
|
1446
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1447
|
+
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
|
|
1448
|
+
res.status(403).json({ error: 'Only workspace owners and admins can add members' });
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const { email, role } = req.body;
|
|
1453
|
+
if (!email || typeof email !== 'string') {
|
|
1454
|
+
res.status(400).json({ error: 'email is required' });
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// Enforce member limit based on workspace plan
|
|
1459
|
+
const wsForLimit = workspaceStore.getById(workspaceId);
|
|
1460
|
+
if (wsForLimit) {
|
|
1461
|
+
const plan = (wsForLimit.plan || 'free') as PlanTier;
|
|
1462
|
+
const currentMembers = workspaceMemberStore.getByWorkspace(workspaceId);
|
|
1463
|
+
const memberLimitCheck = PlanEnforcer.checkMemberLimit(plan, currentMembers.length);
|
|
1464
|
+
if (!memberLimitCheck.allowed) {
|
|
1465
|
+
res.status(403).json({ error: memberLimitCheck.reason });
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const validRoles = ['admin', 'member', 'viewer'];
|
|
1471
|
+
const memberRole = validRoles.includes(role) ? role : 'member';
|
|
1472
|
+
|
|
1473
|
+
const targetUser = userStore.getByEmail(email.trim().toLowerCase());
|
|
1474
|
+
if (!targetUser) {
|
|
1475
|
+
res.status(404).json({ error: 'User not found' });
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// Check if already a member
|
|
1480
|
+
const existing = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, targetUser.id);
|
|
1481
|
+
if (existing) {
|
|
1482
|
+
res.status(409).json({ error: 'User is already a member of this workspace' });
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const newMember = {
|
|
1487
|
+
id: randomUUID(),
|
|
1488
|
+
workspace_id: workspaceId,
|
|
1489
|
+
user_id: targetUser.id,
|
|
1490
|
+
role: memberRole,
|
|
1491
|
+
joined_at: new Date().toISOString(),
|
|
1492
|
+
};
|
|
1493
|
+
|
|
1494
|
+
workspaceMemberStore.create(newMember);
|
|
1495
|
+
|
|
1496
|
+
const enriched = {
|
|
1497
|
+
id: newMember.id,
|
|
1498
|
+
user_id: targetUser.id,
|
|
1499
|
+
role: newMember.role,
|
|
1500
|
+
joined_at: newMember.joined_at,
|
|
1501
|
+
email: targetUser.email,
|
|
1502
|
+
display_name: targetUser.display_name,
|
|
1503
|
+
};
|
|
1504
|
+
|
|
1505
|
+
res.status(201).json(enriched);
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
// ---------------------------------------------------------------------------
|
|
1509
|
+
// Trace Detail (single task)
|
|
1510
|
+
// ---------------------------------------------------------------------------
|
|
1511
|
+
|
|
1512
|
+
router.get('/workspaces/:id/traces/:taskId', (req: Request, res: Response) => {
|
|
1513
|
+
if (!requireSession(req, res)) return;
|
|
1514
|
+
const user = (req as any).sessionUser;
|
|
1515
|
+
const workspaceId = param(req, 'id');
|
|
1516
|
+
const taskId = param(req, 'taskId');
|
|
1517
|
+
|
|
1518
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1519
|
+
if (!membership) {
|
|
1520
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const events = gateway.getTaskTrace(taskId);
|
|
1525
|
+
// Filter to events belonging to this workspace
|
|
1526
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
1527
|
+
const wsSlug = workspace?.slug;
|
|
1528
|
+
const filteredEvents = events.filter(
|
|
1529
|
+
e => e.workspace_id === workspaceId || (wsSlug && e.workspace_id === wsSlug)
|
|
1530
|
+
);
|
|
1531
|
+
|
|
1532
|
+
// Cap at 200 events to prevent UI overload
|
|
1533
|
+
res.json({
|
|
1534
|
+
task_id: taskId,
|
|
1535
|
+
events: filteredEvents.slice(0, 200),
|
|
1536
|
+
total: filteredEvents.length,
|
|
1537
|
+
});
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
// ---------------------------------------------------------------------------
|
|
1541
|
+
// Dashboard Stats (aggregated metrics for dashboard widgets)
|
|
1542
|
+
// ---------------------------------------------------------------------------
|
|
1543
|
+
|
|
1544
|
+
router.get('/workspaces/:id/dashboard/stats', (req: Request, res: Response) => {
|
|
1545
|
+
if (!requireSession(req, res)) return;
|
|
1546
|
+
const user = (req as any).sessionUser;
|
|
1547
|
+
const workspaceId = param(req, 'id');
|
|
1548
|
+
|
|
1549
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1550
|
+
if (!membership) {
|
|
1551
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
1556
|
+
const wsSlug = workspace?.slug;
|
|
1557
|
+
|
|
1558
|
+
// Use the audit logger's getEventStats for aggregated metrics
|
|
1559
|
+
// Pass both workspace UUID and slug to match events (no ws_default — strict isolation)
|
|
1560
|
+
const stats = gateway.getAuditLogger().getEventStats(workspaceId, 24);
|
|
1561
|
+
const slugStats = wsSlug ? gateway.getAuditLogger().getEventStats(wsSlug, 24) : null;
|
|
1562
|
+
|
|
1563
|
+
// Merge stats from UUID and slug matches only
|
|
1564
|
+
const allStats = [stats, slugStats].filter((s): s is NonNullable<typeof s> => s !== null);
|
|
1565
|
+
const mergeSum = (fn: (s: typeof stats) => number) => allStats.reduce((acc, s) => acc + fn(s), 0);
|
|
1566
|
+
const metrics = {
|
|
1567
|
+
requests_per_minute: Math.round(mergeSum(s => s.requests_per_minute) * 10) / 10,
|
|
1568
|
+
blocked_24h: mergeSum(s => s.blocked_count),
|
|
1569
|
+
avg_latency_ms: Math.round(stats.avg_duration_ms || slugStats?.avg_duration_ms || 0),
|
|
1570
|
+
active_agents: mergeSum(s => s.active_agents),
|
|
1571
|
+
pending_approvals: gateway.getPendingApprovals(workspaceId).length,
|
|
1572
|
+
budget_burn_percent: 0,
|
|
1573
|
+
};
|
|
1574
|
+
|
|
1575
|
+
// Compute budget burn
|
|
1576
|
+
const spending = gateway.getBudgetManager().getSpendingSummary();
|
|
1577
|
+
const budgetConfig = config.budget;
|
|
1578
|
+
if (budgetConfig.workspace_monthly_budget_usd && budgetConfig.workspace_monthly_budget_usd > 0) {
|
|
1579
|
+
metrics.budget_burn_percent = Math.round(
|
|
1580
|
+
(spending.workspace_monthly_total / budgetConfig.workspace_monthly_budget_usd) * 100
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Shield score: compute from policy breakdown (across all matching workspace IDs)
|
|
1585
|
+
const totalDecisions = allStats.reduce((acc, s) => acc + Object.values(s.policy_breakdown).reduce((sum, n) => sum + n, 0), 0);
|
|
1586
|
+
const allowCount = allStats.reduce((acc, s) => acc + (s.policy_breakdown['allow'] || 0), 0);
|
|
1587
|
+
const transformCount = allStats.reduce((acc, s) => acc + (s.policy_breakdown['transform'] || 0), 0);
|
|
1588
|
+
const approvalCount = allStats.reduce((acc, s) => acc + (s.policy_breakdown['require_approval'] || 0), 0);
|
|
1589
|
+
const denyCount = allStats.reduce((acc, s) => acc + (s.policy_breakdown['deny'] || 0), 0);
|
|
1590
|
+
|
|
1591
|
+
const shield_score = {
|
|
1592
|
+
score: totalDecisions > 0 ? Math.round(((allowCount + transformCount) / totalDecisions) * 100) : 100,
|
|
1593
|
+
total: totalDecisions,
|
|
1594
|
+
breakdown: {
|
|
1595
|
+
allowed_percent: totalDecisions > 0 ? Math.round((allowCount / totalDecisions) * 100) : 100,
|
|
1596
|
+
transformed_percent: totalDecisions > 0 ? Math.round((transformCount / totalDecisions) * 100) : 0,
|
|
1597
|
+
approval_percent: totalDecisions > 0 ? Math.round((approvalCount / totalDecisions) * 100) : 0,
|
|
1598
|
+
blocked_percent: totalDecisions > 0 ? Math.round((denyCount / totalDecisions) * 100) : 0,
|
|
1599
|
+
allowed_count: allowCount,
|
|
1600
|
+
transformed_count: transformCount,
|
|
1601
|
+
approval_count: approvalCount,
|
|
1602
|
+
blocked_count: denyCount,
|
|
1603
|
+
},
|
|
1604
|
+
};
|
|
1605
|
+
|
|
1606
|
+
// Pipeline throughput from stats (merged across all matching workspace IDs)
|
|
1607
|
+
const pipeline_throughput = stats.pipeline_throughput.map(stage => ({
|
|
1608
|
+
...stage,
|
|
1609
|
+
passed: allStats.reduce((acc, s) => acc + (s.pipeline_throughput.find(t => t.stage === stage.stage)?.passed || 0), 0),
|
|
1610
|
+
failed: allStats.reduce((acc, s) => acc + (s.pipeline_throughput.find(t => t.stage === stage.stage)?.failed || 0), 0),
|
|
1611
|
+
}));
|
|
1612
|
+
|
|
1613
|
+
res.json({
|
|
1614
|
+
metrics,
|
|
1615
|
+
shield_score,
|
|
1616
|
+
pipeline_throughput,
|
|
1617
|
+
});
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
// ---------------------------------------------------------------------------
|
|
1621
|
+
// Anomaly Baseline (for anomaly radar widget)
|
|
1622
|
+
// ---------------------------------------------------------------------------
|
|
1623
|
+
|
|
1624
|
+
router.get('/workspaces/:id/anomalies/baseline', (req: Request, res: Response) => {
|
|
1625
|
+
if (!requireSession(req, res)) return;
|
|
1626
|
+
const user = (req as any).sessionUser;
|
|
1627
|
+
const workspaceId = param(req, 'id');
|
|
1628
|
+
|
|
1629
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1630
|
+
if (!membership) {
|
|
1631
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
const detector = gateway.getAnomalyDetector();
|
|
1636
|
+
if (!detector) {
|
|
1637
|
+
res.json({ current: {}, baseline: {}, alerts: [] });
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
const report = detector.getBaselineReport(workspaceId);
|
|
1642
|
+
res.json(report);
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
// ---------------------------------------------------------------------------
|
|
1646
|
+
// Agent Trust Score
|
|
1647
|
+
// ---------------------------------------------------------------------------
|
|
1648
|
+
|
|
1649
|
+
router.get('/workspaces/:id/agents/:actorId/trust', (req: Request, res: Response) => {
|
|
1650
|
+
if (!requireSession(req, res)) return;
|
|
1651
|
+
const user = (req as any).sessionUser;
|
|
1652
|
+
const workspaceId = param(req, 'id');
|
|
1653
|
+
const actorId = param(req, 'actorId');
|
|
1654
|
+
|
|
1655
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1656
|
+
if (!membership) {
|
|
1657
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const detector = gateway.getAnomalyDetector();
|
|
1662
|
+
if (!detector) {
|
|
1663
|
+
res.json({ actor_id: actorId, score: 100, risk_level: 'low', breakdown: {}, calculated_at: new Date().toISOString() });
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
const calculator = new TrustScoreCalculator(
|
|
1668
|
+
detector,
|
|
1669
|
+
gateway.getAuditLogger(),
|
|
1670
|
+
gateway.getBudgetManager(),
|
|
1671
|
+
);
|
|
1672
|
+
|
|
1673
|
+
res.json(calculator.calculate(actorId));
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
router.get('/workspaces/:id/agents/trust-leaderboard', (req: Request, res: Response) => {
|
|
1677
|
+
if (!requireSession(req, res)) return;
|
|
1678
|
+
const user = (req as any).sessionUser;
|
|
1679
|
+
const workspaceId = param(req, 'id');
|
|
1680
|
+
|
|
1681
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1682
|
+
if (!membership) {
|
|
1683
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// Get distinct actor IDs from workspace events
|
|
1688
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
1689
|
+
const wsSlug = workspace?.slug;
|
|
1690
|
+
const allEvents = gateway.getAuditLogger().getAllEvents();
|
|
1691
|
+
const actorIds = new Set<string>();
|
|
1692
|
+
for (const e of allEvents) {
|
|
1693
|
+
if (e.workspace_id === workspaceId || (wsSlug && e.workspace_id === wsSlug)) {
|
|
1694
|
+
if (e.actor_id) actorIds.add(e.actor_id);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
const detector = gateway.getAnomalyDetector();
|
|
1699
|
+
if (!detector || actorIds.size === 0) {
|
|
1700
|
+
res.json({ agents: [] });
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
const calculator = new TrustScoreCalculator(
|
|
1705
|
+
detector,
|
|
1706
|
+
gateway.getAuditLogger(),
|
|
1707
|
+
gateway.getBudgetManager(),
|
|
1708
|
+
);
|
|
1709
|
+
|
|
1710
|
+
const leaderboard = calculator.getLeaderboard([...actorIds]);
|
|
1711
|
+
res.json({ agents: leaderboard });
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
// ---------------------------------------------------------------------------
|
|
1715
|
+
// Session Replay
|
|
1716
|
+
// ---------------------------------------------------------------------------
|
|
1717
|
+
|
|
1718
|
+
router.post('/workspaces/:id/replay', (req: Request, res: Response) => {
|
|
1719
|
+
if (!requireSession(req, res)) return;
|
|
1720
|
+
const user = (req as any).sessionUser;
|
|
1721
|
+
const workspaceId = param(req, 'id');
|
|
1722
|
+
|
|
1723
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1724
|
+
if (!membership) {
|
|
1725
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
const { task_id, policy_pack_path } = req.body;
|
|
1730
|
+
if (!task_id || typeof task_id !== 'string') {
|
|
1731
|
+
res.status(400).json({ error: 'task_id is required' });
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
if (!policy_pack_path || typeof policy_pack_path !== 'string') {
|
|
1735
|
+
res.status(400).json({ error: 'policy_pack_path is required' });
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
const engine = new SessionReplayEngine(gateway.getAuditLogger());
|
|
1740
|
+
const result = engine.replay(task_id, policy_pack_path);
|
|
1741
|
+
res.json(result);
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
// ---------------------------------------------------------------------------
|
|
1745
|
+
// LLM Usage (model/provider breakdown)
|
|
1746
|
+
// ---------------------------------------------------------------------------
|
|
1747
|
+
|
|
1748
|
+
router.get('/workspaces/:id/llm-usage', (req: Request, res: Response) => {
|
|
1749
|
+
if (!requireSession(req, res)) return;
|
|
1750
|
+
const user = (req as any).sessionUser;
|
|
1751
|
+
const workspaceId = param(req, 'id');
|
|
1752
|
+
|
|
1753
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1754
|
+
if (!membership) {
|
|
1755
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
const range = (req.query.range as string) || '24h';
|
|
1760
|
+
const rangeMs: Record<string, number> = {
|
|
1761
|
+
'1h': 3_600_000,
|
|
1762
|
+
'6h': 21_600_000,
|
|
1763
|
+
'24h': 86_400_000,
|
|
1764
|
+
'7d': 604_800_000,
|
|
1765
|
+
'30d': 2_592_000_000,
|
|
1766
|
+
};
|
|
1767
|
+
const windowMs = rangeMs[range] || rangeMs['24h'];
|
|
1768
|
+
const cutoff = Date.now() - windowMs;
|
|
1769
|
+
|
|
1770
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
1771
|
+
const wsSlug = workspace?.slug;
|
|
1772
|
+
const allEvents = gateway.getAuditLogger().getAllEvents();
|
|
1773
|
+
|
|
1774
|
+
// Filter events that have model metadata and belong to this workspace
|
|
1775
|
+
const wsEvents = allEvents.filter(e => {
|
|
1776
|
+
if (e.workspace_id !== workspaceId && (!wsSlug || e.workspace_id !== wsSlug)) return false;
|
|
1777
|
+
if (new Date(e.timestamp).getTime() < cutoff) return false;
|
|
1778
|
+
return e.metadata?.model;
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
// Aggregate by model
|
|
1782
|
+
const modelMap = new Map<string, {
|
|
1783
|
+
model: string;
|
|
1784
|
+
provider: string;
|
|
1785
|
+
requests: number;
|
|
1786
|
+
input_tokens: number;
|
|
1787
|
+
output_tokens: number;
|
|
1788
|
+
cost_usd: number;
|
|
1789
|
+
total_latency_ms: number;
|
|
1790
|
+
}>();
|
|
1791
|
+
|
|
1792
|
+
let totalRequests = 0;
|
|
1793
|
+
let totalCostUsd = 0;
|
|
1794
|
+
let totalTokens = 0;
|
|
1795
|
+
let totalLatencyMs = 0;
|
|
1796
|
+
|
|
1797
|
+
for (const e of wsEvents) {
|
|
1798
|
+
const model = e.metadata!.model as string;
|
|
1799
|
+
const provider = (e.metadata!.provider as string) || 'unknown';
|
|
1800
|
+
const inputTokens = (e.metadata!.input_tokens as number) || 0;
|
|
1801
|
+
const outputTokens = (e.metadata!.output_tokens as number) || 0;
|
|
1802
|
+
const costUsd = (e.metadata!.cost_usd as number) || 0;
|
|
1803
|
+
const durationMs = (e.metadata!.duration_ms as number) || 0;
|
|
1804
|
+
|
|
1805
|
+
let entry = modelMap.get(model);
|
|
1806
|
+
if (!entry) {
|
|
1807
|
+
entry = { model, provider, requests: 0, input_tokens: 0, output_tokens: 0, cost_usd: 0, total_latency_ms: 0 };
|
|
1808
|
+
modelMap.set(model, entry);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
entry.requests++;
|
|
1812
|
+
entry.input_tokens += inputTokens;
|
|
1813
|
+
entry.output_tokens += outputTokens;
|
|
1814
|
+
entry.cost_usd += costUsd;
|
|
1815
|
+
entry.total_latency_ms += durationMs;
|
|
1816
|
+
|
|
1817
|
+
totalRequests++;
|
|
1818
|
+
totalCostUsd += costUsd;
|
|
1819
|
+
totalTokens += inputTokens + outputTokens;
|
|
1820
|
+
totalLatencyMs += durationMs;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
const byModel = Array.from(modelMap.values()).map(m => ({
|
|
1824
|
+
model: m.model,
|
|
1825
|
+
provider: m.provider,
|
|
1826
|
+
requests: m.requests,
|
|
1827
|
+
input_tokens: m.input_tokens,
|
|
1828
|
+
output_tokens: m.output_tokens,
|
|
1829
|
+
cost_usd: m.cost_usd,
|
|
1830
|
+
avg_latency_ms: m.requests > 0 ? Math.round(m.total_latency_ms / m.requests) : 0,
|
|
1831
|
+
})).sort((a, b) => b.requests - a.requests);
|
|
1832
|
+
|
|
1833
|
+
// Build time series (bucket events into intervals)
|
|
1834
|
+
const bucketCount = Math.min(24, Math.max(6, Math.floor(windowMs / 3_600_000)));
|
|
1835
|
+
const bucketMs = windowMs / bucketCount;
|
|
1836
|
+
const timeSeries: { timestamp: string; requests: number; cost_usd: number; tokens: number }[] = [];
|
|
1837
|
+
|
|
1838
|
+
for (let i = 0; i < bucketCount; i++) {
|
|
1839
|
+
const bucketStart = cutoff + i * bucketMs;
|
|
1840
|
+
const bucketEnd = bucketStart + bucketMs;
|
|
1841
|
+
let requests = 0;
|
|
1842
|
+
let cost = 0;
|
|
1843
|
+
let tokens = 0;
|
|
1844
|
+
|
|
1845
|
+
for (const e of wsEvents) {
|
|
1846
|
+
const t = new Date(e.timestamp).getTime();
|
|
1847
|
+
if (t >= bucketStart && t < bucketEnd) {
|
|
1848
|
+
requests++;
|
|
1849
|
+
cost += (e.metadata!.cost_usd as number) || 0;
|
|
1850
|
+
tokens += ((e.metadata!.input_tokens as number) || 0) + ((e.metadata!.output_tokens as number) || 0);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
timeSeries.push({
|
|
1855
|
+
timestamp: new Date(bucketStart).toISOString(),
|
|
1856
|
+
requests,
|
|
1857
|
+
cost_usd: cost,
|
|
1858
|
+
tokens,
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
res.json({
|
|
1863
|
+
summary: {
|
|
1864
|
+
total_requests: totalRequests,
|
|
1865
|
+
total_cost_usd: totalCostUsd,
|
|
1866
|
+
total_tokens: totalTokens,
|
|
1867
|
+
avg_latency_ms: totalRequests > 0 ? Math.round(totalLatencyMs / totalRequests) : 0,
|
|
1868
|
+
},
|
|
1869
|
+
by_model: byModel,
|
|
1870
|
+
time_series: timeSeries,
|
|
1871
|
+
});
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
// ---------------------------------------------------------------------------
|
|
1875
|
+
// Per-Workspace Rate Limit Configuration
|
|
1876
|
+
// ---------------------------------------------------------------------------
|
|
1877
|
+
|
|
1878
|
+
// Ensure stores are available on the gateway
|
|
1879
|
+
if (!gateway.getRateLimitConfigStore()) {
|
|
1880
|
+
gateway.setStores({ rateLimitConfigStore: deps.rateLimitConfigStore || new InMemoryRateLimitConfigStore() });
|
|
1881
|
+
}
|
|
1882
|
+
const rateLimitConfigStore = gateway.getRateLimitConfigStore()!;
|
|
1883
|
+
|
|
1884
|
+
if (!gateway.getBudgetConfigStore()) {
|
|
1885
|
+
gateway.setStores({ budgetConfigStore: deps.budgetConfigStore || new InMemoryBudgetConfigStore() });
|
|
1886
|
+
}
|
|
1887
|
+
const budgetConfigStore = gateway.getBudgetConfigStore()!;
|
|
1888
|
+
|
|
1889
|
+
router.get('/workspaces/:id/rate-limits', (req: Request, res: Response) => {
|
|
1890
|
+
if (!requireSession(req, res)) return;
|
|
1891
|
+
const user = (req as any).sessionUser;
|
|
1892
|
+
const workspaceId = param(req, 'id');
|
|
1893
|
+
|
|
1894
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1895
|
+
if (!membership) {
|
|
1896
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
const wsConfig = rateLimitConfigStore.getByWorkspaceId(workspaceId);
|
|
1901
|
+
const globalConfig = config.rate_limit || { enabled: false, actor_max_per_window: 100, workspace_max_per_window: 500, window_ms: 60000 };
|
|
1902
|
+
const effectiveConfig = wsConfig ? { ...globalConfig, ...wsConfig } : globalConfig;
|
|
1903
|
+
|
|
1904
|
+
res.json({ config: effectiveConfig, is_custom: !!wsConfig });
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
router.put('/workspaces/:id/rate-limits', (req: Request, res: Response) => {
|
|
1908
|
+
if (!requireSession(req, res)) return;
|
|
1909
|
+
const user = (req as any).sessionUser;
|
|
1910
|
+
const workspaceId = param(req, 'id');
|
|
1911
|
+
|
|
1912
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1913
|
+
if (!membership) {
|
|
1914
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
if (!['owner', 'admin'].includes(membership.role)) {
|
|
1918
|
+
res.status(403).json({ error: 'Only workspace owners and admins can modify rate limits' });
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
const body = req.body as WorkspaceRateLimitConfig;
|
|
1923
|
+
|
|
1924
|
+
// Validate: all values must be positive numbers if present
|
|
1925
|
+
const errors: string[] = [];
|
|
1926
|
+
if (body.actor_max_per_window !== undefined && (typeof body.actor_max_per_window !== 'number' || body.actor_max_per_window <= 0)) {
|
|
1927
|
+
errors.push('actor_max_per_window must be a positive number');
|
|
1928
|
+
}
|
|
1929
|
+
if (body.workspace_max_per_window !== undefined && (typeof body.workspace_max_per_window !== 'number' || body.workspace_max_per_window <= 0)) {
|
|
1930
|
+
errors.push('workspace_max_per_window must be a positive number');
|
|
1931
|
+
}
|
|
1932
|
+
if (body.window_ms !== undefined && (typeof body.window_ms !== 'number' || body.window_ms <= 0)) {
|
|
1933
|
+
errors.push('window_ms must be a positive number');
|
|
1934
|
+
}
|
|
1935
|
+
if (errors.length > 0) {
|
|
1936
|
+
res.status(400).json({ error: 'Validation failed', errors });
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const clean: WorkspaceRateLimitConfig = {};
|
|
1941
|
+
if (body.actor_max_per_window !== undefined) clean.actor_max_per_window = body.actor_max_per_window;
|
|
1942
|
+
if (body.workspace_max_per_window !== undefined) clean.workspace_max_per_window = body.workspace_max_per_window;
|
|
1943
|
+
if (body.window_ms !== undefined) clean.window_ms = body.window_ms;
|
|
1944
|
+
|
|
1945
|
+
rateLimitConfigStore.set(workspaceId, clean);
|
|
1946
|
+
|
|
1947
|
+
gateway.getAuditLogger().log({
|
|
1948
|
+
event_type: 'RATE_LIMIT_CONFIG_UPDATED' as any,
|
|
1949
|
+
tool_call_id: '',
|
|
1950
|
+
task_id: '',
|
|
1951
|
+
workspace_id: workspaceId,
|
|
1952
|
+
actor_id: user.id,
|
|
1953
|
+
tool_name: '',
|
|
1954
|
+
metadata: { config: clean, updated_by: user.id },
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
const globalConfig = config.rate_limit || { enabled: false, actor_max_per_window: 100, workspace_max_per_window: 500, window_ms: 60000 };
|
|
1958
|
+
res.json({ config: { ...globalConfig, ...clean }, is_custom: true });
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
router.delete('/workspaces/:id/rate-limits', (req: Request, res: Response) => {
|
|
1962
|
+
if (!requireSession(req, res)) return;
|
|
1963
|
+
const user = (req as any).sessionUser;
|
|
1964
|
+
const workspaceId = param(req, 'id');
|
|
1965
|
+
|
|
1966
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
1967
|
+
if (!membership) {
|
|
1968
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
if (!['owner', 'admin'].includes(membership.role)) {
|
|
1972
|
+
res.status(403).json({ error: 'Only workspace owners and admins can modify rate limits' });
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
rateLimitConfigStore.delete(workspaceId);
|
|
1977
|
+
|
|
1978
|
+
gateway.getAuditLogger().log({
|
|
1979
|
+
event_type: 'RATE_LIMIT_CONFIG_RESET' as any,
|
|
1980
|
+
tool_call_id: '',
|
|
1981
|
+
task_id: '',
|
|
1982
|
+
workspace_id: workspaceId,
|
|
1983
|
+
actor_id: user.id,
|
|
1984
|
+
tool_name: '',
|
|
1985
|
+
metadata: { reset_by: user.id },
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
const globalConfig = config.rate_limit || { enabled: false, actor_max_per_window: 100, workspace_max_per_window: 500, window_ms: 60000 };
|
|
1989
|
+
res.json({ status: 'reset', config: globalConfig, is_custom: false });
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1992
|
+
// ---------------------------------------------------------------------------
|
|
1993
|
+
// Per-Workspace Budget Configuration
|
|
1994
|
+
// ---------------------------------------------------------------------------
|
|
1995
|
+
|
|
1996
|
+
router.get('/workspaces/:id/budget-config', (req: Request, res: Response) => {
|
|
1997
|
+
if (!requireSession(req, res)) return;
|
|
1998
|
+
const user = (req as any).sessionUser;
|
|
1999
|
+
const workspaceId = param(req, 'id');
|
|
2000
|
+
|
|
2001
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
2002
|
+
if (!membership) {
|
|
2003
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
const wsConfig = budgetConfigStore.getByWorkspaceId(workspaceId);
|
|
2008
|
+
const effectiveConfig = wsConfig ? { ...config.budget, ...wsConfig } : config.budget;
|
|
2009
|
+
|
|
2010
|
+
res.json({ config: effectiveConfig, is_custom: !!wsConfig });
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
router.put('/workspaces/:id/budget-config', (req: Request, res: Response) => {
|
|
2014
|
+
if (!requireSession(req, res)) return;
|
|
2015
|
+
const user = (req as any).sessionUser;
|
|
2016
|
+
const workspaceId = param(req, 'id');
|
|
2017
|
+
|
|
2018
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
2019
|
+
if (!membership) {
|
|
2020
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
if (!['owner', 'admin'].includes(membership.role)) {
|
|
2024
|
+
res.status(403).json({ error: 'Only workspace owners and admins can modify budget config' });
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
const body = req.body as WorkspaceBudgetConfig;
|
|
2029
|
+
|
|
2030
|
+
// Validate: all values must be positive numbers if present
|
|
2031
|
+
const errors: string[] = [];
|
|
2032
|
+
const numFields: (keyof WorkspaceBudgetConfig)[] = [
|
|
2033
|
+
'task_budget_usd', 'user_daily_budget_usd', 'user_monthly_budget_usd',
|
|
2034
|
+
'workspace_daily_budget_usd', 'workspace_monthly_budget_usd',
|
|
2035
|
+
'max_steps_per_task', 'max_retries_per_call', 'max_wall_clock_ms',
|
|
2036
|
+
];
|
|
2037
|
+
for (const field of numFields) {
|
|
2038
|
+
const val = body[field];
|
|
2039
|
+
if (val !== undefined && (typeof val !== 'number' || val <= 0)) {
|
|
2040
|
+
errors.push(`${field} must be a positive number`);
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
if (errors.length > 0) {
|
|
2044
|
+
res.status(400).json({ error: 'Validation failed', errors });
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
const clean: WorkspaceBudgetConfig = {};
|
|
2049
|
+
for (const field of numFields) {
|
|
2050
|
+
if (body[field] !== undefined) (clean as any)[field] = body[field];
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
budgetConfigStore.set(workspaceId, clean);
|
|
2054
|
+
|
|
2055
|
+
gateway.getAuditLogger().log({
|
|
2056
|
+
event_type: 'BUDGET_CONFIG_UPDATED' as any,
|
|
2057
|
+
tool_call_id: '',
|
|
2058
|
+
task_id: '',
|
|
2059
|
+
workspace_id: workspaceId,
|
|
2060
|
+
actor_id: user.id,
|
|
2061
|
+
tool_name: '',
|
|
2062
|
+
metadata: { config: clean, updated_by: user.id },
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
res.json({ config: { ...config.budget, ...clean }, is_custom: true });
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
router.delete('/workspaces/:id/budget-config', (req: Request, res: Response) => {
|
|
2069
|
+
if (!requireSession(req, res)) return;
|
|
2070
|
+
const user = (req as any).sessionUser;
|
|
2071
|
+
const workspaceId = param(req, 'id');
|
|
2072
|
+
|
|
2073
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
2074
|
+
if (!membership) {
|
|
2075
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
if (!['owner', 'admin'].includes(membership.role)) {
|
|
2079
|
+
res.status(403).json({ error: 'Only workspace owners and admins can modify budget config' });
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
budgetConfigStore.delete(workspaceId);
|
|
2084
|
+
|
|
2085
|
+
gateway.getAuditLogger().log({
|
|
2086
|
+
event_type: 'BUDGET_CONFIG_RESET' as any,
|
|
2087
|
+
tool_call_id: '',
|
|
2088
|
+
task_id: '',
|
|
2089
|
+
workspace_id: workspaceId,
|
|
2090
|
+
actor_id: user.id,
|
|
2091
|
+
tool_name: '',
|
|
2092
|
+
metadata: { reset_by: user.id },
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
res.json({ status: 'reset', config: config.budget, is_custom: false });
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
// ---------------------------------------------------------------------------
|
|
2099
|
+
// Model Pricing: Get merged pricing (built-in + workspace overrides)
|
|
2100
|
+
// ---------------------------------------------------------------------------
|
|
2101
|
+
|
|
2102
|
+
router.get('/workspaces/:id/model-pricing', (req: Request, res: Response) => {
|
|
2103
|
+
if (!requireSession(req, res)) return;
|
|
2104
|
+
const user = (req as any).sessionUser;
|
|
2105
|
+
const workspaceId = param(req, 'id');
|
|
2106
|
+
|
|
2107
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
2108
|
+
if (!membership) {
|
|
2109
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// Get workspace-level budget config overrides (if any)
|
|
2114
|
+
const wsConfig = budgetConfigStore.getByWorkspaceId(workspaceId);
|
|
2115
|
+
const workspaceOverrides = wsConfig?.token_pricing;
|
|
2116
|
+
|
|
2117
|
+
// Merge: built-in pricing as base, workspace overrides on top
|
|
2118
|
+
const merged: Record<string, { input_per_token: number; output_per_token: number; source: string }> = {};
|
|
2119
|
+
|
|
2120
|
+
for (const [model, pricing] of Object.entries(MODEL_PRICING)) {
|
|
2121
|
+
merged[model] = {
|
|
2122
|
+
input_per_token: pricing.input_per_token,
|
|
2123
|
+
output_per_token: pricing.output_per_token,
|
|
2124
|
+
source: 'built-in',
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
if (workspaceOverrides) {
|
|
2129
|
+
for (const [model, pricing] of Object.entries(workspaceOverrides)) {
|
|
2130
|
+
merged[model] = {
|
|
2131
|
+
input_per_token: pricing.input_per_token,
|
|
2132
|
+
output_per_token: pricing.output_per_token,
|
|
2133
|
+
source: 'workspace',
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
res.json({
|
|
2139
|
+
models: merged,
|
|
2140
|
+
has_workspace_overrides: !!workspaceOverrides,
|
|
2141
|
+
});
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
// ---------------------------------------------------------------------------
|
|
2145
|
+
// Admin: Update Workspace Plan (platform admin only)
|
|
2146
|
+
// ---------------------------------------------------------------------------
|
|
2147
|
+
|
|
2148
|
+
const VALID_PLANS: PlanTier[] = ['free', 'pro', 'business', 'enterprise'];
|
|
2149
|
+
|
|
2150
|
+
router.put('/workspaces/:id/plan', (req: Request, res: Response) => {
|
|
2151
|
+
if (!requireSession(req, res)) return;
|
|
2152
|
+
const user = (req as any).sessionUser;
|
|
2153
|
+
|
|
2154
|
+
const adminEmail = process.env.SEED_ADMIN_EMAIL;
|
|
2155
|
+
if (!adminEmail || user.email !== adminEmail) {
|
|
2156
|
+
res.status(403).json({ error: 'Platform admin access required' });
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
const { plan } = req.body || {};
|
|
2161
|
+
if (!plan || !VALID_PLANS.includes(plan)) {
|
|
2162
|
+
res.status(400).json({ error: `Invalid plan. Must be one of: ${VALID_PLANS.join(', ')}` });
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
const workspaceId = param(req, 'id');
|
|
2167
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
2168
|
+
if (!workspace) {
|
|
2169
|
+
res.status(404).json({ error: 'Workspace not found' });
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
const updated = workspaceStore.update(workspaceId, { plan, updated_at: new Date().toISOString() });
|
|
2174
|
+
res.json(updated);
|
|
2175
|
+
});
|
|
2176
|
+
|
|
2177
|
+
return router;
|
|
2178
|
+
}
|