@simonfestl/husky-cli 1.38.4 → 1.38.6
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/dist/commands/agent-msg.js +9 -4
- package/dist/commands/biz/gotess.js +10 -4
- package/dist/commands/chat.js +33 -37
- package/dist/commands/config.d.ts +9 -6
- package/dist/commands/config.js +103 -127
- package/dist/commands/interactive/auth.js +7 -2
- package/dist/commands/research.d.ts +2 -0
- package/dist/commands/research.js +774 -0
- package/dist/commands/supervisor.js +0 -1
- package/dist/index.js +46 -1
- package/dist/lib/api-client.d.ts +1 -0
- package/dist/lib/api-client.js +16 -83
- package/dist/lib/biz/api-brain.js +6 -23
- package/dist/lib/biz/emove-playwright.js +0 -1
- package/dist/lib/biz/index.d.ts +2 -0
- package/dist/lib/biz/index.js +2 -0
- package/dist/lib/biz/reddit.d.ts +83 -0
- package/dist/lib/biz/reddit.js +168 -0
- package/dist/lib/biz/skuterzone-playwright.js +0 -1
- package/dist/lib/biz/x-client.d.ts +27 -0
- package/dist/lib/biz/x-client.js +123 -0
- package/dist/lib/biz/youtube-monitor.d.ts +72 -0
- package/dist/lib/biz/youtube-monitor.js +180 -0
- package/dist/lib/permissions-cache.js +9 -35
- package/package.json +1 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { getConfig } from "../../commands/config.js";
|
|
2
|
+
export class XClient {
|
|
3
|
+
config;
|
|
4
|
+
baseUrl = 'https://api.twitter.com/2';
|
|
5
|
+
userIdCache = new Map();
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
}
|
|
9
|
+
static fromConfig() {
|
|
10
|
+
const config = getConfig();
|
|
11
|
+
const env = process.env.HUSKY_ENV || 'PROD';
|
|
12
|
+
const bearerToken = process.env[`${env}_X_BEARER_TOKEN`] || process.env.X_BEARER_TOKEN || config.xBearerToken;
|
|
13
|
+
if (!bearerToken) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return new XClient({ bearerToken });
|
|
17
|
+
}
|
|
18
|
+
async makeRequest(endpoint, params, retryCount = 0) {
|
|
19
|
+
const url = new URL(`${this.baseUrl}${endpoint}`);
|
|
20
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
21
|
+
url.searchParams.append(key, value);
|
|
22
|
+
});
|
|
23
|
+
const timeout = 15000;
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(url.toString(), {
|
|
28
|
+
headers: {
|
|
29
|
+
'Authorization': `Bearer ${this.config.bearerToken}`,
|
|
30
|
+
},
|
|
31
|
+
signal: controller.signal,
|
|
32
|
+
});
|
|
33
|
+
clearTimeout(id);
|
|
34
|
+
if (response.status === 429 && retryCount < 2) {
|
|
35
|
+
const resetTime = parseInt(response.headers.get('x-rate-limit-reset') || '0', 10);
|
|
36
|
+
const waitTime = resetTime ? Math.max(0, (resetTime * 1000) - Date.now() + 1000) : 5000;
|
|
37
|
+
// Cap wait time to 30s for CLI
|
|
38
|
+
if (waitTime < 30000) {
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
40
|
+
return this.makeRequest(endpoint, params, retryCount + 1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
const error = await response.text();
|
|
45
|
+
// Redact token if it somehow ended up in the error text
|
|
46
|
+
const redactedError = error.replace(this.config.bearerToken, '[REDACTED]');
|
|
47
|
+
throw new Error(`X API error: ${response.status} ${response.statusText} - ${redactedError}`);
|
|
48
|
+
}
|
|
49
|
+
return response.json();
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
clearTimeout(id);
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async searchRecentTweets(query, maxResults = 10) {
|
|
57
|
+
const clampedResults = Math.max(10, Math.min(maxResults, 100));
|
|
58
|
+
const data = await this.makeRequest('/tweets/search/recent', {
|
|
59
|
+
query,
|
|
60
|
+
max_results: clampedResults.toString(),
|
|
61
|
+
'tweet.fields': 'created_at,public_metrics,author_id',
|
|
62
|
+
expansions: 'author_id',
|
|
63
|
+
'user.fields': 'username',
|
|
64
|
+
});
|
|
65
|
+
if (!data.data)
|
|
66
|
+
return [];
|
|
67
|
+
const users = new Map(data.includes?.users?.map(u => [u.id, u.username]) || []);
|
|
68
|
+
return data.data.map(tweet => ({
|
|
69
|
+
id: tweet.id,
|
|
70
|
+
text: tweet.text,
|
|
71
|
+
authorId: tweet.author_id,
|
|
72
|
+
username: users.get(tweet.author_id) || 'unknown',
|
|
73
|
+
createdAt: tweet.created_at,
|
|
74
|
+
publicMetrics: {
|
|
75
|
+
retweetCount: tweet.public_metrics?.retweet_count ?? 0,
|
|
76
|
+
replyCount: tweet.public_metrics?.reply_count ?? 0,
|
|
77
|
+
likeCount: tweet.public_metrics?.like_count ?? 0,
|
|
78
|
+
quoteCount: tweet.public_metrics?.quote_count ?? 0,
|
|
79
|
+
},
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
async getUserTweets(username, maxResults = 10) {
|
|
83
|
+
const clampedResults = Math.max(5, Math.min(maxResults, 100));
|
|
84
|
+
const cleanUsername = encodeURIComponent(username.replace('@', ''));
|
|
85
|
+
// 1. Resolve username to ID (with cache)
|
|
86
|
+
let userId = this.userIdCache.get(cleanUsername);
|
|
87
|
+
if (!userId) {
|
|
88
|
+
const userData = await this.makeRequest(`/users/by/username/${cleanUsername}`, {});
|
|
89
|
+
if (!userData.data)
|
|
90
|
+
return [];
|
|
91
|
+
userId = userData.data.id;
|
|
92
|
+
this.userIdCache.set(cleanUsername, userId);
|
|
93
|
+
}
|
|
94
|
+
// 2. Get tweets
|
|
95
|
+
const data = await this.makeRequest(`/users/${encodeURIComponent(userId)}/tweets`, {
|
|
96
|
+
max_results: clampedResults.toString(),
|
|
97
|
+
'tweet.fields': 'created_at,public_metrics,author_id',
|
|
98
|
+
expansions: 'author_id',
|
|
99
|
+
'user.fields': 'username',
|
|
100
|
+
});
|
|
101
|
+
if (!data.data)
|
|
102
|
+
return [];
|
|
103
|
+
return data.data.map(tweet => ({
|
|
104
|
+
id: tweet.id,
|
|
105
|
+
text: tweet.text,
|
|
106
|
+
authorId: tweet.author_id,
|
|
107
|
+
username: username,
|
|
108
|
+
createdAt: tweet.created_at,
|
|
109
|
+
publicMetrics: {
|
|
110
|
+
retweetCount: tweet.public_metrics?.retweet_count ?? 0,
|
|
111
|
+
replyCount: tweet.public_metrics?.reply_count ?? 0,
|
|
112
|
+
likeCount: tweet.public_metrics?.like_count ?? 0,
|
|
113
|
+
quoteCount: tweet.public_metrics?.quote_count ?? 0,
|
|
114
|
+
},
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export function isXConfigured() {
|
|
119
|
+
const config = getConfig();
|
|
120
|
+
const env = process.env.HUSKY_ENV || 'PROD';
|
|
121
|
+
const bearerToken = process.env[`${env}_X_BEARER_TOKEN`] || process.env.X_BEARER_TOKEN || config.xBearerToken;
|
|
122
|
+
return !!bearerToken;
|
|
123
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export interface YouTubeConfig {
|
|
2
|
+
apiKey: string;
|
|
3
|
+
}
|
|
4
|
+
export interface YouTubeVideo {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
channelId: string;
|
|
9
|
+
channelTitle: string;
|
|
10
|
+
publishedAt: string;
|
|
11
|
+
thumbnails: {
|
|
12
|
+
default?: {
|
|
13
|
+
url: string;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
};
|
|
17
|
+
medium?: {
|
|
18
|
+
url: string;
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
};
|
|
22
|
+
high?: {
|
|
23
|
+
url: string;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
duration?: string;
|
|
29
|
+
viewCount?: string;
|
|
30
|
+
likeCount?: string;
|
|
31
|
+
commentCount?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface YouTubeChannel {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
description: string;
|
|
37
|
+
customUrl?: string;
|
|
38
|
+
publishedAt: string;
|
|
39
|
+
thumbnails: {
|
|
40
|
+
default?: {
|
|
41
|
+
url: string;
|
|
42
|
+
};
|
|
43
|
+
medium?: {
|
|
44
|
+
url: string;
|
|
45
|
+
};
|
|
46
|
+
high?: {
|
|
47
|
+
url: string;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
subscriberCount?: string;
|
|
51
|
+
videoCount?: string;
|
|
52
|
+
}
|
|
53
|
+
export declare class YouTubeMonitorClient {
|
|
54
|
+
private config;
|
|
55
|
+
private baseUrl;
|
|
56
|
+
constructor(config: YouTubeConfig);
|
|
57
|
+
static fromConfig(): YouTubeMonitorClient;
|
|
58
|
+
private makeRequest;
|
|
59
|
+
getChannelById(channelId: string): Promise<YouTubeChannel | null>;
|
|
60
|
+
getChannelByHandle(handle: string): Promise<YouTubeChannel | null>;
|
|
61
|
+
getLatestVideos(channelId: string, options?: {
|
|
62
|
+
maxResults?: number;
|
|
63
|
+
publishedAfter?: Date;
|
|
64
|
+
}): Promise<YouTubeVideo[]>;
|
|
65
|
+
getVideoTranscript(videoId: string): Promise<string | null>;
|
|
66
|
+
searchVideos(query: string, options?: {
|
|
67
|
+
maxResults?: number;
|
|
68
|
+
publishedAfter?: Date;
|
|
69
|
+
channelId?: string;
|
|
70
|
+
}): Promise<YouTubeVideo[]>;
|
|
71
|
+
}
|
|
72
|
+
export declare function isYouTubeConfigured(): boolean;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { getConfig } from "../../commands/config.js";
|
|
2
|
+
function mapChannelItem(item) {
|
|
3
|
+
return {
|
|
4
|
+
id: item.id,
|
|
5
|
+
title: item.snippet.title,
|
|
6
|
+
description: item.snippet.description,
|
|
7
|
+
customUrl: item.snippet.customUrl,
|
|
8
|
+
publishedAt: item.snippet.publishedAt,
|
|
9
|
+
thumbnails: item.snippet.thumbnails,
|
|
10
|
+
subscriberCount: item.statistics?.subscriberCount,
|
|
11
|
+
videoCount: item.statistics?.videoCount,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export class YouTubeMonitorClient {
|
|
15
|
+
config;
|
|
16
|
+
baseUrl = 'https://www.googleapis.com/youtube/v3';
|
|
17
|
+
constructor(config) {
|
|
18
|
+
this.config = config;
|
|
19
|
+
}
|
|
20
|
+
static fromConfig() {
|
|
21
|
+
const config = getConfig();
|
|
22
|
+
const env = process.env.HUSKY_ENV || 'PROD';
|
|
23
|
+
const apiKey = process.env[`${env}_YOUTUBE_API_KEY`] || process.env.YOUTUBE_API_KEY || config.youtubeApiKey;
|
|
24
|
+
if (!apiKey) {
|
|
25
|
+
throw new Error("YouTube API Key not configured. Please set youtube-api-key.");
|
|
26
|
+
}
|
|
27
|
+
const clientConfig = {
|
|
28
|
+
apiKey,
|
|
29
|
+
};
|
|
30
|
+
return new YouTubeMonitorClient(clientConfig);
|
|
31
|
+
}
|
|
32
|
+
async makeRequest(endpoint, params, retryCount = 0) {
|
|
33
|
+
const url = new URL(`${this.baseUrl}${endpoint}`);
|
|
34
|
+
url.searchParams.append('key', this.config.apiKey);
|
|
35
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
36
|
+
if (value !== undefined && value !== null)
|
|
37
|
+
url.searchParams.append(key, value);
|
|
38
|
+
});
|
|
39
|
+
const timeout = 15000;
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(url.toString(), {
|
|
44
|
+
signal: controller.signal,
|
|
45
|
+
});
|
|
46
|
+
clearTimeout(id);
|
|
47
|
+
if (response.status === 429 && retryCount < 2) {
|
|
48
|
+
await new Promise(resolve => setTimeout(resolve, 5000 * (retryCount + 1)));
|
|
49
|
+
return this.makeRequest(endpoint, params, retryCount + 1);
|
|
50
|
+
}
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
const error = await response.text();
|
|
53
|
+
// Redact API key from error message
|
|
54
|
+
const redactedError = error.replace(this.config.apiKey, '[REDACTED]');
|
|
55
|
+
throw new Error(`YouTube API error: ${response.status} ${response.statusText} - ${redactedError}`);
|
|
56
|
+
}
|
|
57
|
+
return response.json();
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
clearTimeout(id);
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async getChannelById(channelId) {
|
|
65
|
+
const data = await this.makeRequest('/channels', {
|
|
66
|
+
part: 'snippet,statistics',
|
|
67
|
+
id: channelId,
|
|
68
|
+
});
|
|
69
|
+
if (!data.items || data.items.length === 0) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return mapChannelItem(data.items[0]);
|
|
73
|
+
}
|
|
74
|
+
async getChannelByHandle(handle) {
|
|
75
|
+
const cleanHandle = handle.startsWith('@') ? handle : `@${handle}`;
|
|
76
|
+
const data = await this.makeRequest('/channels', {
|
|
77
|
+
part: 'snippet,statistics',
|
|
78
|
+
forHandle: cleanHandle,
|
|
79
|
+
});
|
|
80
|
+
if (!data.items || data.items.length === 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return mapChannelItem(data.items[0]);
|
|
84
|
+
}
|
|
85
|
+
async getLatestVideos(channelId, options = {}) {
|
|
86
|
+
const { maxResults = 10, publishedAfter } = options;
|
|
87
|
+
const clampedResults = Math.min(maxResults, 50);
|
|
88
|
+
const params = {
|
|
89
|
+
part: 'snippet',
|
|
90
|
+
channelId,
|
|
91
|
+
type: 'video',
|
|
92
|
+
order: 'date',
|
|
93
|
+
maxResults: clampedResults.toString(),
|
|
94
|
+
};
|
|
95
|
+
if (publishedAfter) {
|
|
96
|
+
params.publishedAfter = publishedAfter.toISOString();
|
|
97
|
+
}
|
|
98
|
+
const data = await this.makeRequest('/search', params);
|
|
99
|
+
if (!data.items || data.items.length === 0) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const videoIds = data.items.map(item => item.id.videoId).filter(Boolean);
|
|
103
|
+
if (videoIds.length === 0) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
// Get video details for duration, view count, etc. (Max 50 IDs per call)
|
|
107
|
+
const detailsData = await this.makeRequest('/videos', {
|
|
108
|
+
part: 'contentDetails,statistics',
|
|
109
|
+
id: videoIds.slice(0, 50).join(','),
|
|
110
|
+
});
|
|
111
|
+
const detailsMap = new Map(detailsData.items?.map(item => [item.id, item]) || []);
|
|
112
|
+
return data.items
|
|
113
|
+
.filter(item => item.id.videoId)
|
|
114
|
+
.map(item => {
|
|
115
|
+
const videoId = item.id.videoId;
|
|
116
|
+
const details = detailsMap.get(videoId);
|
|
117
|
+
return {
|
|
118
|
+
id: videoId,
|
|
119
|
+
title: item.snippet.title,
|
|
120
|
+
description: item.snippet.description,
|
|
121
|
+
channelId: item.snippet.channelId,
|
|
122
|
+
channelTitle: item.snippet.channelTitle,
|
|
123
|
+
publishedAt: item.snippet.publishedAt,
|
|
124
|
+
thumbnails: item.snippet.thumbnails,
|
|
125
|
+
duration: details?.contentDetails?.duration,
|
|
126
|
+
viewCount: details?.statistics?.viewCount,
|
|
127
|
+
likeCount: details?.statistics?.likeCount,
|
|
128
|
+
commentCount: details?.statistics?.commentCount,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async getVideoTranscript(videoId) {
|
|
133
|
+
try {
|
|
134
|
+
const { YoutubeTranscript } = await import('youtube-transcript');
|
|
135
|
+
const transcript = await YoutubeTranscript.fetchTranscript(videoId);
|
|
136
|
+
return transcript.map(entry => entry.text).join(' ');
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async searchVideos(query, options = {}) {
|
|
143
|
+
const { maxResults = 10, publishedAfter, channelId } = options;
|
|
144
|
+
const clampedResults = Math.min(maxResults, 50);
|
|
145
|
+
const params = {
|
|
146
|
+
part: 'snippet',
|
|
147
|
+
q: query,
|
|
148
|
+
type: 'video',
|
|
149
|
+
order: 'date',
|
|
150
|
+
maxResults: clampedResults.toString(),
|
|
151
|
+
};
|
|
152
|
+
if (publishedAfter) {
|
|
153
|
+
params.publishedAfter = publishedAfter.toISOString();
|
|
154
|
+
}
|
|
155
|
+
if (channelId) {
|
|
156
|
+
params.channelId = channelId;
|
|
157
|
+
}
|
|
158
|
+
const data = await this.makeRequest('/search', params);
|
|
159
|
+
if (!data.items) {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
return data.items
|
|
163
|
+
.filter(item => item.id.videoId)
|
|
164
|
+
.map(item => ({
|
|
165
|
+
id: item.id.videoId,
|
|
166
|
+
title: item.snippet.title,
|
|
167
|
+
description: item.snippet.description,
|
|
168
|
+
channelId: item.snippet.channelId,
|
|
169
|
+
channelTitle: item.snippet.channelTitle,
|
|
170
|
+
publishedAt: item.snippet.publishedAt,
|
|
171
|
+
thumbnails: item.snippet.thumbnails,
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export function isYouTubeConfigured() {
|
|
176
|
+
const config = getConfig();
|
|
177
|
+
const env = process.env.HUSKY_ENV || 'PROD';
|
|
178
|
+
const apiKey = process.env[`${env}_YOUTUBE_API_KEY`] || process.env.YOUTUBE_API_KEY || config.youtubeApiKey;
|
|
179
|
+
return !!apiKey;
|
|
180
|
+
}
|
|
@@ -1,26 +1,21 @@
|
|
|
1
|
-
import { getConfig,
|
|
1
|
+
import { getConfig, getAuthHeaders } from "../commands/config.js";
|
|
2
2
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
3
3
|
let cache = null;
|
|
4
4
|
let fetchPromise = null;
|
|
5
5
|
async function fetchPermissions() {
|
|
6
6
|
const config = getConfig();
|
|
7
|
-
if (!config.apiUrl
|
|
7
|
+
if (!config.apiUrl) {
|
|
8
8
|
throw new Error("API not configured");
|
|
9
9
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
? config.sessionRole
|
|
14
|
-
: undefined;
|
|
15
|
-
// Build URL - if we have a session role, request permissions for that specific role
|
|
16
|
-
const url = new URL("/api/auth/whoami", config.apiUrl);
|
|
17
|
-
if (roleToFetch) {
|
|
18
|
-
url.searchParams.set("role", roleToFetch);
|
|
10
|
+
const authHeaders = getAuthHeaders();
|
|
11
|
+
if (!authHeaders.Authorization) {
|
|
12
|
+
throw new Error("No active session. Run: husky auth login --agent <name>");
|
|
19
13
|
}
|
|
14
|
+
const url = new URL("/api/auth/whoami", config.apiUrl);
|
|
20
15
|
const res = await fetch(url.toString(), {
|
|
21
16
|
method: "GET",
|
|
22
17
|
headers: {
|
|
23
|
-
|
|
18
|
+
...authHeaders,
|
|
24
19
|
"Content-Type": "application/json",
|
|
25
20
|
},
|
|
26
21
|
});
|
|
@@ -29,12 +24,8 @@ async function fetchPermissions() {
|
|
|
29
24
|
throw new Error(error.message || error.error || `HTTP ${res.status}`);
|
|
30
25
|
}
|
|
31
26
|
const data = await res.json();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const effectiveRole = roleToFetch || data.role;
|
|
35
|
-
const effectivePermissions = roleToFetch
|
|
36
|
-
? await fetchPermissionsForRole(config.apiUrl, config.apiKey, roleToFetch)
|
|
37
|
-
: data.permissions;
|
|
27
|
+
const effectiveRole = data.role || config.sessionRole || "unknown";
|
|
28
|
+
const effectivePermissions = data.permissions || [];
|
|
38
29
|
const kbPermissions = effectivePermissions
|
|
39
30
|
.filter((p) => p.startsWith("kb:"))
|
|
40
31
|
.map((p) => p.replace("kb:", ""));
|
|
@@ -45,23 +36,6 @@ async function fetchPermissions() {
|
|
|
45
36
|
fetchedAt: Date.now(),
|
|
46
37
|
};
|
|
47
38
|
}
|
|
48
|
-
async function fetchPermissionsForRole(apiUrl, apiKey, role) {
|
|
49
|
-
// Use the /permissions/:role endpoint to get permissions for a specific role
|
|
50
|
-
const url = new URL(`/api/auth/permissions/${encodeURIComponent(role)}`, apiUrl);
|
|
51
|
-
const res = await fetch(url.toString(), {
|
|
52
|
-
method: "GET",
|
|
53
|
-
headers: {
|
|
54
|
-
"x-api-key": apiKey,
|
|
55
|
-
"Content-Type": "application/json",
|
|
56
|
-
},
|
|
57
|
-
});
|
|
58
|
-
if (!res.ok) {
|
|
59
|
-
// Fall back to empty permissions if fetch fails
|
|
60
|
-
return [];
|
|
61
|
-
}
|
|
62
|
-
const data = await res.json();
|
|
63
|
-
return data.permissions || [];
|
|
64
|
-
}
|
|
65
39
|
export async function getPermissions() {
|
|
66
40
|
const now = Date.now();
|
|
67
41
|
if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
|