@nexvora/mcp-server 0.3.1 → 0.3.3

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 (66) hide show
  1. package/README.md +15 -13
  2. package/dist/NexvoraClient.d.ts.map +1 -1
  3. package/dist/NexvoraClient.js +21 -3
  4. package/dist/NexvoraClient.js.map +1 -1
  5. package/dist/cli.d.ts +2 -2
  6. package/dist/cli.js +26 -20
  7. package/dist/cli.js.map +1 -1
  8. package/dist/createServer.d.ts +7 -0
  9. package/dist/createServer.d.ts.map +1 -1
  10. package/dist/createServer.js +3 -3
  11. package/dist/createServer.js.map +1 -1
  12. package/package.json +6 -2
  13. package/CHANGELOG.md +0 -208
  14. package/docs/setup/chatgpt-desktop.md +0 -120
  15. package/docs/setup/claude-code.md +0 -152
  16. package/docs/setup/cursor.md +0 -129
  17. package/src/NexvoraClient.ts +0 -328
  18. package/src/RateLimiter.ts +0 -74
  19. package/src/__tests__/NexvoraClient.test.ts +0 -424
  20. package/src/__tests__/RateLimiter.test.ts +0 -151
  21. package/src/__tests__/auth/oauth.test.ts +0 -246
  22. package/src/__tests__/cache.test.ts +0 -64
  23. package/src/__tests__/config.test.ts +0 -98
  24. package/src/__tests__/defineTool.test.ts +0 -223
  25. package/src/__tests__/fixtures/config.json +0 -7
  26. package/src/__tests__/integration/agentstack.integration.test.ts +0 -259
  27. package/src/__tests__/integration/auth_refresh.integration.test.ts +0 -227
  28. package/src/__tests__/integration/consulting.integration.test.ts +0 -213
  29. package/src/__tests__/integration/feed.integration.test.ts +0 -200
  30. package/src/__tests__/integration/helpers.ts +0 -118
  31. package/src/__tests__/integration/knowledge.integration.test.ts +0 -194
  32. package/src/__tests__/integration/rate_limiting.integration.test.ts +0 -207
  33. package/src/__tests__/integration/submit_task.integration.test.ts +0 -120
  34. package/src/__tests__/integration/wallet_observatory.integration.test.ts +0 -240
  35. package/src/__tests__/nexvora_agentstack_answer.test.ts +0 -120
  36. package/src/__tests__/nexvora_agentstack_ask.test.ts +0 -140
  37. package/src/__tests__/nexvora_agentstack_search.test.ts +0 -188
  38. package/src/__tests__/nexvora_consulting_book.test.ts +0 -277
  39. package/src/__tests__/nexvora_consulting_search.test.ts +0 -153
  40. package/src/__tests__/nexvora_feed_post.test.ts +0 -147
  41. package/src/__tests__/nexvora_feed_react.test.ts +0 -98
  42. package/src/__tests__/nexvora_knowledge_search.test.ts +0 -148
  43. package/src/__tests__/nexvora_knowledge_subscribe.test.ts +0 -173
  44. package/src/__tests__/nexvora_observatory.test.ts +0 -125
  45. package/src/__tests__/nexvora_wallet_balance.test.ts +0 -165
  46. package/src/auth/oauth.ts +0 -247
  47. package/src/cache.ts +0 -34
  48. package/src/cli.ts +0 -171
  49. package/src/config.ts +0 -70
  50. package/src/createServer.ts +0 -90
  51. package/src/defineTool.ts +0 -120
  52. package/src/index.ts +0 -36
  53. package/src/server/sse.ts +0 -149
  54. package/src/tools/nexvora_agentstack_answer.ts +0 -62
  55. package/src/tools/nexvora_agentstack_ask.ts +0 -70
  56. package/src/tools/nexvora_agentstack_search.ts +0 -82
  57. package/src/tools/nexvora_consulting_book.ts +0 -130
  58. package/src/tools/nexvora_consulting_search.ts +0 -85
  59. package/src/tools/nexvora_feed_post.ts +0 -69
  60. package/src/tools/nexvora_feed_react.ts +0 -48
  61. package/src/tools/nexvora_knowledge_search.ts +0 -81
  62. package/src/tools/nexvora_knowledge_subscribe.ts +0 -90
  63. package/src/tools/nexvora_observatory.ts +0 -87
  64. package/src/tools/nexvora_submit_task.ts +0 -42
  65. package/src/tools/nexvora_wallet_balance.ts +0 -112
  66. package/tsconfig.json +0 -19
@@ -1,173 +0,0 @@
1
- import { jest } from "@jest/globals";
2
-
3
- import { NexvoraApiError, NexvoraClient } from "../NexvoraClient.js";
4
- import { nexvora_knowledge_subscribe } from "../tools/nexvora_knowledge_subscribe.js";
5
-
6
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
- function makeClient(): any {
8
- const client = new NexvoraClient({
9
- baseUrl: "https://api.nxvora.online",
10
- accessToken: "token",
11
- });
12
- (client as any).get = jest.fn();
13
- (client as any).post = jest.fn();
14
- (client as any).sendAudit = jest.fn().mockResolvedValue(undefined);
15
- return client;
16
- }
17
-
18
- const KB_ID = "aaaaaaaa-0000-0000-0000-000000000001";
19
-
20
- const KB = {
21
- id: KB_ID,
22
- agentName: "DataBot",
23
- title: "ML Engineering Handbook",
24
- monthlyPriceCoins: 100,
25
- };
26
-
27
- const SUB_RESPONSE = {
28
- id: "cccccccc-0000-0000-0000-000000000001",
29
- knowledgeId: KB_ID,
30
- status: "ACTIVE",
31
- nextBillingAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
32
- };
33
-
34
- describe("nexvora_knowledge_subscribe tool", () => {
35
- beforeEach(() => {
36
- jest.clearAllMocks();
37
- });
38
-
39
- it("defaults confirm to false", () => {
40
- const parsed = nexvora_knowledge_subscribe.inputSchema.parse({ knowledgeId: KB_ID });
41
- expect(parsed.confirm).toBe(false);
42
- });
43
-
44
- describe("preview mode (confirm=false)", () => {
45
- it("returns subscription preview without posting", async () => {
46
- const client = makeClient();
47
- client.get.mockResolvedValueOnce(KB);
48
-
49
- const result = await nexvora_knowledge_subscribe.handler(
50
- { knowledgeId: KB_ID, confirm: false },
51
- client,
52
- );
53
-
54
- expect(result).toContain("## Subscription Preview");
55
- expect(result).toContain("ML Engineering Handbook");
56
- expect(result).toContain("DataBot");
57
- expect(result).toContain("100");
58
- expect(result).toContain("renews automatically");
59
- expect(result).toContain("confirm: true");
60
- expect(client.post).not.toHaveBeenCalled();
61
- });
62
-
63
- it("shows next bill date as today + 30 days", async () => {
64
- const client = makeClient();
65
- client.get.mockResolvedValueOnce(KB);
66
-
67
- const result = await nexvora_knowledge_subscribe.handler(
68
- { knowledgeId: KB_ID, confirm: false },
69
- client,
70
- );
71
-
72
- const expectedDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
73
- .toISOString()
74
- .slice(0, 10);
75
- expect(result).toContain(expectedDate);
76
- });
77
-
78
- it("calls GET /knowledge/{id} to fetch KB details", async () => {
79
- const client = makeClient();
80
- client.get.mockResolvedValueOnce(KB);
81
-
82
- await nexvora_knowledge_subscribe.handler({ knowledgeId: KB_ID, confirm: false }, client);
83
-
84
- expect(client.get).toHaveBeenCalledWith(`/knowledge/${KB_ID}`);
85
- });
86
- });
87
-
88
- describe("confirm mode (confirm=true)", () => {
89
- it("submits subscription and returns confirmation", async () => {
90
- const client = makeClient();
91
- client.get.mockResolvedValueOnce(KB);
92
- client.post.mockResolvedValueOnce(SUB_RESPONSE);
93
-
94
- const result = await nexvora_knowledge_subscribe.handler(
95
- { knowledgeId: KB_ID, confirm: true },
96
- client,
97
- );
98
-
99
- expect(result).toContain("## Subscription Confirmed");
100
- expect(result).toContain(SUB_RESPONSE.id);
101
- expect(result).toContain("ACTIVE");
102
- expect(result).toContain("100");
103
- expect(result).toContain("nexvora_knowledge_unsubscribe");
104
- });
105
-
106
- it("posts to /knowledge/{id}/subscribe with empty body", async () => {
107
- const client = makeClient();
108
- client.get.mockResolvedValueOnce(KB);
109
- client.post.mockResolvedValueOnce(SUB_RESPONSE);
110
-
111
- await nexvora_knowledge_subscribe.handler({ knowledgeId: KB_ID, confirm: true }, client);
112
-
113
- expect(client.post).toHaveBeenCalledWith(`/knowledge/${KB_ID}/subscribe`, {});
114
- });
115
- });
116
-
117
- describe("error handling", () => {
118
- it("returns 404 message when knowledge base not found", async () => {
119
- const client = makeClient();
120
- client.get.mockRejectedValueOnce(
121
- new NexvoraApiError(404, "Not Found", `/knowledge/${KB_ID}`),
122
- );
123
-
124
- const result = await nexvora_knowledge_subscribe.handler(
125
- { knowledgeId: KB_ID, confirm: false },
126
- client,
127
- );
128
-
129
- expect(result).toContain("not found");
130
- expect(result).toContain(KB_ID);
131
- });
132
-
133
- it("returns 409 message when already subscribed", async () => {
134
- const client = makeClient();
135
- client.get.mockResolvedValueOnce(KB);
136
- client.post.mockRejectedValueOnce(
137
- new NexvoraApiError(409, "Conflict", `/knowledge/${KB_ID}/subscribe`),
138
- );
139
-
140
- const result = await nexvora_knowledge_subscribe.handler(
141
- { knowledgeId: KB_ID, confirm: true },
142
- client,
143
- );
144
-
145
- expect(result).toContain("already subscribed");
146
- });
147
-
148
- it("returns auth error message on 401", async () => {
149
- const client = makeClient();
150
- client.get.mockRejectedValueOnce(
151
- new NexvoraApiError(401, "Unauthorized", `/knowledge/${KB_ID}`),
152
- );
153
-
154
- const result = await nexvora_knowledge_subscribe.handler(
155
- { knowledgeId: KB_ID, confirm: false },
156
- client,
157
- );
158
-
159
- expect(result).toContain("nexvora login");
160
- });
161
-
162
- it("re-throws non-handled errors", async () => {
163
- const client = makeClient();
164
- client.get.mockRejectedValueOnce(
165
- new NexvoraApiError(500, "Server Error", `/knowledge/${KB_ID}`),
166
- );
167
-
168
- await expect(
169
- nexvora_knowledge_subscribe.handler({ knowledgeId: KB_ID, confirm: false }, client),
170
- ).rejects.toBeInstanceOf(NexvoraApiError);
171
- });
172
- });
173
- });
@@ -1,125 +0,0 @@
1
- import { jest } from "@jest/globals";
2
-
3
- import { NexvoraApiError, NexvoraClient } from "../NexvoraClient.js";
4
- import { nexvora_observatory } from "../tools/nexvora_observatory.js";
5
-
6
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
- function makeClient(): any {
8
- const client = new NexvoraClient({
9
- baseUrl: "https://api.nxvora.online",
10
- accessToken: "token",
11
- });
12
- (client as any).get = jest.fn();
13
- (client as any).sendAudit = jest.fn().mockResolvedValue(undefined);
14
- return client;
15
- }
16
-
17
- const SNAPSHOT = {
18
- onlineAgentsCount: 42,
19
- tasksCompletedToday: 1337,
20
- tasksInQueue: 7,
21
- totalCoinsInCirculation: 999999,
22
- avgTaskLatencyMs: 2450.5,
23
- topDonors: [
24
- {
25
- agentId: "agent-uuid-1",
26
- agentName: "AlphaBot",
27
- tasksCompleted: 200,
28
- reputationScore: 4.9,
29
- },
30
- {
31
- agentId: "agent-uuid-2",
32
- agentName: "BetaBot",
33
- tasksCompleted: 150,
34
- reputationScore: 4.7,
35
- },
36
- ],
37
- };
38
-
39
- describe("nexvora_observatory tool", () => {
40
- // Advance 1.5x TTL (90s) per test so the module-level TtlCache expires between tests.
41
- let epoch = 1_000_000_000_000;
42
-
43
- beforeEach(() => {
44
- jest.clearAllMocks();
45
- jest.useFakeTimers({ now: epoch });
46
- epoch += 90_000;
47
- });
48
-
49
- afterEach(() => {
50
- jest.useRealTimers();
51
- });
52
-
53
- it("returns formatted markdown health snapshot", async () => {
54
- const client = makeClient();
55
- client.get.mockResolvedValueOnce(SNAPSHOT);
56
-
57
- const result = await nexvora_observatory.handler({}, client);
58
-
59
- expect(result).toContain("## NexVora Platform Health");
60
- expect(result).toContain("42");
61
- expect(result).toContain("1,337");
62
- expect(result).toContain("7");
63
- expect(result).toContain("999,999");
64
- expect(result).toContain("2,451 ms");
65
- });
66
-
67
- it("shows top donor table with names and reputation", async () => {
68
- const client = makeClient();
69
- client.get.mockResolvedValueOnce(SNAPSHOT);
70
-
71
- const result = await nexvora_observatory.handler({}, client);
72
-
73
- expect(result).toContain("Top Donor Agents");
74
- expect(result).toContain("AlphaBot");
75
- expect(result).toContain("BetaBot");
76
- expect(result).toContain("4.9");
77
- expect(result).toContain("200");
78
- });
79
-
80
- it("shows no donor data message when topDonors is empty", async () => {
81
- const client = makeClient();
82
- client.get.mockResolvedValueOnce({ ...SNAPSHOT, topDonors: [] });
83
-
84
- const result = await nexvora_observatory.handler({}, client);
85
-
86
- expect(result).toContain("No donor agent data available yet");
87
- });
88
-
89
- it("shows N/A for latency when avgTaskLatencyMs is 0", async () => {
90
- const client = makeClient();
91
- client.get.mockResolvedValueOnce({ ...SNAPSHOT, avgTaskLatencyMs: 0 });
92
-
93
- const result = await nexvora_observatory.handler({}, client);
94
-
95
- expect(result).toContain("N/A");
96
- });
97
-
98
- it("calls GET /observatory", async () => {
99
- const client = makeClient();
100
- client.get.mockResolvedValueOnce(SNAPSHOT);
101
-
102
- await nexvora_observatory.handler({}, client);
103
-
104
- expect(client.get).toHaveBeenCalledWith("/observatory");
105
- });
106
-
107
- it("returns auth error message on 401", async () => {
108
- const client = makeClient();
109
- client.get.mockRejectedValueOnce(new NexvoraApiError(401, "Unauthorized", "/observatory"));
110
-
111
- const result = await nexvora_observatory.handler({}, client);
112
-
113
- expect(result).toContain("nexvora login");
114
- expect(result).toContain("Not authenticated");
115
- });
116
-
117
- it("re-throws non-auth errors", async () => {
118
- const client = makeClient();
119
- client.get.mockRejectedValueOnce(new NexvoraApiError(500, "Server Error", "/observatory"));
120
-
121
- await expect(nexvora_observatory.handler({}, client)).rejects.toBeInstanceOf(
122
- NexvoraApiError,
123
- );
124
- });
125
- });
@@ -1,165 +0,0 @@
1
- import { jest } from "@jest/globals";
2
-
3
- import { NexvoraApiError, NexvoraClient } from "../NexvoraClient.js";
4
- import { nexvora_wallet_balance } from "../tools/nexvora_wallet_balance.js";
5
-
6
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
- function makeClient(): any {
8
- const client = new NexvoraClient({
9
- baseUrl: "https://api.nxvora.online",
10
- accessToken: "token",
11
- });
12
- (client as any).get = jest.fn();
13
- (client as any).sendAudit = jest.fn().mockResolvedValue(undefined);
14
- return client;
15
- }
16
-
17
- // Backend region-filters the /wallet response — non-IN users get only usd*
18
- // fields, IN users get only inr*. Reflect that here so the test data matches
19
- // real wire shape.
20
- const WALLET_RESPONSE_USD = {
21
- coinBalance: 1500,
22
- usdEquivalent: "7.50",
23
- coinUsdRate: "0.005",
24
- };
25
-
26
- const WALLET_RESPONSE_INR = {
27
- coinBalance: 1500,
28
- inrEquivalent: "750.00",
29
- coinInrRate: "0.50",
30
- };
31
-
32
- const WALLET_RESPONSE = WALLET_RESPONSE_USD;
33
-
34
- const LEDGER_RESPONSE = {
35
- content: [
36
- {
37
- id: "uuid-1",
38
- amount: 500,
39
- txType: "SIGNUP_BONUS",
40
- note: "Welcome bonus",
41
- createdAt: "2026-05-01T10:00:00Z",
42
- },
43
- {
44
- id: "uuid-2",
45
- amount: -10,
46
- txType: "TASK_FEE",
47
- note: "Task: Summarise PDF",
48
- createdAt: "2026-05-02T12:00:00Z",
49
- },
50
- ],
51
- totalElements: 2,
52
- };
53
-
54
- describe("nexvora_wallet_balance tool", () => {
55
- // Advance 2x TTL (120s) per test so the module-level TtlCache expires between tests.
56
- let epoch = 1_000_000_000_000;
57
-
58
- beforeEach(() => {
59
- jest.clearAllMocks();
60
- jest.useFakeTimers({ now: epoch });
61
- epoch += 120_000;
62
- });
63
-
64
- afterEach(() => {
65
- jest.useRealTimers();
66
- });
67
-
68
- it("returns formatted markdown with balance and transactions (USD region)", async () => {
69
- const client = makeClient();
70
- client.get
71
- .mockResolvedValueOnce(WALLET_RESPONSE_USD)
72
- .mockResolvedValueOnce(LEDGER_RESPONSE);
73
-
74
- const result = await nexvora_wallet_balance.handler({}, client);
75
-
76
- expect(result).toContain("## NexVora Wallet");
77
- expect(result).toContain("1,500 coins");
78
- expect(result).toContain("$7.50");
79
- expect(result).toContain("1 coin = $0.005");
80
- // INR fields are absent in the response and must NOT leak into the output.
81
- expect(result).not.toContain("INR");
82
- expect(result).not.toContain("undefined");
83
- expect(result).toContain("SIGNUP_BONUS");
84
- expect(result).toContain("Welcome bonus");
85
- expect(result).toContain("+500 coins");
86
- expect(result).toContain("-10 coins");
87
- });
88
-
89
- it("renders INR-only when the backend returns the India currency block", async () => {
90
- const client = makeClient();
91
- client.get
92
- .mockResolvedValueOnce(WALLET_RESPONSE_INR)
93
- .mockResolvedValueOnce(LEDGER_RESPONSE);
94
-
95
- const result = await nexvora_wallet_balance.handler({}, client);
96
-
97
- expect(result).toContain("₹750.00");
98
- expect(result).toContain("1 coin = ₹0.50");
99
- expect(result).not.toContain("USD");
100
- expect(result).not.toContain("undefined");
101
- });
102
-
103
- it("calls /wallet and /wallet/ledger?size=10 in parallel", async () => {
104
- const client = makeClient();
105
- client.get
106
- .mockResolvedValueOnce(WALLET_RESPONSE)
107
- .mockResolvedValueOnce(LEDGER_RESPONSE);
108
-
109
- await nexvora_wallet_balance.handler({}, client);
110
-
111
- expect(client.get).toHaveBeenCalledWith("/wallet");
112
- expect(client.get).toHaveBeenCalledWith("/wallet/ledger?size=10");
113
- });
114
-
115
- it("shows total count when more transactions exist", async () => {
116
- const client = makeClient();
117
- const manyLedger = { content: LEDGER_RESPONSE.content, totalElements: 50 };
118
- client.get
119
- .mockResolvedValueOnce(WALLET_RESPONSE)
120
- .mockResolvedValueOnce(manyLedger);
121
-
122
- const result = await nexvora_wallet_balance.handler({}, client);
123
-
124
- expect(result).toContain("48 more");
125
- });
126
-
127
- it("shows empty state when no transactions exist", async () => {
128
- const client = makeClient();
129
- client.get
130
- .mockResolvedValueOnce(WALLET_RESPONSE)
131
- .mockResolvedValueOnce({ content: [], totalElements: 0 });
132
-
133
- const result = await nexvora_wallet_balance.handler({}, client);
134
-
135
- expect(result).toContain("No transactions yet");
136
- });
137
-
138
- it("returns auth error message on 401", async () => {
139
- const client = makeClient();
140
- client.get.mockRejectedValueOnce(new NexvoraApiError(401, "Unauthorized", "/wallet"));
141
-
142
- const result = await nexvora_wallet_balance.handler({}, client);
143
-
144
- expect(result).toContain("nexvora login");
145
- expect(result).toContain("Not authenticated");
146
- });
147
-
148
- it("returns auth error message on 403", async () => {
149
- const client = makeClient();
150
- client.get.mockRejectedValueOnce(new NexvoraApiError(403, "Forbidden", "/wallet"));
151
-
152
- const result = await nexvora_wallet_balance.handler({}, client);
153
-
154
- expect(result).toContain("nexvora login");
155
- });
156
-
157
- it("re-throws non-auth errors", async () => {
158
- const client = makeClient();
159
- client.get.mockRejectedValueOnce(new NexvoraApiError(500, "Server Error", "/wallet"));
160
-
161
- await expect(nexvora_wallet_balance.handler({}, client)).rejects.toBeInstanceOf(
162
- NexvoraApiError,
163
- );
164
- });
165
- });
package/src/auth/oauth.ts DELETED
@@ -1,247 +0,0 @@
1
- /**
2
- * OAuth 2.0 Device Authorization Grant (RFC 8628) for the NexVora MCP CLI.
3
- *
4
- * This is the same flow the Rust daemon uses (see
5
- * nexvora-daemon/crates/daemon-main/src/auth.rs). It is the locked auth model
6
- * for headless clients per the daemon-auth-model design.
7
- *
8
- * Flow:
9
- * 1. POST /oauth/device/authorize → device_code + user_code +
10
- * verification_uri_complete
11
- * 2. Print the user_code in the terminal and open
12
- * verification_uri_complete in the user's browser
13
- * 3. Poll POST /oauth/device/token at the negotiated interval, honouring
14
- * authorization_pending / slow_down, until the user approves, denies,
15
- * or the code expires
16
- * 4. Fetch the authenticated user profile to obtain the userId
17
- * 5. Return a Config-compatible object for the caller to persist
18
- */
19
-
20
- import { exec } from "node:child_process";
21
- import { hostname } from "node:os";
22
-
23
- import type { Config } from "../config.js";
24
-
25
- const CLIENT_ID = "nexvora-mcp-server";
26
- const SCOPE = "mcp:tools offline_access";
27
- const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
28
-
29
- /** Hard ceiling regardless of the server-advertised expires_in. */
30
- const SAFETY_TIMEOUT_MS = 15 * 60 * 1000;
31
-
32
- /** Minimum interval between polls in seconds (defensive floor). */
33
- const MIN_POLL_INTERVAL_SECONDS = 1;
34
-
35
- /** RFC 8628 §3.5: on slow_down, increase the polling interval by 5 seconds. */
36
- const SLOW_DOWN_INCREMENT_MS = 5_000;
37
-
38
- // ── Public API ─────────────────────────────────────────────────────────────────
39
-
40
- export interface DeviceAuthorizationResponse {
41
- device_code: string;
42
- user_code: string;
43
- verification_uri: string;
44
- verification_uri_complete: string;
45
- expires_in: number;
46
- interval: number;
47
- }
48
-
49
- export interface DeviceTokenSuccess {
50
- access_token: string;
51
- refresh_token: string;
52
- token_type: string;
53
- expires_in: number;
54
- scope?: string;
55
- }
56
-
57
- interface OAuthErrorBody {
58
- error: string;
59
- error_description?: string;
60
- }
61
-
62
- /**
63
- * Error thrown when the device-grant flow ends without issuing tokens
64
- * (denial, expiry, timeout, server error). The {@code code} property is the
65
- * RFC 8628 / 6749 error code or "timeout".
66
- */
67
- export class DeviceGrantError extends Error {
68
- constructor(public readonly code: string, message: string) {
69
- super(message);
70
- this.name = "DeviceGrantError";
71
- }
72
- }
73
-
74
- /**
75
- * Run the full interactive Device Grant login flow.
76
- *
77
- * @param serverUrl Base URL of the NexVora API (e.g. https://api.nxvora.online)
78
- * @returns A completed Config object ready to be written by ConfigManager
79
- */
80
- export async function loginInteractive(serverUrl: string): Promise<Config> {
81
- const base = serverUrl.replace(/\/$/, "");
82
-
83
- // 1. Start the device authorization
84
- const grant = await startDeviceAuthorization(base);
85
-
86
- // 2. Print code + open browser
87
- printUserPrompt(grant);
88
- openBrowser(grant.verification_uri_complete);
89
-
90
- // 3. Poll until decision
91
- const tokens = await pollForToken(base, grant);
92
-
93
- // 4. Fetch user profile (best-effort)
94
- const userId = await fetchUserId(base, tokens.access_token);
95
-
96
- const nowSecs = Math.floor(Date.now() / 1000);
97
- return {
98
- accessToken: tokens.access_token,
99
- refreshToken: tokens.refresh_token,
100
- expiresAt: nowSecs + tokens.expires_in,
101
- serverUrl: base,
102
- userId,
103
- };
104
- }
105
-
106
- // ── Internal helpers ───────────────────────────────────────────────────────────
107
-
108
- export async function startDeviceAuthorization(
109
- base: string,
110
- ): Promise<DeviceAuthorizationResponse> {
111
- const url = `${base}/oauth/device/authorize`;
112
- let response: Response;
113
- try {
114
- response = await fetch(url, {
115
- method: "POST",
116
- headers: { "Content-Type": "application/json" },
117
- body: JSON.stringify({
118
- client_id: CLIENT_ID,
119
- scope: SCOPE,
120
- device_name: hostname(),
121
- }),
122
- });
123
- } catch (err) {
124
- throw new Error(
125
- `Could not reach ${url}: ${(err as Error).message}.\n` +
126
- `Check NEXVORA_BASE_URL and your network connection.`,
127
- );
128
- }
129
- if (!response.ok) {
130
- const text = await response.text().catch(() => "");
131
- throw new Error(
132
- `Failed to start OAuth device authorization (${response.status}) at ${url}.\n` +
133
- `Is ${base} a valid NexVora server?` +
134
- (text ? `\nServer said: ${text}` : ""),
135
- );
136
- }
137
- return response.json() as Promise<DeviceAuthorizationResponse>;
138
- }
139
-
140
- function printUserPrompt(grant: DeviceAuthorizationResponse): void {
141
- console.error("");
142
- console.error("To authenticate, visit:");
143
- console.error(` ${grant.verification_uri}`);
144
- console.error("");
145
- console.error("And enter this code:");
146
- console.error(` ${grant.user_code}`);
147
- console.error("");
148
- console.error("If your browser does not open automatically, paste this URL:");
149
- console.error(` ${grant.verification_uri_complete}`);
150
- console.error("");
151
- console.error(
152
- `Waiting for approval (code expires in ${grant.expires_in} seconds)...`,
153
- );
154
- }
155
-
156
- export async function pollForToken(
157
- base: string,
158
- grant: DeviceAuthorizationResponse,
159
- ): Promise<DeviceTokenSuccess> {
160
- const url = `${base}/oauth/device/token`;
161
- const deadline = Date.now() + Math.min(grant.expires_in * 1000, SAFETY_TIMEOUT_MS);
162
- let intervalMs = Math.max(MIN_POLL_INTERVAL_SECONDS, grant.interval) * 1000;
163
-
164
- while (Date.now() < deadline) {
165
- await sleep(intervalMs);
166
-
167
- const response = await fetch(url, {
168
- method: "POST",
169
- headers: { "Content-Type": "application/json" },
170
- body: JSON.stringify({
171
- grant_type: DEVICE_CODE_GRANT_TYPE,
172
- device_code: grant.device_code,
173
- client_id: CLIENT_ID,
174
- }),
175
- });
176
-
177
- if (response.ok) {
178
- return response.json() as Promise<DeviceTokenSuccess>;
179
- }
180
-
181
- const body = (await response.json().catch(() => null)) as OAuthErrorBody | null;
182
- const code = body?.error ?? "unknown_error";
183
-
184
- switch (code) {
185
- case "authorization_pending":
186
- // Keep polling at the current interval.
187
- continue;
188
- case "slow_down":
189
- // RFC 8628 §3.5: server says we're polling too fast.
190
- intervalMs += SLOW_DOWN_INCREMENT_MS;
191
- continue;
192
- case "access_denied":
193
- throw new DeviceGrantError(code, "Login cancelled — you denied the request.");
194
- case "expired_token":
195
- throw new DeviceGrantError(
196
- code,
197
- "Login expired before it was approved. Run `nexvora login` again.",
198
- );
199
- case "invalid_grant":
200
- throw new DeviceGrantError(
201
- code,
202
- body?.error_description ??
203
- "The device grant is no longer valid. Run `nexvora login` again.",
204
- );
205
- default:
206
- throw new DeviceGrantError(
207
- code,
208
- body?.error_description ?? `OAuth error: ${code}`,
209
- );
210
- }
211
- }
212
-
213
- throw new DeviceGrantError(
214
- "timeout",
215
- "Login timed out before the device code expired. Run `nexvora login` again.",
216
- );
217
- }
218
-
219
- async function fetchUserId(baseUrl: string, accessToken: string): Promise<string> {
220
- const response = await fetch(`${baseUrl}/api/users/me`, {
221
- headers: { Authorization: `Bearer ${accessToken}` },
222
- });
223
- if (!response.ok) {
224
- return "";
225
- }
226
- const user = (await response.json()) as { id?: string; userId?: string };
227
- return user.id ?? user.userId ?? "";
228
- }
229
-
230
- function openBrowser(url: string): void {
231
- const cmd =
232
- process.platform === "win32"
233
- ? `start "" "${url}"`
234
- : process.platform === "darwin"
235
- ? `open "${url}"`
236
- : `xdg-open "${url}"`;
237
-
238
- exec(cmd, (err) => {
239
- if (err) {
240
- console.error(`(Could not open browser automatically: ${err.message})`);
241
- }
242
- });
243
- }
244
-
245
- function sleep(ms: number): Promise<void> {
246
- return new Promise((resolve) => setTimeout(resolve, ms));
247
- }