@zeroexcore/tuna 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-test.log +14 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/package.json +60 -0
- package/src/commands/delete.ts +193 -0
- package/src/commands/init.ts +219 -0
- package/src/commands/list.ts +159 -0
- package/src/commands/login.ts +133 -0
- package/src/commands/run.ts +241 -0
- package/src/commands/stop.ts +44 -0
- package/src/index.ts +129 -0
- package/src/lib/access.ts +191 -0
- package/src/lib/api.ts +383 -0
- package/src/lib/cloudflared.ts +279 -0
- package/src/lib/config.ts +125 -0
- package/src/lib/credentials.ts +67 -0
- package/src/lib/dns.ts +164 -0
- package/src/lib/service.ts +354 -0
- package/src/types/cloudflare.ts +155 -0
- package/src/types/config.ts +33 -0
- package/src/types/index.ts +6 -0
- package/tests/unit/config.test.ts +176 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +18 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero Trust Access management - handles Access applications and policies
|
|
3
|
+
* Uses snapshot-based diffing to sync config with Cloudflare
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { CloudflareAPI } from './api.ts';
|
|
7
|
+
import type {
|
|
8
|
+
Credentials,
|
|
9
|
+
AccessConfig,
|
|
10
|
+
ParsedAccessConfig,
|
|
11
|
+
AccessRule,
|
|
12
|
+
AccessPolicyCreate,
|
|
13
|
+
} from '../types/index.ts';
|
|
14
|
+
|
|
15
|
+
const TUNA_POLICY_NAME = 'tuna-access-policy';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse access config array into emails and domains
|
|
19
|
+
* - Strings starting with @ are email domains
|
|
20
|
+
* - Other strings are specific emails
|
|
21
|
+
*/
|
|
22
|
+
export function parseAccessConfig(access: AccessConfig): ParsedAccessConfig {
|
|
23
|
+
const emails: string[] = [];
|
|
24
|
+
const emailDomains: string[] = [];
|
|
25
|
+
|
|
26
|
+
for (const entry of access) {
|
|
27
|
+
if (entry.startsWith('@')) {
|
|
28
|
+
// Email domain - remove the @ prefix
|
|
29
|
+
emailDomains.push(entry.slice(1));
|
|
30
|
+
} else {
|
|
31
|
+
// Specific email
|
|
32
|
+
emails.push(entry);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { emails, emailDomains };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build Access rules from parsed config
|
|
41
|
+
*/
|
|
42
|
+
function buildAccessRules(parsed: ParsedAccessConfig): AccessRule[] {
|
|
43
|
+
const rules: AccessRule[] = [];
|
|
44
|
+
|
|
45
|
+
// Add email rules
|
|
46
|
+
for (const email of parsed.emails) {
|
|
47
|
+
rules.push({ email: { email } });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Add email domain rules
|
|
51
|
+
for (const domain of parsed.emailDomains) {
|
|
52
|
+
rules.push({ email_domain: { domain } });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return rules;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compare two access rule sets to check if they're equivalent
|
|
60
|
+
*/
|
|
61
|
+
function accessRulesEqual(a: AccessRule[], b: AccessRule[]): boolean {
|
|
62
|
+
if (a.length !== b.length) return false;
|
|
63
|
+
|
|
64
|
+
// Normalize and sort for comparison
|
|
65
|
+
const normalize = (rules: AccessRule[]): string[] => {
|
|
66
|
+
return rules.map((rule) => {
|
|
67
|
+
if ('email' in rule) return `email:${rule.email.email}`;
|
|
68
|
+
if ('email_domain' in rule) return `domain:${rule.email_domain.domain}`;
|
|
69
|
+
return JSON.stringify(rule);
|
|
70
|
+
}).sort();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const aNorm = normalize(a);
|
|
74
|
+
const bNorm = normalize(b);
|
|
75
|
+
|
|
76
|
+
return aNorm.every((val, i) => val === bNorm[i]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Ensure Access application and policy exist and are in sync with config
|
|
81
|
+
* Returns the app ID if access is configured, null otherwise
|
|
82
|
+
*/
|
|
83
|
+
export async function ensureAccess(
|
|
84
|
+
credentials: Credentials,
|
|
85
|
+
hostname: string,
|
|
86
|
+
access: AccessConfig | undefined
|
|
87
|
+
): Promise<string | null> {
|
|
88
|
+
const api = new CloudflareAPI(credentials);
|
|
89
|
+
|
|
90
|
+
// Check if there's an existing Access app for this hostname
|
|
91
|
+
const existingApp = await api.getAccessApplicationByDomain(hostname);
|
|
92
|
+
|
|
93
|
+
// No access config - remove any existing Access app
|
|
94
|
+
if (!access || access.length === 0) {
|
|
95
|
+
if (existingApp) {
|
|
96
|
+
await api.deleteAccessApplication(existingApp.id);
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Parse the access config
|
|
102
|
+
const parsed = parseAccessConfig(access);
|
|
103
|
+
const desiredRules = buildAccessRules(parsed);
|
|
104
|
+
|
|
105
|
+
if (desiredRules.length === 0) {
|
|
106
|
+
// No valid rules - remove existing if any
|
|
107
|
+
if (existingApp) {
|
|
108
|
+
await api.deleteAccessApplication(existingApp.id);
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Create or get the Access application
|
|
114
|
+
let appId: string;
|
|
115
|
+
|
|
116
|
+
if (existingApp) {
|
|
117
|
+
appId = existingApp.id;
|
|
118
|
+
} else {
|
|
119
|
+
// Create new Access application
|
|
120
|
+
const app = await api.createAccessApplication({
|
|
121
|
+
name: `tuna-${hostname}`,
|
|
122
|
+
domain: hostname,
|
|
123
|
+
type: 'self_hosted',
|
|
124
|
+
session_duration: '24h',
|
|
125
|
+
});
|
|
126
|
+
appId = app.id;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Now sync the policy
|
|
130
|
+
const existingPolicies = await api.listAccessPolicies(appId);
|
|
131
|
+
const tunaPolicy = existingPolicies.find((p) => p.name === TUNA_POLICY_NAME);
|
|
132
|
+
|
|
133
|
+
const desiredPolicy: AccessPolicyCreate = {
|
|
134
|
+
name: TUNA_POLICY_NAME,
|
|
135
|
+
decision: 'allow',
|
|
136
|
+
include: desiredRules,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
if (tunaPolicy) {
|
|
140
|
+
// Check if policy needs updating
|
|
141
|
+
if (!accessRulesEqual(tunaPolicy.include, desiredRules)) {
|
|
142
|
+
await api.updateAccessPolicy(appId, tunaPolicy.id, desiredPolicy);
|
|
143
|
+
}
|
|
144
|
+
// else: policy is already in sync, no action needed
|
|
145
|
+
} else {
|
|
146
|
+
// Create new policy
|
|
147
|
+
await api.createAccessPolicy(appId, desiredPolicy);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return appId;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Remove Access application for a hostname
|
|
155
|
+
*/
|
|
156
|
+
export async function removeAccess(
|
|
157
|
+
credentials: Credentials,
|
|
158
|
+
hostname: string
|
|
159
|
+
): Promise<boolean> {
|
|
160
|
+
const api = new CloudflareAPI(credentials);
|
|
161
|
+
|
|
162
|
+
const existingApp = await api.getAccessApplicationByDomain(hostname);
|
|
163
|
+
if (existingApp) {
|
|
164
|
+
await api.deleteAccessApplication(existingApp.id);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get access rules description for display
|
|
173
|
+
*/
|
|
174
|
+
export function getAccessDescription(access: AccessConfig): string {
|
|
175
|
+
const parsed = parseAccessConfig(access);
|
|
176
|
+
const parts: string[] = [];
|
|
177
|
+
|
|
178
|
+
if (parsed.emailDomains.length > 0) {
|
|
179
|
+
parts.push(...parsed.emailDomains.map((d) => `@${d}`));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (parsed.emails.length > 0) {
|
|
183
|
+
if (parsed.emails.length <= 3) {
|
|
184
|
+
parts.push(...parsed.emails);
|
|
185
|
+
} else {
|
|
186
|
+
parts.push(`${parsed.emails.length} emails`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return parts.join(', ');
|
|
191
|
+
}
|
package/src/lib/api.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare API client for tunnel and DNS management
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
Credentials,
|
|
7
|
+
Tunnel,
|
|
8
|
+
DnsRecord,
|
|
9
|
+
Zone,
|
|
10
|
+
CloudflareApiResponse,
|
|
11
|
+
AccessApplication,
|
|
12
|
+
AccessPolicy,
|
|
13
|
+
AccessApplicationCreate,
|
|
14
|
+
AccessPolicyCreate,
|
|
15
|
+
} from '../types/index.ts';
|
|
16
|
+
|
|
17
|
+
const BASE_URL = 'https://api.cloudflare.com/client/v4';
|
|
18
|
+
|
|
19
|
+
export class CloudflareAPI {
|
|
20
|
+
private apiToken: string;
|
|
21
|
+
private accountId: string;
|
|
22
|
+
|
|
23
|
+
constructor(credentials: Credentials) {
|
|
24
|
+
this.accountId = credentials.accountId;
|
|
25
|
+
this.apiToken = credentials.apiToken;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Make an authenticated request to the Cloudflare API
|
|
30
|
+
*/
|
|
31
|
+
private async request<T>(
|
|
32
|
+
path: string,
|
|
33
|
+
options: RequestInit = {}
|
|
34
|
+
): Promise<CloudflareApiResponse<T>> {
|
|
35
|
+
const url = `${BASE_URL}${path}`;
|
|
36
|
+
|
|
37
|
+
const response = await fetch(url, {
|
|
38
|
+
...options,
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
...options.headers,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Handle rate limiting with retry
|
|
47
|
+
if (response.status === 429) {
|
|
48
|
+
const retryAfter = parseInt(response.headers.get('retry-after') || '5');
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
|
|
50
|
+
return this.request<T>(path, options);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const data = (await response.json()) as CloudflareApiResponse<T>;
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
this.handleApiError(response.status, data);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validate API token and get account ID
|
|
64
|
+
*/
|
|
65
|
+
async validateToken(): Promise<string> {
|
|
66
|
+
const verifyResponse = await this.request<{ status: string }>('/user/tokens/verify');
|
|
67
|
+
|
|
68
|
+
if (!verifyResponse.success) {
|
|
69
|
+
throw new Error('Invalid API token');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Get account ID
|
|
73
|
+
const accountsResponse = await this.request<Array<{ id: string }>>('/accounts');
|
|
74
|
+
const accounts = accountsResponse.result;
|
|
75
|
+
|
|
76
|
+
if (!accounts || accounts.length === 0) {
|
|
77
|
+
throw new Error('No accounts found for this token');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return accounts[0].id;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create a new tunnel
|
|
85
|
+
*/
|
|
86
|
+
async createTunnel(name: string, secret: string): Promise<Tunnel> {
|
|
87
|
+
const response = await this.request<Tunnel>(`/accounts/${this.accountId}/cfd_tunnel`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
name,
|
|
91
|
+
tunnel_secret: secret,
|
|
92
|
+
config_src: 'local',
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!response.success) {
|
|
97
|
+
throw new Error(`Failed to create tunnel: ${response.errors[0]?.message}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return response.result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* List all tunnels
|
|
105
|
+
*/
|
|
106
|
+
async listTunnels(): Promise<Tunnel[]> {
|
|
107
|
+
const response = await this.request<Tunnel[]>(`/accounts/${this.accountId}/cfd_tunnel`);
|
|
108
|
+
|
|
109
|
+
if (!response.success) {
|
|
110
|
+
throw new Error('Failed to list tunnels');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return response.result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get tunnel details
|
|
118
|
+
*/
|
|
119
|
+
async getTunnel(tunnelId: string): Promise<Tunnel> {
|
|
120
|
+
const response = await this.request<Tunnel>(
|
|
121
|
+
`/accounts/${this.accountId}/cfd_tunnel/${tunnelId}`
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
if (!response.success) {
|
|
125
|
+
throw new Error('Tunnel not found');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return response.result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Delete a tunnel
|
|
133
|
+
*/
|
|
134
|
+
async deleteTunnel(tunnelId: string): Promise<void> {
|
|
135
|
+
const response = await this.request<{ id: string }>(
|
|
136
|
+
`/accounts/${this.accountId}/cfd_tunnel/${tunnelId}`,
|
|
137
|
+
{ method: 'DELETE' }
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (!response.success) {
|
|
141
|
+
throw new Error('Failed to delete tunnel');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get zone by domain name
|
|
147
|
+
*/
|
|
148
|
+
async getZoneByName(domain: string): Promise<Zone> {
|
|
149
|
+
const response = await this.request<Zone[]>(`/zones?name=${encodeURIComponent(domain)}`);
|
|
150
|
+
|
|
151
|
+
if (!response.success || response.result.length === 0) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Zone not found: ${domain}. Make sure the domain is added to your Cloudflare account.`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return response.result[0];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create DNS record
|
|
162
|
+
*/
|
|
163
|
+
async createDnsRecord(zoneId: string, record: DnsRecord): Promise<DnsRecord> {
|
|
164
|
+
const response = await this.request<DnsRecord>(`/zones/${zoneId}/dns_records`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
body: JSON.stringify(record),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (!response.success) {
|
|
170
|
+
throw new Error('Failed to create DNS record');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return response.result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* List DNS records
|
|
178
|
+
*/
|
|
179
|
+
async listDnsRecords(zoneId: string, name?: string): Promise<DnsRecord[]> {
|
|
180
|
+
const params = name ? `?name=${encodeURIComponent(name)}` : '';
|
|
181
|
+
const response = await this.request<DnsRecord[]>(`/zones/${zoneId}/dns_records${params}`);
|
|
182
|
+
|
|
183
|
+
if (!response.success) {
|
|
184
|
+
throw new Error('Failed to list DNS records');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return response.result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Update DNS record
|
|
192
|
+
*/
|
|
193
|
+
async updateDnsRecord(zoneId: string, recordId: string, record: DnsRecord): Promise<DnsRecord> {
|
|
194
|
+
const response = await this.request<DnsRecord>(`/zones/${zoneId}/dns_records/${recordId}`, {
|
|
195
|
+
method: 'PUT',
|
|
196
|
+
body: JSON.stringify(record),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (!response.success) {
|
|
200
|
+
throw new Error('Failed to update DNS record');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return response.result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Delete DNS record
|
|
208
|
+
*/
|
|
209
|
+
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
|
|
210
|
+
const response = await this.request<{ id: string }>(
|
|
211
|
+
`/zones/${zoneId}/dns_records/${recordId}`,
|
|
212
|
+
{ method: 'DELETE' }
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (!response.success) {
|
|
216
|
+
throw new Error('Failed to delete DNS record');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ============================================
|
|
221
|
+
// Zero Trust Access API Methods
|
|
222
|
+
// ============================================
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* List Access applications
|
|
226
|
+
*/
|
|
227
|
+
async listAccessApplications(): Promise<AccessApplication[]> {
|
|
228
|
+
const response = await this.request<AccessApplication[]>(
|
|
229
|
+
`/accounts/${this.accountId}/access/apps`
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
if (!response.success) {
|
|
233
|
+
throw new Error('Failed to list Access applications');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return response.result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get Access application by domain
|
|
241
|
+
*/
|
|
242
|
+
async getAccessApplicationByDomain(domain: string): Promise<AccessApplication | null> {
|
|
243
|
+
const apps = await this.listAccessApplications();
|
|
244
|
+
return apps.find((app) => app.domain === domain) || null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Create Access application
|
|
249
|
+
*/
|
|
250
|
+
async createAccessApplication(app: AccessApplicationCreate): Promise<AccessApplication> {
|
|
251
|
+
const response = await this.request<AccessApplication>(
|
|
252
|
+
`/accounts/${this.accountId}/access/apps`,
|
|
253
|
+
{
|
|
254
|
+
method: 'POST',
|
|
255
|
+
body: JSON.stringify(app),
|
|
256
|
+
}
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if (!response.success) {
|
|
260
|
+
throw new Error(`Failed to create Access application: ${response.errors[0]?.message}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return response.result;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Delete Access application
|
|
268
|
+
*/
|
|
269
|
+
async deleteAccessApplication(appId: string): Promise<void> {
|
|
270
|
+
const response = await this.request<{ id: string }>(
|
|
271
|
+
`/accounts/${this.accountId}/access/apps/${appId}`,
|
|
272
|
+
{ method: 'DELETE' }
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
if (!response.success) {
|
|
276
|
+
throw new Error('Failed to delete Access application');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* List policies for an Access application
|
|
282
|
+
*/
|
|
283
|
+
async listAccessPolicies(appId: string): Promise<AccessPolicy[]> {
|
|
284
|
+
const response = await this.request<AccessPolicy[]>(
|
|
285
|
+
`/accounts/${this.accountId}/access/apps/${appId}/policies`
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
if (!response.success) {
|
|
289
|
+
throw new Error('Failed to list Access policies');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return response.result;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Create Access policy for an application
|
|
297
|
+
*/
|
|
298
|
+
async createAccessPolicy(appId: string, policy: AccessPolicyCreate): Promise<AccessPolicy> {
|
|
299
|
+
const response = await this.request<AccessPolicy>(
|
|
300
|
+
`/accounts/${this.accountId}/access/apps/${appId}/policies`,
|
|
301
|
+
{
|
|
302
|
+
method: 'POST',
|
|
303
|
+
body: JSON.stringify(policy),
|
|
304
|
+
}
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
if (!response.success) {
|
|
308
|
+
throw new Error(`Failed to create Access policy: ${response.errors[0]?.message}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return response.result;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Update Access policy
|
|
316
|
+
*/
|
|
317
|
+
async updateAccessPolicy(
|
|
318
|
+
appId: string,
|
|
319
|
+
policyId: string,
|
|
320
|
+
policy: AccessPolicyCreate
|
|
321
|
+
): Promise<AccessPolicy> {
|
|
322
|
+
const response = await this.request<AccessPolicy>(
|
|
323
|
+
`/accounts/${this.accountId}/access/apps/${appId}/policies/${policyId}`,
|
|
324
|
+
{
|
|
325
|
+
method: 'PUT',
|
|
326
|
+
body: JSON.stringify(policy),
|
|
327
|
+
}
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
if (!response.success) {
|
|
331
|
+
throw new Error(`Failed to update Access policy: ${response.errors[0]?.message}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return response.result;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Delete Access policy
|
|
339
|
+
*/
|
|
340
|
+
async deleteAccessPolicy(appId: string, policyId: string): Promise<void> {
|
|
341
|
+
const response = await this.request<{ id: string }>(
|
|
342
|
+
`/accounts/${this.accountId}/access/apps/${appId}/policies/${policyId}`,
|
|
343
|
+
{ method: 'DELETE' }
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
if (!response.success) {
|
|
347
|
+
throw new Error('Failed to delete Access policy');
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Handle API errors with user-friendly messages
|
|
353
|
+
*/
|
|
354
|
+
private handleApiError(status: number, data: CloudflareApiResponse<unknown>): never {
|
|
355
|
+
if (status === 401) {
|
|
356
|
+
throw new Error('Invalid API token. Run: tuna --login');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (status === 403) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
'Insufficient permissions. Your API token needs:\n' +
|
|
362
|
+
' - Account → Cloudflare Tunnel → Edit\n' +
|
|
363
|
+
' - Account → Access: Apps and Policies → Edit\n' +
|
|
364
|
+
' - Zone → DNS → Edit\n' +
|
|
365
|
+
' - Account → Account Settings → Read'
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (status === 404) {
|
|
370
|
+
throw new Error('Resource not found. Check tunnel ID or domain.');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (status === 429) {
|
|
374
|
+
throw new Error('Rate limited. Please wait a moment and try again.');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (data?.errors?.[0]) {
|
|
378
|
+
throw new Error(`Cloudflare API error: ${data.errors[0].message}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
throw new Error(`API request failed with status ${status}`);
|
|
382
|
+
}
|
|
383
|
+
}
|