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.
- package/README.md +182 -0
- package/dist/agent/loop.d.ts +63 -0
- package/dist/agent/loop.js +186 -0
- package/dist/agent/system-prompt.d.ts +7 -0
- package/dist/agent/system-prompt.js +41 -0
- package/dist/agent/tools.d.ts +9 -0
- package/dist/agent/tools.js +154 -0
- package/dist/cli/detect-credentials.d.ts +31 -0
- package/dist/cli/detect-credentials.js +44 -0
- package/dist/cli/import-credentials-handler.d.ts +14 -0
- package/dist/cli/import-credentials-handler.js +22 -0
- package/dist/cli/session-files.d.ts +28 -0
- package/dist/cli/session-files.js +118 -0
- package/dist/cli/setup.d.ts +10 -0
- package/dist/cli/setup.js +915 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +506 -0
- package/dist/dashboard/assets/index-CEFyesbT.js +46 -0
- package/dist/dashboard/assets/index-Dnht2kLU.css +1 -0
- package/dist/dashboard/index.html +13 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1116 -0
- package/dist/ipc/index.d.ts +8 -0
- package/dist/ipc/index.js +8 -0
- package/dist/ipc/native-host.d.ts +96 -0
- package/dist/ipc/native-host.js +223 -0
- package/dist/ipc/websocket-client.d.ts +73 -0
- package/dist/ipc/websocket-client.js +199 -0
- package/dist/license/manager.d.ts +20 -0
- package/dist/license/manager.js +15 -0
- package/dist/llm/client.d.ts +72 -0
- package/dist/llm/client.js +227 -0
- package/dist/llm/credentials.d.ts +61 -0
- package/dist/llm/credentials.js +200 -0
- package/dist/llm/vertex.d.ts +22 -0
- package/dist/llm/vertex.js +335 -0
- package/dist/managed/api-http.test.d.ts +7 -0
- package/dist/managed/api-http.test.js +623 -0
- package/dist/managed/api.d.ts +51 -0
- package/dist/managed/api.js +1448 -0
- package/dist/managed/api.test.d.ts +10 -0
- package/dist/managed/api.test.js +146 -0
- package/dist/managed/auth.d.ts +38 -0
- package/dist/managed/auth.js +192 -0
- package/dist/managed/billing.d.ts +70 -0
- package/dist/managed/billing.js +227 -0
- package/dist/managed/deploy.d.ts +17 -0
- package/dist/managed/deploy.js +385 -0
- package/dist/managed/e2e.test.d.ts +15 -0
- package/dist/managed/e2e.test.js +151 -0
- package/dist/managed/hardening.test.d.ts +14 -0
- package/dist/managed/hardening.test.js +346 -0
- package/dist/managed/integration.test.d.ts +8 -0
- package/dist/managed/integration.test.js +274 -0
- package/dist/managed/log.d.ts +18 -0
- package/dist/managed/log.js +31 -0
- package/dist/managed/server.d.ts +12 -0
- package/dist/managed/server.js +69 -0
- package/dist/managed/store-pg.d.ts +191 -0
- package/dist/managed/store-pg.js +479 -0
- package/dist/managed/store.d.ts +188 -0
- package/dist/managed/store.js +379 -0
- package/dist/relay/auto-start.d.ts +19 -0
- package/dist/relay/auto-start.js +71 -0
- package/dist/relay/server.d.ts +17 -0
- package/dist/relay/server.js +403 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +4 -0
- package/dist/types/session.d.ts +134 -0
- package/dist/types/session.js +16 -0
- package/package.json +61 -0
- package/skills/README.md +48 -0
- package/skills/a11y-auditor/SKILL.md +42 -0
- package/skills/e2e-tester/SKILL.md +154 -0
- package/skills/hanzi-browse/SKILL.md +182 -0
- package/skills/linkedin-prospector/SKILL.md +149 -0
- package/skills/social-poster/SKILL.md +146 -0
- package/skills/x-marketer/SKILL.md +479 -0
|
@@ -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
|
+
}
|