hanzi-browse 2.2.0

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.
Files changed (78) hide show
  1. package/README.md +182 -0
  2. package/dist/agent/loop.d.ts +63 -0
  3. package/dist/agent/loop.js +186 -0
  4. package/dist/agent/system-prompt.d.ts +7 -0
  5. package/dist/agent/system-prompt.js +41 -0
  6. package/dist/agent/tools.d.ts +9 -0
  7. package/dist/agent/tools.js +154 -0
  8. package/dist/cli/detect-credentials.d.ts +31 -0
  9. package/dist/cli/detect-credentials.js +44 -0
  10. package/dist/cli/import-credentials-handler.d.ts +14 -0
  11. package/dist/cli/import-credentials-handler.js +22 -0
  12. package/dist/cli/session-files.d.ts +28 -0
  13. package/dist/cli/session-files.js +118 -0
  14. package/dist/cli/setup.d.ts +10 -0
  15. package/dist/cli/setup.js +915 -0
  16. package/dist/cli.d.ts +16 -0
  17. package/dist/cli.js +506 -0
  18. package/dist/dashboard/assets/index-CEFyesbT.js +46 -0
  19. package/dist/dashboard/assets/index-Dnht2kLU.css +1 -0
  20. package/dist/dashboard/index.html +13 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +1116 -0
  23. package/dist/ipc/index.d.ts +8 -0
  24. package/dist/ipc/index.js +8 -0
  25. package/dist/ipc/native-host.d.ts +96 -0
  26. package/dist/ipc/native-host.js +223 -0
  27. package/dist/ipc/websocket-client.d.ts +73 -0
  28. package/dist/ipc/websocket-client.js +199 -0
  29. package/dist/license/manager.d.ts +20 -0
  30. package/dist/license/manager.js +15 -0
  31. package/dist/llm/client.d.ts +72 -0
  32. package/dist/llm/client.js +227 -0
  33. package/dist/llm/credentials.d.ts +61 -0
  34. package/dist/llm/credentials.js +200 -0
  35. package/dist/llm/vertex.d.ts +22 -0
  36. package/dist/llm/vertex.js +335 -0
  37. package/dist/managed/api-http.test.d.ts +7 -0
  38. package/dist/managed/api-http.test.js +623 -0
  39. package/dist/managed/api.d.ts +51 -0
  40. package/dist/managed/api.js +1448 -0
  41. package/dist/managed/api.test.d.ts +10 -0
  42. package/dist/managed/api.test.js +146 -0
  43. package/dist/managed/auth.d.ts +38 -0
  44. package/dist/managed/auth.js +192 -0
  45. package/dist/managed/billing.d.ts +70 -0
  46. package/dist/managed/billing.js +227 -0
  47. package/dist/managed/deploy.d.ts +17 -0
  48. package/dist/managed/deploy.js +385 -0
  49. package/dist/managed/e2e.test.d.ts +15 -0
  50. package/dist/managed/e2e.test.js +151 -0
  51. package/dist/managed/hardening.test.d.ts +14 -0
  52. package/dist/managed/hardening.test.js +346 -0
  53. package/dist/managed/integration.test.d.ts +8 -0
  54. package/dist/managed/integration.test.js +274 -0
  55. package/dist/managed/log.d.ts +18 -0
  56. package/dist/managed/log.js +31 -0
  57. package/dist/managed/server.d.ts +12 -0
  58. package/dist/managed/server.js +69 -0
  59. package/dist/managed/store-pg.d.ts +191 -0
  60. package/dist/managed/store-pg.js +479 -0
  61. package/dist/managed/store.d.ts +188 -0
  62. package/dist/managed/store.js +379 -0
  63. package/dist/relay/auto-start.d.ts +19 -0
  64. package/dist/relay/auto-start.js +71 -0
  65. package/dist/relay/server.d.ts +17 -0
  66. package/dist/relay/server.js +403 -0
  67. package/dist/types/index.d.ts +5 -0
  68. package/dist/types/index.js +4 -0
  69. package/dist/types/session.d.ts +134 -0
  70. package/dist/types/session.js +16 -0
  71. package/package.json +61 -0
  72. package/skills/README.md +48 -0
  73. package/skills/a11y-auditor/SKILL.md +42 -0
  74. package/skills/e2e-tester/SKILL.md +154 -0
  75. package/skills/hanzi-browse/SKILL.md +182 -0
  76. package/skills/linkedin-prospector/SKILL.md +149 -0
  77. package/skills/social-poster/SKILL.md +146 -0
  78. package/skills/x-marketer/SKILL.md +479 -0
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Tests for Managed API
3
+ *
4
+ * Covers:
5
+ * - Browser session pairing/registration lifecycle
6
+ * - Workspace ownership enforcement on tasks
7
+ * - Session connectivity validation
8
+ * - Unauthorized access (wrong workspace, missing auth)
9
+ */
10
+ export {};
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Tests for Managed API
3
+ *
4
+ * Covers:
5
+ * - Browser session pairing/registration lifecycle
6
+ * - Workspace ownership enforcement on tasks
7
+ * - Session connectivity validation
8
+ * - Unauthorized access (wrong workspace, missing auth)
9
+ */
10
+ import { createWorkspace, createApiKey, createPairingToken, consumePairingToken, getBrowserSession, validateSessionToken, createTaskRun, getTaskRun, listTaskRuns, recordUsage, getUsageSummary, } from "./store.js";
11
+ let wsA;
12
+ let wsB;
13
+ let keyA;
14
+ let keyB;
15
+ function setup() {
16
+ wsA = createWorkspace("Workspace A");
17
+ wsB = createWorkspace("Workspace B");
18
+ keyA = createApiKey(wsA.id, "key-a");
19
+ keyB = createApiKey(wsB.id, "key-b");
20
+ }
21
+ function assert(condition, msg) {
22
+ if (!condition)
23
+ throw new Error(`FAIL: ${msg}`);
24
+ console.log(` ✓ ${msg}`);
25
+ }
26
+ // --- Test: Pairing Token Lifecycle ---
27
+ function testPairingTokenLifecycle() {
28
+ console.log("\n--- Pairing Token Lifecycle ---");
29
+ // Create pairing token from workspace A
30
+ const pt = createPairingToken(wsA.id, keyA.id);
31
+ assert(pt._plainToken.startsWith("hic_pair_"), "Pairing token has correct prefix");
32
+ assert(pt.workspaceId === wsA.id, "Pairing token bound to workspace A");
33
+ assert(!pt.consumed, "Pairing token not yet consumed");
34
+ assert(pt.expiresAt > Date.now(), "Pairing token not expired");
35
+ // Consume it using the plaintext token — creates a browser session
36
+ const session = consumePairingToken(pt._plainToken);
37
+ assert(session !== null, "Session created from pairing token");
38
+ assert(session.workspaceId === wsA.id, "Session inherits workspace from pairing token");
39
+ assert(session.sessionToken.startsWith("hic_sess_"), "Session token has correct prefix");
40
+ assert(session.status === "connected", "Session starts as connected");
41
+ // Cannot consume again
42
+ const session2 = consumePairingToken(pt._plainToken);
43
+ assert(session2 === null, "Cannot consume pairing token twice");
44
+ // Invalid token returns null
45
+ const session3 = consumePairingToken("hic_pair_bogus");
46
+ assert(session3 === null, "Invalid pairing token returns null");
47
+ }
48
+ // --- Test: Session Token Validation ---
49
+ function testSessionTokenValidation() {
50
+ console.log("\n--- Session Token Validation ---");
51
+ const pt = createPairingToken(wsA.id, keyA.id);
52
+ const session = consumePairingToken(pt._plainToken);
53
+ // Valid session token (session.sessionToken is plaintext from consumePairingToken)
54
+ const validated = validateSessionToken(session.sessionToken);
55
+ assert(validated !== null, "Valid session token validates");
56
+ assert(validated.id === session.id, "Validated session has correct ID");
57
+ assert(validated.workspaceId === wsA.id, "Validated session has correct workspace");
58
+ // Invalid session token
59
+ const invalid = validateSessionToken("hic_sess_bogus");
60
+ assert(invalid === null, "Invalid session token returns null");
61
+ }
62
+ // --- Test: Workspace Ownership on Tasks ---
63
+ function testWorkspaceOwnership() {
64
+ console.log("\n--- Workspace Ownership ---");
65
+ // Create a task in workspace A
66
+ const task = createTaskRun({
67
+ workspaceId: wsA.id,
68
+ apiKeyId: keyA.id,
69
+ task: "test task",
70
+ browserSessionId: "session-1",
71
+ });
72
+ // Workspace A can see it
73
+ const fromA = getTaskRun(task.id);
74
+ assert(fromA !== null, "Task exists");
75
+ assert(fromA.workspaceId === wsA.id, "Task belongs to workspace A");
76
+ // List tasks for workspace A includes it
77
+ const listA = listTaskRuns(wsA.id);
78
+ assert(listA.some((t) => t.id === task.id), "Workspace A list includes the task");
79
+ // List tasks for workspace B does NOT include it
80
+ const listB = listTaskRuns(wsB.id);
81
+ assert(!listB.some((t) => t.id === task.id), "Workspace B list does NOT include the task");
82
+ // Cross-workspace check
83
+ const crossCheck = getTaskRun(task.id);
84
+ assert(crossCheck.workspaceId !== wsB.id, "Task workspace does not match workspace B");
85
+ }
86
+ // --- Test: Session Must Belong to Workspace ---
87
+ function testSessionWorkspaceBinding() {
88
+ console.log("\n--- Session Workspace Binding ---");
89
+ // Create session in workspace A via pairing token
90
+ const ptA = createPairingToken(wsA.id, keyA.id);
91
+ const sessionA = consumePairingToken(ptA._plainToken);
92
+ // Session belongs to workspace A
93
+ assert(sessionA.workspaceId === wsA.id, "Session belongs to workspace A");
94
+ // Create session in workspace B
95
+ const ptB = createPairingToken(wsB.id, keyB.id);
96
+ const sessionB = consumePairingToken(ptB._plainToken);
97
+ assert(sessionB.workspaceId === wsB.id, "Session belongs to workspace B");
98
+ // Workspace A cannot use workspace B's session for task creation
99
+ // (The API enforces this — we test the data model here)
100
+ const sessionFromStore = getBrowserSession(sessionB.id);
101
+ assert(sessionFromStore.workspaceId !== wsA.id, "Workspace A cannot claim workspace B's session");
102
+ }
103
+ // --- Test: Pairing Token Expiry ---
104
+ function testPairingTokenExpiry() {
105
+ console.log("\n--- Pairing Token Expiry ---");
106
+ // Test that a nonexistent/invalid token returns null (covers the expired path)
107
+ const session = consumePairingToken("hic_pair_this_token_does_not_exist_at_all");
108
+ assert(session === null, "Invalid/nonexistent pairing token cannot be consumed");
109
+ }
110
+ // --- Test: Usage Attribution ---
111
+ function testUsageAttribution() {
112
+ console.log("\n--- Usage Attribution ---");
113
+ const task = createTaskRun({
114
+ workspaceId: wsA.id,
115
+ apiKeyId: keyA.id,
116
+ task: "usage test",
117
+ });
118
+ recordUsage({
119
+ workspaceId: wsA.id,
120
+ apiKeyId: keyA.id,
121
+ taskRunId: task.id,
122
+ inputTokens: 10000,
123
+ outputTokens: 500,
124
+ apiCalls: 5,
125
+ model: "gemini-2.5-flash",
126
+ });
127
+ const summaryA = getUsageSummary(wsA.id);
128
+ assert(summaryA.totalInputTokens >= 10000, "Usage attributed to workspace A");
129
+ assert(summaryA.totalApiCalls >= 5, "API calls attributed to workspace A");
130
+ assert(summaryA.totalCostUsd > 0, "Cost calculated");
131
+ const summaryB = getUsageSummary(wsB.id);
132
+ assert(summaryB.totalInputTokens === 0, "Workspace B has no usage");
133
+ }
134
+ // --- Run all ---
135
+ function runAll() {
136
+ console.log("=== Managed API Tests ===");
137
+ setup();
138
+ testPairingTokenLifecycle();
139
+ testSessionTokenValidation();
140
+ testWorkspaceOwnership();
141
+ testSessionWorkspaceBinding();
142
+ testPairingTokenExpiry();
143
+ testUsageAttribution();
144
+ console.log("\n=== All tests passed ===\n");
145
+ }
146
+ runAll();
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Better Auth Configuration
3
+ *
4
+ * Human auth for the managed platform.
5
+ * - Google sign-in (default)
6
+ * - Email/password (fallback)
7
+ * - Session management
8
+ * - Linked to Hanzi workspace model
9
+ *
10
+ * Better Auth handles: user accounts, sessions, OAuth.
11
+ * Hanzi handles: workspaces, API keys, browser sessions, tasks, billing.
12
+ */
13
+ export declare function createAuth(): any;
14
+ /**
15
+ * Resolve a Better Auth session cookie to workspace info.
16
+ * Returns { userId, workspaceId } or null.
17
+ * Uses direct DB lookup (same reason as resolveSessionProfile).
18
+ */
19
+ export declare function resolveSessionToWorkspace(req: import("http").IncomingMessage): Promise<{
20
+ userId: string;
21
+ workspaceId: string;
22
+ } | null>;
23
+ /**
24
+ * Resolve session to full profile (user name, email, workspace name).
25
+ * Used by GET /v1/me for the developer console.
26
+ *
27
+ * Uses direct DB lookup instead of auth.api.getSession() because
28
+ * Better Auth's cookie reading fails behind Caddy reverse proxy
29
+ * (cookie prefix mismatch between set and read paths).
30
+ */
31
+ export declare function resolveSessionProfile(req: import("http").IncomingMessage): Promise<{
32
+ userId: string;
33
+ workspaceId: string;
34
+ userName: string;
35
+ userEmail: string;
36
+ workspaceName: string;
37
+ plan: string;
38
+ } | null>;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Better Auth Configuration
3
+ *
4
+ * Human auth for the managed platform.
5
+ * - Google sign-in (default)
6
+ * - Email/password (fallback)
7
+ * - Session management
8
+ * - Linked to Hanzi workspace model
9
+ *
10
+ * Better Auth handles: user accounts, sessions, OAuth.
11
+ * Hanzi handles: workspaces, API keys, browser sessions, tasks, billing.
12
+ */
13
+ import { betterAuth } from "better-auth";
14
+ import pg from "pg";
15
+ import { log } from "./log.js";
16
+ const { Pool } = pg;
17
+ const DATABASE_URL = process.env.DATABASE_URL || "";
18
+ // Shared pool for workspace provisioning queries (separate from Better Auth's pool)
19
+ let provisionPool = null;
20
+ function getProvisionPool() {
21
+ if (!provisionPool) {
22
+ provisionPool = new Pool({ connectionString: DATABASE_URL, max: 3 });
23
+ }
24
+ return provisionPool;
25
+ }
26
+ // Singleton — created once, reused across all requests
27
+ let authInstance = null;
28
+ let authInitialized = false;
29
+ export function createAuth() {
30
+ if (authInitialized)
31
+ return authInstance;
32
+ authInitialized = true;
33
+ if (!DATABASE_URL) {
34
+ log.info("No DATABASE_URL — Better Auth disabled");
35
+ return null;
36
+ }
37
+ const authSecret = process.env.BETTER_AUTH_SECRET;
38
+ if (!authSecret) {
39
+ if (process.env.NODE_ENV === "production") {
40
+ log.error("FATAL: BETTER_AUTH_SECRET not set — sessions lost on restart");
41
+ process.exit(1);
42
+ }
43
+ log.warn("BETTER_AUTH_SECRET not set — sessions invalidated on restart");
44
+ }
45
+ authInstance = betterAuth({
46
+ database: new Pool({ connectionString: DATABASE_URL, max: 5 }),
47
+ secret: authSecret,
48
+ baseURL: process.env.BETTER_AUTH_URL || "https://api.hanzilla.co",
49
+ emailAndPassword: {
50
+ enabled: true,
51
+ },
52
+ socialProviders: {
53
+ google: {
54
+ clientId: process.env.GOOGLE_CLIENT_ID || "",
55
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
56
+ },
57
+ },
58
+ basePath: "/api/auth",
59
+ advanced: {
60
+ useSecureCookies: true, // Behind Caddy reverse proxy — force consistent __Secure- prefix
61
+ },
62
+ trustedOrigins: [
63
+ "https://browse.hanzilla.co",
64
+ "https://api.hanzilla.co",
65
+ "http://localhost:3000",
66
+ "http://localhost:3456",
67
+ ],
68
+ databaseHooks: {
69
+ user: {
70
+ create: {
71
+ after: async (user) => {
72
+ // Auto-provision workspace when a new user is created
73
+ const userId = user.id;
74
+ if (!userId)
75
+ return;
76
+ const client = await getProvisionPool().connect();
77
+ try {
78
+ await client.query("BEGIN");
79
+ const wsRes = await client.query("INSERT INTO workspaces (name) VALUES ($1) RETURNING id", [`${user.name || "My"}'s Workspace`]);
80
+ const workspaceId = wsRes.rows[0].id;
81
+ await client.query("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1, $2, 'owner')", [workspaceId, userId]);
82
+ await client.query("COMMIT");
83
+ log.info("Provisioned workspace", { workspaceId }, { userId });
84
+ }
85
+ catch (err) {
86
+ await client.query("ROLLBACK").catch(() => { });
87
+ log.error("Workspace provisioning error", undefined, { error: err.message });
88
+ }
89
+ finally {
90
+ client.release();
91
+ }
92
+ },
93
+ },
94
+ },
95
+ },
96
+ });
97
+ log.info("Better Auth initialized");
98
+ return authInstance;
99
+ }
100
+ /**
101
+ * Resolve a Better Auth session cookie to workspace info.
102
+ * Returns { userId, workspaceId } or null.
103
+ * Uses direct DB lookup (same reason as resolveSessionProfile).
104
+ */
105
+ export async function resolveSessionToWorkspace(req) {
106
+ try {
107
+ const cookieHeader = req.headers.cookie || '';
108
+ const tokenMatch = cookieHeader.match(/better-auth[.\-]session_token=([^;]+)/);
109
+ if (!tokenMatch)
110
+ return null;
111
+ const rawValue = decodeURIComponent(tokenMatch[1]);
112
+ const token = rawValue.split('.')[0];
113
+ if (!token)
114
+ return null;
115
+ const db = getProvisionPool();
116
+ const sessionRes = await db.query(`SELECT "userId", "expiresAt" FROM session WHERE token = $1 LIMIT 1`, [token]);
117
+ if (sessionRes.rows.length === 0)
118
+ return null;
119
+ if (new Date(sessionRes.rows[0].expiresAt) < new Date())
120
+ return null;
121
+ const userId = sessionRes.rows[0].userId;
122
+ const requestedWs = req.headers["x-workspace-id"];
123
+ const query = requestedWs
124
+ ? "SELECT workspace_id FROM workspace_members WHERE user_id = $1 AND workspace_id = $2 LIMIT 1"
125
+ : "SELECT workspace_id FROM workspace_members WHERE user_id = $1 ORDER BY created_at ASC LIMIT 1";
126
+ const params = requestedWs ? [userId, requestedWs] : [userId];
127
+ const res = await db.query(query, params);
128
+ if (res.rows.length === 0)
129
+ return null;
130
+ return { userId, workspaceId: res.rows[0].workspace_id };
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ /**
137
+ * Resolve session to full profile (user name, email, workspace name).
138
+ * Used by GET /v1/me for the developer console.
139
+ *
140
+ * Uses direct DB lookup instead of auth.api.getSession() because
141
+ * Better Auth's cookie reading fails behind Caddy reverse proxy
142
+ * (cookie prefix mismatch between set and read paths).
143
+ */
144
+ export async function resolveSessionProfile(req) {
145
+ try {
146
+ // Extract session token from cookie (handles both __Secure- and plain prefix)
147
+ const cookieHeader = req.headers.cookie || '';
148
+ const tokenMatch = cookieHeader.match(/better-auth[.\-]session_token=([^;]+)/);
149
+ console.error(`[AUTH] step1: match=${!!tokenMatch} cookieLen=${cookieHeader.length}`);
150
+ if (!tokenMatch)
151
+ return null;
152
+ // Token format: "rawToken.signature" — we only need the raw token for DB lookup
153
+ const rawValue = decodeURIComponent(tokenMatch[1]);
154
+ const token = rawValue.split('.')[0];
155
+ console.error(`[AUTH] step2: token=${token.substring(0, 10)}... rawLen=${rawValue.length}`);
156
+ if (!token)
157
+ return null;
158
+ const db = getProvisionPool();
159
+ const sessionRes = await db.query(`SELECT s."userId", s."expiresAt", u.name, u.email
160
+ FROM session s
161
+ JOIN "user" u ON u.id = s."userId"
162
+ WHERE s.token = $1 LIMIT 1`, [token]);
163
+ console.error(`[AUTH] step3: rows=${sessionRes.rows.length}`);
164
+ if (sessionRes.rows.length === 0)
165
+ return null;
166
+ const row = sessionRes.rows[0];
167
+ // Check expiry
168
+ console.error(`[AUTH] step4: userId=${row.userId} expires=${row.expiresAt} expired=${new Date(row.expiresAt) < new Date()}`);
169
+ if (new Date(row.expiresAt) < new Date())
170
+ return null;
171
+ const wsRes = await db.query(`SELECT wm.workspace_id, w.name as workspace_name
172
+ FROM workspace_members wm
173
+ JOIN workspaces w ON w.id = wm.workspace_id
174
+ WHERE wm.user_id = $1
175
+ ORDER BY wm.created_at ASC LIMIT 1`, [row.userId]);
176
+ console.error(`[AUTH] step5: wsRows=${wsRes.rows.length}`);
177
+ if (wsRes.rows.length === 0)
178
+ return null;
179
+ return {
180
+ userId: row.userId,
181
+ workspaceId: wsRes.rows[0].workspace_id,
182
+ userName: row.name || "",
183
+ userEmail: row.email || "",
184
+ workspaceName: wsRes.rows[0].workspace_name,
185
+ plan: "free",
186
+ };
187
+ }
188
+ catch (err) {
189
+ log.error("resolveSessionProfile failed", undefined, { error: err.message });
190
+ return null;
191
+ }
192
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Stripe Billing Integration
3
+ *
4
+ * Wired but not yet activated in production (billing env vars not set).
5
+ *
6
+ * What's implemented:
7
+ * - Checkout session creation with workspace metadata
8
+ * - Webhook handlers persist subscription status to workspace
9
+ * - Usage metering called from task completion flow
10
+ * - Plan gating scaffolded in api.ts (soft check, log only — uncomment to enforce)
11
+ * - Customer ID mapped from checkout.session.completed webhook
12
+ *
13
+ * To activate:
14
+ * 1. Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_MANAGED_PRICE_ID
15
+ * 2. Optionally set STRIPE_API_METER_ID for usage metering
16
+ * 3. Uncomment the 402 return in api.ts handleCreateTask() to enforce plan gating
17
+ * 4. Run schema migrations (ALTER TABLE workspaces ADD COLUMN ...)
18
+ *
19
+ * Requires env vars:
20
+ * - STRIPE_SECRET_KEY
21
+ * - STRIPE_WEBHOOK_SECRET
22
+ * - STRIPE_MANAGED_PRICE_ID (monthly subscription price)
23
+ * - STRIPE_API_METER_ID (usage meter for API tasks)
24
+ */
25
+ import type * as fileStore from "./store.js";
26
+ /** Set the backing store so billing can persist webhook results. */
27
+ export declare function setBillingStore(store: typeof fileStore): void;
28
+ export declare function initBilling(): boolean;
29
+ export declare function isBillingEnabled(): boolean;
30
+ /**
31
+ * Create a Stripe Checkout session to buy credits.
32
+ */
33
+ export declare function createCheckoutSession(params: {
34
+ workspaceId: string;
35
+ userId: string;
36
+ email?: string;
37
+ credits?: number;
38
+ successUrl: string;
39
+ cancelUrl: string;
40
+ }): Promise<{
41
+ url: string;
42
+ }>;
43
+ /**
44
+ * Create a Stripe Billing Portal session for managing subscription.
45
+ */
46
+ export declare function createPortalSession(params: {
47
+ customerId: string;
48
+ returnUrl: string;
49
+ }): Promise<{
50
+ url: string;
51
+ }>;
52
+ /**
53
+ * Record a completed API task for usage-based billing.
54
+ * Uses Stripe's Billing Meter Events API.
55
+ */
56
+ export declare function recordTaskUsage(params: {
57
+ workspaceId: string;
58
+ taskId: string;
59
+ steps: number;
60
+ inputTokens: number;
61
+ outputTokens: number;
62
+ }): Promise<void>;
63
+ /**
64
+ * Handle Stripe webhook events.
65
+ * Returns true if the event was handled, false if not recognized.
66
+ */
67
+ export declare function handleWebhook(rawBody: string, signature: string): Promise<{
68
+ handled: boolean;
69
+ event?: string;
70
+ }>;
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Stripe Billing Integration
3
+ *
4
+ * Wired but not yet activated in production (billing env vars not set).
5
+ *
6
+ * What's implemented:
7
+ * - Checkout session creation with workspace metadata
8
+ * - Webhook handlers persist subscription status to workspace
9
+ * - Usage metering called from task completion flow
10
+ * - Plan gating scaffolded in api.ts (soft check, log only — uncomment to enforce)
11
+ * - Customer ID mapped from checkout.session.completed webhook
12
+ *
13
+ * To activate:
14
+ * 1. Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_MANAGED_PRICE_ID
15
+ * 2. Optionally set STRIPE_API_METER_ID for usage metering
16
+ * 3. Uncomment the 402 return in api.ts handleCreateTask() to enforce plan gating
17
+ * 4. Run schema migrations (ALTER TABLE workspaces ADD COLUMN ...)
18
+ *
19
+ * Requires env vars:
20
+ * - STRIPE_SECRET_KEY
21
+ * - STRIPE_WEBHOOK_SECRET
22
+ * - STRIPE_MANAGED_PRICE_ID (monthly subscription price)
23
+ * - STRIPE_API_METER_ID (usage meter for API tasks)
24
+ */
25
+ import Stripe from "stripe";
26
+ import { log } from "./log.js";
27
+ let stripe = null;
28
+ let S = null;
29
+ /** Set the backing store so billing can persist webhook results. */
30
+ export function setBillingStore(store) {
31
+ S = store;
32
+ }
33
+ export function initBilling() {
34
+ const key = process.env.STRIPE_SECRET_KEY;
35
+ if (!key) {
36
+ log.info("No STRIPE_SECRET_KEY — billing disabled");
37
+ return false;
38
+ }
39
+ stripe = new Stripe(key);
40
+ log.info("Stripe billing initialized");
41
+ return true;
42
+ }
43
+ export function isBillingEnabled() {
44
+ return stripe !== null;
45
+ }
46
+ // --- Credit Packs ---
47
+ const CREDIT_PACKS = {};
48
+ /**
49
+ * Initialize credit packs from env. Format: STRIPE_CREDIT_PACK_<N>=<credits>:<stripe_price_id>
50
+ * Example: STRIPE_CREDIT_PACK_1=100:price_abc123
51
+ * STRIPE_CREDIT_PACK_2=500:price_def456
52
+ * Or use a single default: STRIPE_CREDIT_PRICE_ID for 100 credits.
53
+ */
54
+ function loadCreditPacks() {
55
+ // Default pack
56
+ const defaultPrice = process.env.STRIPE_CREDIT_PRICE_ID;
57
+ if (defaultPrice) {
58
+ CREDIT_PACKS["100"] = { credits: 100, priceId: defaultPrice };
59
+ }
60
+ // Numbered packs
61
+ for (let i = 1; i <= 5; i++) {
62
+ const val = process.env[`STRIPE_CREDIT_PACK_${i}`];
63
+ if (val) {
64
+ const [credits, priceId] = val.split(":");
65
+ if (credits && priceId) {
66
+ CREDIT_PACKS[credits] = { credits: parseInt(credits, 10), priceId };
67
+ }
68
+ }
69
+ }
70
+ }
71
+ /**
72
+ * Create a Stripe Checkout session to buy credits.
73
+ */
74
+ export async function createCheckoutSession(params) {
75
+ if (!stripe || !S)
76
+ throw new Error("Billing not configured");
77
+ loadCreditPacks();
78
+ const requestedCredits = String(params.credits || 100);
79
+ const pack = CREDIT_PACKS[requestedCredits];
80
+ if (!pack) {
81
+ const available = Object.keys(CREDIT_PACKS).join(", ");
82
+ throw new Error(`No credit pack for ${requestedCredits} credits. Available: ${available || "none configured"}`);
83
+ }
84
+ const workspace = await S.getWorkspace(params.workspaceId);
85
+ let customerId;
86
+ if (workspace?.stripeCustomerId) {
87
+ customerId = workspace.stripeCustomerId;
88
+ }
89
+ const sessionParams = {
90
+ mode: "payment",
91
+ line_items: [{ price: pack.priceId, quantity: 1 }],
92
+ success_url: params.successUrl,
93
+ cancel_url: params.cancelUrl,
94
+ metadata: {
95
+ workspace_id: params.workspaceId,
96
+ user_id: params.userId,
97
+ credits: String(pack.credits),
98
+ },
99
+ };
100
+ if (customerId) {
101
+ sessionParams.customer = customerId;
102
+ }
103
+ else {
104
+ sessionParams.customer_email = params.email;
105
+ }
106
+ const session = await stripe.checkout.sessions.create(sessionParams);
107
+ return { url: session.url };
108
+ }
109
+ /**
110
+ * Create a Stripe Billing Portal session for managing subscription.
111
+ */
112
+ export async function createPortalSession(params) {
113
+ if (!stripe)
114
+ throw new Error("Billing not configured");
115
+ const session = await stripe.billingPortal.sessions.create({
116
+ customer: params.customerId,
117
+ return_url: params.returnUrl,
118
+ });
119
+ return { url: session.url };
120
+ }
121
+ // --- Usage Metering ---
122
+ /**
123
+ * Record a completed API task for usage-based billing.
124
+ * Uses Stripe's Billing Meter Events API.
125
+ */
126
+ export async function recordTaskUsage(params) {
127
+ if (!stripe || !S)
128
+ return;
129
+ const meterId = process.env.STRIPE_API_METER_ID;
130
+ if (!meterId)
131
+ return;
132
+ // Look up the workspace's Stripe customer ID
133
+ const workspace = await S.getWorkspace(params.workspaceId);
134
+ if (!workspace?.stripeCustomerId) {
135
+ log.warn("Cannot meter usage — workspace has no Stripe customer ID", { workspaceId: params.workspaceId, taskId: params.taskId });
136
+ return;
137
+ }
138
+ try {
139
+ await stripe.billing.meterEvents.create({
140
+ event_name: "browser_task_completed",
141
+ payload: {
142
+ stripe_customer_id: workspace.stripeCustomerId,
143
+ value: "1",
144
+ },
145
+ });
146
+ }
147
+ catch (err) {
148
+ log.error("Failed to record usage", { taskId: params.taskId }, { error: err.message });
149
+ }
150
+ }
151
+ // --- Webhooks ---
152
+ /**
153
+ * Handle Stripe webhook events.
154
+ * Returns true if the event was handled, false if not recognized.
155
+ */
156
+ export async function handleWebhook(rawBody, signature) {
157
+ if (!stripe)
158
+ return { handled: false };
159
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
160
+ if (!webhookSecret)
161
+ return { handled: false };
162
+ let event;
163
+ try {
164
+ event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
165
+ }
166
+ catch (err) {
167
+ log.error("Webhook signature verification failed", undefined, { error: err.message });
168
+ return { handled: false };
169
+ }
170
+ switch (event.type) {
171
+ case "checkout.session.completed": {
172
+ const session = event.data.object;
173
+ const workspaceId = session.metadata?.workspace_id;
174
+ const credits = parseInt(session.metadata?.credits || "0", 10);
175
+ if (workspaceId && S) {
176
+ try {
177
+ // Persist Stripe customer ID
178
+ await S.updateWorkspaceBilling(workspaceId, {
179
+ stripeCustomerId: session.customer,
180
+ });
181
+ // Add purchased credits
182
+ if (credits > 0) {
183
+ const newBalance = await S.addCredits(workspaceId, credits);
184
+ log.info("Credits purchased", { workspaceId }, { credits, newBalance });
185
+ }
186
+ }
187
+ catch (err) {
188
+ log.error("Failed to persist checkout result", { workspaceId }, { error: err.message });
189
+ }
190
+ }
191
+ return { handled: true, event: event.type };
192
+ }
193
+ case "customer.subscription.updated":
194
+ case "customer.subscription.deleted": {
195
+ const subscription = event.data.object;
196
+ const workspaceId = subscription.metadata?.workspace_id;
197
+ const status = subscription.status;
198
+ if (workspaceId && S) {
199
+ const plan = status === "active" ? "pro" : "free";
200
+ const subStatus = status === "active" ? "active"
201
+ : status === "canceled" ? "cancelled"
202
+ : status === "past_due" ? "past_due"
203
+ : "cancelled";
204
+ try {
205
+ await S.updateWorkspaceBilling(workspaceId, {
206
+ plan,
207
+ subscriptionStatus: subStatus,
208
+ });
209
+ log.info("Subscription updated", { workspaceId }, { event: event.type, plan, status: subStatus });
210
+ }
211
+ catch (err) {
212
+ log.error("Failed to persist subscription update", { workspaceId }, { error: err.message });
213
+ }
214
+ }
215
+ return { handled: true, event: event.type };
216
+ }
217
+ case "invoice.payment_failed": {
218
+ const invoice = event.data.object;
219
+ // Find workspace by Stripe customer ID — requires iterating or a reverse lookup.
220
+ // For now, log the failure. The subscription.updated webhook will handle the status change.
221
+ log.warn("Payment failed", undefined, { customer: String(invoice.customer) });
222
+ return { handled: true, event: event.type };
223
+ }
224
+ default:
225
+ return { handled: false, event: event.type };
226
+ }
227
+ }