Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion apps/gateway/src/apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,26 @@ static APP_PROVIDERS: &[AppProvider] = &[
client_secret_env: "GOOGLE_CLIENT_SECRET",
}),
},
AppProvider {
provider: "google-drive",
host_rules: &[
HostRule {
host: "www.googleapis.com",
path_prefix: Some("/drive/"),
strategy: AuthStrategy::Bearer,
},
HostRule {
host: "www.googleapis.com",
path_prefix: Some("/upload/drive/"),
strategy: AuthStrategy::Bearer,
},
],
refresh: Some(RefreshConfig {
token_url: "https://oauth2.googleapis.com/token",
client_id_env: "GOOGLE_CLIENT_ID",
client_secret_env: "GOOGLE_CLIENT_SECRET",
}),
},
AppProvider {
provider: "resend",
host_rules: &[HostRule {
Expand Down Expand Up @@ -268,10 +288,11 @@ mod tests {
#[test]
fn providers_for_googleapis_hosts() {
assert_eq!(providers_for_host("gmail.googleapis.com"), vec!["gmail"]);
// www.googleapis.com is shared — both Gmail (/gmail/) and Calendar (/calendar/)
// www.googleapis.com is shared — Gmail, Calendar, and Drive use path prefixes
let www = providers_for_host("www.googleapis.com");
assert!(www.contains(&"gmail"));
assert!(www.contains(&"google-calendar"));
assert!(www.contains(&"google-drive"));
}

#[test]
Expand All @@ -282,6 +303,10 @@ mod tests {
path_pattern_for("google-calendar", "www.googleapis.com"),
"/calendar/*"
);
assert_eq!(
path_pattern_for("google-drive", "www.googleapis.com"),
"/drive/*"
);
// Dedicated subdomains use wildcard
assert_eq!(path_pattern_for("gmail", "gmail.googleapis.com"), "*");
assert_eq!(path_pattern_for("github", "api.github.com"), "*");
Expand Down Expand Up @@ -369,6 +394,45 @@ mod tests {
);
}

// ── Google Drive ──────────────────────────────────────────────────

#[test]
fn google_drive_www_api_uses_bearer() {
let injections =
build_app_injections("google-drive", "www.googleapis.com", "ya29.drive_test");
assert_eq!(injections.len(), 1);
assert_eq!(
injections[0],
Injection::SetHeader {
name: "authorization".to_string(),
value: "Bearer ya29.drive_test".to_string(),
}
);
}

#[test]
fn google_drive_upload_uses_bearer() {
// Drive uploads use /upload/drive/ path prefix on the same host
let injections =
build_app_injections("google-drive", "www.googleapis.com", "ya29.drive_test");
assert_eq!(injections.len(), 1);
assert_eq!(
injections[0],
Injection::SetHeader {
name: "authorization".to_string(),
value: "Bearer ya29.drive_test".to_string(),
}
);
}

#[test]
fn google_drive_path_patterns() {
assert_eq!(
path_pattern_for("google-drive", "www.googleapis.com"),
"/drive/*"
);
}

// ── Resend ────────────────────────────────────────────────────────

#[test]
Expand Down
8 changes: 8 additions & 0 deletions apps/web/public/icons/google-drive.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
137 changes: 137 additions & 0 deletions apps/web/src/lib/apps/google-drive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { AppDefinition } from "./types";

export const googleDrive: AppDefinition = {
id: "google-drive",
name: "Google Drive",
icon: "/icons/google-drive.svg",
description: "Read, create, and manage files and folders.",
connectionMethod: {
type: "oauth",
defaultScopes: [
"openid",
"email",
"profile",
"https://www.googleapis.com/auth/drive",
],
permissions: [
{
scope: "https://www.googleapis.com/auth/drive",
name: "Drive",
description: "Read, create, edit, and delete files and folders",
access: "write",
},
{
scope: "https://www.googleapis.com/auth/userinfo.email",
name: "Email address",
description: "View your email address",
access: "read",
},
{
scope: "https://www.googleapis.com/auth/userinfo.profile",
name: "Profile",
description: "Name and profile picture",
access: "read",
},
],
buildAuthUrl: ({ clientId, redirectUri, scopes, state }) => {
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
url.searchParams.set("client_id", clientId);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", scopes.join(" "));
url.searchParams.set("state", state);
url.searchParams.set("access_type", "offline");
url.searchParams.set("prompt", "consent");
return url.toString();
},
exchangeCode: async ({ code, clientId, clientSecret, redirectUri }) => {
// Google requires form-encoded body, not JSON
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: "authorization_code",
}),
});

const tokenData = (await tokenRes.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
scope?: string;
token_type?: string;
error?: string;
error_description?: string;
};

if (tokenData.error || !tokenData.access_token) {
throw new Error(
tokenData.error_description ?? "Failed to exchange code for token",
);
}

// Store expires_at as unix timestamp for the gateway to check
const expiresAt = tokenData.expires_in
? Math.floor(Date.now() / 1000) + tokenData.expires_in
: undefined;

const credentials: Record<string, unknown> = {
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token,
token_type: tokenData.token_type,
expires_at: expiresAt,
};

// Google returns scopes space-separated (not comma like GitHub)
const scopes = tokenData.scope?.split(" ").filter(Boolean) ?? [];

// Fetch user info for metadata
let metadata: Record<string, unknown> | undefined;
const userRes = await fetch(
"https://www.googleapis.com/oauth2/v2/userinfo",
{
headers: { Authorization: `Bearer ${tokenData.access_token}` },
},
);

if (userRes.ok) {
const user = (await userRes.json()) as {
email?: string;
name?: string;
picture?: string;
};
metadata = {
username: user.email,
name: user.name,
avatarUrl: user.picture,
};
}

return { credentials, scopes, metadata };
},
},
available: true,
configurable: {
fields: [
{
name: "clientId",
label: "Client ID",
placeholder: "123...apps.googleusercontent.com",
},
{
name: "clientSecret",
label: "Client Secret",
placeholder: "GOCSPX-...",
secret: true,
},
],
envDefaults: {
clientId: "GOOGLE_CLIENT_ID",
clientSecret: "GOOGLE_CLIENT_SECRET",
},
},
};
9 changes: 8 additions & 1 deletion apps/web/src/lib/apps/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ import type { AppDefinition } from "./types";
import { github } from "./github";
import { gmail } from "./gmail";
import { googleCalendar } from "./google-calendar";
import { googleDrive } from "./google-drive";
import { resend } from "./resend";

export const apps: AppDefinition[] = [github, gmail, googleCalendar, resend];
export const apps: AppDefinition[] = [
github,
gmail,
googleCalendar,
googleDrive,
resend,
];

export const getApp = (id: string): AppDefinition | undefined =>
apps.find((app) => app.id === id);
4 changes: 2 additions & 2 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ model OnboardingSurvey {
model AppConnection {
id String @id @default(uuid())
accountId String @map("account_id")
provider String // "github", "gmail", "google-calendar", etc.
provider String // "github", "gmail", "google-calendar", "google-drive", etc.
status String @default("connected")
credentials String? @map("credentials") // encrypted JSON (access_token, refresh_token, etc.)
scopes String[] @default([])
Expand Down Expand Up @@ -271,7 +271,7 @@ model AgentAppConnection {
model AppConfig {
id String @id @default(uuid())
accountId String @map("account_id")
provider String // "github", "gmail", "google-calendar"
provider String // "github", "gmail", "google-calendar", "google-drive"
enabled Boolean @default(false)
credentials String? @map("credentials") // encrypted (clientSecret)
settings Json? // plain (clientId, scopes)
Expand Down
Loading