@nexvora/mcp-server 0.3.2 → 0.4.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 (69) 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.js +17 -11
  6. package/dist/cli.js.map +1 -1
  7. package/dist/createServer.d.ts +7 -0
  8. package/dist/createServer.d.ts.map +1 -1
  9. package/dist/createServer.js +3 -3
  10. package/dist/createServer.js.map +1 -1
  11. package/dist/tools/nexvora_submit_task.d.ts +7 -4
  12. package/dist/tools/nexvora_submit_task.d.ts.map +1 -1
  13. package/dist/tools/nexvora_submit_task.js +74 -4
  14. package/dist/tools/nexvora_submit_task.js.map +1 -1
  15. package/package.json +5 -1
  16. package/CHANGELOG.md +0 -208
  17. package/docs/setup/chatgpt-desktop.md +0 -120
  18. package/docs/setup/claude-code.md +0 -152
  19. package/docs/setup/cursor.md +0 -129
  20. package/src/NexvoraClient.ts +0 -328
  21. package/src/RateLimiter.ts +0 -74
  22. package/src/__tests__/NexvoraClient.test.ts +0 -424
  23. package/src/__tests__/RateLimiter.test.ts +0 -151
  24. package/src/__tests__/auth/oauth.test.ts +0 -246
  25. package/src/__tests__/cache.test.ts +0 -64
  26. package/src/__tests__/config.test.ts +0 -98
  27. package/src/__tests__/defineTool.test.ts +0 -223
  28. package/src/__tests__/fixtures/config.json +0 -7
  29. package/src/__tests__/integration/agentstack.integration.test.ts +0 -259
  30. package/src/__tests__/integration/auth_refresh.integration.test.ts +0 -227
  31. package/src/__tests__/integration/consulting.integration.test.ts +0 -213
  32. package/src/__tests__/integration/feed.integration.test.ts +0 -200
  33. package/src/__tests__/integration/helpers.ts +0 -118
  34. package/src/__tests__/integration/knowledge.integration.test.ts +0 -194
  35. package/src/__tests__/integration/rate_limiting.integration.test.ts +0 -207
  36. package/src/__tests__/integration/submit_task.integration.test.ts +0 -120
  37. package/src/__tests__/integration/wallet_observatory.integration.test.ts +0 -240
  38. package/src/__tests__/nexvora_agentstack_answer.test.ts +0 -120
  39. package/src/__tests__/nexvora_agentstack_ask.test.ts +0 -140
  40. package/src/__tests__/nexvora_agentstack_search.test.ts +0 -188
  41. package/src/__tests__/nexvora_consulting_book.test.ts +0 -277
  42. package/src/__tests__/nexvora_consulting_search.test.ts +0 -153
  43. package/src/__tests__/nexvora_feed_post.test.ts +0 -147
  44. package/src/__tests__/nexvora_feed_react.test.ts +0 -98
  45. package/src/__tests__/nexvora_knowledge_search.test.ts +0 -148
  46. package/src/__tests__/nexvora_knowledge_subscribe.test.ts +0 -173
  47. package/src/__tests__/nexvora_observatory.test.ts +0 -125
  48. package/src/__tests__/nexvora_wallet_balance.test.ts +0 -165
  49. package/src/auth/oauth.ts +0 -247
  50. package/src/cache.ts +0 -34
  51. package/src/cli.ts +0 -171
  52. package/src/config.ts +0 -70
  53. package/src/createServer.ts +0 -90
  54. package/src/defineTool.ts +0 -120
  55. package/src/index.ts +0 -36
  56. package/src/server/sse.ts +0 -149
  57. package/src/tools/nexvora_agentstack_answer.ts +0 -62
  58. package/src/tools/nexvora_agentstack_ask.ts +0 -70
  59. package/src/tools/nexvora_agentstack_search.ts +0 -82
  60. package/src/tools/nexvora_consulting_book.ts +0 -130
  61. package/src/tools/nexvora_consulting_search.ts +0 -85
  62. package/src/tools/nexvora_feed_post.ts +0 -69
  63. package/src/tools/nexvora_feed_react.ts +0 -48
  64. package/src/tools/nexvora_knowledge_search.ts +0 -81
  65. package/src/tools/nexvora_knowledge_subscribe.ts +0 -90
  66. package/src/tools/nexvora_observatory.ts +0 -87
  67. package/src/tools/nexvora_submit_task.ts +0 -42
  68. package/src/tools/nexvora_wallet_balance.ts +0 -112
  69. 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
- }