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,135 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { PlanTier, PLAN_LIMITS, PlanLimits } from '../types/subscription';
|
|
3
|
+
import { WorkspaceStore, WorkspaceMemberStore, UserApiKeyStore } from '../storage/interfaces';
|
|
4
|
+
|
|
5
|
+
export interface PlanEnforceResult {
|
|
6
|
+
allowed: boolean;
|
|
7
|
+
reason?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class PlanEnforcer {
|
|
11
|
+
static getLimits(plan: PlanTier): PlanLimits {
|
|
12
|
+
return PLAN_LIMITS[plan];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static checkCallLimit(plan: PlanTier, currentCount: number): { allowed: boolean; limit: number; current: number } {
|
|
16
|
+
const limits = PLAN_LIMITS[plan];
|
|
17
|
+
return {
|
|
18
|
+
allowed: currentCount < limits.calls_per_month,
|
|
19
|
+
limit: limits.calls_per_month,
|
|
20
|
+
current: currentCount,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static checkFeature(plan: PlanTier, feature: string): boolean {
|
|
25
|
+
return PLAN_LIMITS[plan].features.includes(feature);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a workspace can add more members.
|
|
30
|
+
*/
|
|
31
|
+
static checkMemberLimit(
|
|
32
|
+
plan: PlanTier,
|
|
33
|
+
currentMemberCount: number,
|
|
34
|
+
): PlanEnforceResult {
|
|
35
|
+
const limits = PLAN_LIMITS[plan];
|
|
36
|
+
if (currentMemberCount >= limits.members_per_workspace) {
|
|
37
|
+
return {
|
|
38
|
+
allowed: false,
|
|
39
|
+
reason: `Plan "${plan}" allows a maximum of ${limits.members_per_workspace} members per workspace. Current: ${currentMemberCount}.`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { allowed: true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if a workspace can create more API keys.
|
|
47
|
+
*/
|
|
48
|
+
static checkApiKeyLimit(
|
|
49
|
+
plan: PlanTier,
|
|
50
|
+
currentKeyCount: number,
|
|
51
|
+
): PlanEnforceResult {
|
|
52
|
+
const limits = PLAN_LIMITS[plan];
|
|
53
|
+
if (currentKeyCount >= limits.api_keys_per_workspace) {
|
|
54
|
+
return {
|
|
55
|
+
allowed: false,
|
|
56
|
+
reason: `Plan "${plan}" allows a maximum of ${limits.api_keys_per_workspace} API keys per workspace. Current: ${currentKeyCount}.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { allowed: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if an org/user can create more workspaces.
|
|
64
|
+
*/
|
|
65
|
+
static checkWorkspaceLimit(
|
|
66
|
+
plan: PlanTier,
|
|
67
|
+
currentWorkspaceCount: number,
|
|
68
|
+
): PlanEnforceResult {
|
|
69
|
+
const limits = PLAN_LIMITS[plan];
|
|
70
|
+
if (currentWorkspaceCount >= limits.workspaces) {
|
|
71
|
+
return {
|
|
72
|
+
allowed: false,
|
|
73
|
+
reason: `Plan "${plan}" allows a maximum of ${limits.workspaces} workspaces. Current: ${currentWorkspaceCount}.`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { allowed: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// TODO: Integrate into gateway pipeline — call enforce() from gateway.preExecute()
|
|
80
|
+
// or wire createPlanEnforcerMiddleware() in src/server/app.ts before POST /v1/tool/execute.
|
|
81
|
+
/**
|
|
82
|
+
* Enforce call limits for a tool execution.
|
|
83
|
+
* Returns { allowed: true } or { allowed: false, reason: '...' }.
|
|
84
|
+
*/
|
|
85
|
+
static enforce(plan: PlanTier, currentMonthlyCount: number): PlanEnforceResult {
|
|
86
|
+
const check = PlanEnforcer.checkCallLimit(plan, currentMonthlyCount);
|
|
87
|
+
if (!check.allowed) {
|
|
88
|
+
return {
|
|
89
|
+
allowed: false,
|
|
90
|
+
reason: `Monthly call limit reached for plan "${plan}": ${check.current}/${check.limit}. Upgrade your plan for more calls.`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { allowed: true };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Express middleware factory that enforces plan call limits before tool execution.
|
|
99
|
+
*
|
|
100
|
+
* Wire into app.ts:
|
|
101
|
+
* app.post('/v1/tool/execute', createPlanEnforcerMiddleware(deps), executeHandler);
|
|
102
|
+
*/
|
|
103
|
+
export function createPlanEnforcerMiddleware(deps: {
|
|
104
|
+
workspaceStore: WorkspaceStore;
|
|
105
|
+
getMonthlyCallCount: (workspaceId: string) => number;
|
|
106
|
+
}): (req: Request, res: Response, next: NextFunction) => void {
|
|
107
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
108
|
+
const workspaceId = req.body?.workspace_id || (req as any).workspace_id;
|
|
109
|
+
if (!workspaceId) {
|
|
110
|
+
next();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const workspace = deps.workspaceStore.getById(workspaceId);
|
|
115
|
+
if (!workspace) {
|
|
116
|
+
next();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const plan = (workspace.plan || 'free') as PlanTier;
|
|
121
|
+
const count = deps.getMonthlyCallCount(workspaceId);
|
|
122
|
+
const result = PlanEnforcer.enforce(plan, count);
|
|
123
|
+
|
|
124
|
+
if (!result.allowed) {
|
|
125
|
+
res.status(403).json({
|
|
126
|
+
error: result.reason,
|
|
127
|
+
error_code: 'PLAN_LIMIT_EXCEEDED',
|
|
128
|
+
hint: 'Upgrade your plan at /billing or wait until the next billing cycle.',
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
next();
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { Router, Request, Response } from 'express';
|
|
2
|
+
import { StripeClient } from './stripe-client';
|
|
3
|
+
import { PlanEnforcer } from './plan-enforcer';
|
|
4
|
+
import { PlanTier, PLAN_LIMITS } from '../types/subscription';
|
|
5
|
+
import { StripeConfig } from '../types/stripe-config';
|
|
6
|
+
import {
|
|
7
|
+
SubscriptionStore,
|
|
8
|
+
WorkspaceStore,
|
|
9
|
+
WorkspaceMemberStore,
|
|
10
|
+
} from '../storage/interfaces';
|
|
11
|
+
import { Gateway } from '../server/gateway';
|
|
12
|
+
|
|
13
|
+
export interface BillingRouteDeps {
|
|
14
|
+
stripeClient: StripeClient;
|
|
15
|
+
stripeConfig: StripeConfig;
|
|
16
|
+
subscriptionStore: SubscriptionStore;
|
|
17
|
+
workspaceStore: WorkspaceStore;
|
|
18
|
+
workspaceMemberStore: WorkspaceMemberStore;
|
|
19
|
+
gateway: Gateway;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Extract a route param as string (Express 5 returns string | string[]). */
|
|
23
|
+
function param(req: Request, name: string): string {
|
|
24
|
+
const val = req.params[name];
|
|
25
|
+
return Array.isArray(val) ? val[0] : val;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requireSession(req: Request, res: Response): boolean {
|
|
29
|
+
if (!(req as any).sessionUser) {
|
|
30
|
+
res.status(401).json({ error: 'Session authentication required' });
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createBillingRouter(deps: BillingRouteDeps): Router {
|
|
37
|
+
const router = Router();
|
|
38
|
+
const { stripeClient, stripeConfig, subscriptionStore, workspaceStore, workspaceMemberStore, gateway } = deps;
|
|
39
|
+
|
|
40
|
+
// GET /workspaces/:id/billing - current plan + subscription status
|
|
41
|
+
router.get('/workspaces/:id/billing', (req: Request, res: Response) => {
|
|
42
|
+
if (!requireSession(req, res)) return;
|
|
43
|
+
const user = (req as any).sessionUser;
|
|
44
|
+
const workspaceId = param(req, 'id');
|
|
45
|
+
|
|
46
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
47
|
+
if (!membership) {
|
|
48
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
53
|
+
if (!workspace) {
|
|
54
|
+
res.status(404).json({ error: 'Workspace not found' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const subscription = subscriptionStore.getByWorkspace(workspaceId);
|
|
59
|
+
const plan = (workspace.plan || 'free') as PlanTier;
|
|
60
|
+
const limits = PlanEnforcer.getLimits(plan);
|
|
61
|
+
|
|
62
|
+
res.json({
|
|
63
|
+
plan,
|
|
64
|
+
limits,
|
|
65
|
+
subscription: subscription ? {
|
|
66
|
+
id: subscription.id,
|
|
67
|
+
status: subscription.status,
|
|
68
|
+
current_period_start: subscription.current_period_start,
|
|
69
|
+
current_period_end: subscription.current_period_end,
|
|
70
|
+
cancel_at_period_end: subscription.cancel_at_period_end,
|
|
71
|
+
} : null,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// POST /workspaces/:id/billing/checkout - create Stripe Checkout session
|
|
76
|
+
router.post('/workspaces/:id/billing/checkout', async (req: Request, res: Response) => {
|
|
77
|
+
if (!requireSession(req, res)) return;
|
|
78
|
+
const user = (req as any).sessionUser;
|
|
79
|
+
const workspaceId = param(req, 'id');
|
|
80
|
+
|
|
81
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
82
|
+
if (!membership || !['owner', 'admin'].includes(membership.role)) {
|
|
83
|
+
res.status(403).json({ error: 'Only workspace owners and admins can manage billing' });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { plan } = req.body;
|
|
88
|
+
if (!plan || !['pro', 'business'].includes(plan)) {
|
|
89
|
+
res.status(400).json({ error: 'plan must be "pro" or "business"' });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const priceId = plan === 'pro'
|
|
94
|
+
? stripeConfig.price_ids.pro_monthly
|
|
95
|
+
: stripeConfig.price_ids.business_monthly;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
// Get or create Stripe customer
|
|
99
|
+
let subscription = subscriptionStore.getByWorkspace(workspaceId);
|
|
100
|
+
let customerId: string;
|
|
101
|
+
|
|
102
|
+
if (subscription?.stripe_customer_id) {
|
|
103
|
+
customerId = subscription.stripe_customer_id;
|
|
104
|
+
} else {
|
|
105
|
+
const customer = await stripeClient.createCustomer(user.email, workspaceId, user.display_name);
|
|
106
|
+
customerId = customer.id;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const session = await stripeClient.createCheckoutSession(customerId, priceId, workspaceId);
|
|
110
|
+
res.json({ url: session.url });
|
|
111
|
+
} catch (err) {
|
|
112
|
+
const message = err instanceof Error ? err.message : 'Failed to create checkout session';
|
|
113
|
+
res.status(500).json({ error: message });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// POST /workspaces/:id/billing/portal - create Customer Portal session
|
|
118
|
+
router.post('/workspaces/:id/billing/portal', async (req: Request, res: Response) => {
|
|
119
|
+
if (!requireSession(req, res)) return;
|
|
120
|
+
const user = (req as any).sessionUser;
|
|
121
|
+
const workspaceId = param(req, 'id');
|
|
122
|
+
|
|
123
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
124
|
+
if (!membership || !['owner', 'admin'].includes(membership.role)) {
|
|
125
|
+
res.status(403).json({ error: 'Only workspace owners and admins can manage billing' });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const subscription = subscriptionStore.getByWorkspace(workspaceId);
|
|
130
|
+
if (!subscription?.stripe_customer_id) {
|
|
131
|
+
res.status(400).json({ error: 'No billing account found. Subscribe to a plan first.' });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const session = await stripeClient.createPortalSession(subscription.stripe_customer_id);
|
|
137
|
+
res.json({ url: session.url });
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const message = err instanceof Error ? err.message : 'Failed to create portal session';
|
|
140
|
+
res.status(500).json({ error: message });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// GET /workspaces/:id/billing/invoices - invoice history
|
|
145
|
+
router.get('/workspaces/:id/billing/invoices', async (req: Request, res: Response) => {
|
|
146
|
+
if (!requireSession(req, res)) return;
|
|
147
|
+
const user = (req as any).sessionUser;
|
|
148
|
+
const workspaceId = param(req, 'id');
|
|
149
|
+
|
|
150
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
151
|
+
if (!membership) {
|
|
152
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const subscription = subscriptionStore.getByWorkspace(workspaceId);
|
|
157
|
+
if (!subscription?.stripe_customer_id) {
|
|
158
|
+
res.json({ invoices: [] });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const invoices = await stripeClient.getInvoices(subscription.stripe_customer_id);
|
|
164
|
+
res.json({
|
|
165
|
+
invoices: invoices.map(inv => ({
|
|
166
|
+
id: inv.id,
|
|
167
|
+
number: inv.number,
|
|
168
|
+
status: inv.status,
|
|
169
|
+
amount_due: inv.amount_due,
|
|
170
|
+
amount_paid: inv.amount_paid,
|
|
171
|
+
currency: inv.currency,
|
|
172
|
+
period_start: inv.period_start ? new Date(inv.period_start * 1000).toISOString() : null,
|
|
173
|
+
period_end: inv.period_end ? new Date(inv.period_end * 1000).toISOString() : null,
|
|
174
|
+
hosted_invoice_url: inv.hosted_invoice_url,
|
|
175
|
+
created: inv.created ? new Date(inv.created * 1000).toISOString() : null,
|
|
176
|
+
})),
|
|
177
|
+
});
|
|
178
|
+
} catch (err) {
|
|
179
|
+
const message = err instanceof Error ? err.message : 'Failed to fetch invoices';
|
|
180
|
+
res.status(500).json({ error: message });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// GET /workspaces/:id/billing/usage - monthly call count vs limit
|
|
185
|
+
router.get('/workspaces/:id/billing/usage', (req: Request, res: Response) => {
|
|
186
|
+
if (!requireSession(req, res)) return;
|
|
187
|
+
const user = (req as any).sessionUser;
|
|
188
|
+
const workspaceId = param(req, 'id');
|
|
189
|
+
|
|
190
|
+
const membership = workspaceMemberStore.getByWorkspaceAndUser(workspaceId, user.id);
|
|
191
|
+
if (!membership) {
|
|
192
|
+
res.status(403).json({ error: 'Not a member of this workspace' });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const workspace = workspaceStore.getById(workspaceId);
|
|
197
|
+
if (!workspace) {
|
|
198
|
+
res.status(404).json({ error: 'Workspace not found' });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const plan = (workspace.plan || 'free') as PlanTier;
|
|
203
|
+
|
|
204
|
+
// Count events for this workspace in the current month
|
|
205
|
+
const allEvents = gateway.getAuditLogger().getAllEvents();
|
|
206
|
+
const now = new Date();
|
|
207
|
+
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
208
|
+
const wsSlug = workspace.slug;
|
|
209
|
+
|
|
210
|
+
const monthlyCount = allEvents.filter(e => {
|
|
211
|
+
const matches = e.workspace_id === workspaceId || (wsSlug && e.workspace_id === wsSlug);
|
|
212
|
+
if (!matches) return false;
|
|
213
|
+
const eventDate = new Date(e.timestamp);
|
|
214
|
+
return eventDate >= monthStart;
|
|
215
|
+
}).length;
|
|
216
|
+
|
|
217
|
+
const check = PlanEnforcer.checkCallLimit(plan, monthlyCount);
|
|
218
|
+
|
|
219
|
+
res.json({
|
|
220
|
+
plan,
|
|
221
|
+
calls_this_month: monthlyCount,
|
|
222
|
+
calls_limit: check.limit,
|
|
223
|
+
allowed: check.allowed,
|
|
224
|
+
usage_percent: check.limit === Infinity ? 0 : Math.round((monthlyCount / check.limit) * 100),
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return router;
|
|
229
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import Stripe from 'stripe';
|
|
2
|
+
import { StripeConfig } from '../types/stripe-config';
|
|
3
|
+
|
|
4
|
+
export class StripeClient {
|
|
5
|
+
private stripe: Stripe;
|
|
6
|
+
private config: StripeConfig;
|
|
7
|
+
|
|
8
|
+
constructor(config: StripeConfig) {
|
|
9
|
+
this.stripe = new Stripe(config.secret_key);
|
|
10
|
+
this.config = config;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async createCustomer(email: string, workspaceId: string, name?: string): Promise<Stripe.Customer> {
|
|
14
|
+
return this.stripe.customers.create({
|
|
15
|
+
email,
|
|
16
|
+
name: name || undefined,
|
|
17
|
+
metadata: { workspace_id: workspaceId },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async createCheckoutSession(customerId: string, priceId: string, workspaceId: string): Promise<Stripe.Checkout.Session> {
|
|
22
|
+
return this.stripe.checkout.sessions.create({
|
|
23
|
+
customer: customerId,
|
|
24
|
+
mode: 'subscription',
|
|
25
|
+
line_items: [{ price: priceId, quantity: 1 }],
|
|
26
|
+
success_url: this.config.checkout_success_url || `${process.env.APP_BASE_URL || 'http://localhost:5173'}/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
|
27
|
+
cancel_url: this.config.checkout_cancel_url || `${process.env.APP_BASE_URL || 'http://localhost:5173'}/billing?canceled=true`,
|
|
28
|
+
metadata: { workspace_id: workspaceId },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async createPortalSession(customerId: string): Promise<Stripe.BillingPortal.Session> {
|
|
33
|
+
return this.stripe.billingPortal.sessions.create({
|
|
34
|
+
customer: customerId,
|
|
35
|
+
return_url: this.config.portal_return_url || undefined,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async getSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
|
|
40
|
+
return this.stripe.subscriptions.retrieve(subscriptionId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getInvoices(customerId: string, limit: number = 10): Promise<Stripe.Invoice[]> {
|
|
44
|
+
const result = await this.stripe.invoices.list({
|
|
45
|
+
customer: customerId,
|
|
46
|
+
limit,
|
|
47
|
+
});
|
|
48
|
+
return result.data;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
constructEvent(payload: Buffer, signature: string): Stripe.Event {
|
|
52
|
+
return this.stripe.webhooks.constructEvent(
|
|
53
|
+
payload,
|
|
54
|
+
signature,
|
|
55
|
+
this.config.webhook_secret,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import Stripe from 'stripe';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { SubscriptionStore, WorkspaceStore } from '../storage/interfaces';
|
|
4
|
+
import { PlanTier } from '../types/subscription';
|
|
5
|
+
import { StripeConfig } from '../types/stripe-config';
|
|
6
|
+
|
|
7
|
+
export class WebhookHandler {
|
|
8
|
+
constructor(
|
|
9
|
+
private subscriptionStore: SubscriptionStore,
|
|
10
|
+
private workspaceStore: WorkspaceStore,
|
|
11
|
+
private config: StripeConfig,
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
async handleEvent(event: Stripe.Event): Promise<void> {
|
|
15
|
+
switch (event.type) {
|
|
16
|
+
case 'checkout.session.completed':
|
|
17
|
+
await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
|
|
18
|
+
break;
|
|
19
|
+
case 'customer.subscription.updated':
|
|
20
|
+
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
|
|
21
|
+
break;
|
|
22
|
+
case 'customer.subscription.deleted':
|
|
23
|
+
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
|
24
|
+
break;
|
|
25
|
+
case 'invoice.payment_failed':
|
|
26
|
+
await this.handlePaymentFailed(event.data.object as Stripe.Invoice);
|
|
27
|
+
break;
|
|
28
|
+
case 'invoice.payment_succeeded':
|
|
29
|
+
await this.handlePaymentSucceeded(event.data.object as Stripe.Invoice);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private resolvePlan(priceId: string): PlanTier {
|
|
35
|
+
if (priceId === this.config.price_ids.pro_monthly) return 'pro';
|
|
36
|
+
if (priceId === this.config.price_ids.business_monthly) return 'business';
|
|
37
|
+
return 'free';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
|
|
41
|
+
const workspaceId = session.metadata?.workspace_id;
|
|
42
|
+
if (!workspaceId || !session.subscription || !session.customer) return;
|
|
43
|
+
|
|
44
|
+
const subscriptionId = typeof session.subscription === 'string' ? session.subscription : session.subscription.id;
|
|
45
|
+
const customerId = typeof session.customer === 'string' ? session.customer : session.customer.id;
|
|
46
|
+
|
|
47
|
+
// Check for existing subscription by workspace (idempotency)
|
|
48
|
+
const existing = this.subscriptionStore.getByWorkspace(workspaceId);
|
|
49
|
+
if (existing && existing.stripe_subscription_id === subscriptionId) return;
|
|
50
|
+
|
|
51
|
+
// Resolve plan from the checkout session line items (stored in subscription)
|
|
52
|
+
// We need to determine plan from the price ID - use a default for now,
|
|
53
|
+
// the subscription.updated event will set the correct plan
|
|
54
|
+
const now = new Date().toISOString();
|
|
55
|
+
|
|
56
|
+
if (existing) {
|
|
57
|
+
// Update existing subscription
|
|
58
|
+
this.subscriptionStore.update(existing.id, {
|
|
59
|
+
stripe_subscription_id: subscriptionId,
|
|
60
|
+
stripe_customer_id: customerId,
|
|
61
|
+
status: 'active',
|
|
62
|
+
updated_at: now,
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
// Create new subscription record
|
|
66
|
+
this.subscriptionStore.create({
|
|
67
|
+
id: randomUUID(),
|
|
68
|
+
workspace_id: workspaceId,
|
|
69
|
+
stripe_customer_id: customerId,
|
|
70
|
+
stripe_subscription_id: subscriptionId,
|
|
71
|
+
plan: 'pro', // Default; subscription.updated will correct this
|
|
72
|
+
status: 'active',
|
|
73
|
+
created_at: now,
|
|
74
|
+
updated_at: now,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
|
|
80
|
+
const existing = this.subscriptionStore.getByStripeSubscriptionId(subscription.id);
|
|
81
|
+
if (!existing) return;
|
|
82
|
+
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
// Guard: skip if our record is strictly newer than this event's creation timestamp
|
|
85
|
+
const eventCreatedAt = new Date(subscription.created * 1000).toISOString();
|
|
86
|
+
if (existing.updated_at > eventCreatedAt) return;
|
|
87
|
+
|
|
88
|
+
// Resolve plan from the first line item's price
|
|
89
|
+
const priceId = subscription.items?.data?.[0]?.price?.id;
|
|
90
|
+
const plan = priceId ? this.resolvePlan(priceId) : existing.plan;
|
|
91
|
+
|
|
92
|
+
const status = this.mapStripeStatus(subscription.status);
|
|
93
|
+
|
|
94
|
+
// Access period timestamps (Stripe SDK types vary across versions)
|
|
95
|
+
const subAny = subscription as any;
|
|
96
|
+
const periodStart = subAny.current_period_start;
|
|
97
|
+
const periodEnd = subAny.current_period_end;
|
|
98
|
+
|
|
99
|
+
this.subscriptionStore.update(existing.id, {
|
|
100
|
+
plan,
|
|
101
|
+
status,
|
|
102
|
+
current_period_start: periodStart ? new Date(periodStart * 1000).toISOString() : undefined,
|
|
103
|
+
current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : undefined,
|
|
104
|
+
cancel_at_period_end: subscription.cancel_at_period_end,
|
|
105
|
+
updated_at: now,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Update workspace plan
|
|
109
|
+
this.workspaceStore.update(existing.workspace_id, {
|
|
110
|
+
plan,
|
|
111
|
+
updated_at: now,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
|
|
116
|
+
const existing = this.subscriptionStore.getByStripeSubscriptionId(subscription.id);
|
|
117
|
+
if (!existing) return;
|
|
118
|
+
|
|
119
|
+
const now = new Date().toISOString();
|
|
120
|
+
|
|
121
|
+
this.subscriptionStore.update(existing.id, {
|
|
122
|
+
status: 'canceled',
|
|
123
|
+
plan: 'free',
|
|
124
|
+
updated_at: now,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Downgrade workspace to free
|
|
128
|
+
this.workspaceStore.update(existing.workspace_id, {
|
|
129
|
+
plan: 'free',
|
|
130
|
+
updated_at: now,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | undefined {
|
|
135
|
+
// Stripe SDK types vary across versions for invoice.subscription
|
|
136
|
+
const sub = (invoice as any).subscription;
|
|
137
|
+
if (!sub) return undefined;
|
|
138
|
+
return typeof sub === 'string' ? sub : sub.id;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
|
|
142
|
+
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
|
143
|
+
if (!subscriptionId) return;
|
|
144
|
+
|
|
145
|
+
const existing = this.subscriptionStore.getByStripeSubscriptionId(subscriptionId);
|
|
146
|
+
if (!existing) return;
|
|
147
|
+
|
|
148
|
+
this.subscriptionStore.update(existing.id, {
|
|
149
|
+
status: 'past_due',
|
|
150
|
+
updated_at: new Date().toISOString(),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
|
|
155
|
+
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
|
156
|
+
if (!subscriptionId) return;
|
|
157
|
+
|
|
158
|
+
const existing = this.subscriptionStore.getByStripeSubscriptionId(subscriptionId);
|
|
159
|
+
if (!existing) return;
|
|
160
|
+
|
|
161
|
+
if (existing.status === 'past_due' || existing.status === 'incomplete') {
|
|
162
|
+
this.subscriptionStore.update(existing.id, {
|
|
163
|
+
status: 'active',
|
|
164
|
+
updated_at: new Date().toISOString(),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private mapStripeStatus(status: Stripe.Subscription.Status): 'active' | 'past_due' | 'canceled' | 'trialing' | 'incomplete' {
|
|
170
|
+
switch (status) {
|
|
171
|
+
case 'active': return 'active';
|
|
172
|
+
case 'past_due': return 'past_due';
|
|
173
|
+
case 'canceled': return 'canceled';
|
|
174
|
+
case 'trialing': return 'trialing';
|
|
175
|
+
case 'incomplete': return 'incomplete';
|
|
176
|
+
case 'incomplete_expired': return 'canceled';
|
|
177
|
+
case 'unpaid': return 'past_due';
|
|
178
|
+
case 'paused': return 'canceled';
|
|
179
|
+
default: return 'active';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Router, Request, Response } from 'express';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { StripeClient } from './stripe-client';
|
|
4
|
+
import { WebhookHandler } from './webhook-handler';
|
|
5
|
+
|
|
6
|
+
export function createWebhookRouter(stripeClient: StripeClient, handler: WebhookHandler): Router {
|
|
7
|
+
const router = Router();
|
|
8
|
+
|
|
9
|
+
// IMPORTANT: Use express.raw() for Stripe signature verification
|
|
10
|
+
router.post('/stripe/webhook', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
|
|
11
|
+
const sig = req.headers['stripe-signature'] as string;
|
|
12
|
+
if (!sig) {
|
|
13
|
+
res.status(400).json({ error: 'Missing stripe-signature header' });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const event = stripeClient.constructEvent(req.body, sig);
|
|
19
|
+
await handler.handleEvent(event);
|
|
20
|
+
res.json({ received: true });
|
|
21
|
+
} catch (err) {
|
|
22
|
+
const message = err instanceof Error ? err.message : 'Webhook signature verification failed';
|
|
23
|
+
res.status(400).json({ error: message });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return router;
|
|
28
|
+
}
|