@hasna/connectors 1.3.46 → 1.4.1
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 +0 -10
- package/bin/index.js +880 -12650
- package/bin/mcp.js +324 -10279
- package/bin/serve.js +1862 -11817
- package/connectors/googlephotos/src/cli/index.ts +1 -1
- package/connectors/x/src/api/oauth.test.ts +205 -0
- package/connectors/yousearch/README.md +1 -1
- package/connectors/zendesk/CLAUDE.md +1 -1
- package/connectors/zendesk/Makefile +3 -1
- package/connectors/zendesk/README.md +3 -2
- package/connectors/zendesk/SCAFFOLD.md +2 -2
- package/connectors/zendesk/nginx-connector.conf +8 -8
- package/connectors/zendesk/nginx.conf +8 -8
- package/connectors/zendesk/src/cli/index.ts +19 -8
- package/connectors/zendesk/src/server/index.ts +1 -1
- package/connectors/zendesk/src/utils/config.test.ts +70 -0
- package/connectors/zendesk/src/utils/config.ts +12 -2
- package/connectors/zendesk/src/utils/remote-url.test.ts +72 -0
- package/dashboard/dist/assets/index-DlEljRrW.js +285 -0
- package/dashboard/dist/index.html +1 -1
- package/dist/db/database.d.ts +2 -1
- package/dist/db/sqlite-adapter.d.ts +43 -0
- package/dist/index.js +138 -9654
- package/dist/no-cloud-boundary.test.d.ts +1 -0
- package/package.json +11 -4
- package/connectors/tiktok-events-api/.test-home/.bun/install/cache/@t@/b0fec00ef4c1a326.pile +0 -0
- package/dashboard/dist/assets/index-XPlN-9Xy.js +0 -280
- package/dist/cli/commands/sync.d.ts +0 -2
- package/dist/db/pg-migrations.d.ts +0 -6
|
@@ -122,7 +122,7 @@ authCmd
|
|
|
122
122
|
const userInfo = await getUserInfo(result.tokens!.accessToken);
|
|
123
123
|
const email = userInfo.email;
|
|
124
124
|
|
|
125
|
-
// Convert email to profile slug:
|
|
125
|
+
// Convert email to profile slug: user@example.com → userexamplecom
|
|
126
126
|
const profileSlug = email.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
|
127
127
|
|
|
128
128
|
// Create profile if it doesn't exist
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
exchangeCodeForTokens,
|
|
4
|
+
refreshAccessToken,
|
|
5
|
+
revokeToken,
|
|
6
|
+
type OAuth2Config,
|
|
7
|
+
} from './oauth';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Regression tests for https://github.com/hasna/connectors/issues/1
|
|
11
|
+
*
|
|
12
|
+
* X rejects `Authorization: Basic <client_id:client_secret>` on
|
|
13
|
+
* POST /2/oauth2/token when the app is registered as a *public* client:
|
|
14
|
+
* {"error":"unauthorized_client",
|
|
15
|
+
* "error_description":"Missing valid authorization header"}
|
|
16
|
+
*
|
|
17
|
+
* The connector therefore must always authenticate the client with POST body
|
|
18
|
+
* parameters (`client_id`, plus `client_secret` when one is configured) and
|
|
19
|
+
* must never fall back to an Authorization header on the token/revoke
|
|
20
|
+
* endpoints.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface CapturedRequest {
|
|
24
|
+
url: string;
|
|
25
|
+
method?: string;
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
body: URLSearchParams;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const realFetch = globalThis.fetch;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalise every shape `RequestInit.headers` can take (plain object, entry
|
|
34
|
+
* array, `Headers` instance) into a lower-cased record. Going through
|
|
35
|
+
* `new Headers(...)` matters: if the implementation ever switched to a
|
|
36
|
+
* `Headers` object, a naive `Object.entries()` would yield `[]` and the
|
|
37
|
+
* "no Authorization header" assertions below would pass vacuously.
|
|
38
|
+
*/
|
|
39
|
+
function normaliseHeaders(init?: RequestInit): Record<string, string> {
|
|
40
|
+
const out: Record<string, string> = {};
|
|
41
|
+
if (!init?.headers) return out;
|
|
42
|
+
new Headers(init.headers).forEach((value, key) => {
|
|
43
|
+
out[key.toLowerCase()] = value;
|
|
44
|
+
});
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function captureTokenRequest(): { calls: CapturedRequest[] } {
|
|
49
|
+
const calls: CapturedRequest[] = [];
|
|
50
|
+
|
|
51
|
+
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
|
52
|
+
const headers = normaliseHeaders(init);
|
|
53
|
+
|
|
54
|
+
calls.push({
|
|
55
|
+
url: String(input),
|
|
56
|
+
method: init?.method,
|
|
57
|
+
headers,
|
|
58
|
+
body: new URLSearchParams(String(init?.body ?? '')),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return new Response(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
access_token: 'ACCESS_TOKEN',
|
|
64
|
+
refresh_token: 'NEW_REFRESH_TOKEN',
|
|
65
|
+
expires_in: 7200,
|
|
66
|
+
scope: 'tweet.read tweet.write offline.access',
|
|
67
|
+
token_type: 'bearer',
|
|
68
|
+
}),
|
|
69
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
70
|
+
);
|
|
71
|
+
}) as typeof fetch;
|
|
72
|
+
|
|
73
|
+
return { calls };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const PUBLIC_CLIENT: OAuth2Config = {
|
|
77
|
+
clientId: 'PUBLIC_CLIENT_ID',
|
|
78
|
+
redirectUri: 'http://localhost:8888/callback',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const CONFIDENTIAL_CLIENT: OAuth2Config = {
|
|
82
|
+
clientId: 'CONFIDENTIAL_CLIENT_ID',
|
|
83
|
+
clientSecret: 'CONFIDENTIAL_CLIENT_SECRET',
|
|
84
|
+
redirectUri: 'http://localhost:8888/callback',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
globalThis.fetch = realFetch;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('OAuth 2.0 token requests - public client (issue #1)', () => {
|
|
92
|
+
test('exchangeCodeForTokens sends client_id in the body and no auth header', async () => {
|
|
93
|
+
const { calls } = captureTokenRequest();
|
|
94
|
+
|
|
95
|
+
const tokens = await exchangeCodeForTokens(
|
|
96
|
+
PUBLIC_CLIENT,
|
|
97
|
+
'AUTH_CODE',
|
|
98
|
+
'CODE_VERIFIER'
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(calls).toHaveLength(1);
|
|
102
|
+
const req = calls[0]!;
|
|
103
|
+
expect(req.url).toBe('https://api.twitter.com/2/oauth2/token');
|
|
104
|
+
expect(req.method).toBe('POST');
|
|
105
|
+
expect(req.headers['authorization']).toBeUndefined();
|
|
106
|
+
expect(req.headers['content-type']).toBe(
|
|
107
|
+
'application/x-www-form-urlencoded'
|
|
108
|
+
);
|
|
109
|
+
expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
|
|
110
|
+
expect(req.body.get('client_secret')).toBeNull();
|
|
111
|
+
expect(req.body.get('grant_type')).toBe('authorization_code');
|
|
112
|
+
expect(req.body.get('code')).toBe('AUTH_CODE');
|
|
113
|
+
expect(req.body.get('code_verifier')).toBe('CODE_VERIFIER');
|
|
114
|
+
expect(req.body.get('redirect_uri')).toBe(
|
|
115
|
+
'http://localhost:8888/callback'
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(tokens.accessToken).toBe('ACCESS_TOKEN');
|
|
119
|
+
expect(tokens.refreshToken).toBe('NEW_REFRESH_TOKEN');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('refreshAccessToken sends client_id in the body and no auth header', async () => {
|
|
123
|
+
const { calls } = captureTokenRequest();
|
|
124
|
+
|
|
125
|
+
await refreshAccessToken(PUBLIC_CLIENT, 'OLD_REFRESH_TOKEN');
|
|
126
|
+
|
|
127
|
+
expect(calls).toHaveLength(1);
|
|
128
|
+
const req = calls[0]!;
|
|
129
|
+
expect(req.url).toBe('https://api.twitter.com/2/oauth2/token');
|
|
130
|
+
expect(req.headers['authorization']).toBeUndefined();
|
|
131
|
+
expect(req.body.get('grant_type')).toBe('refresh_token');
|
|
132
|
+
expect(req.body.get('refresh_token')).toBe('OLD_REFRESH_TOKEN');
|
|
133
|
+
expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
|
|
134
|
+
expect(req.body.get('client_secret')).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('revokeToken sends client_id in the body and no auth header', async () => {
|
|
138
|
+
const { calls } = captureTokenRequest();
|
|
139
|
+
|
|
140
|
+
await revokeToken(PUBLIC_CLIENT, 'SOME_ACCESS_TOKEN', 'access_token');
|
|
141
|
+
|
|
142
|
+
expect(calls).toHaveLength(1);
|
|
143
|
+
const req = calls[0]!;
|
|
144
|
+
expect(req.url).toBe('https://api.twitter.com/2/oauth2/revoke');
|
|
145
|
+
expect(req.headers['authorization']).toBeUndefined();
|
|
146
|
+
expect(req.body.get('token')).toBe('SOME_ACCESS_TOKEN');
|
|
147
|
+
expect(req.body.get('token_type_hint')).toBe('access_token');
|
|
148
|
+
expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('OAuth 2.0 token requests - configured client secret (issue #1)', () => {
|
|
153
|
+
// This is the exact trigger of issue #1: a client_secret is present in the
|
|
154
|
+
// connector config (env var or ~/.hasna/connectors/connect-x/credentials.json)
|
|
155
|
+
// while the X app itself is registered as a public client. Sending Basic auth
|
|
156
|
+
// in that situation is what produced "Missing valid authorization header".
|
|
157
|
+
test('never falls back to Basic auth when a client secret is configured', async () => {
|
|
158
|
+
const { calls } = captureTokenRequest();
|
|
159
|
+
|
|
160
|
+
await exchangeCodeForTokens(
|
|
161
|
+
CONFIDENTIAL_CLIENT,
|
|
162
|
+
'AUTH_CODE',
|
|
163
|
+
'CODE_VERIFIER'
|
|
164
|
+
);
|
|
165
|
+
await refreshAccessToken(CONFIDENTIAL_CLIENT, 'OLD_REFRESH_TOKEN');
|
|
166
|
+
await revokeToken(CONFIDENTIAL_CLIENT, 'SOME_ACCESS_TOKEN');
|
|
167
|
+
|
|
168
|
+
expect(calls).toHaveLength(3);
|
|
169
|
+
for (const req of calls) {
|
|
170
|
+
expect(req.headers['authorization']).toBeUndefined();
|
|
171
|
+
expect(req.body.get('client_id')).toBe('CONFIDENTIAL_CLIENT_ID');
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('still authenticates the client via client_secret_post', async () => {
|
|
176
|
+
const { calls } = captureTokenRequest();
|
|
177
|
+
|
|
178
|
+
await exchangeCodeForTokens(
|
|
179
|
+
CONFIDENTIAL_CLIENT,
|
|
180
|
+
'AUTH_CODE',
|
|
181
|
+
'CODE_VERIFIER'
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(calls[0]!.body.get('client_secret')).toBe(
|
|
185
|
+
'CONFIDENTIAL_CLIENT_SECRET'
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('OAuth 2.0 token requests - error surfacing', () => {
|
|
191
|
+
test('exchangeCodeForTokens surfaces the X error payload', async () => {
|
|
192
|
+
globalThis.fetch = (async () =>
|
|
193
|
+
new Response(
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
error: 'unauthorized_client',
|
|
196
|
+
error_description: 'Missing valid authorization header',
|
|
197
|
+
}),
|
|
198
|
+
{ status: 400 }
|
|
199
|
+
)) as typeof fetch;
|
|
200
|
+
|
|
201
|
+
await expect(
|
|
202
|
+
exchangeCodeForTokens(PUBLIC_CLIENT, 'AUTH_CODE', 'CODE_VERIFIER')
|
|
203
|
+
).rejects.toThrow(/Token exchange failed:.*unauthorized_client/s);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -25,7 +25,7 @@ export YOUSEARCH_API_KEY=your-api-key
|
|
|
25
25
|
connect-yousearch search "latest AI news" --count 5
|
|
26
26
|
|
|
27
27
|
# Web search with domain filters (POST)
|
|
28
|
-
connect-yousearch search-post "
|
|
28
|
+
connect-yousearch search-post "agent frameworks" --include-domains "example.com,github.com"
|
|
29
29
|
|
|
30
30
|
# Multi-step research
|
|
31
31
|
connect-yousearch research "What are the latest developments in AI agents?" --effort deep
|
|
@@ -43,7 +43,7 @@ connect-zendesk/
|
|
|
43
43
|
- **EC2 Instance**: `hasna-prod-connect-zendesk`
|
|
44
44
|
- **RDS Database**: `hasna-prod-connect-zendesk`
|
|
45
45
|
- **S3 Bucket**: `hasna-prod-connect-zendesk`
|
|
46
|
-
- **Remote API**: `
|
|
46
|
+
- **Remote API**: deployment-specific; set `ZENDESK_REMOTE_API_URL` (no shipped default)
|
|
47
47
|
|
|
48
48
|
## Key Patterns
|
|
49
49
|
|
|
@@ -17,6 +17,8 @@ EC2_USER ?= ec2-user
|
|
|
17
17
|
DEPLOY_PATH ?= /home/ec2-user/connectors/connect-zendesk
|
|
18
18
|
SERVICE_NAME ?= connect-zendesk
|
|
19
19
|
PORT ?= 21010
|
|
20
|
+
# Public URL the deployed server is reachable at. Deployment-specific — no default.
|
|
21
|
+
REMOTE_API_URL ?=
|
|
20
22
|
|
|
21
23
|
# Default target
|
|
22
24
|
help:
|
|
@@ -92,7 +94,7 @@ deploy-ec2: deploy-sync
|
|
|
92
94
|
|
|
93
95
|
deploy: build deploy-ec2
|
|
94
96
|
@echo "✅ Deployment complete"
|
|
95
|
-
@echo "Server running at
|
|
97
|
+
@echo "Server running at $(if $(REMOTE_API_URL),$(REMOTE_API_URL),<REMOTE_API_URL not set>)"
|
|
96
98
|
|
|
97
99
|
logs:
|
|
98
100
|
ssh $(EC2_USER)@$(EC2_HOST) "sudo journalctl -u $(SERVICE_NAME) -f"
|
|
@@ -11,7 +11,7 @@ This connector provides programmatic access to Zendesk's Support API, including
|
|
|
11
11
|
| EC2 Instance | `hasna-prod-connect-zendesk` |
|
|
12
12
|
| RDS Database | `hasna-prod-connect-zendesk` |
|
|
13
13
|
| S3 Bucket | `hasna-prod-connect-zendesk` |
|
|
14
|
-
| Remote API | `
|
|
14
|
+
| Remote API | configured per deployment via `ZENDESK_REMOTE_API_URL` (no default) |
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
@@ -279,7 +279,8 @@ This connector is deployed to:
|
|
|
279
279
|
- **Database**: `hasna-prod-connect-zendesk`
|
|
280
280
|
- **S3**: `hasna-prod-connect-zendesk`
|
|
281
281
|
|
|
282
|
-
The remote API is
|
|
282
|
+
The remote API host is deployment-specific and has no built-in default. Point the CLI at your
|
|
283
|
+
deployment with `ZENDESK_REMOTE_API_URL` or `connect-zendesk config set-remote-url <url>`.
|
|
283
284
|
|
|
284
285
|
## License
|
|
285
286
|
|
|
@@ -43,7 +43,7 @@ Each connector follows this naming pattern:
|
|
|
43
43
|
| EC2 Instance | `hasna-prod-connect-{name}` | `hasna-prod-connect-notion` |
|
|
44
44
|
| RDS Database | `hasna-prod-connect-{name}` | `hasna-prod-connect-notion` |
|
|
45
45
|
| S3 Bucket | `hasna-prod-connect-{name}` | `hasna-prod-connect-notion` |
|
|
46
|
-
| Remote API | `https
|
|
46
|
+
| Remote API | `https://<connect-host>/{name}` | `https://connect.example.com/notion` |
|
|
47
47
|
|
|
48
48
|
## Project Structure
|
|
49
49
|
|
|
@@ -175,4 +175,4 @@ The connector is deployed to AWS infrastructure:
|
|
|
175
175
|
|
|
176
176
|
1. Build the project: `make build`
|
|
177
177
|
2. Deploy to EC2: `make deploy-ec2`
|
|
178
|
-
3.
|
|
178
|
+
3. Point the CLI at the deployment via `<NAME>_REMOTE_API_URL` (no default is shipped)
|
|
@@ -54,7 +54,7 @@ http {
|
|
|
54
54
|
|
|
55
55
|
server {
|
|
56
56
|
listen 80;
|
|
57
|
-
server_name connect.
|
|
57
|
+
server_name connect.example.com;
|
|
58
58
|
|
|
59
59
|
location / {
|
|
60
60
|
root /usr/share/nginx/html;
|
|
@@ -127,7 +127,7 @@ http {
|
|
|
127
127
|
|
|
128
128
|
server {
|
|
129
129
|
listen 80;
|
|
130
|
-
server_name gmail.connect.
|
|
130
|
+
server_name gmail.connect.example.com;
|
|
131
131
|
|
|
132
132
|
location / {
|
|
133
133
|
proxy_pass http://127.0.0.1:3001;
|
|
@@ -140,7 +140,7 @@ http {
|
|
|
140
140
|
|
|
141
141
|
server {
|
|
142
142
|
listen 80;
|
|
143
|
-
server_name googlecontacts.connect.
|
|
143
|
+
server_name googlecontacts.connect.example.com;
|
|
144
144
|
|
|
145
145
|
location / {
|
|
146
146
|
proxy_pass http://127.0.0.1:3002;
|
|
@@ -153,7 +153,7 @@ http {
|
|
|
153
153
|
|
|
154
154
|
server {
|
|
155
155
|
listen 80;
|
|
156
|
-
server_name googledrive.connect.
|
|
156
|
+
server_name googledrive.connect.example.com;
|
|
157
157
|
|
|
158
158
|
location / {
|
|
159
159
|
proxy_pass http://127.0.0.1:3003;
|
|
@@ -166,7 +166,7 @@ http {
|
|
|
166
166
|
|
|
167
167
|
server {
|
|
168
168
|
listen 80;
|
|
169
|
-
server_name linear.connect.
|
|
169
|
+
server_name linear.connect.example.com;
|
|
170
170
|
|
|
171
171
|
location / {
|
|
172
172
|
proxy_pass http://127.0.0.1:3004;
|
|
@@ -179,7 +179,7 @@ http {
|
|
|
179
179
|
|
|
180
180
|
server {
|
|
181
181
|
listen 80;
|
|
182
|
-
server_name notion.connect.
|
|
182
|
+
server_name notion.connect.example.com;
|
|
183
183
|
|
|
184
184
|
location / {
|
|
185
185
|
proxy_pass http://127.0.0.1:3005;
|
|
@@ -192,7 +192,7 @@ http {
|
|
|
192
192
|
|
|
193
193
|
server {
|
|
194
194
|
listen 80;
|
|
195
|
-
server_name clickbank.connect.
|
|
195
|
+
server_name clickbank.connect.example.com;
|
|
196
196
|
|
|
197
197
|
location / {
|
|
198
198
|
proxy_pass http://127.0.0.1:3013;
|
|
@@ -205,7 +205,7 @@ http {
|
|
|
205
205
|
|
|
206
206
|
server {
|
|
207
207
|
listen 80;
|
|
208
|
-
server_name zendesk.connect.
|
|
208
|
+
server_name zendesk.connect.example.com;
|
|
209
209
|
|
|
210
210
|
location / {
|
|
211
211
|
proxy_pass http://127.0.0.1:21010;
|
|
@@ -12,10 +12,10 @@ upstream connect_zendesk {
|
|
|
12
12
|
keepalive 32;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
# Main server block for connect.
|
|
16
|
-
# Add this location block to the existing connect.
|
|
15
|
+
# Main server block for connect.example.com/zendesk
|
|
16
|
+
# Add this location block to the existing connect.example.com server
|
|
17
17
|
#
|
|
18
|
-
# If you have a separate server block for connect.
|
|
18
|
+
# If you have a separate server block for connect.example.com, add:
|
|
19
19
|
#
|
|
20
20
|
# location /zendesk {
|
|
21
21
|
# proxy_pass http://connect_zendesk;
|
|
@@ -30,21 +30,21 @@ upstream connect_zendesk {
|
|
|
30
30
|
# proxy_read_timeout 86400;
|
|
31
31
|
# }
|
|
32
32
|
|
|
33
|
-
# Standalone server configuration (use if connect.
|
|
33
|
+
# Standalone server configuration (use if connect.example.com doesn't exist)
|
|
34
34
|
# Uncomment and modify as needed:
|
|
35
35
|
#
|
|
36
36
|
# server {
|
|
37
37
|
# listen 80;
|
|
38
|
-
# server_name connect.
|
|
38
|
+
# server_name connect.example.com;
|
|
39
39
|
# return 301 https://$host$request_uri;
|
|
40
40
|
# }
|
|
41
41
|
#
|
|
42
42
|
# server {
|
|
43
43
|
# listen 443 ssl http2;
|
|
44
|
-
# server_name connect.
|
|
44
|
+
# server_name connect.example.com;
|
|
45
45
|
#
|
|
46
|
-
# ssl_certificate /etc/letsencrypt/live/connect.
|
|
47
|
-
# ssl_certificate_key /etc/letsencrypt/live/connect.
|
|
46
|
+
# ssl_certificate /etc/letsencrypt/live/connect.example.com/fullchain.pem;
|
|
47
|
+
# ssl_certificate_key /etc/letsencrypt/live/connect.example.com/privkey.pem;
|
|
48
48
|
#
|
|
49
49
|
# location /zendesk {
|
|
50
50
|
# proxy_pass http://connect_zendesk;
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
getConfigDir,
|
|
17
17
|
getBaseConfigDir,
|
|
18
18
|
getExportsDir,
|
|
19
|
-
|
|
19
|
+
findRemoteApiUrl,
|
|
20
20
|
setRemoteApiUrl,
|
|
21
21
|
setProfileOverride,
|
|
22
22
|
getCurrentProfile,
|
|
@@ -238,7 +238,7 @@ configCmd
|
|
|
238
238
|
|
|
239
239
|
configCmd
|
|
240
240
|
.command('set-remote-url <url>')
|
|
241
|
-
.description('Set remote API URL (default
|
|
241
|
+
.description('Set remote API URL (no default; also settable via ZENDESK_REMOTE_API_URL)')
|
|
242
242
|
.action((url: string) => {
|
|
243
243
|
setRemoteApiUrl(url);
|
|
244
244
|
success(`Remote API URL set to: ${url}`);
|
|
@@ -253,13 +253,13 @@ configCmd
|
|
|
253
253
|
const apiToken = getApiToken();
|
|
254
254
|
const baseUrl = getBaseUrl();
|
|
255
255
|
const account = getDefaultAccount();
|
|
256
|
-
const remoteUrl =
|
|
256
|
+
const remoteUrl = findRemoteApiUrl();
|
|
257
257
|
info(`Profile: ${chalk.cyan(profile)}`);
|
|
258
258
|
info(`Email: ${email || chalk.gray('not set')}`);
|
|
259
259
|
info(`API Token: ${apiToken ? `${apiToken.substring(0, 6)}...${apiToken.substring(apiToken.length - 4)}` : chalk.gray('not set')}`);
|
|
260
260
|
info(`Base URL: ${baseUrl || chalk.gray('not set')}`);
|
|
261
261
|
info(`Default Account: ${account || chalk.gray('not set')}`);
|
|
262
|
-
info(`Remote API URL: ${remoteUrl}`);
|
|
262
|
+
info(`Remote API URL: ${remoteUrl || chalk.gray('not set')}`);
|
|
263
263
|
info(`Config Directory: ${getBaseConfigDir()}`);
|
|
264
264
|
info(`Profile Config: ${getConfigDir()}`);
|
|
265
265
|
info(`Exports Directory: ${getExportsDir()}`);
|
|
@@ -274,17 +274,28 @@ configCmd
|
|
|
274
274
|
});
|
|
275
275
|
|
|
276
276
|
// ============================================
|
|
277
|
-
// Remote API Commands (
|
|
277
|
+
// Remote API Commands (host comes from ZENDESK_REMOTE_API_URL / config)
|
|
278
278
|
// ============================================
|
|
279
279
|
const remoteCmd = program
|
|
280
280
|
.command('remote')
|
|
281
281
|
.description('Interact with the remote Zendesk connector API');
|
|
282
282
|
|
|
283
|
+
// The remote host has no shipped default. Commands that need it exit with the
|
|
284
|
+
// connector's usual error convention rather than an uncaught throw.
|
|
285
|
+
function requireRemoteApiUrl(): string {
|
|
286
|
+
const remoteUrl = findRemoteApiUrl();
|
|
287
|
+
if (!remoteUrl) {
|
|
288
|
+
error('Remote API URL is not configured. Set ZENDESK_REMOTE_API_URL or run: connect-zendesk config set-remote-url <url>');
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
return remoteUrl;
|
|
292
|
+
}
|
|
293
|
+
|
|
283
294
|
remoteCmd
|
|
284
295
|
.command('status')
|
|
285
296
|
.description('Check remote API status')
|
|
286
297
|
.action(async () => {
|
|
287
|
-
const remoteUrl =
|
|
298
|
+
const remoteUrl = requireRemoteApiUrl();
|
|
288
299
|
logger.command('remote status', { remoteUrl });
|
|
289
300
|
try {
|
|
290
301
|
const response = await fetch(`${remoteUrl}/status`);
|
|
@@ -301,7 +312,7 @@ remoteCmd
|
|
|
301
312
|
.command('health')
|
|
302
313
|
.description('Check remote API health')
|
|
303
314
|
.action(async () => {
|
|
304
|
-
const remoteUrl =
|
|
315
|
+
const remoteUrl = requireRemoteApiUrl();
|
|
305
316
|
logger.command('remote health', { remoteUrl });
|
|
306
317
|
try {
|
|
307
318
|
const response = await fetch(`${remoteUrl}/health`);
|
|
@@ -321,7 +332,7 @@ remoteCmd
|
|
|
321
332
|
.command('url')
|
|
322
333
|
.description('Show current remote API URL')
|
|
323
334
|
.action(() => {
|
|
324
|
-
info(`Remote API URL: ${
|
|
335
|
+
info(`Remote API URL: ${findRemoteApiUrl() || chalk.gray('not set')}`);
|
|
325
336
|
});
|
|
326
337
|
|
|
327
338
|
// ============================================
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* connect-zendesk server
|
|
4
4
|
* Remote API server for Zendesk connector
|
|
5
|
-
*
|
|
5
|
+
* Deployment host is environment-specific; see nginx.conf for the reverse-proxy template.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const PORT = parseInt(process.env.PORT || '3000');
|
|
@@ -2,6 +2,14 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
|
2
2
|
import { existsSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
import { join } from 'path';
|
|
5
|
+
import {
|
|
6
|
+
findRemoteApiUrl,
|
|
7
|
+
getRemoteApiUrl,
|
|
8
|
+
setRemoteApiUrl,
|
|
9
|
+
setProfileOverride,
|
|
10
|
+
getBaseConfigDir,
|
|
11
|
+
clearConfig,
|
|
12
|
+
} from './config';
|
|
5
13
|
|
|
6
14
|
// We need to test the config module with a custom config dir
|
|
7
15
|
// to avoid messing with actual user config
|
|
@@ -190,4 +198,66 @@ describe('Config utilities', () => {
|
|
|
190
198
|
else delete process.env.ZENDESK_EMAIL;
|
|
191
199
|
});
|
|
192
200
|
});
|
|
201
|
+
|
|
202
|
+
// The remote API URL no longer falls back to a hardcoded deployment host, so
|
|
203
|
+
// these exercise the real module instead of re-implementing the priority
|
|
204
|
+
// logic: an unconfigured URL has to fail loudly rather than silently resolve
|
|
205
|
+
// to a baked-in default, and each configured source has to round-trip.
|
|
206
|
+
describe('remote API URL resolution', () => {
|
|
207
|
+
const REMOTE_URL_TEST_PROFILE = 'remote-url-test';
|
|
208
|
+
const ENV_URL = 'https://env.example.com/zendesk';
|
|
209
|
+
const STORED_URL = 'https://stored.example.com/zendesk';
|
|
210
|
+
|
|
211
|
+
let originalEnv: string | undefined;
|
|
212
|
+
|
|
213
|
+
beforeEach(() => {
|
|
214
|
+
originalEnv = process.env.ZENDESK_REMOTE_API_URL;
|
|
215
|
+
delete process.env.ZENDESK_REMOTE_API_URL;
|
|
216
|
+
// Profiles are the module's own isolation seam: point config reads and
|
|
217
|
+
// writes at a throwaway profile so the real one is never touched.
|
|
218
|
+
setProfileOverride(REMOTE_URL_TEST_PROFILE);
|
|
219
|
+
clearConfig();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
afterEach(() => {
|
|
223
|
+
setProfileOverride(undefined);
|
|
224
|
+
// Derive the throwaway path by name rather than from getConfigDir(), so
|
|
225
|
+
// this stays a delete of the test profile no matter how the override is
|
|
226
|
+
// sequenced above it.
|
|
227
|
+
rmSync(join(getBaseConfigDir(), 'profiles', REMOTE_URL_TEST_PROFILE), {
|
|
228
|
+
recursive: true,
|
|
229
|
+
force: true,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (originalEnv === undefined) delete process.env.ZENDESK_REMOTE_API_URL;
|
|
233
|
+
else process.env.ZENDESK_REMOTE_API_URL = originalEnv;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('is unset and throws when neither env nor config provides a URL', () => {
|
|
237
|
+
expect(findRemoteApiUrl()).toBeUndefined();
|
|
238
|
+
expect(() => getRemoteApiUrl()).toThrow(/ZENDESK_REMOTE_API_URL/);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('resolves from the environment variable', () => {
|
|
242
|
+
process.env.ZENDESK_REMOTE_API_URL = ENV_URL;
|
|
243
|
+
|
|
244
|
+
expect(findRemoteApiUrl()).toBe(ENV_URL);
|
|
245
|
+
expect(getRemoteApiUrl()).toBe(ENV_URL);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('resolves from the stored config value', () => {
|
|
249
|
+
setRemoteApiUrl(STORED_URL);
|
|
250
|
+
|
|
251
|
+
expect(findRemoteApiUrl()).toBe(STORED_URL);
|
|
252
|
+
expect(getRemoteApiUrl()).toBe(STORED_URL);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('environment variable takes precedence over the stored config value', () => {
|
|
256
|
+
setRemoteApiUrl(STORED_URL);
|
|
257
|
+
process.env.ZENDESK_REMOTE_API_URL = ENV_URL;
|
|
258
|
+
|
|
259
|
+
expect(findRemoteApiUrl()).toBe(ENV_URL);
|
|
260
|
+
expect(getRemoteApiUrl()).toBe(ENV_URL);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
193
263
|
});
|
|
@@ -499,10 +499,20 @@ export function clearConfig(): void {
|
|
|
499
499
|
saveConfig({});
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
-
|
|
502
|
+
// The remote API host is deployment-specific and has no shippable default.
|
|
503
|
+
// Configure it with ZENDESK_REMOTE_API_URL or `connect-zendesk config set-remote-url <url>`.
|
|
504
|
+
export function findRemoteApiUrl(): string | undefined {
|
|
505
|
+
return process.env.ZENDESK_REMOTE_API_URL || loadConfig().remoteApiUrl || undefined;
|
|
506
|
+
}
|
|
503
507
|
|
|
504
508
|
export function getRemoteApiUrl(): string {
|
|
505
|
-
|
|
509
|
+
const url = findRemoteApiUrl();
|
|
510
|
+
if (!url) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
'Remote API URL is not configured. Set ZENDESK_REMOTE_API_URL or run: connect-zendesk config set-remote-url <url>',
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
return url;
|
|
506
516
|
}
|
|
507
517
|
|
|
508
518
|
export function setRemoteApiUrl(url: string): void {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
// The remote API URL used to fall back to a hardcoded deployment host. It no
|
|
7
|
+
// longer has a shipped default, so these lock in how the CLI behaves when it is
|
|
8
|
+
// unset: display commands must still print, and commands that genuinely need
|
|
9
|
+
// the URL must fail with actionable guidance rather than an uncaught throw.
|
|
10
|
+
//
|
|
11
|
+
// Resolution order itself is covered in config.test.ts. This file covers the
|
|
12
|
+
// CLI surface: os.homedir() does not observe runtime process.env.HOME mutation,
|
|
13
|
+
// so each case runs the real CLI in a subprocess with its own HOME.
|
|
14
|
+
|
|
15
|
+
const CLI = join(import.meta.dir, '..', 'cli', 'index.ts');
|
|
16
|
+
const ENV_KEY = 'ZENDESK_REMOTE_API_URL';
|
|
17
|
+
|
|
18
|
+
let home: string;
|
|
19
|
+
|
|
20
|
+
function runCli(args: string[], env: Record<string, string> = {}) {
|
|
21
|
+
const result = Bun.spawnSync({
|
|
22
|
+
cmd: ['bun', 'run', CLI, ...args],
|
|
23
|
+
env: { ...process.env, HOME: home, [ENV_KEY]: '', ...env },
|
|
24
|
+
stdout: 'pipe',
|
|
25
|
+
stderr: 'pipe',
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
code: result.exitCode,
|
|
29
|
+
out: new TextDecoder().decode(result.stdout) + new TextDecoder().decode(result.stderr),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('CLI behaviour when the remote API URL is unset', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
home = mkdtempSync(join(tmpdir(), 'connect-zendesk-remote-url-'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
rmSync(home, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('config show reports it as unset instead of throwing', () => {
|
|
43
|
+
const { code, out } = runCli(['config', 'show']);
|
|
44
|
+
expect(code).toBe(0);
|
|
45
|
+
expect(out).toContain('Remote API URL:');
|
|
46
|
+
expect(out).toContain('not set');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('remote url reports it as unset instead of throwing', () => {
|
|
50
|
+
const { code, out } = runCli(['remote', 'url']);
|
|
51
|
+
expect(code).toBe(0);
|
|
52
|
+
expect(out).toContain('not set');
|
|
53
|
+
expect(out).not.toContain('at getRemoteApiUrl');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test.each(['status', 'health'])(
|
|
57
|
+
'remote %s fails with actionable guidance, not a stack trace',
|
|
58
|
+
(sub) => {
|
|
59
|
+
const { code, out } = runCli(['remote', sub]);
|
|
60
|
+
expect(code).toBe(1);
|
|
61
|
+
expect(out).toContain(ENV_KEY);
|
|
62
|
+
expect(out).toContain('config set-remote-url');
|
|
63
|
+
expect(out).not.toContain('at getRemoteApiUrl');
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
test('no deployment host is baked in as a fallback', () => {
|
|
68
|
+
// A regression here means someone reintroduced a literal default.
|
|
69
|
+
const { out } = runCli(['remote', 'url']);
|
|
70
|
+
expect(out).not.toMatch(/https?:\/\/\S+/);
|
|
71
|
+
});
|
|
72
|
+
});
|