@nordsym/apiclaw 1.3.3 → 1.3.5

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 (42) hide show
  1. package/convex/_generated/api.d.ts +6 -0
  2. package/convex/billing.ts +341 -0
  3. package/convex/email.ts +276 -0
  4. package/convex/http.ts +154 -0
  5. package/convex/schema.ts +43 -0
  6. package/convex/workspaces.ts +663 -0
  7. package/dist/cli.d.ts +7 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +272 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/index.js +396 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/session.d.ts +29 -0
  14. package/dist/session.d.ts.map +1 -0
  15. package/dist/session.js +87 -0
  16. package/dist/session.js.map +1 -0
  17. package/docs/PRD-agent-first-billing.md +525 -0
  18. package/docs/PRD-workspace-fixes.md +178 -0
  19. package/landing/package-lock.json +21 -3
  20. package/landing/package.json +2 -1
  21. package/landing/src/app/api/stripe/webhook/route.ts +178 -0
  22. package/landing/src/app/api/workspace-auth/magic-link/route.ts +84 -0
  23. package/landing/src/app/api/workspace-auth/session/route.ts +73 -0
  24. package/landing/src/app/api/workspace-auth/verify/route.ts +57 -0
  25. package/landing/src/app/auth/verify/page.tsx +292 -0
  26. package/landing/src/app/dashboard/layout.tsx +22 -0
  27. package/landing/src/app/dashboard/page.tsx +22 -0
  28. package/landing/src/app/dashboard/verify/page.tsx +108 -0
  29. package/landing/src/app/login/page.tsx +204 -0
  30. package/landing/src/app/page.tsx +23 -7
  31. package/landing/src/app/providers/dashboard/layout.tsx +5 -4
  32. package/landing/src/app/providers/dashboard/page.tsx +11 -641
  33. package/landing/src/app/upgrade/page.tsx +288 -0
  34. package/landing/src/app/workspace/layout.tsx +30 -0
  35. package/landing/src/app/workspace/page.tsx +1637 -0
  36. package/landing/src/lib/stats.json +14 -15
  37. package/landing/src/middleware.ts +50 -0
  38. package/landing/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +1 -1
  40. package/src/cli.ts +320 -0
  41. package/src/index.ts +444 -4
  42. package/src/session.ts +103 -0
@@ -0,0 +1,178 @@
1
+ # PRD: Workspace Navigation & Error Fixes
2
+
3
+ **Date:** 2026-02-28
4
+ **Status:** Draft
5
+ **Priority:** P0 (blocking UX)
6
+
7
+ ---
8
+
9
+ ## Problem Summary
10
+
11
+ Three issues identified after workspace merge:
12
+
13
+ ### Issue 1: Header "Add Your API" should be "Workspace"
14
+ **Location:** Landing page header (logged-in state)
15
+ **Current:** Shows "Add Your API" button
16
+ **Expected:** Should show "Workspace" button for logged-in users
17
+
18
+ ### Issue 2: APIs Tab Client-Side Error
19
+ **Location:** `/workspace?tab=apis`
20
+ **Current:** "Application error: a client-side exception has occurred"
21
+ **Expected:** Should render APIs list for providers
22
+
23
+ ### Issue 3: Navigation Disharmony
24
+ **Location:** `/providers/dashboard`
25
+ **Current:** Renders old Provider Dashboard with its own tabs (Overview, APIs, Analytics) inside a sidebar that links to /workspace
26
+ **Expected:** Should redirect to `/workspace` OR be removed entirely
27
+
28
+ ---
29
+
30
+ ## Root Cause Analysis
31
+
32
+ ### Issue 1: Header
33
+ The header component checks for session but still shows provider-centric CTA. Needs conditional rendering:
34
+ - Logged out: "Add Your API" / "Connect Your Agent"
35
+ - Logged in: "Workspace" button
36
+
37
+ ### Issue 2: APIs Tab Error
38
+ Likely cause: `ApisTab` component tries to render `providerApis` but:
39
+ - Provider session might not be properly loaded
40
+ - Or `isProvider` check passes but `providerApis` fetch fails
41
+ - Or missing null check on API data
42
+
43
+ ### Issue 3: Navigation Disharmony
44
+ `/providers/dashboard/page.tsx` still exists and renders full dashboard. The layout wraps it with sidebar pointing to /workspace, creating two competing navigation systems.
45
+
46
+ ---
47
+
48
+ ## Proposed Fixes
49
+
50
+ ### Fix 1: Header Update
51
+
52
+ **File:** `landing/src/components/Header.tsx` (or equivalent)
53
+
54
+ ```tsx
55
+ // Pseudo-code
56
+ const isLoggedIn = checkSession();
57
+
58
+ {isLoggedIn ? (
59
+ <Link href="/workspace" className="btn-primary">
60
+ Workspace
61
+ </Link>
62
+ ) : (
63
+ <>
64
+ <Link href="/workspace">Connect Your Agent</Link>
65
+ <Link href="/providers/register">Add Your API</Link>
66
+ </>
67
+ )}
68
+ ```
69
+
70
+ ### Fix 2: APIs Tab Error Fix
71
+
72
+ **File:** `landing/src/app/workspace/page.tsx`
73
+
74
+ 1. Add error boundary around ApisTab
75
+ 2. Add null checks for providerApis
76
+ 3. Ensure isProvider only true when providerApis successfully loaded
77
+ 4. Add try-catch in fetchProviderData
78
+
79
+ ```tsx
80
+ // In fetchProviderData
81
+ const fetchProviderData = useCallback(async () => {
82
+ try {
83
+ const providerData = localStorage.getItem("apiclaw_provider");
84
+ const providerSession = localStorage.getItem("apiclaw_session");
85
+
86
+ if (!providerData || !providerSession) {
87
+ setIsProvider(false);
88
+ return;
89
+ }
90
+
91
+ // ... fetch APIs
92
+
93
+ if (apisData.value) {
94
+ setProviderApis(apisData.value);
95
+ setIsProvider(true); // Only set true AFTER successful fetch
96
+ }
97
+ } catch (err) {
98
+ console.error("Fetch provider error:", err);
99
+ setIsProvider(false); // Fail safe
100
+ }
101
+ }, []);
102
+
103
+ // In ApisTab, add defensive check
104
+ function ApisTab({ apis }: { apis: ProviderAPI[] }) {
105
+ if (!apis) {
106
+ return <LoadingState />;
107
+ }
108
+ // ...
109
+ }
110
+ ```
111
+
112
+ ### Fix 3: Remove Old Provider Dashboard
113
+
114
+ **Option A: Hard Redirect (Recommended)**
115
+
116
+ Replace `/providers/dashboard/page.tsx` content with redirect:
117
+
118
+ ```tsx
119
+ "use client";
120
+ import { useEffect } from "react";
121
+ import { useRouter } from "next/navigation";
122
+
123
+ export default function ProviderDashboardRedirect() {
124
+ const router = useRouter();
125
+ useEffect(() => {
126
+ router.replace("/workspace?tab=apis");
127
+ }, [router]);
128
+ return null;
129
+ }
130
+ ```
131
+
132
+ **Option B: Remove entirely**
133
+
134
+ Delete `/providers/dashboard/page.tsx` and update layout to not render when at root path.
135
+
136
+ **Note:** Keep `/providers/dashboard/[apiId]/*` routes for API detail pages.
137
+
138
+ ---
139
+
140
+ ## Migration Path
141
+
142
+ 1. **Phase 1:** Fix APIs tab error (unblocks testing)
143
+ 2. **Phase 2:** Redirect /providers/dashboard → /workspace
144
+ 3. **Phase 3:** Update header for logged-in state
145
+ 4. **Phase 4:** Clean up unused provider dashboard components
146
+
147
+ ---
148
+
149
+ ## Files to Modify
150
+
151
+ | File | Change |
152
+ |------|--------|
153
+ | `workspace/page.tsx` | Add null checks, error handling for APIs tab |
154
+ | `providers/dashboard/page.tsx` | Replace with redirect to /workspace?tab=apis |
155
+ | `components/Header.tsx` or `page.tsx` (landing) | Add logged-in state check, show "Workspace" button |
156
+ | `providers/dashboard/layout.tsx` | Optional: simplify since main page redirects |
157
+
158
+ ---
159
+
160
+ ## Acceptance Criteria
161
+
162
+ - [ ] Logged-in user sees "Workspace" in header (not "Add Your API")
163
+ - [ ] Clicking "APIs" tab in /workspace shows API list without error
164
+ - [ ] Navigating to /providers/dashboard redirects to /workspace?tab=apis
165
+ - [ ] API detail pages (/providers/dashboard/{id}) still work
166
+ - [ ] No duplicate sidebars/navigation visible
167
+
168
+ ---
169
+
170
+ ## Estimated Effort
171
+
172
+ | Task | Time |
173
+ |------|------|
174
+ | Fix APIs tab error | 15 min |
175
+ | Redirect provider dashboard | 10 min |
176
+ | Update header | 20 min |
177
+ | Testing | 15 min |
178
+ | **Total** | **~1 hour** |
@@ -14,7 +14,8 @@
14
14
  "next": "^14.2.0",
15
15
  "react": "^18.2.0",
16
16
  "react-dom": "^18.2.0",
17
- "recharts": "^3.7.0"
17
+ "recharts": "^3.7.0",
18
+ "stripe": "^20.4.0"
18
19
  },
19
20
  "devDependencies": {
20
21
  "@types/node": "^20",
@@ -924,7 +925,7 @@
924
925
  "version": "20.19.33",
925
926
  "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
926
927
  "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
927
- "dev": true,
928
+ "devOptional": true,
928
929
  "license": "MIT",
929
930
  "dependencies": {
930
931
  "undici-types": "~6.21.0"
@@ -2539,6 +2540,23 @@
2539
2540
  "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==",
2540
2541
  "license": "MIT"
2541
2542
  },
2543
+ "node_modules/stripe": {
2544
+ "version": "20.4.0",
2545
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz",
2546
+ "integrity": "sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==",
2547
+ "license": "MIT",
2548
+ "engines": {
2549
+ "node": ">=16"
2550
+ },
2551
+ "peerDependencies": {
2552
+ "@types/node": ">=16"
2553
+ },
2554
+ "peerDependenciesMeta": {
2555
+ "@types/node": {
2556
+ "optional": true
2557
+ }
2558
+ }
2559
+ },
2542
2560
  "node_modules/styled-jsx": {
2543
2561
  "version": "5.1.1",
2544
2562
  "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
@@ -2763,7 +2781,7 @@
2763
2781
  "version": "6.21.0",
2764
2782
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
2765
2783
  "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
2766
- "dev": true,
2784
+ "devOptional": true,
2767
2785
  "license": "MIT"
2768
2786
  },
2769
2787
  "node_modules/unicode-trie": {
@@ -16,7 +16,8 @@
16
16
  "next": "^14.2.0",
17
17
  "react": "^18.2.0",
18
18
  "react-dom": "^18.2.0",
19
- "recharts": "^3.7.0"
19
+ "recharts": "^3.7.0",
20
+ "stripe": "^20.4.0"
20
21
  },
21
22
  "devDependencies": {
22
23
  "@types/node": "^20",
@@ -0,0 +1,178 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import Stripe from "stripe";
3
+
4
+ const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
5
+
6
+ // Initialize Stripe
7
+ const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
8
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
9
+
10
+ const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null;
11
+
12
+ // Helper to call Convex mutations
13
+ async function callConvex(path: string, args: Record<string, unknown>) {
14
+ const response = await fetch(`${CONVEX_URL}/api/mutation`, {
15
+ method: "POST",
16
+ headers: { "Content-Type": "application/json" },
17
+ body: JSON.stringify({ path, args }),
18
+ });
19
+
20
+ const result = await response.json();
21
+ return result.value || result;
22
+ }
23
+
24
+ // Helper to call Convex queries
25
+ async function queryConvex(path: string, args: Record<string, unknown>) {
26
+ const response = await fetch(`${CONVEX_URL}/api/query`, {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify({ path, args }),
30
+ });
31
+
32
+ const result = await response.json();
33
+ return result.value || result;
34
+ }
35
+
36
+ export async function POST(req: NextRequest) {
37
+ if (!stripe || !webhookSecret) {
38
+ console.error("Stripe not configured");
39
+ return NextResponse.json({ error: "Stripe not configured" }, { status: 500 });
40
+ }
41
+
42
+ try {
43
+ // Get raw body for signature verification
44
+ const body = await req.text();
45
+ const signature = req.headers.get("stripe-signature");
46
+
47
+ if (!signature) {
48
+ return NextResponse.json({ error: "Missing signature" }, { status: 400 });
49
+ }
50
+
51
+ // Verify webhook signature
52
+ let event: Stripe.Event;
53
+ try {
54
+ event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
55
+ } catch (err) {
56
+ console.error("Webhook signature verification failed:", err);
57
+ return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
58
+ }
59
+
60
+ console.log(`[Stripe Webhook] Event: ${event.type}`);
61
+
62
+ // Handle events
63
+ switch (event.type) {
64
+ case "checkout.session.completed": {
65
+ const session = event.data.object as Stripe.Checkout.Session;
66
+ await handleCheckoutCompleted(session);
67
+ break;
68
+ }
69
+
70
+ case "invoice.payment_failed": {
71
+ const invoice = event.data.object as Stripe.Invoice;
72
+ await handlePaymentFailed(invoice);
73
+ break;
74
+ }
75
+
76
+ case "customer.subscription.deleted": {
77
+ const subscription = event.data.object as Stripe.Subscription;
78
+ await handleSubscriptionCanceled(subscription);
79
+ break;
80
+ }
81
+
82
+ default:
83
+ console.log(`[Stripe Webhook] Unhandled event: ${event.type}`);
84
+ }
85
+
86
+ return NextResponse.json({ received: true });
87
+ } catch (error) {
88
+ console.error("Webhook error:", error);
89
+ return NextResponse.json({ error: "Webhook failed" }, { status: 500 });
90
+ }
91
+ }
92
+
93
+ // Handle successful checkout
94
+ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
95
+ const workspaceId = session.metadata?.workspaceId;
96
+ const type = session.metadata?.type;
97
+
98
+ console.log(`[Stripe] Checkout completed: ${session.id}, workspace: ${workspaceId}, type: ${type}`);
99
+
100
+ if (!workspaceId) {
101
+ // Try to find workspace by customer ID
102
+ if (session.customer) {
103
+ const workspace = await queryConvex("billing:getWorkspaceByStripeCustomer", {
104
+ stripeCustomerId: session.customer as string,
105
+ });
106
+
107
+ if (workspace) {
108
+ await callConvex("billing:upgradeWorkspace", {
109
+ workspaceId: workspace._id,
110
+ tier: "pro",
111
+ });
112
+ console.log(`[Stripe] Upgraded workspace ${workspace._id} to pro`);
113
+ return;
114
+ }
115
+ }
116
+ console.error("[Stripe] No workspace found for checkout session");
117
+ return;
118
+ }
119
+
120
+ // Upgrade workspace to pro
121
+ const result = await callConvex("billing:upgradeWorkspace", {
122
+ workspaceId,
123
+ tier: "pro",
124
+ stripeSubscriptionId: session.subscription as string,
125
+ });
126
+
127
+ console.log(`[Stripe] Upgrade result:`, result);
128
+ }
129
+
130
+ // Handle failed payment
131
+ async function handlePaymentFailed(invoice: Stripe.Invoice) {
132
+ const customerId = invoice.customer as string;
133
+
134
+ console.log(`[Stripe] Payment failed for customer: ${customerId}`);
135
+
136
+ // Find workspace by customer ID
137
+ const workspace = await queryConvex("billing:getWorkspaceByStripeCustomer", {
138
+ stripeCustomerId: customerId,
139
+ });
140
+
141
+ if (!workspace) {
142
+ console.error("[Stripe] No workspace found for customer:", customerId);
143
+ return;
144
+ }
145
+
146
+ // Suspend workspace
147
+ await callConvex("billing:suspendWorkspace", {
148
+ workspaceId: workspace._id,
149
+ reason: "payment_failed",
150
+ });
151
+
152
+ console.log(`[Stripe] Suspended workspace ${workspace._id} due to payment failure`);
153
+ }
154
+
155
+ // Handle subscription cancellation
156
+ async function handleSubscriptionCanceled(subscription: Stripe.Subscription) {
157
+ const customerId = subscription.customer as string;
158
+
159
+ console.log(`[Stripe] Subscription canceled for customer: ${customerId}`);
160
+
161
+ // Find workspace by customer ID
162
+ const workspace = await queryConvex("billing:getWorkspaceByStripeCustomer", {
163
+ stripeCustomerId: customerId,
164
+ });
165
+
166
+ if (!workspace) {
167
+ console.error("[Stripe] No workspace found for customer:", customerId);
168
+ return;
169
+ }
170
+
171
+ // Downgrade to free tier
172
+ await callConvex("billing:upgradeWorkspace", {
173
+ workspaceId: workspace._id,
174
+ tier: "free",
175
+ });
176
+
177
+ console.log(`[Stripe] Downgraded workspace ${workspace._id} to free tier`);
178
+ }
@@ -0,0 +1,84 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
4
+
5
+ export async function POST(req: NextRequest) {
6
+ try {
7
+ const { email, fingerprint } = await req.json();
8
+
9
+ if (!email || !email.includes("@")) {
10
+ return NextResponse.json({ error: "Valid email required" }, { status: 400 });
11
+ }
12
+
13
+ // Create magic link in Convex
14
+ const response = await fetch(`${CONVEX_URL}/api/mutation`, {
15
+ method: "POST",
16
+ headers: { "Content-Type": "application/json" },
17
+ body: JSON.stringify({
18
+ path: "workspaces:createMagicLink",
19
+ args: { email: email.toLowerCase(), fingerprint },
20
+ }),
21
+ });
22
+
23
+ if (!response.ok) {
24
+ throw new Error("Failed to create magic link");
25
+ }
26
+
27
+ const result = await response.json();
28
+
29
+ // Convex wraps response in { status: "success", value: {...} }
30
+ const token = result.value?.token || result.token;
31
+
32
+ if (!token) {
33
+ throw new Error("Failed to get token from Convex");
34
+ }
35
+
36
+ // Send magic link email via n8n
37
+ const magicLinkUrl = `${process.env.NEXT_PUBLIC_APP_URL || "https://apiclaw.nordsym.com"}/dashboard/verify?token=${token}`;
38
+
39
+ await fetch("https://nordsym.app.n8n.cloud/webhook/symbot-gmail", {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({
43
+ action: "smtp",
44
+ to: email,
45
+ subject: "🦞 Sign in to APIClaw Workspace",
46
+ message: `
47
+ <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 500px; margin: 0 auto; padding: 40px 20px;">
48
+ <div style="text-align: center; margin-bottom: 32px;">
49
+ <span style="font-size: 48px;">🦞</span>
50
+ <h1 style="margin: 16px 0 8px; font-size: 24px; font-weight: 700;">Sign in to APIClaw</h1>
51
+ <p style="color: #737373; margin: 0;">Agent Workspace Dashboard</p>
52
+ </div>
53
+
54
+ <p style="color: #525252; font-size: 16px; line-height: 1.6; text-align: center;">
55
+ Click the button below to sign in to your workspace dashboard and manage your AI agents.
56
+ </p>
57
+
58
+ <div style="text-align: center; margin: 32px 0;">
59
+ <a href="${magicLinkUrl}" style="display: inline-block; background: #ef4444; color: white; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">
60
+ Sign In to Workspace
61
+ </a>
62
+ </div>
63
+
64
+ <p style="color: #737373; font-size: 14px; text-align: center;">
65
+ This link expires in 15 minutes.
66
+ </p>
67
+
68
+ <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e5e5;">
69
+ <p style="color: #a3a3a3; font-size: 12px; text-align: center;">
70
+ If you didn't request this email, you can safely ignore it.<br/>
71
+ APIClaw by NordSym
72
+ </p>
73
+ </div>
74
+ </div>
75
+ `,
76
+ }),
77
+ });
78
+
79
+ return NextResponse.json({ success: true });
80
+ } catch (error) {
81
+ console.error("Magic link error:", error);
82
+ return NextResponse.json({ error: "Failed to send magic link" }, { status: 500 });
83
+ }
84
+ }
@@ -0,0 +1,73 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
4
+
5
+ export async function GET(req: NextRequest) {
6
+ try {
7
+ // Get session from cookie or Authorization header
8
+ const cookieToken = req.cookies.get("apiclaw_workspace_session")?.value;
9
+ const headerToken = req.headers.get("Authorization")?.replace("Bearer ", "");
10
+ const token = cookieToken || headerToken;
11
+
12
+ if (!token) {
13
+ return NextResponse.json({ session: null }, { status: 200 });
14
+ }
15
+
16
+ // Verify session in Convex
17
+ const response = await fetch(`${CONVEX_URL}/api/query`, {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/json" },
20
+ body: JSON.stringify({
21
+ path: "workspaces:getSession",
22
+ args: { token },
23
+ }),
24
+ });
25
+
26
+ if (!response.ok) {
27
+ return NextResponse.json({ session: null }, { status: 200 });
28
+ }
29
+
30
+ const result = await response.json();
31
+ const session = result.value || result;
32
+
33
+ if (!session) {
34
+ // Clear invalid cookie
35
+ const res = NextResponse.json({ session: null }, { status: 200 });
36
+ res.cookies.delete("apiclaw_workspace_session");
37
+ return res;
38
+ }
39
+
40
+ return NextResponse.json({ session });
41
+ } catch (error) {
42
+ console.error("Session check error:", error);
43
+ return NextResponse.json({ session: null }, { status: 200 });
44
+ }
45
+ }
46
+
47
+ export async function DELETE(req: NextRequest) {
48
+ try {
49
+ const cookieToken = req.cookies.get("apiclaw_workspace_session")?.value;
50
+ const headerToken = req.headers.get("Authorization")?.replace("Bearer ", "");
51
+ const token = cookieToken || headerToken;
52
+
53
+ if (token) {
54
+ // Logout in Convex
55
+ await fetch(`${CONVEX_URL}/api/mutation`, {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json" },
58
+ body: JSON.stringify({
59
+ path: "workspaces:logout",
60
+ args: { token },
61
+ }),
62
+ });
63
+ }
64
+
65
+ // Clear cookie
66
+ const res = NextResponse.json({ success: true });
67
+ res.cookies.delete("apiclaw_workspace_session");
68
+ return res;
69
+ } catch (error) {
70
+ console.error("Logout error:", error);
71
+ return NextResponse.json({ error: "Logout failed" }, { status: 500 });
72
+ }
73
+ }
@@ -0,0 +1,57 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
4
+
5
+ export async function POST(req: NextRequest) {
6
+ try {
7
+ const { token, fingerprint } = await req.json();
8
+
9
+ if (!token) {
10
+ return NextResponse.json({ error: "Token required" }, { status: 400 });
11
+ }
12
+
13
+ // Verify magic link in Convex
14
+ const response = await fetch(`${CONVEX_URL}/api/mutation`, {
15
+ method: "POST",
16
+ headers: { "Content-Type": "application/json" },
17
+ body: JSON.stringify({
18
+ path: "workspaces:verifyMagicLink",
19
+ args: { token, fingerprint },
20
+ }),
21
+ });
22
+
23
+ if (!response.ok) {
24
+ throw new Error("Verification failed");
25
+ }
26
+
27
+ const result = await response.json();
28
+
29
+ // Convex wraps response in { status: "success", value: {...} }
30
+ const data = result.value || result;
31
+
32
+ if (!data.success) {
33
+ return NextResponse.json({ error: data.error || "Invalid token" }, { status: 400 });
34
+ }
35
+
36
+ // Create response with session cookie
37
+ const res = NextResponse.json({
38
+ success: true,
39
+ sessionToken: data.sessionToken,
40
+ workspace: data.workspace,
41
+ });
42
+
43
+ // Set httpOnly cookie for session
44
+ res.cookies.set("apiclaw_workspace_session", data.sessionToken, {
45
+ httpOnly: true,
46
+ secure: process.env.NODE_ENV === "production",
47
+ sameSite: "lax",
48
+ maxAge: 30 * 24 * 60 * 60, // 30 days
49
+ path: "/",
50
+ });
51
+
52
+ return res;
53
+ } catch (error) {
54
+ console.error("Verify error:", error);
55
+ return NextResponse.json({ error: "Verification failed" }, { status: 500 });
56
+ }
57
+ }