veryfront 0.0.45 → 0.0.47
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/ai/components.js +3 -3
- package/dist/ai/components.js.map +2 -2
- package/dist/ai/index.d.ts +18 -3
- package/dist/ai/index.js +12 -2
- package/dist/ai/index.js.map +2 -2
- package/dist/cli.js +4 -5
- package/dist/components.js +1 -1
- package/dist/components.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/data.js +1 -1
- package/dist/data.js.map +1 -1
- package/dist/index.js +2 -5
- package/dist/index.js.map +2 -2
- package/dist/integrations/_base/connector.json +11 -0
- package/dist/integrations/_base/files/SETUP.md +132 -0
- package/dist/integrations/_base/files/app/api/integrations/status/route.ts +38 -0
- package/dist/integrations/_base/files/app/setup/page.tsx +461 -0
- package/dist/integrations/_base/files/lib/oauth.ts +145 -0
- package/dist/integrations/_base/files/lib/token-store.ts +109 -0
- package/dist/integrations/calendar/connector.json +77 -0
- package/dist/integrations/calendar/files/ai/tools/create-event.ts +83 -0
- package/dist/integrations/calendar/files/ai/tools/find-free-time.ts +108 -0
- package/dist/integrations/calendar/files/ai/tools/list-events.ts +98 -0
- package/dist/integrations/calendar/files/app/api/auth/calendar/callback/route.ts +114 -0
- package/dist/integrations/calendar/files/app/api/auth/calendar/route.ts +29 -0
- package/dist/integrations/calendar/files/lib/calendar-client.ts +309 -0
- package/dist/integrations/calendar/files/lib/oauth.ts +145 -0
- package/dist/integrations/calendar/files/lib/token-store.ts +109 -0
- package/dist/integrations/github/connector.json +84 -0
- package/dist/integrations/github/files/ai/tools/create-issue.ts +75 -0
- package/dist/integrations/github/files/ai/tools/get-pr-diff.ts +82 -0
- package/dist/integrations/github/files/ai/tools/list-prs.ts +93 -0
- package/dist/integrations/github/files/ai/tools/list-repos.ts +81 -0
- package/dist/integrations/github/files/app/api/auth/github/callback/route.ts +132 -0
- package/dist/integrations/github/files/app/api/auth/github/route.ts +29 -0
- package/dist/integrations/github/files/lib/github-client.ts +282 -0
- package/dist/integrations/github/files/lib/oauth.ts +145 -0
- package/dist/integrations/github/files/lib/token-store.ts +109 -0
- package/dist/integrations/gmail/connector.json +78 -0
- package/dist/integrations/gmail/files/ai/tools/list-emails.ts +92 -0
- package/dist/integrations/gmail/files/ai/tools/search-emails.ts +92 -0
- package/dist/integrations/gmail/files/ai/tools/send-email.ts +77 -0
- package/dist/integrations/gmail/files/app/api/auth/gmail/callback/route.ts +114 -0
- package/dist/integrations/gmail/files/app/api/auth/gmail/route.ts +29 -0
- package/dist/integrations/gmail/files/lib/gmail-client.ts +259 -0
- package/dist/integrations/gmail/files/lib/oauth.ts +145 -0
- package/dist/integrations/gmail/files/lib/token-store.ts +109 -0
- package/dist/integrations/slack/connector.json +74 -0
- package/dist/integrations/slack/files/ai/tools/get-messages.ts +65 -0
- package/dist/integrations/slack/files/ai/tools/list-channels.ts +63 -0
- package/dist/integrations/slack/files/ai/tools/send-message.ts +49 -0
- package/dist/integrations/slack/files/app/api/auth/slack/callback/route.ts +132 -0
- package/dist/integrations/slack/files/app/api/auth/slack/route.ts +29 -0
- package/dist/integrations/slack/files/lib/oauth.ts +145 -0
- package/dist/integrations/slack/files/lib/slack-client.ts +215 -0
- package/dist/integrations/slack/files/lib/token-store.ts +109 -0
- package/dist/templates/ai/app/page.tsx +4 -4
- package/dist/templates/ai/tsconfig.json +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Calendar OAuth Callback
|
|
3
|
+
*
|
|
4
|
+
* Handles the OAuth callback from Google, exchanges code for tokens,
|
|
5
|
+
* and stores them securely.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { exchangeCodeForTokens } from "../../../../../lib/oauth.ts";
|
|
9
|
+
import { tokenStore } from "../../../../../lib/token-store.ts";
|
|
10
|
+
import { calendarOAuthProvider } from "../../../../../lib/calendar-client.ts";
|
|
11
|
+
|
|
12
|
+
export async function GET(req: Request) {
|
|
13
|
+
const url = new URL(req.url);
|
|
14
|
+
const code = url.searchParams.get("code");
|
|
15
|
+
const state = url.searchParams.get("state");
|
|
16
|
+
const error = url.searchParams.get("error");
|
|
17
|
+
|
|
18
|
+
// Handle OAuth errors
|
|
19
|
+
if (error) {
|
|
20
|
+
const errorDescription = url.searchParams.get("error_description") || error;
|
|
21
|
+
return new Response(
|
|
22
|
+
`
|
|
23
|
+
<html>
|
|
24
|
+
<head><title>Connection Failed</title></head>
|
|
25
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
26
|
+
<h1>Calendar Connection Failed</h1>
|
|
27
|
+
<p style="color: #666;">${errorDescription}</p>
|
|
28
|
+
<a href="/" style="color: #0066cc;">Return to App</a>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
|
31
|
+
`,
|
|
32
|
+
{
|
|
33
|
+
status: 400,
|
|
34
|
+
headers: { "Content-Type": "text/html" },
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!code || !state) {
|
|
40
|
+
return new Response("Missing code or state parameter", { status: 400 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Validate state from cookie
|
|
44
|
+
const cookies = req.headers.get("cookie") || "";
|
|
45
|
+
const stateCookie = cookies
|
|
46
|
+
.split(";")
|
|
47
|
+
.find((c) => c.trim().startsWith("calendar_oauth_state="));
|
|
48
|
+
const savedState = stateCookie?.split("=")[1]?.trim();
|
|
49
|
+
|
|
50
|
+
if (state !== savedState) {
|
|
51
|
+
return new Response("Invalid state parameter", { status: 400 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const origin = url.origin;
|
|
56
|
+
const redirectUri = `${origin}${calendarOAuthProvider.callbackPath}`;
|
|
57
|
+
|
|
58
|
+
// Exchange code for tokens
|
|
59
|
+
const tokens = await exchangeCodeForTokens(
|
|
60
|
+
calendarOAuthProvider,
|
|
61
|
+
code,
|
|
62
|
+
redirectUri,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Get actual user ID from session in production
|
|
66
|
+
const userId = "current-user";
|
|
67
|
+
|
|
68
|
+
// Store tokens
|
|
69
|
+
await tokenStore.setToken(userId, "calendar", tokens);
|
|
70
|
+
|
|
71
|
+
// Clear state cookie and redirect to success page
|
|
72
|
+
return new Response(
|
|
73
|
+
`
|
|
74
|
+
<html>
|
|
75
|
+
<head>
|
|
76
|
+
<title>Calendar Connected</title>
|
|
77
|
+
<meta http-equiv="refresh" content="2;url=/" />
|
|
78
|
+
</head>
|
|
79
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
80
|
+
<h1 style="color: #22c55e;">✓ Calendar Connected!</h1>
|
|
81
|
+
<p style="color: #666;">Your Google Calendar has been connected successfully.</p>
|
|
82
|
+
<p style="color: #999; font-size: 14px;">Redirecting...</p>
|
|
83
|
+
<a href="/" style="color: #0066cc;">Return to App</a>
|
|
84
|
+
</body>
|
|
85
|
+
</html>
|
|
86
|
+
`,
|
|
87
|
+
{
|
|
88
|
+
status: 200,
|
|
89
|
+
headers: {
|
|
90
|
+
"Content-Type": "text/html",
|
|
91
|
+
"Set-Cookie": "calendar_oauth_state=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error("Calendar OAuth error:", err);
|
|
97
|
+
return new Response(
|
|
98
|
+
`
|
|
99
|
+
<html>
|
|
100
|
+
<head><title>Connection Failed</title></head>
|
|
101
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
102
|
+
<h1>Calendar Connection Failed</h1>
|
|
103
|
+
<p style="color: #666;">Unable to complete Calendar authorization. Please try again.</p>
|
|
104
|
+
<a href="/api/auth/calendar" style="color: #0066cc;">Try Again</a>
|
|
105
|
+
</body>
|
|
106
|
+
</html>
|
|
107
|
+
`,
|
|
108
|
+
{
|
|
109
|
+
status: 500,
|
|
110
|
+
headers: { "Content-Type": "text/html" },
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Calendar OAuth Initiation
|
|
3
|
+
*
|
|
4
|
+
* Redirects to Google OAuth consent screen for Calendar access
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getAuthorizationUrl } from "../../../../lib/oauth.ts";
|
|
8
|
+
import { calendarOAuthProvider } from "../../../../lib/calendar-client.ts";
|
|
9
|
+
|
|
10
|
+
export function GET(req: Request) {
|
|
11
|
+
const url = new URL(req.url);
|
|
12
|
+
const origin = url.origin;
|
|
13
|
+
|
|
14
|
+
// Generate a random state for CSRF protection
|
|
15
|
+
const state = crypto.randomUUID();
|
|
16
|
+
|
|
17
|
+
// Store state in a cookie for validation
|
|
18
|
+
const redirectUri = `${origin}${calendarOAuthProvider.callbackPath}`;
|
|
19
|
+
const authUrl = getAuthorizationUrl(calendarOAuthProvider, state, redirectUri);
|
|
20
|
+
|
|
21
|
+
// Set state cookie and redirect to Google
|
|
22
|
+
return new Response(null, {
|
|
23
|
+
status: 302,
|
|
24
|
+
headers: {
|
|
25
|
+
Location: authUrl,
|
|
26
|
+
"Set-Cookie": `calendar_oauth_state=${state}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Calendar API Client
|
|
3
|
+
*
|
|
4
|
+
* Provides a type-safe interface to Google Calendar API operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { tokenStore as _tokenStore } from "./token-store.ts";
|
|
8
|
+
import { getValidToken } from "./oauth.ts";
|
|
9
|
+
|
|
10
|
+
// Helper for Cross-Platform environment access
|
|
11
|
+
function getEnv(key: string): string | undefined {
|
|
12
|
+
// @ts-ignore - Deno global
|
|
13
|
+
if (typeof Deno !== "undefined") {
|
|
14
|
+
// @ts-ignore - Deno global
|
|
15
|
+
return Deno.env.get(key);
|
|
16
|
+
} // @ts-ignore - process global
|
|
17
|
+
else if (typeof process !== "undefined" && process.env) {
|
|
18
|
+
// @ts-ignore - process global
|
|
19
|
+
return process.env[key];
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3";
|
|
25
|
+
|
|
26
|
+
export interface CalendarEvent {
|
|
27
|
+
id: string;
|
|
28
|
+
summary: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
location?: string;
|
|
31
|
+
start: {
|
|
32
|
+
dateTime?: string;
|
|
33
|
+
date?: string;
|
|
34
|
+
timeZone?: string;
|
|
35
|
+
};
|
|
36
|
+
end: {
|
|
37
|
+
dateTime?: string;
|
|
38
|
+
date?: string;
|
|
39
|
+
timeZone?: string;
|
|
40
|
+
};
|
|
41
|
+
attendees?: Array<{
|
|
42
|
+
email: string;
|
|
43
|
+
responseStatus: "needsAction" | "declined" | "tentative" | "accepted";
|
|
44
|
+
displayName?: string;
|
|
45
|
+
}>;
|
|
46
|
+
htmlLink: string;
|
|
47
|
+
status: "confirmed" | "tentative" | "cancelled";
|
|
48
|
+
organizer?: { email: string; displayName?: string };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CreateEventOptions {
|
|
52
|
+
summary: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
location?: string;
|
|
55
|
+
start: Date | string;
|
|
56
|
+
end: Date | string;
|
|
57
|
+
attendees?: string[];
|
|
58
|
+
timeZone?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface FreeBusySlot {
|
|
62
|
+
start: string;
|
|
63
|
+
end: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Google Calendar OAuth provider configuration
|
|
68
|
+
*/
|
|
69
|
+
export const calendarOAuthProvider = {
|
|
70
|
+
name: "calendar",
|
|
71
|
+
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
72
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
73
|
+
clientId: getEnv("GOOGLE_CLIENT_ID") || "",
|
|
74
|
+
clientSecret: getEnv("GOOGLE_CLIENT_SECRET") || "",
|
|
75
|
+
scopes: [
|
|
76
|
+
"https://www.googleapis.com/auth/calendar.readonly",
|
|
77
|
+
"https://www.googleapis.com/auth/calendar.events",
|
|
78
|
+
],
|
|
79
|
+
callbackPath: "/api/auth/calendar/callback",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create a Calendar client for a specific user
|
|
84
|
+
*/
|
|
85
|
+
export function createCalendarClient(userId: string) {
|
|
86
|
+
async function getAccessToken(): Promise<string> {
|
|
87
|
+
const token = await getValidToken(calendarOAuthProvider, userId, "calendar");
|
|
88
|
+
if (!token) {
|
|
89
|
+
throw new Error("Calendar not connected. Please connect your Google Calendar first.");
|
|
90
|
+
}
|
|
91
|
+
return token;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function apiRequest<T>(
|
|
95
|
+
endpoint: string,
|
|
96
|
+
options: RequestInit = {},
|
|
97
|
+
): Promise<T> {
|
|
98
|
+
const accessToken = await getAccessToken();
|
|
99
|
+
|
|
100
|
+
const response = await fetch(`${CALENDAR_API_BASE}${endpoint}`, {
|
|
101
|
+
...options,
|
|
102
|
+
headers: {
|
|
103
|
+
Authorization: `Bearer ${accessToken}`,
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
...options.headers,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
const error = await response.text();
|
|
111
|
+
throw new Error(`Calendar API error: ${response.status} - ${error}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return response.json();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
/**
|
|
119
|
+
* List upcoming events
|
|
120
|
+
*/
|
|
121
|
+
async listEvents(options: {
|
|
122
|
+
maxResults?: number;
|
|
123
|
+
timeMin?: Date | string;
|
|
124
|
+
timeMax?: Date | string;
|
|
125
|
+
calendarId?: string;
|
|
126
|
+
} = {}): Promise<CalendarEvent[]> {
|
|
127
|
+
const params = new URLSearchParams();
|
|
128
|
+
|
|
129
|
+
const timeMin = options.timeMin
|
|
130
|
+
? new Date(options.timeMin).toISOString()
|
|
131
|
+
: new Date().toISOString();
|
|
132
|
+
params.set("timeMin", timeMin);
|
|
133
|
+
|
|
134
|
+
if (options.timeMax) {
|
|
135
|
+
params.set("timeMax", new Date(options.timeMax).toISOString());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
params.set("maxResults", String(options.maxResults || 10));
|
|
139
|
+
params.set("singleEvents", "true");
|
|
140
|
+
params.set("orderBy", "startTime");
|
|
141
|
+
|
|
142
|
+
const calendarId = encodeURIComponent(options.calendarId || "primary");
|
|
143
|
+
const result = await apiRequest<{ items: CalendarEvent[] }>(
|
|
144
|
+
`/calendars/${calendarId}/events?${params.toString()}`,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return result.items || [];
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get events for today
|
|
152
|
+
*/
|
|
153
|
+
getTodayEvents(): Promise<CalendarEvent[]> {
|
|
154
|
+
const today = new Date();
|
|
155
|
+
today.setHours(0, 0, 0, 0);
|
|
156
|
+
|
|
157
|
+
const tomorrow = new Date(today);
|
|
158
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
159
|
+
|
|
160
|
+
return this.listEvents({
|
|
161
|
+
timeMin: today,
|
|
162
|
+
timeMax: tomorrow,
|
|
163
|
+
maxResults: 50,
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Create a new event
|
|
169
|
+
*/
|
|
170
|
+
createEvent(
|
|
171
|
+
options: CreateEventOptions,
|
|
172
|
+
calendarId = "primary",
|
|
173
|
+
): Promise<CalendarEvent> {
|
|
174
|
+
const startDate = typeof options.start === "string"
|
|
175
|
+
? options.start
|
|
176
|
+
: options.start.toISOString();
|
|
177
|
+
const endDate = typeof options.end === "string" ? options.end : options.end.toISOString();
|
|
178
|
+
|
|
179
|
+
const event = {
|
|
180
|
+
summary: options.summary,
|
|
181
|
+
description: options.description,
|
|
182
|
+
location: options.location,
|
|
183
|
+
start: {
|
|
184
|
+
dateTime: startDate,
|
|
185
|
+
timeZone: options.timeZone || "UTC",
|
|
186
|
+
},
|
|
187
|
+
end: {
|
|
188
|
+
dateTime: endDate,
|
|
189
|
+
timeZone: options.timeZone || "UTC",
|
|
190
|
+
},
|
|
191
|
+
attendees: options.attendees?.map((email) => ({ email })),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return apiRequest<CalendarEvent>(
|
|
195
|
+
`/calendars/${encodeURIComponent(calendarId)}/events`,
|
|
196
|
+
{
|
|
197
|
+
method: "POST",
|
|
198
|
+
body: JSON.stringify(event),
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get free/busy information
|
|
205
|
+
*/
|
|
206
|
+
async getFreeBusy(options: {
|
|
207
|
+
timeMin: Date | string;
|
|
208
|
+
timeMax: Date | string;
|
|
209
|
+
calendarId?: string;
|
|
210
|
+
}): Promise<FreeBusySlot[]> {
|
|
211
|
+
const result = await apiRequest<{
|
|
212
|
+
calendars: {
|
|
213
|
+
[key: string]: { busy: FreeBusySlot[] };
|
|
214
|
+
};
|
|
215
|
+
}>("/freeBusy", {
|
|
216
|
+
method: "POST",
|
|
217
|
+
body: JSON.stringify({
|
|
218
|
+
timeMin: new Date(options.timeMin).toISOString(),
|
|
219
|
+
timeMax: new Date(options.timeMax).toISOString(),
|
|
220
|
+
items: [{ id: options.calendarId || "primary" }],
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const calendarId = options.calendarId || "primary";
|
|
225
|
+
return result.calendars[calendarId]?.busy || [];
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Find free time slots in a given range
|
|
230
|
+
*/
|
|
231
|
+
async findFreeSlots(options: {
|
|
232
|
+
timeMin: Date | string;
|
|
233
|
+
timeMax: Date | string;
|
|
234
|
+
durationMinutes: number;
|
|
235
|
+
calendarId?: string;
|
|
236
|
+
}): Promise<Array<{ start: Date; end: Date }>> {
|
|
237
|
+
const busySlots = await this.getFreeBusy({
|
|
238
|
+
timeMin: options.timeMin,
|
|
239
|
+
timeMax: options.timeMax,
|
|
240
|
+
calendarId: options.calendarId,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const freeSlots: Array<{ start: Date; end: Date }> = [];
|
|
244
|
+
const rangeStart = new Date(options.timeMin);
|
|
245
|
+
const rangeEnd = new Date(options.timeMax);
|
|
246
|
+
const durationMs = options.durationMinutes * 60 * 1000;
|
|
247
|
+
|
|
248
|
+
let currentStart = rangeStart;
|
|
249
|
+
|
|
250
|
+
// Sort busy slots by start time
|
|
251
|
+
const sortedBusy = busySlots
|
|
252
|
+
.map((s) => ({
|
|
253
|
+
start: new Date(s.start),
|
|
254
|
+
end: new Date(s.end),
|
|
255
|
+
}))
|
|
256
|
+
.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
257
|
+
|
|
258
|
+
for (const busy of sortedBusy) {
|
|
259
|
+
// Check if there's a free slot before this busy period
|
|
260
|
+
if (busy.start.getTime() - currentStart.getTime() >= durationMs) {
|
|
261
|
+
freeSlots.push({
|
|
262
|
+
start: new Date(currentStart),
|
|
263
|
+
end: new Date(busy.start),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// Move current start to after this busy period
|
|
267
|
+
if (busy.end > currentStart) {
|
|
268
|
+
currentStart = busy.end;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Check if there's a free slot after the last busy period
|
|
273
|
+
if (rangeEnd.getTime() - currentStart.getTime() >= durationMs) {
|
|
274
|
+
freeSlots.push({
|
|
275
|
+
start: new Date(currentStart),
|
|
276
|
+
end: rangeEnd,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return freeSlots;
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Delete an event
|
|
285
|
+
*/
|
|
286
|
+
async deleteEvent(
|
|
287
|
+
eventId: string,
|
|
288
|
+
calendarId = "primary",
|
|
289
|
+
): Promise<void> {
|
|
290
|
+
const accessToken = await getAccessToken();
|
|
291
|
+
|
|
292
|
+
const response = await fetch(
|
|
293
|
+
`${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`,
|
|
294
|
+
{
|
|
295
|
+
method: "DELETE",
|
|
296
|
+
headers: {
|
|
297
|
+
Authorization: `Bearer ${accessToken}`,
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
if (!response.ok && response.status !== 204) {
|
|
303
|
+
throw new Error(`Failed to delete event: ${response.status}`);
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export type CalendarClient = ReturnType<typeof createCalendarClient>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Helper Functions
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for OAuth 2.0 authorization flows.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { type OAuthToken, tokenStore } from "./token-store.ts";
|
|
8
|
+
|
|
9
|
+
export interface OAuthProvider {
|
|
10
|
+
name: string;
|
|
11
|
+
authorizationUrl: string;
|
|
12
|
+
tokenUrl: string;
|
|
13
|
+
clientId: string;
|
|
14
|
+
clientSecret: string;
|
|
15
|
+
scopes: string[];
|
|
16
|
+
callbackPath: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate OAuth authorization URL
|
|
21
|
+
*/
|
|
22
|
+
export function getAuthorizationUrl(
|
|
23
|
+
provider: OAuthProvider,
|
|
24
|
+
state: string,
|
|
25
|
+
redirectUri: string,
|
|
26
|
+
): string {
|
|
27
|
+
const params = new URLSearchParams({
|
|
28
|
+
client_id: provider.clientId,
|
|
29
|
+
redirect_uri: redirectUri,
|
|
30
|
+
response_type: "code",
|
|
31
|
+
scope: provider.scopes.join(" "),
|
|
32
|
+
state,
|
|
33
|
+
access_type: "offline",
|
|
34
|
+
prompt: "consent",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return `${provider.authorizationUrl}?${params.toString()}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Exchange authorization code for tokens
|
|
42
|
+
*/
|
|
43
|
+
export async function exchangeCodeForTokens(
|
|
44
|
+
provider: OAuthProvider,
|
|
45
|
+
code: string,
|
|
46
|
+
redirectUri: string,
|
|
47
|
+
): Promise<OAuthToken> {
|
|
48
|
+
const response = await fetch(provider.tokenUrl, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
52
|
+
},
|
|
53
|
+
body: new URLSearchParams({
|
|
54
|
+
client_id: provider.clientId,
|
|
55
|
+
client_secret: provider.clientSecret,
|
|
56
|
+
code,
|
|
57
|
+
grant_type: "authorization_code",
|
|
58
|
+
redirect_uri: redirectUri,
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
const error = await response.text();
|
|
64
|
+
throw new Error(`Token exchange failed: ${response.status} - ${error}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const data = await response.json();
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
accessToken: data.access_token,
|
|
71
|
+
refreshToken: data.refresh_token,
|
|
72
|
+
expiresAt: data.expires_in ? Date.now() + (data.expires_in * 1000) : undefined,
|
|
73
|
+
tokenType: data.token_type || "Bearer",
|
|
74
|
+
scope: data.scope,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Refresh an expired access token
|
|
80
|
+
*/
|
|
81
|
+
export async function refreshAccessToken(
|
|
82
|
+
provider: OAuthProvider,
|
|
83
|
+
refreshToken: string,
|
|
84
|
+
): Promise<OAuthToken> {
|
|
85
|
+
const response = await fetch(provider.tokenUrl, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: {
|
|
88
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
89
|
+
},
|
|
90
|
+
body: new URLSearchParams({
|
|
91
|
+
client_id: provider.clientId,
|
|
92
|
+
client_secret: provider.clientSecret,
|
|
93
|
+
refresh_token: refreshToken,
|
|
94
|
+
grant_type: "refresh_token",
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const error = await response.text();
|
|
100
|
+
throw new Error(`Token refresh failed: ${response.status} - ${error}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const data = await response.json();
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
accessToken: data.access_token,
|
|
107
|
+
refreshToken: data.refresh_token || refreshToken,
|
|
108
|
+
expiresAt: data.expires_in ? Date.now() + (data.expires_in * 1000) : undefined,
|
|
109
|
+
tokenType: data.token_type || "Bearer",
|
|
110
|
+
scope: data.scope,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get a valid access token, refreshing if necessary
|
|
116
|
+
*/
|
|
117
|
+
export async function getValidToken(
|
|
118
|
+
provider: OAuthProvider,
|
|
119
|
+
userId: string,
|
|
120
|
+
service: string,
|
|
121
|
+
): Promise<string | null> {
|
|
122
|
+
const token = await tokenStore.getToken(userId, service);
|
|
123
|
+
|
|
124
|
+
if (!token) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check if token is expired (with 5 minute buffer)
|
|
129
|
+
// If no expiresAt, token doesn't expire (e.g., GitHub)
|
|
130
|
+
const isExpired = token.expiresAt ? token.expiresAt < Date.now() + 5 * 60 * 1000 : false;
|
|
131
|
+
|
|
132
|
+
if (isExpired && token.refreshToken) {
|
|
133
|
+
try {
|
|
134
|
+
const newToken = await refreshAccessToken(provider, token.refreshToken);
|
|
135
|
+
await tokenStore.setToken(userId, service, newToken);
|
|
136
|
+
return newToken.accessToken;
|
|
137
|
+
} catch {
|
|
138
|
+
// Refresh failed, user needs to re-authorize
|
|
139
|
+
await tokenStore.revokeToken(userId, service);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return token.accessToken;
|
|
145
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Token Store
|
|
3
|
+
*
|
|
4
|
+
* Simple in-memory token store for development.
|
|
5
|
+
* Replace with a database or KV store for production.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface OAuthToken {
|
|
9
|
+
accessToken: string;
|
|
10
|
+
refreshToken?: string;
|
|
11
|
+
expiresAt?: number;
|
|
12
|
+
tokenType?: string;
|
|
13
|
+
scope?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TokenStore {
|
|
17
|
+
getToken(userId: string, service: string): Promise<OAuthToken | null>;
|
|
18
|
+
setToken(userId: string, service: string, token: OAuthToken): Promise<void>;
|
|
19
|
+
revokeToken(userId: string, service: string): Promise<void>;
|
|
20
|
+
isConnected(userId: string, service: string): Promise<boolean>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// In-memory storage for development
|
|
24
|
+
const tokens = new Map<string, OAuthToken>();
|
|
25
|
+
|
|
26
|
+
function getKey(userId: string, service: string): string {
|
|
27
|
+
return `${userId}:${service}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Simple in-memory token store
|
|
32
|
+
*
|
|
33
|
+
* NOTE: This is for development only. In production, use:
|
|
34
|
+
* - Database (Postgres, SQLite, etc.)
|
|
35
|
+
* - KV store (Cloudflare Workers KV, Vercel KV, etc.)
|
|
36
|
+
* - Encrypted file storage
|
|
37
|
+
*/
|
|
38
|
+
export const tokenStore: TokenStore = {
|
|
39
|
+
getToken(userId: string, service: string): Promise<OAuthToken | null> {
|
|
40
|
+
const key = getKey(userId, service);
|
|
41
|
+
return Promise.resolve(tokens.get(key) || null);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
setToken(
|
|
45
|
+
userId: string,
|
|
46
|
+
service: string,
|
|
47
|
+
token: OAuthToken,
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
const key = getKey(userId, service);
|
|
50
|
+
tokens.set(key, token);
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
revokeToken(userId: string, service: string): Promise<void> {
|
|
55
|
+
const key = getKey(userId, service);
|
|
56
|
+
tokens.delete(key);
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async isConnected(userId: string, service: string): Promise<boolean> {
|
|
61
|
+
const token = await this.getToken(userId, service);
|
|
62
|
+
if (!token) return false;
|
|
63
|
+
// Check if token is not expired (if no expiry, token doesn't expire)
|
|
64
|
+
return !token.expiresAt || token.expiresAt > Date.now();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Factory function to create a custom token store
|
|
70
|
+
*/
|
|
71
|
+
export function createTokenStore(options: {
|
|
72
|
+
get: (key: string) => Promise<string | null>;
|
|
73
|
+
set: (key: string, value: string) => Promise<void>;
|
|
74
|
+
delete: (key: string) => Promise<void>;
|
|
75
|
+
}): TokenStore {
|
|
76
|
+
return {
|
|
77
|
+
async getToken(userId: string, service: string): Promise<OAuthToken | null> {
|
|
78
|
+
const key = getKey(userId, service);
|
|
79
|
+
const data = await options.get(key);
|
|
80
|
+
if (!data) return null;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(data) as OAuthToken;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async setToken(
|
|
89
|
+
userId: string,
|
|
90
|
+
service: string,
|
|
91
|
+
token: OAuthToken,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
const key = getKey(userId, service);
|
|
94
|
+
await options.set(key, JSON.stringify(token));
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async revokeToken(userId: string, service: string): Promise<void> {
|
|
98
|
+
const key = getKey(userId, service);
|
|
99
|
+
await options.delete(key);
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async isConnected(userId: string, service: string): Promise<boolean> {
|
|
103
|
+
const token = await this.getToken(userId, service);
|
|
104
|
+
if (!token) return false;
|
|
105
|
+
// Check if token is not expired (if no expiry, token doesn't expire)
|
|
106
|
+
return !token.expiresAt || token.expiresAt > Date.now();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|