veryfront 0.1.241 → 0.1.243
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/esm/cli/templates/manifest.js +77 -77
- package/esm/deno.js +1 -1
- package/esm/src/agent/conversation-root-run-context.d.ts.map +1 -1
- package/esm/src/agent/conversation-root-run-context.js +2 -0
- package/esm/src/agent/conversation-run-context.d.ts +2 -0
- package/esm/src/agent/conversation-run-context.d.ts.map +1 -1
- package/esm/src/agent/durable.d.ts +23 -0
- package/esm/src/agent/durable.d.ts.map +1 -1
- package/esm/src/agent/durable.js +39 -0
- package/esm/src/agent/index.d.ts +1 -1
- package/esm/src/agent/index.d.ts.map +1 -1
- package/esm/src/agent/index.js +1 -1
- package/esm/src/oauth/handlers/callback-handler.d.ts +2 -2
- package/esm/src/oauth/handlers/callback-handler.d.ts.map +1 -1
- package/esm/src/oauth/handlers/callback-handler.js +17 -5
- package/esm/src/oauth/handlers/init-handler.d.ts +24 -4
- package/esm/src/oauth/handlers/init-handler.d.ts.map +1 -1
- package/esm/src/oauth/handlers/init-handler.js +47 -10
- package/esm/src/oauth/providers/base.d.ts +9 -2
- package/esm/src/oauth/providers/base.d.ts.map +1 -1
- package/esm/src/oauth/providers/base.js +12 -5
- package/esm/src/oauth/token-store/index.d.ts +1 -1
- package/esm/src/oauth/token-store/index.d.ts.map +1 -1
- package/esm/src/oauth/token-store/memory.d.ts +21 -9
- package/esm/src/oauth/token-store/memory.d.ts.map +1 -1
- package/esm/src/oauth/token-store/memory.js +42 -28
- package/esm/src/oauth/types.d.ts +33 -7
- package/esm/src/oauth/types.d.ts.map +1 -1
- package/esm/src/platform/compat/framework-source-resolver.d.ts.map +1 -1
- package/esm/src/platform/compat/framework-source-resolver.js +34 -0
- package/esm/src/routing/api/module-loader/loader.d.ts +11 -0
- package/esm/src/routing/api/module-loader/loader.d.ts.map +1 -1
- package/esm/src/routing/api/module-loader/loader.js +18 -2
- package/esm/src/server/handlers/dev/dashboard/api.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/dashboard/api.js +34 -13
- package/esm/src/server/handlers/dev/files/esbuild-plugins.d.ts.map +1 -1
- package/esm/src/server/handlers/dev/files/esbuild-plugins.js +45 -4
- package/esm/src/utils/version-constant.d.ts +1 -1
- package/esm/src/utils/version-constant.js +1 -1
- package/package.json +1 -1
- package/src/cli/templates/manifest.js +77 -77
- package/src/deno.js +1 -1
- package/src/src/agent/conversation-root-run-context.ts +2 -0
- package/src/src/agent/durable.ts +60 -0
- package/src/src/agent/index.ts +3 -0
- package/src/src/oauth/handlers/callback-handler.ts +25 -8
- package/src/src/oauth/handlers/init-handler.ts +83 -15
- package/src/src/oauth/providers/base.ts +12 -5
- package/src/src/oauth/token-store/index.ts +1 -1
- package/src/src/oauth/token-store/memory.ts +48 -35
- package/src/src/oauth/types.ts +34 -7
- package/src/src/platform/compat/framework-source-resolver.ts +32 -0
- package/src/src/routing/api/module-loader/loader.ts +18 -2
- package/src/src/server/handlers/dev/dashboard/api.ts +32 -10
- package/src/src/server/handlers/dev/files/esbuild-plugins.ts +54 -5
- package/src/src/utils/version-constant.ts +1 -1
|
@@ -101,8 +101,8 @@ export default {
|
|
|
101
101
|
"tools/get-list.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { getList } from \"../../lib/mailchimp-client.ts\";\n\nexport default tool({\n id: \"get-list\",\n description: \"Get details of a specific Mailchimp audience list by its ID.\",\n inputSchema: z.object({\n listId: z.string().describe(\"The ID of the audience list to retrieve\"),\n }),\n async execute({ listId }) {\n const {\n id,\n web_id,\n name,\n date_created,\n list_rating,\n permission_reminder,\n subscribe_url_short,\n subscribe_url_long,\n contact,\n campaign_defaults,\n stats,\n } = await getList(listId);\n\n return {\n id,\n webId: web_id,\n name,\n dateCreated: date_created,\n listRating: list_rating,\n permissionReminder: permission_reminder,\n subscribeUrlShort: subscribe_url_short,\n subscribeUrlLong: subscribe_url_long,\n contact: {\n company: contact.company,\n address1: contact.address1,\n city: contact.city,\n state: contact.state,\n zip: contact.zip,\n country: contact.country,\n },\n campaignDefaults: {\n fromName: campaign_defaults.from_name,\n fromEmail: campaign_defaults.from_email,\n subject: campaign_defaults.subject,\n language: campaign_defaults.language,\n },\n stats: {\n memberCount: stats.member_count,\n totalContacts: stats.total_contacts,\n unsubscribeCount: stats.unsubscribe_count,\n cleanedCount: stats.cleaned_count,\n memberCountSinceSend: stats.member_count_since_send,\n unsubscribeCountSinceSend: stats.unsubscribe_count_since_send,\n cleanedCountSinceSend: stats.cleaned_count_since_send,\n campaignCount: stats.campaign_count,\n openRate: stats.open_rate,\n clickRate: stats.click_rate,\n },\n };\n },\n});\n",
|
|
102
102
|
".env.example": "# Mailchimp OAuth Configuration\n# Get your credentials from https://admin.mailchimp.com/account/oauth2/\nMAILCHIMP_CLIENT_ID=your-client-id\nMAILCHIMP_CLIENT_SECRET=your-client-secret\n",
|
|
103
103
|
"lib/mailchimp-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nlet MAILCHIMP_BASE_URL = \"https://us1.api.mailchimp.com/3.0\";\n\ninterface MailchimpCampaign {\n id: string;\n web_id: number;\n type: string;\n create_time: string;\n archive_url: string;\n long_archive_url: string;\n status: string;\n emails_sent: number;\n send_time?: string;\n content_type: string;\n needs_block_refresh: boolean;\n recipients: {\n list_id: string;\n list_name: string;\n segment_text?: string;\n };\n settings: {\n subject_line: string;\n preview_text?: string;\n title: string;\n from_name: string;\n reply_to: string;\n };\n tracking: {\n opens: boolean;\n html_clicks: boolean;\n text_clicks: boolean;\n };\n report_summary?: {\n opens: number;\n unique_opens: number;\n open_rate: number;\n clicks: number;\n subscriber_clicks: number;\n click_rate: number;\n };\n}\n\ninterface MailchimpList {\n id: string;\n web_id: number;\n name: string;\n contact: {\n company: string;\n address1: string;\n city: string;\n state: string;\n zip: string;\n country: string;\n };\n permission_reminder: string;\n campaign_defaults: {\n from_name: string;\n from_email: string;\n subject: string;\n language: string;\n };\n stats: {\n member_count: number;\n total_contacts: number;\n unsubscribe_count: number;\n cleaned_count: number;\n member_count_since_send: number;\n unsubscribe_count_since_send: number;\n cleaned_count_since_send: number;\n campaign_count: number;\n open_rate: number;\n click_rate: number;\n };\n date_created: string;\n list_rating: number;\n subscribe_url_short: string;\n subscribe_url_long: string;\n}\n\ninterface MailchimpMember {\n id: string;\n email_address: string;\n unique_email_id: string;\n contact_id: string;\n full_name: string;\n web_id: number;\n email_type: string;\n status: \"subscribed\" | \"unsubscribed\" | \"cleaned\" | \"pending\" | \"transactional\";\n merge_fields: Record<string, unknown>;\n stats: {\n avg_open_rate: number;\n avg_click_rate: number;\n };\n ip_signup?: string;\n timestamp_signup?: string;\n ip_opt?: string;\n timestamp_opt?: string;\n member_rating: number;\n last_changed: string;\n language: string;\n vip: boolean;\n email_client?: string;\n location?: {\n latitude: number;\n longitude: number;\n gmtoff: number;\n dstoff: number;\n country_code: string;\n timezone: string;\n };\n tags: Array<{ id: number; name: string }>;\n}\n\ninterface MailchimpMetadata {\n dc: string;\n role: string;\n accountname: string;\n user_id: string;\n login: {\n email: string;\n avatar?: string;\n login_id: string;\n login_name: string;\n login_email: string;\n };\n}\n\nfunction buildQueryString(params: Record<string, string | number | undefined>): string {\n const searchParams = new URLSearchParams();\n\n for (const [key, value] of Object.entries(params)) {\n if (value == null) continue;\n searchParams.set(key, String(value));\n }\n\n const query = searchParams.toString();\n return query ? `?${query}` : \"\";\n}\n\nasync function fetchMetadata(token: string): Promise<MailchimpMetadata> {\n const response = await fetch(\"https://login.mailchimp.com/oauth2/metadata\", {\n headers: { Authorization: `OAuth ${token}` },\n });\n\n if (!response.ok) {\n throw new Error(`Failed to fetch Mailchimp metadata: ${response.statusText}`);\n }\n\n return response.json();\n}\n\nasync function initializeBaseUrl(): Promise<void> {\n const token = await getAccessToken();\n if (!token) return;\n\n try {\n const metadata = await fetchMetadata(token);\n MAILCHIMP_BASE_URL = `https://${metadata.dc}.api.mailchimp.com/3.0`;\n } catch (error) {\n // Fallback to us1 if metadata fetch fails\n console.error(\"Failed to fetch Mailchimp metadata:\", error);\n }\n}\n\nasync function requireAccessToken(): Promise<string> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Mailchimp. Please connect your account.\");\n }\n return token;\n}\n\nasync function mailchimpFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await requireAccessToken();\n\n if (MAILCHIMP_BASE_URL === \"https://us1.api.mailchimp.com/3.0\") {\n await initializeBaseUrl();\n }\n\n const response = await fetch(`${MAILCHIMP_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({}))) as { detail?: string };\n throw new Error(`Mailchimp API error: ${response.status} ${error.detail ?? response.statusText}`);\n }\n\n return response.json();\n}\n\nexport async function listCampaigns(options?: {\n status?: \"save\" | \"paused\" | \"schedule\" | \"sending\" | \"sent\";\n count?: number;\n offset?: number;\n}): Promise<MailchimpCampaign[]> {\n const query = buildQueryString({\n status: options?.status,\n count: options?.count,\n offset: options?.offset,\n });\n\n const response = await mailchimpFetch<{ campaigns: MailchimpCampaign[] }>(`/campaigns${query}`);\n return response.campaigns;\n}\n\nexport async function getCampaign(campaignId: string): Promise<MailchimpCampaign> {\n return mailchimpFetch<MailchimpCampaign>(`/campaigns/${campaignId}`);\n}\n\nexport async function listLists(options?: { count?: number; offset?: number }): Promise<MailchimpList[]> {\n const query = buildQueryString({\n count: options?.count,\n offset: options?.offset,\n });\n\n const response = await mailchimpFetch<{ lists: MailchimpList[] }>(`/lists${query}`);\n return response.lists;\n}\n\nexport async function getList(listId: string): Promise<MailchimpList> {\n return mailchimpFetch<MailchimpList>(`/lists/${listId}`);\n}\n\nexport async function listMembers(\n listId: string,\n options?: {\n status?: \"subscribed\" | \"unsubscribed\" | \"cleaned\" | \"pending\" | \"transactional\";\n count?: number;\n offset?: number;\n },\n): Promise<MailchimpMember[]> {\n const query = buildQueryString({\n status: options?.status,\n count: options?.count,\n offset: options?.offset,\n });\n\n const response = await mailchimpFetch<{ members: MailchimpMember[] }>(\n `/lists/${listId}/members${query}`,\n );\n return response.members;\n}\n\nexport async function getMetadata(): Promise<MailchimpMetadata> {\n const token = await requireAccessToken();\n return fetchMetadata(token);\n}\n",
|
|
104
|
-
"app/api/auth/mailchimp/callback/route.ts": "import { createOAuthCallbackHandler, mailchimpConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
105
|
-
"app/api/auth/mailchimp/route.ts": "import { createOAuthInitHandler, mailchimpConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(mailchimpConfig, {
|
|
104
|
+
"app/api/auth/mailchimp/callback/route.ts": "import { createOAuthCallbackHandler, mailchimpConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n async getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(mailchimpConfig, { tokenStore: hybridTokenStore });\n",
|
|
105
|
+
"app/api/auth/mailchimp/route.ts": "import { createOAuthInitHandler, mailchimpConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(mailchimpConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
106
106
|
}
|
|
107
107
|
},
|
|
108
108
|
"integration:intercom": {
|
|
@@ -114,8 +114,8 @@ export default {
|
|
|
114
114
|
"tools/get-contact.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { getContact } from \"../../lib/intercom-client.ts\";\n\nfunction toIsoOrNull(timestamp?: number | null): string | null {\n if (!timestamp) return null;\n return new Date(timestamp * 1000).toISOString();\n}\n\nfunction toIso(timestamp: number): string {\n return new Date(timestamp * 1000).toISOString();\n}\n\nexport default tool({\n id: \"get-contact\",\n description: \"Get details of a specific contact from Intercom by their ID.\",\n inputSchema: z.object({\n contactId: z.string().describe(\"The ID of the contact to retrieve\"),\n }),\n async execute({ contactId }) {\n const contact = await getContact(contactId);\n\n return {\n id: contact.id,\n email: contact.email,\n name: contact.name,\n phone: contact.phone,\n role: contact.role,\n externalId: contact.external_id,\n avatar: contact.avatar,\n createdAt: toIso(contact.created_at),\n updatedAt: toIso(contact.updated_at),\n signedUpAt: toIsoOrNull(contact.signed_up_at),\n lastSeenAt: toIsoOrNull(contact.last_seen_at),\n ownerId: contact.owner_id,\n customAttributes: contact.custom_attributes,\n tags: contact.tags?.map((tag) => ({ id: tag.id, name: tag.name })),\n };\n },\n});\n",
|
|
115
115
|
".env.example": "# Intercom OAuth Configuration\n# Get your credentials from https://developers.intercom.com/building-apps/\nINTERCOM_CLIENT_ID=your-client-id\nINTERCOM_CLIENT_SECRET=your-client-secret\n",
|
|
116
116
|
"lib/intercom-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst INTERCOM_BASE_URL = \"https://api.intercom.io\";\n\ninterface IntercomResponse<T> {\n type: string;\n data?: T;\n pages?: {\n next?: string | null;\n page: number;\n per_page: number;\n total_pages: number;\n };\n}\n\ninterface IntercomContact {\n type: \"contact\";\n id: string;\n external_id?: string;\n email?: string;\n phone?: string;\n name?: string;\n avatar?: string;\n role?: string;\n created_at: number;\n updated_at: number;\n signed_up_at?: number;\n last_seen_at?: number;\n owner_id?: number;\n custom_attributes?: Record<string, unknown>;\n tags?: Array<{ id: string; name: string }>;\n}\n\ninterface IntercomConversation {\n type: \"conversation\";\n id: string;\n created_at: number;\n updated_at: number;\n source: {\n type: string;\n id: string;\n delivered_as: string;\n subject?: string;\n body?: string;\n author: {\n type: string;\n id: string;\n name?: string;\n email?: string;\n };\n };\n contacts?: Array<{\n type: string;\n id: string;\n }>;\n teammates?: Array<{\n type: string;\n id: string;\n }>;\n title?: string;\n state: \"open\" | \"closed\" | \"snoozed\";\n read: boolean;\n waiting_since?: number;\n snoozed_until?: number;\n priority?: \"priority\" | \"not_priority\";\n conversation_parts?: {\n type: string;\n conversation_parts: Array<{\n type: string;\n id: string;\n part_type: string;\n body: string;\n created_at: number;\n updated_at: number;\n author: {\n type: string;\n id: string;\n name?: string;\n email?: string;\n };\n }>;\n };\n}\n\ninterface IntercomMessageRequest {\n message_type: \"inapp\" | \"email\" | \"comment\";\n body: string;\n from: {\n type: \"admin\" | \"user\" | \"contact\";\n id: string;\n };\n to?: {\n type: \"user\" | \"contact\";\n id?: string;\n email?: string;\n };\n}\n\nfunction hasMorePages(pages?: IntercomResponse<unknown>[\"pages\"]): boolean {\n return pages ? pages.page < pages.total_pages : false;\n}\n\nasync function intercomFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) throw new Error(\"Not authenticated with Intercom. Please connect your account.\");\n\n const response = await fetch(`${INTERCOM_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n \"Intercom-Version\": \"2.11\",\n ...options.headers,\n },\n });\n\n if (response.ok) return (await response.json()) as T;\n\n const error = (await response.json().catch(() => ({}))) as {\n errors?: Array<{ message?: string }>;\n message?: string;\n };\n\n const message = error.errors?.[0]?.message ?? error.message ?? response.statusText;\n throw new Error(`Intercom API error: ${response.status} ${message}`);\n}\n\nexport async function listContacts(\n options: { page?: number; perPage?: number } = {},\n): Promise<{ contacts: IntercomContact[]; hasMore: boolean }> {\n const params = new URLSearchParams({ per_page: String(options.perPage ?? 50) });\n if (options.page) params.set(\"page\", String(options.page));\n\n const response = await intercomFetch<IntercomResponse<IntercomContact[]>>(`/contacts?${params}`);\n\n return { contacts: response.data ?? [], hasMore: hasMorePages(response.pages) };\n}\n\nexport async function getContact(contactId: string): Promise<IntercomContact> {\n return intercomFetch<IntercomContact>(`/contacts/${contactId}`);\n}\n\nexport async function searchContacts(query: { email?: string; name?: string }): Promise<IntercomContact[]> {\n const value: Array<Record<string, unknown>> = [];\n\n if (query.email) value.push({ field: \"email\", operator: \"=\", value: query.email });\n if (query.name) value.push({ field: \"name\", operator: \"~\", value: query.name });\n\n const response = await intercomFetch<IntercomResponse<IntercomContact[]>>(\"/contacts/search\", {\n method: \"POST\",\n body: JSON.stringify({ query: { operator: \"AND\", value } }),\n });\n\n return response.data ?? [];\n}\n\nexport async function listConversations(\n options: { page?: number; perPage?: number; open?: boolean } = {},\n): Promise<{ conversations: IntercomConversation[]; hasMore: boolean }> {\n const params = new URLSearchParams({\n per_page: String(options.perPage ?? 50),\n display_as: \"plaintext\",\n });\n\n if (options.page) params.set(\"page\", String(options.page));\n if (options.open !== undefined) params.set(\"state\", options.open ? \"open\" : \"closed\");\n\n const response = await intercomFetch<IntercomResponse<IntercomConversation[]>>(\n `/conversations?${params}`,\n );\n\n return { conversations: response.data ?? [], hasMore: hasMorePages(response.pages) };\n}\n\nexport async function getConversation(conversationId: string): Promise<IntercomConversation> {\n return intercomFetch<IntercomConversation>(`/conversations/${conversationId}`);\n}\n\nexport async function sendMessage(options: {\n conversationId?: string;\n body: string;\n messageType?: \"comment\" | \"note\";\n adminId?: string;\n}): Promise<IntercomConversation> {\n if (!options.conversationId) throw new Error(\"conversationId is required to send a message\");\n\n const payload: Record<string, unknown> = {\n message_type: options.messageType ?? \"comment\",\n type: \"admin\",\n body: options.body,\n ...(options.adminId ? { admin_id: options.adminId } : {}),\n };\n\n return intercomFetch<IntercomConversation>(`/conversations/${options.conversationId}/reply`, {\n method: \"POST\",\n body: JSON.stringify(payload),\n });\n}\n\nexport async function createMessage(options: {\n contactId?: string;\n email?: string;\n body: string;\n messageType: \"inapp\" | \"email\";\n fromId: string;\n}): Promise<{ type: string; id: string }> {\n const messageBody: IntercomMessageRequest = {\n message_type: options.messageType,\n body: options.body,\n from: { type: \"admin\", id: options.fromId },\n };\n\n if (options.contactId) {\n messageBody.to = { type: \"contact\", id: options.contactId };\n } else if (options.email) {\n messageBody.to = { type: \"contact\", email: options.email };\n } else {\n throw new Error(\"Either contactId or email is required\");\n }\n\n return intercomFetch<{ type: string; id: string }>(\"/messages\", {\n method: \"POST\",\n body: JSON.stringify(messageBody),\n });\n}\n\nexport async function getMe(): Promise<{ type: string; id: string; name: string; email: string }> {\n return intercomFetch<{ type: string; id: string; name: string; email: string }>(\"/me\");\n}\n",
|
|
117
|
-
"app/api/auth/intercom/callback/route.ts": "import { createOAuthCallbackHandler, intercomConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
118
|
-
"app/api/auth/intercom/route.ts": "import { createOAuthInitHandler, intercomConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(intercomConfig, {
|
|
117
|
+
"app/api/auth/intercom/callback/route.ts": "import { createOAuthCallbackHandler, intercomConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(intercomConfig, { tokenStore: hybridTokenStore });\n",
|
|
118
|
+
"app/api/auth/intercom/route.ts": "import { createOAuthInitHandler, intercomConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(intercomConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
119
119
|
}
|
|
120
120
|
},
|
|
121
121
|
"integration:neon": {
|
|
@@ -139,8 +139,8 @@ export default {
|
|
|
139
139
|
"tools/search-issues.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { formatIssueForDisplay, searchIssues } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"search-issues\",\n description:\n \"Search for issues in GitLab projects. Can search across all accessible projects or within a specific project. Returns issue titles, states, assignees, and labels.\",\n inputSchema: z.object({\n scope: z\n .enum([\"created_by_me\", \"assigned_to_me\", \"all\"])\n .default(\"all\")\n .describe(\"Scope of issues to search\"),\n state: z\n .enum([\"opened\", \"closed\", \"all\"])\n .default(\"opened\")\n .describe(\"State of issues to search for\"),\n search: z.string().optional().describe(\"Search query to filter issues by title or description\"),\n labels: z.array(z.string()).optional().describe('Filter by labels (e.g., [\"bug\", \"urgent\"])'),\n projectId: z\n .union([z.number(), z.string()])\n .optional()\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n limit: z.number().min(1).max(100).default(20).describe(\"Maximum number of results to return\"),\n }),\n async execute({ scope, state, search, labels, projectId, limit }) {\n const issues = await searchIssues({\n scope,\n state,\n search,\n labels,\n projectId,\n perPage: limit,\n });\n\n if (issues.length === 0) {\n return {\n message: \"No issues found matching the criteria.\",\n count: 0,\n issues: [],\n };\n }\n\n return {\n count: issues.length,\n issues: issues.map((issue) => {\n const description = issue.description ?? \"\";\n const truncatedDescription =\n description.length > 200 ? `${description.substring(0, 200)}...` : description;\n\n return {\n id: issue.id,\n iid: issue.iid,\n projectId: issue.project_id,\n title: issue.title,\n state: issue.state,\n labels: issue.labels,\n assignees: issue.assignees.map(({ username, name }) => ({ username, name })),\n author: {\n username: issue.author.username,\n name: issue.author.name,\n },\n createdAt: issue.created_at,\n updatedAt: issue.updated_at,\n webUrl: issue.web_url,\n description: truncatedDescription,\n };\n }),\n summary: issues.map(formatIssueForDisplay).join(\"\\n\\n\"),\n };\n },\n});\n",
|
|
140
140
|
".env.example": "# GitLab OAuth Configuration\n# Create a new application at: https://gitlab.com/-/profile/applications\n# Set the redirect URI to: http://localhost:3000/api/auth/gitlab/callback\n# (Update the URL for production)\n\nGITLAB_CLIENT_ID=your_gitlab_application_id\nGITLAB_CLIENT_SECRET=your_gitlab_application_secret\n",
|
|
141
141
|
"lib/gitlab-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst GITLAB_BASE_URL = \"https://gitlab.com/api/v4\";\n\nexport interface GitLabProject {\n id: number;\n name: string;\n name_with_namespace: string;\n description: string | null;\n web_url: string;\n path_with_namespace: string;\n default_branch: string;\n visibility: \"private\" | \"internal\" | \"public\";\n created_at: string;\n last_activity_at: string;\n}\n\nexport interface GitLabIssue {\n id: number;\n iid: number;\n project_id: number;\n title: string;\n description: string | null;\n state: \"opened\" | \"closed\";\n created_at: string;\n updated_at: string;\n closed_at: string | null;\n labels: string[];\n milestone: {\n id: number;\n title: string;\n } | null;\n assignees: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n author: {\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n };\n web_url: string;\n time_stats: {\n time_estimate: number;\n total_time_spent: number;\n };\n}\n\nexport interface GitLabMergeRequest {\n id: number;\n iid: number;\n project_id: number;\n title: string;\n description: string | null;\n state: \"opened\" | \"closed\" | \"merged\";\n created_at: string;\n updated_at: string;\n merged_at: string | null;\n closed_at: string | null;\n target_branch: string;\n source_branch: string;\n author: {\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n };\n assignees: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n reviewers: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n labels: string[];\n draft: boolean;\n web_url: string;\n changes_count: string;\n diff_refs: {\n base_sha: string;\n head_sha: string;\n start_sha: string;\n };\n}\n\nexport interface GitLabUser {\n id: number;\n username: string;\n name: string;\n email: string;\n avatar_url: string;\n web_url: string;\n}\n\nfunction encodeProjectId(projectId: number | string): number | string {\n return typeof projectId === \"string\" ? encodeURIComponent(projectId) : projectId;\n}\n\nfunction buildQuery(params: URLSearchParams): string {\n const query = params.toString();\n return query ? `?${query}` : \"\";\n}\n\nasync function gitlabFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) throw new Error(\"Not authenticated with GitLab. Please connect your account.\");\n\n const response = await fetch(`${GITLAB_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({}))) as {\n message?: string;\n error?: string;\n };\n\n const message = error.message ?? error.error ?? response.statusText;\n throw new Error(`GitLab API error: ${response.status} ${message}`);\n }\n\n return (await response.json()) as T;\n}\n\nexport function getCurrentUser(): Promise<GitLabUser> {\n return gitlabFetch<GitLabUser>(\"/user\");\n}\n\nexport function listProjects(options?: {\n membership?: boolean;\n search?: string;\n orderBy?: \"id\" | \"name\" | \"created_at\" | \"updated_at\" | \"last_activity_at\";\n sort?: \"asc\" | \"desc\";\n perPage?: number;\n}): Promise<GitLabProject[]> {\n const params = new URLSearchParams();\n\n if (options?.membership !== false) params.set(\"membership\", \"true\");\n if (options?.search) params.set(\"search\", options.search);\n if (options?.orderBy) params.set(\"order_by\", options.orderBy);\n if (options?.sort) params.set(\"sort\", options.sort);\n if (options?.perPage) params.set(\"per_page\", options.perPage.toString());\n\n return gitlabFetch<GitLabProject[]>(`/projects${buildQuery(params)}`);\n}\n\nexport function getProject(projectId: number | string): Promise<GitLabProject> {\n return gitlabFetch<GitLabProject>(`/projects/${encodeProjectId(projectId)}`);\n}\n\nexport function searchIssues(options: {\n scope?: \"created_by_me\" | \"assigned_to_me\" | \"all\";\n state?: \"opened\" | \"closed\" | \"all\";\n labels?: string[];\n search?: string;\n projectId?: number | string;\n perPage?: number;\n}): Promise<GitLabIssue[]> {\n const params = new URLSearchParams();\n\n if (options.scope) params.set(\"scope\", options.scope);\n if (options.state) params.set(\"state\", options.state);\n if (options.labels?.length) params.set(\"labels\", options.labels.join(\",\"));\n if (options.search) params.set(\"search\", options.search);\n if (options.perPage) params.set(\"per_page\", options.perPage.toString());\n\n const base = options.projectId\n ? `/projects/${encodeProjectId(options.projectId)}/issues`\n : \"/issues\";\n\n return gitlabFetch<GitLabIssue[]>(`${base}${buildQuery(params)}`);\n}\n\nexport function getIssue(projectId: number | string, issueIid: number): Promise<GitLabIssue> {\n return gitlabFetch<GitLabIssue>(`/projects/${encodeProjectId(projectId)}/issues/${issueIid}`);\n}\n\nexport function createIssue(\n projectId: number | string,\n options: {\n title: string;\n description?: string;\n labels?: string[];\n assigneeIds?: number[];\n milestoneId?: number;\n dueDate?: string;\n },\n): Promise<GitLabIssue> {\n const body: Record<string, unknown> = { title: options.title };\n\n if (options.description) body.description = options.description;\n if (options.labels?.length) body.labels = options.labels.join(\",\");\n if (options.assigneeIds?.length) body.assignee_ids = options.assigneeIds;\n if (options.milestoneId) body.milestone_id = options.milestoneId;\n if (options.dueDate) body.due_date = options.dueDate;\n\n return gitlabFetch<GitLabIssue>(`/projects/${encodeProjectId(projectId)}/issues`, {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n}\n\nexport function updateIssue(\n projectId: number | string,\n issueIid: number,\n options: {\n title?: string;\n description?: string;\n state?: \"opened\" | \"closed\";\n labels?: string[];\n assigneeIds?: number[];\n },\n): Promise<GitLabIssue> {\n const body: Record<string, unknown> = {};\n\n if (options.title) body.title = options.title;\n if (options.description !== undefined) body.description = options.description;\n if (options.state) body.state_event = options.state === \"closed\" ? \"close\" : \"reopen\";\n if (options.labels) body.labels = options.labels.join(\",\");\n if (options.assigneeIds) body.assignee_ids = options.assigneeIds;\n\n return gitlabFetch<GitLabIssue>(`/projects/${encodeProjectId(projectId)}/issues/${issueIid}`, {\n method: \"PUT\",\n body: JSON.stringify(body),\n });\n}\n\nexport function listMergeRequests(options?: {\n scope?: \"created_by_me\" | \"assigned_to_me\" | \"all\";\n state?: \"opened\" | \"closed\" | \"merged\" | \"all\";\n labels?: string[];\n projectId?: number | string;\n perPage?: number;\n}): Promise<GitLabMergeRequest[]> {\n const params = new URLSearchParams();\n\n if (options?.scope) params.set(\"scope\", options.scope);\n if (options?.state) params.set(\"state\", options.state);\n if (options?.labels?.length) params.set(\"labels\", options.labels.join(\",\"));\n if (options?.perPage) params.set(\"per_page\", options.perPage.toString());\n\n const base = options?.projectId\n ? `/projects/${encodeProjectId(options.projectId)}/merge_requests`\n : \"/merge_requests\";\n\n return gitlabFetch<GitLabMergeRequest[]>(`${base}${buildQuery(params)}`);\n}\n\nexport function getMergeRequest(\n projectId: number | string,\n mrIid: number,\n): Promise<GitLabMergeRequest> {\n return gitlabFetch<GitLabMergeRequest>(\n `/projects/${encodeProjectId(projectId)}/merge_requests/${mrIid}`,\n );\n}\n\nexport function formatIssueForDisplay(issue: GitLabIssue): string {\n const assignees = issue.assignees.map((a) => `@${a.username}`).join(\", \");\n const labels = issue.labels.length ? `[${issue.labels.join(\", \")}]` : \"\";\n\n return `#${issue.iid}: ${issue.title} ${labels}\nState: ${issue.state}\nAssignees: ${assignees || \"None\"}\nCreated: ${new Date(issue.created_at).toLocaleDateString()}\nURL: ${issue.web_url}`;\n}\n\nexport function formatMergeRequestForDisplay(mr: GitLabMergeRequest): string {\n const assignees = mr.assignees.map((a) => `@${a.username}`).join(\", \");\n const reviewers = mr.reviewers.map((r) => `@${r.username}`).join(\", \");\n const labels = mr.labels.length ? `[${mr.labels.join(\", \")}]` : \"\";\n\n return `!${mr.iid}: ${mr.title} ${labels}\nState: ${mr.state}${mr.draft ? \" (Draft)\" : \"\"}\nSource: ${mr.source_branch} → Target: ${mr.target_branch}\nAuthor: @${mr.author.username}\nAssignees: ${assignees || \"None\"}\nReviewers: ${reviewers || \"None\"}\nCreated: ${new Date(mr.created_at).toLocaleDateString()}\nURL: ${mr.web_url}`;\n}\n",
|
|
142
|
-
"app/api/auth/gitlab/callback/route.ts": "import { createOAuthCallbackHandler, gitlabConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
143
|
-
"app/api/auth/gitlab/route.ts": "import { createOAuthInitHandler, gitlabConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(gitlabConfig, {
|
|
142
|
+
"app/api/auth/gitlab/callback/route.ts": "import { createOAuthCallbackHandler, gitlabConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(gitlabConfig, { tokenStore: hybridTokenStore });\n",
|
|
143
|
+
"app/api/auth/gitlab/route.ts": "import { createOAuthInitHandler, gitlabConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(gitlabConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
144
144
|
}
|
|
145
145
|
},
|
|
146
146
|
"integration:twitter": {
|
|
@@ -150,8 +150,8 @@ export default {
|
|
|
150
150
|
"tools/search-tweets.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createTwitterClient } from \"../../lib/twitter-client.ts\";\n\nexport default tool({\n id: \"search-tweets\",\n description:\n \"Search recent tweets on Twitter/X. Returns up to 10 recent tweets matching the query.\",\n inputSchema: z.object({\n query: z\n .string()\n .min(1)\n .describe(\n \"Search query (supports Twitter search operators like 'from:', 'to:', '#hashtag', etc.)\",\n ),\n maxResults: z\n .number()\n .min(1)\n .max(100)\n .optional()\n .describe(\"Maximum number of tweets to return (default: 10)\"),\n sortOrder: z\n .enum([\"recency\", \"relevancy\"])\n .optional()\n .describe(\"Sort order: 'recency' (default) or 'relevancy'\"),\n }),\n execute: async ({ query, maxResults, sortOrder }, context) => {\n const userId = context?.userId ?? \"current-user\";\n\n try {\n const twitter = createTwitterClient(userId);\n const result = await twitter.searchTweets(query, {\n maxResults: maxResults ?? 10,\n sortOrder: sortOrder ?? \"recency\",\n });\n\n const tweets = result.data ?? [];\n const count = result.meta.result_count;\n\n if (tweets.length === 0) {\n return {\n success: true,\n tweets: [],\n count: 0,\n message: `No tweets found matching query: \"${query}\"`,\n };\n }\n\n return {\n success: true,\n tweets: tweets.map((tweet) => ({\n id: tweet.id,\n text: tweet.text,\n author_id: tweet.author_id,\n created_at: tweet.created_at,\n metrics: tweet.public_metrics,\n hashtags: tweet.entities?.hashtags?.map((h) => h.tag),\n mentions: tweet.entities?.mentions?.map((m) => m.username),\n })),\n count,\n message: `Found ${count} tweets matching query: \"${query}\"`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Twitter not connected. Please connect your Twitter account.\",\n connectUrl: \"/api/auth/twitter\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
151
151
|
".env.example": "# Twitter / X Integration\n# Create an app at https://developer.twitter.com/en/portal/dashboard\n# Enable OAuth 2.0 and add callback URL: http://localhost:3000/api/auth/twitter/callback\n\nTWITTER_CLIENT_ID=your_client_id_here\nTWITTER_CLIENT_SECRET=your_client_secret_here\n",
|
|
152
152
|
"lib/twitter-client.ts": "/**\n * Twitter API Client\n *\n * Provides a type-safe interface to Twitter API v2 operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n return undefined;\n}\n\nconst TWITTER_API_BASE = \"https://api.twitter.com/2\";\n\nexport interface TwitterUser {\n id: string;\n name: string;\n username: string;\n created_at?: string;\n description?: string;\n location?: string;\n profile_image_url?: string;\n public_metrics?: {\n followers_count: number;\n following_count: number;\n tweet_count: number;\n listed_count: number;\n };\n verified?: boolean;\n}\n\nexport interface Tweet {\n id: string;\n text: string;\n author_id?: string;\n created_at?: string;\n public_metrics?: {\n retweet_count: number;\n reply_count: number;\n like_count: number;\n quote_count: number;\n };\n referenced_tweets?: Array<{\n type: string;\n id: string;\n }>;\n entities?: {\n hashtags?: Array<{ tag: string }>;\n mentions?: Array<{ username: string; id: string }>;\n urls?: Array<{ url: string; expanded_url: string }>;\n };\n}\n\nexport interface SearchResult {\n data: Tweet[];\n meta: {\n newest_id?: string;\n oldest_id?: string;\n result_count: number;\n next_token?: string;\n };\n}\n\nexport const twitterOAuthProvider = {\n name: \"twitter\",\n authorizationUrl: \"https://twitter.com/i/oauth2/authorize\",\n tokenUrl: \"https://api.twitter.com/2/oauth2/token\",\n clientId: getEnv(\"TWITTER_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"TWITTER_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"tweet.read\",\n \"tweet.write\",\n \"users.read\",\n \"follows.read\",\n \"offline.access\",\n ],\n callbackPath: \"/api/auth/twitter/callback\",\n usePKCE: true,\n};\n\nexport function createTwitterClient(userId: string): {\n getMe: () => Promise<TwitterUser>;\n getUserById: (userId: string) => Promise<TwitterUser>;\n getTweets: (\n userId: string,\n options?: { maxResults?: number; excludeReplies?: boolean },\n ) => Promise<Tweet[]>;\n getTweet: (tweetId: string) => Promise<Tweet>;\n postTweet: (text: string) => Promise<{ id: string; text: string }>;\n searchTweets: (\n query: string,\n options?: { maxResults?: number; sortOrder?: \"recency\" | \"relevancy\" },\n ) => Promise<SearchResult>;\n getTimeline: (options?: { maxResults?: number }) => Promise<Tweet[]>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(twitterOAuthProvider, userId, \"twitter\");\n if (token) return token;\n\n throw new Error(\n \"Twitter not connected. Please connect your Twitter account first.\",\n );\n }\n\n async function twitterFetch<T>(\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${TWITTER_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (response.ok) return response.json();\n\n const error = await response\n .json()\n .catch(() => ({ detail: response.statusText }));\n\n throw new Error(\n `Twitter API error: ${error.detail || error.title || response.statusText}`,\n );\n }\n\n function createTweetFieldsParams(\n options: { maxResults?: number },\n tweetFields: string,\n ): URLSearchParams {\n return new URLSearchParams({\n max_results: String(options.maxResults ?? 10),\n \"tweet.fields\": tweetFields,\n });\n }\n\n return {\n async getMe(): Promise<TwitterUser> {\n const result = await twitterFetch<{ data: TwitterUser }>(\n \"/users/me?user.fields=created_at,description,location,profile_image_url,public_metrics,verified\",\n );\n return result.data;\n },\n\n async getUserById(userId: string): Promise<TwitterUser> {\n const result = await twitterFetch<{ data: TwitterUser }>(\n `/users/${userId}?user.fields=created_at,description,location,profile_image_url,public_metrics,verified`,\n );\n return result.data;\n },\n\n async getTweets(\n userId: string,\n options: { maxResults?: number; excludeReplies?: boolean } = {},\n ): Promise<Tweet[]> {\n const params = createTweetFieldsParams(\n options,\n \"created_at,public_metrics,referenced_tweets,entities\",\n );\n\n if (options.excludeReplies) params.set(\"exclude\", \"replies\");\n\n const result = await twitterFetch<{ data: Tweet[] }>(\n `/users/${userId}/tweets?${params.toString()}`,\n );\n return result.data ?? [];\n },\n\n async getTweet(tweetId: string): Promise<Tweet> {\n const result = await twitterFetch<{ data: Tweet }>(\n `/tweets/${tweetId}?tweet.fields=created_at,public_metrics,referenced_tweets,entities,author_id`,\n );\n return result.data;\n },\n\n async postTweet(text: string): Promise<{ id: string; text: string }> {\n const result = await twitterFetch<{ data: { id: string; text: string } }>(\n \"/tweets\",\n {\n method: \"POST\",\n body: JSON.stringify({ text }),\n },\n );\n return result.data;\n },\n\n searchTweets(\n query: string,\n options: { maxResults?: number; sortOrder?: \"recency\" | \"relevancy\" } = {},\n ): Promise<SearchResult> {\n const params = new URLSearchParams({\n query,\n max_results: String(options.maxResults ?? 10),\n \"tweet.fields\":\n \"created_at,public_metrics,referenced_tweets,entities,author_id\",\n sort_order: options.sortOrder ?? \"recency\",\n });\n\n return twitterFetch<SearchResult>(\n `/tweets/search/recent?${params.toString()}`,\n );\n },\n\n async getTimeline(options: { maxResults?: number } = {}): Promise<Tweet[]> {\n const me = await twitterFetch<{ data: TwitterUser }>(\"/users/me\");\n const params = createTweetFieldsParams(\n options,\n \"created_at,public_metrics,referenced_tweets,entities,author_id\",\n );\n\n const result = await twitterFetch<{ data: Tweet[] }>(\n `/users/${me.data.id}/timelines/reverse_chronological?${params.toString()}`,\n );\n return result.data ?? [];\n },\n };\n}\n\nexport type TwitterClient = ReturnType<typeof createTwitterClient>;\n",
|
|
153
|
-
"app/api/auth/twitter/callback/route.ts": "import { createOAuthCallbackHandler, twitterConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
154
|
-
"app/api/auth/twitter/route.ts": "import { createOAuthInitHandler, twitterConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(twitterConfig, {\n tokenStore: oauthMemoryTokenStore,\n})
|
|
153
|
+
"app/api/auth/twitter/callback/route.ts": "import { createOAuthCallbackHandler, twitterConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(twitterConfig, { tokenStore: hybridTokenStore });\n",
|
|
154
|
+
"app/api/auth/twitter/route.ts": "import { createOAuthInitHandler, twitterConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(twitterConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
155
155
|
}
|
|
156
156
|
},
|
|
157
157
|
"integration:anthropic": {
|
|
@@ -174,8 +174,8 @@ export default {
|
|
|
174
174
|
"tools/send-message.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { formatUsername, sendMessage } from \"../../lib/discord-client.ts\";\n\nexport default tool({\n id: \"send-message\",\n description: \"Send a message to a Discord channel. Returns the sent message details.\",\n inputSchema: z.object({\n channelId: z.string().describe(\"The ID of the Discord channel to send the message to\"),\n content: z\n .string()\n .min(1)\n .max(2000)\n .describe(\"The message content to send (1-2000 characters)\"),\n tts: z\n .boolean()\n .default(false)\n .describe(\"Whether this message should be sent as text-to-speech\"),\n }),\n async execute({ channelId, content, tts }) {\n const message = await sendMessage(channelId, content, { tts });\n\n return {\n id: message.id,\n content: message.content,\n channelId: message.channel_id,\n timestamp: message.timestamp,\n author: {\n id: message.author.id,\n username: formatUsername(message.author),\n globalName: message.author.global_name,\n },\n tts: message.tts,\n };\n },\n});\n",
|
|
175
175
|
".env.example": "# Discord OAuth Configuration\n# Get these from https://discord.com/developers/applications\n\n# Required: Your Discord application's Client ID\nDISCORD_CLIENT_ID=your_client_id_here\n\n# Required: Your Discord application's Client Secret\nDISCORD_CLIENT_SECRET=your_client_secret_here\n\n# Optional: Bot token for advanced bot features\n# Only needed if you want to use bot-specific functionality\nDISCORD_BOT_TOKEN=your_bot_token_here\n",
|
|
176
176
|
"lib/discord-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst DISCORD_API_VERSION = \"v10\";\nconst DISCORD_BASE_URL = `https://discord.com/api/${DISCORD_API_VERSION}`;\n\ninterface DiscordUser {\n id: string;\n username: string;\n discriminator: string;\n global_name?: string | null;\n avatar?: string | null;\n bot?: boolean;\n system?: boolean;\n mfa_enabled?: boolean;\n banner?: string | null;\n accent_color?: number | null;\n locale?: string;\n verified?: boolean;\n email?: string | null;\n flags?: number;\n premium_type?: number;\n public_flags?: number;\n}\n\ninterface DiscordGuild {\n id: string;\n name: string;\n icon?: string | null;\n owner?: boolean;\n permissions?: string;\n features: string[];\n}\n\ninterface DiscordChannel {\n id: string;\n type: number;\n guild_id?: string;\n position?: number;\n name?: string;\n topic?: string | null;\n nsfw?: boolean;\n last_message_id?: string | null;\n bitrate?: number;\n user_limit?: number;\n rate_limit_per_user?: number;\n recipients?: DiscordUser[];\n icon?: string | null;\n owner_id?: string;\n application_id?: string;\n parent_id?: string | null;\n last_pin_timestamp?: string | null;\n rtc_region?: string | null;\n video_quality_mode?: number;\n message_count?: number;\n member_count?: number;\n flags?: number;\n}\n\ninterface DiscordMessage {\n id: string;\n channel_id: string;\n author: DiscordUser;\n content: string;\n timestamp: string;\n edited_timestamp?: string | null;\n tts: boolean;\n mention_everyone: boolean;\n mentions: DiscordUser[];\n mention_roles: string[];\n attachments: Array<{\n id: string;\n filename: string;\n size: number;\n url: string;\n proxy_url: string;\n height?: number | null;\n width?: number | null;\n content_type?: string;\n }>;\n embeds: unknown[];\n reactions?: Array<{\n count: number;\n me: boolean;\n emoji: {\n id: string | null;\n name: string | null;\n };\n }>;\n pinned: boolean;\n type: number;\n}\n\ninterface DiscordGuildMember {\n user?: DiscordUser;\n nick?: string | null;\n avatar?: string | null;\n roles: string[];\n joined_at: string;\n premium_since?: string | null;\n deaf: boolean;\n mute: boolean;\n flags: number;\n pending?: boolean;\n permissions?: string;\n communication_disabled_until?: string | null;\n}\n\nasync function discordFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Discord. Please connect your account.\");\n }\n\n const response = await fetch(`${DISCORD_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({}))) as { message?: string };\n throw new Error(`Discord API error: ${response.status} ${error.message ?? response.statusText}`);\n }\n\n return response.json();\n}\n\nfunction buildQuery(\n options: Record<string, string | number | undefined>,\n limits?: Record<string, number>,\n): string {\n const params = new URLSearchParams();\n\n for (const [key, value] of Object.entries(options)) {\n if (value === undefined) continue;\n\n if (typeof value === \"number\") {\n const limit = limits?.[key];\n params.set(key, Math.min(value, limit ?? value).toString());\n continue;\n }\n\n params.set(key, value);\n }\n\n const query = params.toString();\n return query ? `?${query}` : \"\";\n}\n\nexport function getCurrentUser(): Promise<DiscordUser> {\n return discordFetch(\"/users/@me\");\n}\n\nexport function listGuilds(): Promise<DiscordGuild[]> {\n return discordFetch(\"/users/@me/guilds\");\n}\n\nexport function getGuild(guildId: string): Promise<DiscordGuild> {\n return discordFetch(`/guilds/${guildId}`);\n}\n\nexport function listChannels(guildId: string): Promise<DiscordChannel[]> {\n return discordFetch(`/guilds/${guildId}/channels`);\n}\n\nexport function getChannel(channelId: string): Promise<DiscordChannel> {\n return discordFetch(`/channels/${channelId}`);\n}\n\nexport function getMessages(\n channelId: string,\n options?: {\n limit?: number;\n before?: string;\n after?: string;\n around?: string;\n },\n): Promise<DiscordMessage[]> {\n const query = buildQuery(\n {\n limit: options?.limit,\n before: options?.before,\n after: options?.after,\n around: options?.around,\n },\n { limit: 100 },\n );\n\n return discordFetch(`/channels/${channelId}/messages${query}`);\n}\n\nexport function sendMessage(\n channelId: string,\n content: string,\n options?: {\n tts?: boolean;\n embeds?: unknown[];\n },\n): Promise<DiscordMessage> {\n const body: Record<string, unknown> = { content };\n\n if (options?.tts !== undefined) body.tts = options.tts;\n if (options?.embeds) body.embeds = options.embeds;\n\n return discordFetch(`/channels/${channelId}/messages`, {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n}\n\nexport function getGuildMembers(\n guildId: string,\n options?: {\n limit?: number;\n after?: string;\n },\n): Promise<DiscordGuildMember[]> {\n const query = buildQuery({ limit: options?.limit, after: options?.after }, { limit: 1000 });\n return discordFetch(`/guilds/${guildId}/members${query}`);\n}\n\nexport function formatUsername(user: DiscordUser): string {\n if (user.discriminator === \"0\") return user.username;\n return `${user.username}#${user.discriminator}`;\n}\n\nfunction getCdnAssetUrl(\n basePath: string,\n id: string,\n hash: string | null | undefined,\n size: number,\n): string | null {\n if (!hash) return null;\n const extension = hash.startsWith(\"a_\") ? \"gif\" : \"png\";\n return `https://cdn.discordapp.com/${basePath}/${id}/${hash}.${extension}?size=${size}`;\n}\n\nexport function getAvatarUrl(user: DiscordUser, size: number = 128): string | null {\n return getCdnAssetUrl(\"avatars\", user.id, user.avatar, size);\n}\n\nexport function getGuildIconUrl(guild: DiscordGuild, size: number = 128): string | null {\n return getCdnAssetUrl(\"icons\", guild.id, guild.icon, size);\n}\n\nconst CHANNEL_TYPE_NAMES: Record<number, string> = {\n 0: \"Text\",\n 1: \"DM\",\n 2: \"Voice\",\n 3: \"Group DM\",\n 4: \"Category\",\n 5: \"Announcement\",\n 10: \"Announcement Thread\",\n 11: \"Public Thread\",\n 12: \"Private Thread\",\n 13: \"Stage Voice\",\n 14: \"Directory\",\n 15: \"Forum\",\n};\n\nexport function getChannelTypeName(type: number): string {\n return CHANNEL_TYPE_NAMES[type] ?? \"Unknown\";\n}\n",
|
|
177
|
-
"app/api/auth/discord/callback/route.ts": "/**\n * Discord OAuth Callback\n *\n * Handles the OAuth callback from Discord and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, discordConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
178
|
-
"app/api/auth/discord/route.ts": "import { createOAuthInitHandler, discordConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(discordConfig, {\n tokenStore: oauthMemoryTokenStore,\n})
|
|
177
|
+
"app/api/auth/discord/callback/route.ts": "/**\n * Discord OAuth Callback\n *\n * Handles the OAuth callback from Discord and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, discordConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(discordConfig, { tokenStore: hybridTokenStore });\n",
|
|
178
|
+
"app/api/auth/discord/route.ts": "import { createOAuthInitHandler, discordConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(discordConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
179
179
|
}
|
|
180
180
|
},
|
|
181
181
|
"integration:snowflake": {
|
|
@@ -198,8 +198,8 @@ export default {
|
|
|
198
198
|
"tools/list-items.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { listItems } from \"../../lib/monday-client.ts\";\n\nexport default tool({\n id: \"list-items\",\n description: \"List items from a Monday.com board. Items are the rows in a board.\",\n inputSchema: z.object({\n boardId: z.string().describe(\"The ID of the board to list items from\"),\n limit: z.number().min(1).max(100).default(50).describe(\"Maximum number of items to return\"),\n page: z.number().min(1).default(1).describe(\"Page number for pagination\"),\n }),\n async execute({ boardId, limit, page }) {\n const items = await listItems({ boardId, limit, page });\n\n return items.map((item) => ({\n id: item.id,\n name: item.name,\n state: item.state,\n board: item.board,\n group: item.group,\n columnValues: item.column_values?.map((col) => ({\n id: col.id,\n title: col.title,\n text: col.text,\n type: col.type,\n })),\n createdAt: item.created_at,\n updatedAt: item.updated_at,\n }));\n },\n});\n",
|
|
199
199
|
".env.example": "# Monday.com OAuth Configuration\n# Get your credentials from https://monday.com/developers/apps\nMONDAY_CLIENT_ID=your-client-id\nMONDAY_CLIENT_SECRET=your-client-secret\n",
|
|
200
200
|
"lib/monday-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst MONDAY_API_URL = \"https://api.monday.com/v2\";\n\ninterface MondayResponse<T> {\n data: T;\n account_id?: number;\n errors?: Array<{ message: string; locations?: unknown[] }>;\n}\n\ninterface MondayBoard {\n id: string;\n name: string;\n description?: string;\n board_kind: string;\n state: string;\n workspace_id?: string;\n created_at?: string;\n updated_at?: string;\n}\n\ninterface MondayItem {\n id: string;\n name: string;\n state?: string;\n board?: {\n id: string;\n name: string;\n };\n group?: {\n id: string;\n title: string;\n };\n column_values?: Array<{\n id: string;\n text?: string;\n title?: string;\n type?: string;\n value?: string;\n }>;\n created_at?: string;\n updated_at?: string;\n}\n\ninterface MondayUser {\n id: string;\n name: string;\n email: string;\n account: {\n id: string;\n name: string;\n };\n}\n\nasync function mondayFetch<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Monday.com. Please connect your account.\");\n }\n\n const response = await fetch(MONDAY_API_URL, {\n method: \"POST\",\n headers: {\n Authorization: token,\n \"Content-Type\": \"application/json\",\n \"API-Version\": \"2024-10\",\n },\n body: JSON.stringify({ query, variables }),\n });\n\n if (!response.ok) {\n throw new Error(`Monday.com API error: ${response.status} ${response.statusText}`);\n }\n\n const result: MondayResponse<T> = await response.json();\n const errorMessage = result.errors?.[0]?.message;\n if (errorMessage) {\n throw new Error(`Monday.com GraphQL error: ${errorMessage}`);\n }\n\n return result.data;\n}\n\nexport async function getMe(): Promise<MondayUser> {\n const query = `\n query {\n me {\n id\n name\n email\n account {\n id\n name\n }\n }\n }\n `;\n\n const data = await mondayFetch<{ me: MondayUser }>(query);\n return data.me;\n}\n\nexport async function listBoards(options?: {\n limit?: number;\n page?: number;\n workspaceIds?: string[];\n}): Promise<MondayBoard[]> {\n const limit = options?.limit ?? 50;\n const page = options?.page ?? 1;\n\n const workspaceIds = options?.workspaceIds?.length\n ? options.workspaceIds.map((id) => parseInt(id, 10))\n : null;\n\n const workspaceFilter = workspaceIds ? `, workspace_ids: [${workspaceIds.join(\",\")}]` : \"\";\n\n const query = `\n query {\n boards(limit: ${limit}, page: ${page}${workspaceFilter}) {\n id\n name\n description\n board_kind\n state\n workspace_id\n }\n }\n `;\n\n const data = await mondayFetch<{ boards: MondayBoard[] }>(query);\n return data.boards;\n}\n\nexport async function getBoard(boardId: string): Promise<MondayBoard> {\n const query = `\n query {\n boards(ids: [${boardId}]) {\n id\n name\n description\n board_kind\n state\n workspace_id\n }\n }\n `;\n\n const data = await mondayFetch<{ boards: MondayBoard[] }>(query);\n const board = data.boards?.[0];\n if (!board) {\n throw new Error(`Board with ID ${boardId} not found`);\n }\n\n return board;\n}\n\nexport async function listItems(options: {\n boardId: string;\n limit?: number;\n page?: number;\n}): Promise<MondayItem[]> {\n const limit = options.limit ?? 50;\n const page = options.page ?? 1;\n\n const query = `\n query {\n boards(ids: [${options.boardId}]) {\n items_page(limit: ${limit}, query_params: {page: ${page}}) {\n items {\n id\n name\n state\n board {\n id\n name\n }\n group {\n id\n title\n }\n column_values {\n id\n text\n title\n type\n value\n }\n created_at\n updated_at\n }\n }\n }\n }\n `;\n\n const data = await mondayFetch<{ boards: Array<{ items_page: { items: MondayItem[] } }> }>(query);\n return data.boards?.[0]?.items_page.items ?? [];\n}\n\nexport async function getItem(itemId: string): Promise<MondayItem> {\n const query = `\n query {\n items(ids: [${itemId}]) {\n id\n name\n state\n board {\n id\n name\n }\n group {\n id\n title\n }\n column_values {\n id\n text\n title\n type\n value\n }\n created_at\n updated_at\n }\n }\n `;\n\n const data = await mondayFetch<{ items: MondayItem[] }>(query);\n const item = data.items?.[0];\n if (!item) {\n throw new Error(`Item with ID ${itemId} not found`);\n }\n\n return item;\n}\n\nexport async function createItem(options: {\n boardId: string;\n groupId?: string;\n itemName: string;\n columnValues?: Record<string, unknown>;\n}): Promise<MondayItem> {\n const groupId = options.groupId ? `group_id: \"${options.groupId}\",` : \"\";\n const columnValues = options.columnValues\n ? `column_values: ${JSON.stringify(JSON.stringify(options.columnValues))},`\n : \"\";\n\n const query = `\n mutation {\n create_item(\n board_id: ${options.boardId},\n ${groupId}\n item_name: \"${options.itemName}\",\n ${columnValues}\n ) {\n id\n name\n state\n board {\n id\n name\n }\n group {\n id\n title\n }\n created_at\n }\n }\n `;\n\n const data = await mondayFetch<{ create_item: MondayItem }>(query);\n return data.create_item;\n}\n\nexport async function updateItem(\n itemId: string,\n updates: {\n columnValues?: Record<string, unknown>;\n name?: string;\n },\n): Promise<MondayItem> {\n if (updates.name) {\n const nameQuery = `\n mutation {\n change_simple_column_value(\n item_id: ${itemId},\n column_id: \"name\",\n value: \"${updates.name}\"\n ) {\n id\n name\n }\n }\n `;\n await mondayFetch<{ change_simple_column_value: MondayItem }>(nameQuery);\n }\n\n if (!updates.columnValues) {\n return getItem(itemId);\n }\n\n const columnValuesStr = JSON.stringify(JSON.stringify(updates.columnValues));\n const query = `\n mutation {\n change_multiple_column_values(\n item_id: ${itemId},\n column_values: ${columnValuesStr}\n ) {\n id\n name\n state\n column_values {\n id\n text\n title\n type\n value\n }\n }\n }\n `;\n\n const data = await mondayFetch<{ change_multiple_column_values: MondayItem }>(query);\n return data.change_multiple_column_values;\n}\n",
|
|
201
|
-
"app/api/auth/monday/callback/route.ts": "import { createOAuthCallbackHandler, mondayConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
202
|
-
"app/api/auth/monday/route.ts": "import { createOAuthInitHandler, mondayConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(mondayConfig, {
|
|
201
|
+
"app/api/auth/monday/callback/route.ts": "import { createOAuthCallbackHandler, mondayConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(mondayConfig, { tokenStore: hybridTokenStore });\n",
|
|
202
|
+
"app/api/auth/monday/route.ts": "import { createOAuthInitHandler, mondayConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(mondayConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
203
203
|
}
|
|
204
204
|
},
|
|
205
205
|
"integration:_base": {
|
|
@@ -224,8 +224,8 @@ export default {
|
|
|
224
224
|
"tools/list-events.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createCalendarClient } from \"../../lib/calendar-client.ts\";\n\ntype CalendarEvent = {\n id: string;\n summary: string;\n description?: string;\n location?: string;\n start: { dateTime?: string; date?: string };\n end: { dateTime?: string; date?: string };\n status: string;\n htmlLink: string;\n attendees?: Array<{ email: string; displayName?: string; responseStatus?: string }>;\n};\n\nexport default tool({\n id: \"list-events\",\n description: \"List upcoming calendar events. By default shows events from now onwards.\",\n inputSchema: z.object({\n maxResults: z\n .number()\n .min(1)\n .max(100)\n .default(10)\n .describe(\"Maximum number of events to return\"),\n daysAhead: z.number().min(1).max(30).default(7).describe(\"Number of days to look ahead\"),\n todayOnly: z.boolean().default(false).describe(\"Only show events for today\"),\n }),\n execute: async ({ maxResults, daysAhead, todayOnly }, context) => {\n const userId = context?.userId ?? \"current-user\";\n\n try {\n const calendar = createCalendarClient(userId);\n\n const events = todayOnly\n ? ((await calendar.getTodayEvents()) as CalendarEvent[])\n : ((await calendar.listEvents({\n maxResults,\n timeMin: new Date(),\n timeMax: new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000),\n })) as CalendarEvent[]);\n\n return {\n events: events.map((event) => ({\n id: event.id,\n title: event.summary,\n description: event.description ?? null,\n location: event.location ?? null,\n start: event.start.dateTime || event.start.date,\n end: event.end.dateTime || event.end.date,\n isAllDay: !event.start.dateTime,\n status: event.status,\n url: event.htmlLink,\n attendees:\n event.attendees?.map((a) => ({\n email: a.email,\n name: a.displayName,\n status: a.responseStatus,\n })) ?? [],\n })),\n count: events.length,\n message: todayOnly\n ? `Found ${events.length} event(s) for today.`\n : `Found ${events.length} event(s) in the next ${daysAhead} days.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Calendar not connected. Please connect your Google Calendar.\",\n connectUrl: \"/api/auth/calendar\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
225
225
|
".env.example": "# =============================================================================\n# Google Calendar Integration Setup\n# =============================================================================\n#\n# STEP 1: Create a Google Cloud Project\n# Visit: https://console.cloud.google.com/projectcreate\n#\n# STEP 2: Enable the Google Calendar API\n# Visit: https://console.cloud.google.com/apis/library/calendar-json.googleapis.com\n# Click \"Enable\" to activate the Calendar API for your project\n#\n# STEP 3: Configure OAuth Consent Screen\n# Visit: https://console.cloud.google.com/apis/credentials/consent\n# - Choose \"External\" user type (or \"Internal\" for Workspace)\n# - Fill in app name, support email\n# - Add scopes: calendar.readonly, calendar.events\n# - Add your email as a test user (required for development)\n#\n# STEP 4: Create OAuth Credentials\n# Visit: https://console.cloud.google.com/apis/credentials\n# - Click \"Create Credentials\" > \"OAuth client ID\"\n# - Application type: \"Web application\"\n# - Add Authorized redirect URI: http://localhost:3000/api/auth/calendar/callback\n# - Copy the Client ID and Client Secret below\n#\n# =============================================================================\n\nGOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com\nGOOGLE_CLIENT_SECRET=your-client-secret\n",
|
|
226
226
|
"lib/calendar-client.ts": "/**\n * Google Calendar API Client\n *\n * Provides a type-safe interface to Google Calendar API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") {\n // @ts-ignore - Deno global\n return Deno.env.get(key);\n }\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) {\n // @ts-ignore - process global\n return process.env[key];\n }\n\n return undefined;\n}\n\nconst CALENDAR_API_BASE = \"https://www.googleapis.com/calendar/v3\";\n\nexport interface CalendarEvent {\n id: string;\n summary: string;\n description?: string;\n location?: string;\n start: {\n dateTime?: string;\n date?: string;\n timeZone?: string;\n };\n end: {\n dateTime?: string;\n date?: string;\n timeZone?: string;\n };\n attendees?: Array<{\n email: string;\n responseStatus: \"needsAction\" | \"declined\" | \"tentative\" | \"accepted\";\n displayName?: string;\n }>;\n htmlLink: string;\n status: \"confirmed\" | \"tentative\" | \"cancelled\";\n organizer?: { email: string; displayName?: string };\n}\n\nexport interface CreateEventOptions {\n summary: string;\n description?: string;\n location?: string;\n start: Date | string;\n end: Date | string;\n attendees?: string[];\n timeZone?: string;\n}\n\nexport interface FreeBusySlot {\n start: string;\n end: string;\n}\n\n/**\n * Google Calendar OAuth provider configuration\n */\nexport const calendarOAuthProvider = {\n name: \"calendar\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/calendar.readonly\",\n \"https://www.googleapis.com/auth/calendar.events\",\n ],\n callbackPath: \"/api/auth/calendar/callback\",\n};\n\ntype ListEventsOptions = {\n maxResults?: number;\n timeMin?: Date | string;\n timeMax?: Date | string;\n calendarId?: string;\n};\n\ntype FreeBusyOptions = {\n timeMin: Date | string;\n timeMax: Date | string;\n calendarId?: string;\n};\n\ntype FindFreeSlotsOptions = FreeBusyOptions & {\n durationMinutes: number;\n};\n\ntype CalendarClientShape = {\n listEvents(options?: ListEventsOptions): Promise<CalendarEvent[]>;\n getTodayEvents(): Promise<CalendarEvent[]>;\n createEvent(options: CreateEventOptions, calendarId?: string): Promise<CalendarEvent>;\n getFreeBusy(options: FreeBusyOptions): Promise<FreeBusySlot[]>;\n findFreeSlots(options: FindFreeSlotsOptions): Promise<Array<{ start: Date; end: Date }>>;\n deleteEvent(eventId: string, calendarId?: string): Promise<void>;\n};\n\n/**\n * Create a Calendar client for a specific user\n */\nexport function createCalendarClient(userId: string): CalendarClientShape {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(calendarOAuthProvider, userId, \"calendar\");\n if (!token) {\n throw new Error(\"Calendar not connected. Please connect your Google Calendar first.\");\n }\n return token;\n }\n\n async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${CALENDAR_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Calendar API error: ${response.status} - ${error}`);\n }\n\n return response.json();\n }\n\n async function listEvents(options: ListEventsOptions = {}): Promise<CalendarEvent[]> {\n const params = new URLSearchParams();\n\n const timeMin = options.timeMin ? new Date(options.timeMin) : new Date();\n params.set(\"timeMin\", timeMin.toISOString());\n\n if (options.timeMax) {\n params.set(\"timeMax\", new Date(options.timeMax).toISOString());\n }\n\n params.set(\"maxResults\", String(options.maxResults ?? 10));\n params.set(\"singleEvents\", \"true\");\n params.set(\"orderBy\", \"startTime\");\n\n const calendarId = encodeURIComponent(options.calendarId ?? \"primary\");\n const result = await apiRequest<{ items: CalendarEvent[] }>(\n `/calendars/${calendarId}/events?${params.toString()}`,\n );\n\n return result.items ?? [];\n }\n\n function getTodayEvents(): Promise<CalendarEvent[]> {\n const today = new Date();\n today.setHours(0, 0, 0, 0);\n\n const tomorrow = new Date(today);\n tomorrow.setDate(tomorrow.getDate() + 1);\n\n return listEvents({ timeMin: today, timeMax: tomorrow, maxResults: 50 });\n }\n\n function createEvent(options: CreateEventOptions, calendarId = \"primary\"): Promise<CalendarEvent> {\n const startDate = typeof options.start === \"string\" ? options.start : options.start.toISOString();\n const endDate = typeof options.end === \"string\" ? options.end : options.end.toISOString();\n const timeZone = options.timeZone ?? \"UTC\";\n\n const event = {\n summary: options.summary,\n description: options.description,\n location: options.location,\n start: { dateTime: startDate, timeZone },\n end: { dateTime: endDate, timeZone },\n attendees: options.attendees?.map((email) => ({ email })),\n };\n\n return apiRequest<CalendarEvent>(`/calendars/${encodeURIComponent(calendarId)}/events`, {\n method: \"POST\",\n body: JSON.stringify(event),\n });\n }\n\n async function getFreeBusy(options: FreeBusyOptions): Promise<FreeBusySlot[]> {\n const calendarId = options.calendarId ?? \"primary\";\n\n const result = await apiRequest<{\n calendars: Record<string, { busy: FreeBusySlot[] }>;\n }>(\"/freeBusy\", {\n method: \"POST\",\n body: JSON.stringify({\n timeMin: new Date(options.timeMin).toISOString(),\n timeMax: new Date(options.timeMax).toISOString(),\n items: [{ id: calendarId }],\n }),\n });\n\n return result.calendars[calendarId]?.busy ?? [];\n }\n\n async function findFreeSlots(\n options: FindFreeSlotsOptions,\n ): Promise<Array<{ start: Date; end: Date }>> {\n const busySlots = await getFreeBusy(options);\n\n const freeSlots: Array<{ start: Date; end: Date }> = [];\n const rangeStart = new Date(options.timeMin);\n const rangeEnd = new Date(options.timeMax);\n const durationMs = options.durationMinutes * 60 * 1000;\n\n let currentStart = rangeStart;\n\n const sortedBusy = busySlots\n .map((s) => ({ start: new Date(s.start), end: new Date(s.end) }))\n .sort((a, b) => a.start.getTime() - b.start.getTime());\n\n for (const busy of sortedBusy) {\n if (busy.start.getTime() - currentStart.getTime() >= durationMs) {\n freeSlots.push({ start: new Date(currentStart), end: new Date(busy.start) });\n }\n\n if (busy.end > currentStart) {\n currentStart = busy.end;\n }\n }\n\n if (rangeEnd.getTime() - currentStart.getTime() >= durationMs) {\n freeSlots.push({ start: new Date(currentStart), end: rangeEnd });\n }\n\n return freeSlots;\n }\n\n async function deleteEvent(eventId: string, calendarId = \"primary\"): Promise<void> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(\n `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`,\n {\n method: \"DELETE\",\n headers: { Authorization: `Bearer ${accessToken}` },\n },\n );\n\n if (!response.ok && response.status !== 204) {\n throw new Error(`Failed to delete event: ${response.status}`);\n }\n }\n\n return {\n listEvents,\n getTodayEvents,\n createEvent,\n getFreeBusy,\n findFreeSlots,\n deleteEvent,\n };\n}\n\nexport type CalendarClient = ReturnType<typeof createCalendarClient>;\n",
|
|
227
|
-
"app/api/auth/calendar/callback/route.ts": "/**\n * Calendar OAuth Callback\n *\n * Handles the OAuth callback from Google and stores the tokens.\n */\n\nimport { calendarConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
228
|
-
"app/api/auth/calendar/route.ts": "import { calendarConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(calendarConfig, {
|
|
227
|
+
"app/api/auth/calendar/callback/route.ts": "/**\n * Calendar OAuth Callback\n *\n * Handles the OAuth callback from Google and stores the tokens.\n */\n\nimport { calendarConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(calendarConfig, { tokenStore: hybridTokenStore });\n",
|
|
228
|
+
"app/api/auth/calendar/route.ts": "import { calendarConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(calendarConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
229
229
|
}
|
|
230
230
|
},
|
|
231
231
|
"integration:hubspot": {
|
|
@@ -237,8 +237,8 @@ export default {
|
|
|
237
237
|
"tools/get-contact.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { formatContactName, getContact } from \"../../lib/hubspot-client.ts\";\n\nexport default tool({\n id: \"get-contact\",\n description:\n \"Get detailed information about a specific contact in HubSpot CRM by their contact ID.\",\n inputSchema: z.object({\n contactId: z.string().describe(\"The HubSpot contact ID\"),\n properties: z\n .array(z.string())\n .optional()\n .describe(\n \"Additional properties to retrieve (e.g., website, city, state, notes)\",\n ),\n }),\n async execute({ contactId, properties }) {\n const contact = await getContact(contactId, properties);\n\n const additionalProperties = properties\n ? Object.fromEntries(\n properties\n .filter((prop) => contact.properties[prop] !== undefined)\n .map((prop) => [prop, contact.properties[prop]]),\n )\n : undefined;\n\n return {\n id: contact.id,\n name: formatContactName(contact),\n email: contact.properties.email,\n phone: contact.properties.phone,\n company: contact.properties.company,\n jobTitle: contact.properties.jobtitle,\n website: contact.properties.website,\n createdAt: contact.createdAt,\n updatedAt: contact.updatedAt,\n archived: contact.archived,\n additionalProperties,\n allProperties: contact.properties,\n };\n },\n});\n",
|
|
238
238
|
".env.example": "# HubSpot OAuth Configuration\n# Get these from https://app.hubspot.com/developer\n\nHUBSPOT_CLIENT_ID=your_client_id_here\nHUBSPOT_CLIENT_SECRET=your_client_secret_here\n",
|
|
239
239
|
"lib/hubspot-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst HUBSPOT_BASE_URL = \"https://api.hubapi.com\";\n\ninterface HubSpotPagination {\n after?: string;\n next?: {\n after: string;\n link: string;\n };\n}\n\ninterface HubSpotResponse<T> {\n results: T[];\n paging?: HubSpotPagination;\n}\n\ninterface HubSpotContact {\n id: string;\n properties: {\n email?: string;\n firstname?: string;\n lastname?: string;\n phone?: string;\n company?: string;\n website?: string;\n jobtitle?: string;\n createdate?: string;\n lastmodifieddate?: string;\n [key: string]: string | undefined;\n };\n createdAt: string;\n updatedAt: string;\n archived: boolean;\n}\n\ninterface HubSpotCompany {\n id: string;\n properties: {\n name?: string;\n domain?: string;\n city?: string;\n state?: string;\n country?: string;\n industry?: string;\n phone?: string;\n createdate?: string;\n [key: string]: string | undefined;\n };\n createdAt: string;\n updatedAt: string;\n archived: boolean;\n}\n\ninterface HubSpotDeal {\n id: string;\n properties: {\n dealname?: string;\n amount?: string;\n dealstage?: string;\n pipeline?: string;\n closedate?: string;\n createdate?: string;\n [key: string]: string | undefined;\n };\n createdAt: string;\n updatedAt: string;\n archived: boolean;\n}\n\nfunction buildQueryString(options: {\n limit?: number;\n after?: string;\n properties?: string[];\n defaultProperties: string[];\n}): string {\n const params = new URLSearchParams();\n\n if (options.limit) params.set(\"limit\", options.limit.toString());\n if (options.after) params.set(\"after\", options.after);\n\n const properties =\n options.properties?.length ? options.properties : options.defaultProperties;\n\n for (const prop of properties) params.append(\"properties\", prop);\n\n const queryString = params.toString();\n return queryString ? `?${queryString}` : \"\";\n}\n\nasync function hubspotFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with HubSpot. Please connect your account.\");\n }\n\n const response = await fetch(`${HUBSPOT_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({}))) as { message?: string };\n throw new Error(\n `HubSpot API error: ${response.status} ${error.message ?? response.statusText}`,\n );\n }\n\n return response.json();\n}\n\n// ============================================================================\n// CONTACTS\n// ============================================================================\n\nexport function listContacts(options?: {\n limit?: number;\n after?: string;\n properties?: string[];\n}): Promise<HubSpotResponse<HubSpotContact>> {\n const query = buildQueryString({\n limit: options?.limit,\n after: options?.after,\n properties: options?.properties,\n defaultProperties: [\"email\", \"firstname\", \"lastname\", \"phone\", \"company\", \"jobtitle\"],\n });\n\n return hubspotFetch<HubSpotResponse<HubSpotContact>>(`/crm/v3/objects/contacts${query}`);\n}\n\nexport function getContact(contactId: string, properties?: string[]): Promise<HubSpotContact> {\n const query = buildQueryString({\n properties,\n defaultProperties: [\"email\", \"firstname\", \"lastname\", \"phone\", \"company\", \"jobtitle\", \"website\"],\n });\n\n return hubspotFetch<HubSpotContact>(`/crm/v3/objects/contacts/${contactId}${query}`);\n}\n\nexport function createContact(properties: {\n email: string;\n firstname?: string;\n lastname?: string;\n phone?: string;\n company?: string;\n website?: string;\n jobtitle?: string;\n [key: string]: string | undefined;\n}): Promise<HubSpotContact> {\n return hubspotFetch<HubSpotContact>(\"/crm/v3/objects/contacts\", {\n method: \"POST\",\n body: JSON.stringify({ properties }),\n });\n}\n\nexport function updateContact(\n contactId: string,\n properties: {\n email?: string;\n firstname?: string;\n lastname?: string;\n phone?: string;\n company?: string;\n website?: string;\n jobtitle?: string;\n [key: string]: string | undefined;\n },\n): Promise<HubSpotContact> {\n return hubspotFetch<HubSpotContact>(`/crm/v3/objects/contacts/${contactId}`, {\n method: \"PATCH\",\n body: JSON.stringify({ properties }),\n });\n}\n\nexport function searchContacts(options: {\n query?: string;\n filterGroups?: Array<{\n filters: Array<{\n propertyName: string;\n operator: string;\n value: string;\n }>;\n }>;\n properties?: string[];\n limit?: number;\n after?: string;\n}): Promise<HubSpotResponse<HubSpotContact>> {\n const body: Record<string, unknown> = {\n properties: options.properties?.length\n ? options.properties\n : [\"email\", \"firstname\", \"lastname\", \"phone\", \"company\", \"jobtitle\"],\n };\n\n if (options.filterGroups) body.filterGroups = options.filterGroups;\n if (options.limit) body.limit = options.limit;\n if (options.after) body.after = options.after;\n\n return hubspotFetch<HubSpotResponse<HubSpotContact>>(\"/crm/v3/objects/contacts/search\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n}\n\n// ============================================================================\n// COMPANIES\n// ============================================================================\n\nexport function listCompanies(options?: {\n limit?: number;\n after?: string;\n properties?: string[];\n}): Promise<HubSpotResponse<HubSpotCompany>> {\n const query = buildQueryString({\n limit: options?.limit,\n after: options?.after,\n properties: options?.properties,\n defaultProperties: [\"name\", \"domain\", \"city\", \"state\", \"industry\", \"phone\"],\n });\n\n return hubspotFetch<HubSpotResponse<HubSpotCompany>>(`/crm/v3/objects/companies${query}`);\n}\n\nexport function getCompany(companyId: string, properties?: string[]): Promise<HubSpotCompany> {\n const query = buildQueryString({\n properties,\n defaultProperties: [\"name\", \"domain\", \"city\", \"state\", \"country\", \"industry\", \"phone\"],\n });\n\n return hubspotFetch<HubSpotCompany>(`/crm/v3/objects/companies/${companyId}${query}`);\n}\n\nexport function createCompany(properties: {\n name: string;\n domain?: string;\n city?: string;\n state?: string;\n country?: string;\n industry?: string;\n phone?: string;\n [key: string]: string | undefined;\n}): Promise<HubSpotCompany> {\n return hubspotFetch<HubSpotCompany>(\"/crm/v3/objects/companies\", {\n method: \"POST\",\n body: JSON.stringify({ properties }),\n });\n}\n\n// ============================================================================\n// DEALS\n// ============================================================================\n\nexport function listDeals(options?: {\n limit?: number;\n after?: string;\n properties?: string[];\n}): Promise<HubSpotResponse<HubSpotDeal>> {\n const query = buildQueryString({\n limit: options?.limit,\n after: options?.after,\n properties: options?.properties,\n defaultProperties: [\"dealname\", \"amount\", \"dealstage\", \"pipeline\", \"closedate\"],\n });\n\n return hubspotFetch<HubSpotResponse<HubSpotDeal>>(`/crm/v3/objects/deals${query}`);\n}\n\nexport function getDeal(dealId: string, properties?: string[]): Promise<HubSpotDeal> {\n const query = buildQueryString({\n properties,\n defaultProperties: [\"dealname\", \"amount\", \"dealstage\", \"pipeline\", \"closedate\"],\n });\n\n return hubspotFetch<HubSpotDeal>(`/crm/v3/objects/deals/${dealId}${query}`);\n}\n\nexport function createDeal(properties: {\n dealname: string;\n amount?: string;\n dealstage?: string;\n pipeline?: string;\n closedate?: string;\n [key: string]: string | undefined;\n}): Promise<HubSpotDeal> {\n return hubspotFetch<HubSpotDeal>(\"/crm/v3/objects/deals\", {\n method: \"POST\",\n body: JSON.stringify({ properties }),\n });\n}\n\nexport function updateDeal(\n dealId: string,\n properties: {\n dealname?: string;\n amount?: string;\n dealstage?: string;\n pipeline?: string;\n closedate?: string;\n [key: string]: string | undefined;\n },\n): Promise<HubSpotDeal> {\n return hubspotFetch<HubSpotDeal>(`/crm/v3/objects/deals/${dealId}`, {\n method: \"PATCH\",\n body: JSON.stringify({ properties }),\n });\n}\n\n// ============================================================================\n// HELPER FUNCTIONS\n// ============================================================================\n\nexport function formatContactName(contact: HubSpotContact): string {\n const parts = [contact.properties.firstname, contact.properties.lastname].filter(\n (p): p is string => Boolean(p),\n );\n\n if (parts.length) return parts.join(\" \");\n return contact.properties.email ?? \"Unnamed Contact\";\n}\n\nexport function formatCompanyName(company: HubSpotCompany): string {\n return company.properties.name ?? company.properties.domain ?? \"Unnamed Company\";\n}\n\nexport function formatDealName(deal: HubSpotDeal): string {\n return deal.properties.dealname ?? \"Unnamed Deal\";\n}\n\nexport type { HubSpotCompany, HubSpotContact, HubSpotDeal, HubSpotResponse };\n",
|
|
240
|
-
"app/api/auth/hubspot/callback/route.ts": "import { createOAuthCallbackHandler, hubspotConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
241
|
-
"app/api/auth/hubspot/route.ts": "import { createOAuthInitHandler, hubspotConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(hubspotConfig, {
|
|
240
|
+
"app/api/auth/hubspot/callback/route.ts": "import { createOAuthCallbackHandler, hubspotConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(hubspotConfig, { tokenStore: hybridTokenStore });\n",
|
|
241
|
+
"app/api/auth/hubspot/route.ts": "import { createOAuthInitHandler, hubspotConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(hubspotConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
242
242
|
}
|
|
243
243
|
},
|
|
244
244
|
"integration:shopify": {
|
|
@@ -250,8 +250,8 @@ export default {
|
|
|
250
250
|
"tools/get-product.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { getProduct } from \"../../lib/shopify-client.ts\";\n\nexport default tool({\n id: \"get-product\",\n description: \"Get details of a specific Shopify product by its ID.\",\n inputSchema: z.object({\n productId: z.union([z.number(), z.string()]).describe(\"The ID of the product to retrieve\"),\n }),\n async execute({ productId }) {\n const product = await getProduct(productId);\n\n return {\n id: product.id,\n title: product.title,\n bodyHtml: product.body_html,\n vendor: product.vendor,\n productType: product.product_type,\n status: product.status,\n tags: product.tags,\n createdAt: product.created_at,\n updatedAt: product.updated_at,\n publishedAt: product.published_at,\n variants: product.variants.map((variant) => ({\n id: variant.id,\n title: variant.title,\n price: variant.price,\n sku: variant.sku,\n inventoryQuantity: variant.inventory_quantity,\n })),\n images: product.images.map((image) => ({\n id: image.id,\n src: image.src,\n alt: image.alt,\n })),\n };\n },\n});\n",
|
|
251
251
|
".env.example": "# Shopify OAuth Configuration\n# Get your credentials from https://partners.shopify.com\nSHOPIFY_CLIENT_ID=your-client-id\nSHOPIFY_CLIENT_SECRET=your-client-secret\nSHOPIFY_SHOP_DOMAIN=mystore.myshopify.com\n",
|
|
252
252
|
"lib/shopify-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst SHOPIFY_SHOP_DOMAIN = process.env.SHOPIFY_SHOP_DOMAIN ?? \"shop.myshopify.com\";\nconst SHOPIFY_API_VERSION = \"2024-01\";\nconst SHOPIFY_BASE_URL = `https://${SHOPIFY_SHOP_DOMAIN}/admin/api/${SHOPIFY_API_VERSION}`;\n\ninterface ShopifyProduct {\n id: number;\n title: string;\n body_html: string;\n vendor: string;\n product_type: string;\n created_at: string;\n updated_at: string;\n published_at: string | null;\n status: string;\n tags: string;\n variants: Array<{\n id: number;\n title: string;\n price: string;\n sku: string;\n inventory_quantity: number;\n }>;\n images: Array<{\n id: number;\n src: string;\n alt: string | null;\n }>;\n}\n\ninterface ShopifyOrder {\n id: number;\n order_number: number;\n email: string;\n created_at: string;\n updated_at: string;\n total_price: string;\n subtotal_price: string;\n total_tax: string;\n currency: string;\n financial_status: string;\n fulfillment_status: string | null;\n customer: {\n id: number;\n email: string;\n first_name: string;\n last_name: string;\n } | null;\n line_items: Array<{\n id: number;\n title: string;\n quantity: number;\n price: string;\n sku: string;\n variant_title: string;\n }>;\n shipping_address: {\n address1: string;\n city: string;\n province: string;\n country: string;\n zip: string;\n } | null;\n}\n\ninterface ShopifyCustomer {\n id: number;\n email: string;\n first_name: string;\n last_name: string;\n phone: string | null;\n created_at: string;\n updated_at: string;\n orders_count: number;\n total_spent: string;\n tags: string;\n state: string;\n verified_email: boolean;\n addresses: Array<{\n id: number;\n address1: string;\n city: string;\n province: string;\n country: string;\n zip: string;\n default: boolean;\n }>;\n}\n\nfunction buildQuery(params: URLSearchParams): string {\n const query = params.toString();\n return query ? `?${query}` : \"\";\n}\n\nasync function shopifyFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Shopify. Please connect your account.\");\n }\n\n const response = await fetch(`${SHOPIFY_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n \"X-Shopify-Access-Token\": token,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n let errors: string | undefined;\n try {\n const body = (await response.json()) as { errors?: string };\n errors = body.errors;\n } catch {\n // ignore JSON parse errors\n }\n\n throw new Error(`Shopify API error: ${response.status} ${errors ?? response.statusText}`);\n }\n\n return response.json();\n}\n\nexport async function listProducts(options?: {\n limit?: number;\n status?: \"active\" | \"archived\" | \"draft\";\n productType?: string;\n}): Promise<ShopifyProduct[]> {\n const params = new URLSearchParams();\n if (options?.limit) params.set(\"limit\", options.limit.toString());\n if (options?.status) params.set(\"status\", options.status);\n if (options?.productType) params.set(\"product_type\", options.productType);\n\n const { products } = await shopifyFetch<{ products: ShopifyProduct[] }>(\n `/products.json${buildQuery(params)}`,\n );\n return products;\n}\n\nexport async function getProduct(productId: number | string): Promise<ShopifyProduct> {\n const { product } = await shopifyFetch<{ product: ShopifyProduct }>(`/products/${productId}.json`);\n return product;\n}\n\nexport async function listOrders(options?: {\n limit?: number;\n status?: \"open\" | \"closed\" | \"cancelled\" | \"any\";\n financialStatus?: \"pending\" | \"authorized\" | \"paid\" | \"refunded\" | \"voided\";\n fulfillmentStatus?: \"shipped\" | \"partial\" | \"unshipped\" | \"any\" | \"unfulfilled\";\n}): Promise<ShopifyOrder[]> {\n const params = new URLSearchParams();\n if (options?.limit) params.set(\"limit\", options.limit.toString());\n if (options?.status) params.set(\"status\", options.status);\n if (options?.financialStatus) params.set(\"financial_status\", options.financialStatus);\n if (options?.fulfillmentStatus) params.set(\"fulfillment_status\", options.fulfillmentStatus);\n\n const { orders } = await shopifyFetch<{ orders: ShopifyOrder[] }>(\n `/orders.json${buildQuery(params)}`,\n );\n return orders;\n}\n\nexport async function getOrder(orderId: number | string): Promise<ShopifyOrder> {\n const { order } = await shopifyFetch<{ order: ShopifyOrder }>(`/orders/${orderId}.json`);\n return order;\n}\n\nexport async function listCustomers(options?: {\n limit?: number;\n query?: string;\n}): Promise<ShopifyCustomer[]> {\n const params = new URLSearchParams();\n if (options?.limit) params.set(\"limit\", options.limit.toString());\n if (options?.query) params.set(\"query\", options.query);\n\n const { customers } = await shopifyFetch<{ customers: ShopifyCustomer[] }>(\n `/customers.json${buildQuery(params)}`,\n );\n return customers;\n}\n\nexport async function getCustomer(customerId: number | string): Promise<ShopifyCustomer> {\n const { customer } = await shopifyFetch<{ customer: ShopifyCustomer }>(\n `/customers/${customerId}.json`,\n );\n return customer;\n}\n\nexport async function getShopInfo(): Promise<{\n id: number;\n name: string;\n email: string;\n domain: string;\n currency: string;\n timezone: string;\n}> {\n const { shop } = await shopifyFetch<{\n shop: {\n id: number;\n name: string;\n email: string;\n domain: string;\n currency: string;\n timezone: string;\n };\n }>(\"/shop.json\");\n\n return shop;\n}\n",
|
|
253
|
-
"app/api/auth/shopify/callback/route.ts": "import { createOAuthCallbackHandler, shopifyConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
254
|
-
"app/api/auth/shopify/route.ts": "import { createOAuthInitHandler, shopifyConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(shopifyConfig, {
|
|
253
|
+
"app/api/auth/shopify/callback/route.ts": "import { createOAuthCallbackHandler, shopifyConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(shopifyConfig, { tokenStore: hybridTokenStore });\n",
|
|
254
|
+
"app/api/auth/shopify/route.ts": "import { createOAuthInitHandler, shopifyConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(shopifyConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
255
255
|
}
|
|
256
256
|
},
|
|
257
257
|
"integration:aws": {
|
|
@@ -273,8 +273,8 @@ export default {
|
|
|
273
273
|
"tools/search-notion.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { getPageTitle, searchNotion } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"search-notion\",\n description:\n \"Search for pages and databases in the connected Notion workspace. Returns matching pages with their titles and IDs.\",\n inputSchema: z.object({\n query: z.string().describe(\"Search query to find pages or databases\"),\n type: z\n .enum([\"page\", \"database\", \"all\"])\n .default(\"all\")\n .describe(\"Type of objects to search for\"),\n limit: z\n .number()\n .min(1)\n .max(20)\n .default(10)\n .describe(\"Maximum number of results to return\"),\n }),\n async execute({ query, type, limit }) {\n const filter = type === \"all\" ? undefined : { property: \"object\", value: type };\n const results = await searchNotion(query, { filter, pageSize: limit });\n\n return results.map((item) => {\n if (item.object === \"page\") {\n return {\n id: item.id,\n type: \"page\",\n title: getPageTitle(item),\n url: item.url,\n lastEdited: item.last_edited_time,\n };\n }\n\n return {\n id: item.id,\n type: \"database\",\n title: item.title?.map((t) => t.plain_text).join(\"\") ?? \"\",\n url: item.url,\n };\n });\n },\n});\n",
|
|
274
274
|
".env.example": "# Notion Integration\n# Create an integration at https://www.notion.so/my-integrations\n# Make sure to enable \"Public Integration\" for OAuth\n\nNOTION_CLIENT_ID=your_client_id_here\nNOTION_CLIENT_SECRET=your_client_secret_here\n",
|
|
275
275
|
"lib/notion-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst NOTION_API_VERSION = \"2022-06-28\";\nconst NOTION_BASE_URL = \"https://api.notion.com/v1\";\n\ninterface NotionResponse<T> {\n object: string;\n results?: T[];\n next_cursor?: string | null;\n has_more?: boolean;\n}\n\ninterface NotionPage {\n id: string;\n object: \"page\";\n created_time: string;\n last_edited_time: string;\n parent: { type: string; database_id?: string; page_id?: string };\n properties: Record<string, NotionProperty>;\n url: string;\n}\n\ninterface NotionDatabase {\n id: string;\n object: \"database\";\n title: Array<{ plain_text: string }>;\n properties: Record<string, { type: string }>;\n}\n\ninterface NotionBlock {\n id: string;\n type: string;\n [key: string]: unknown;\n}\n\ninterface NotionProperty {\n type: string;\n title?: Array<{ plain_text: string }>;\n rich_text?: Array<{ plain_text: string }>;\n [key: string]: unknown;\n}\n\nasync function notionFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Notion. Please connect your account.\");\n }\n\n const response = await fetch(`${NOTION_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Notion-Version\": NOTION_API_VERSION,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({} as { message?: string }))) as {\n message?: string;\n };\n throw new Error(\n `Notion API error: ${response.status} ${error.message ?? response.statusText}`,\n );\n }\n\n return response.json();\n}\n\nexport async function searchNotion(\n query: string,\n options?: {\n filter?: { property: \"object\"; value: \"page\" | \"database\" };\n pageSize?: number;\n },\n): Promise<Array<NotionPage | NotionDatabase>> {\n const body: Record<string, unknown> = {\n query,\n ...(options?.filter ? { filter: options.filter } : {}),\n ...(options?.pageSize ? { page_size: options.pageSize } : {}),\n };\n\n const response = await notionFetch<NotionResponse<NotionPage | NotionDatabase>>(\"/search\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n\n return response.results ?? [];\n}\n\nexport function getPage(pageId: string): Promise<NotionPage> {\n return notionFetch<NotionPage>(`/pages/${pageId}`);\n}\n\nexport async function getPageContent(pageId: string): Promise<NotionBlock[]> {\n const response = await notionFetch<NotionResponse<NotionBlock>>(`/blocks/${pageId}/children`);\n return response.results ?? [];\n}\n\nexport async function queryDatabase(\n databaseId: string,\n options?: {\n filter?: Record<string, unknown>;\n sorts?: Array<{ property: string; direction: \"ascending\" | \"descending\" }>;\n pageSize?: number;\n },\n): Promise<NotionPage[]> {\n const body: Record<string, unknown> = {\n ...(options?.filter ? { filter: options.filter } : {}),\n ...(options?.sorts ? { sorts: options.sorts } : {}),\n ...(options?.pageSize ? { page_size: options.pageSize } : {}),\n };\n\n const response = await notionFetch<NotionResponse<NotionPage>>(\n `/databases/${databaseId}/query`,\n { method: \"POST\", body: JSON.stringify(body) },\n );\n\n return response.results ?? [];\n}\n\nexport function createPage(options: {\n parentId: string;\n parentType: \"database\" | \"page\";\n title: string;\n content?: string;\n properties?: Record<string, unknown>;\n}): Promise<NotionPage> {\n const parent =\n options.parentType === \"database\"\n ? { database_id: options.parentId }\n : { page_id: options.parentId };\n\n const properties: Record<string, unknown> = options.properties ?? {};\n\n if (options.parentType === \"database\") {\n properties.title ??= { title: [{ text: { content: options.title } }] };\n }\n\n const children: Array<Record<string, unknown>> = [];\n\n if (options.parentType === \"page\") {\n children.push({\n object: \"block\",\n type: \"heading_1\",\n heading_1: {\n rich_text: [{ type: \"text\", text: { content: options.title } }],\n },\n });\n }\n\n for (const paragraph of options.content?.split(\"\\n\\n\") ?? []) {\n const trimmed = paragraph.trim();\n if (!trimmed) continue;\n\n children.push({\n object: \"block\",\n type: \"paragraph\",\n paragraph: {\n rich_text: [{ type: \"text\", text: { content: trimmed } }],\n },\n });\n }\n\n return notionFetch<NotionPage>(\"/pages\", {\n method: \"POST\",\n body: JSON.stringify({\n parent,\n properties,\n children: children.length ? children : undefined,\n }),\n });\n}\n\nexport function extractPlainText(blocks: NotionBlock[]): string {\n const texts: string[] = [];\n\n for (const block of blocks) {\n const content = block[block.type] as { rich_text?: Array<{ plain_text: string }> } | undefined;\n const text = content?.rich_text?.map((t) => t.plain_text).join(\"\");\n if (text) texts.push(text);\n }\n\n return texts.join(\"\\n\\n\");\n}\n\nexport function getPageTitle(page: NotionPage): string {\n for (const prop of Object.values(page.properties)) {\n if (prop.type === \"title\" && prop.title) {\n return prop.title.map((t) => t.plain_text).join(\"\");\n }\n }\n\n return \"Untitled\";\n}\n",
|
|
276
|
-
"app/api/auth/notion/callback/route.ts": "import { createOAuthCallbackHandler, notionConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
277
|
-
"app/api/auth/notion/route.ts": "import { createOAuthInitHandler, notionConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(notionConfig, {
|
|
276
|
+
"app/api/auth/notion/callback/route.ts": "import { createOAuthCallbackHandler, notionConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(notionConfig, { tokenStore: hybridTokenStore });\n",
|
|
277
|
+
"app/api/auth/notion/route.ts": "import { createOAuthInitHandler, notionConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(notionConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
278
278
|
}
|
|
279
279
|
},
|
|
280
280
|
"integration:github": {
|
|
@@ -284,8 +284,8 @@ export default {
|
|
|
284
284
|
"tools/list-repos.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\n\ntype GitHubRepo = {\n name: string;\n full_name: string;\n description: string | null;\n private: boolean;\n html_url: string;\n default_branch: string;\n language: string | null;\n stargazers_count: number;\n forks_count: number;\n open_issues_count: number;\n updated_at: string;\n};\n\nexport default tool({\n id: \"list-repos\",\n description: \"List GitHub repositories for the authenticated user\",\n inputSchema: z.object({\n type: z\n .enum([\"all\", \"owner\", \"public\", \"private\", \"member\"])\n .default(\"all\")\n .describe(\"Type of repositories to list\"),\n sort: z\n .enum([\"created\", \"updated\", \"pushed\", \"full_name\"])\n .default(\"updated\")\n .describe(\"How to sort the repositories\"),\n limit: z\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of repositories to return\"),\n }),\n execute: async ({ type, sort, limit }, context) => {\n // Default to \"current-user\" for development; in production, always pass userId from session\n const userId = context?.userId ?? \"current-user\";\n\n try {\n const github = createGitHubClient(userId);\n const repos = await github.listRepos({ type, sort, perPage: limit });\n\n return {\n repositories: repos.map((repo: GitHubRepo) => ({\n name: repo.name,\n fullName: repo.full_name,\n description: repo.description ?? null,\n isPrivate: repo.private,\n url: repo.html_url,\n defaultBranch: repo.default_branch,\n language: repo.language,\n stars: repo.stargazers_count,\n forks: repo.forks_count,\n openIssues: repo.open_issues_count,\n updatedAt: repo.updated_at,\n })),\n count: repos.length,\n message: `Found ${repos.length} repository(s).`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
285
285
|
"tools/list-prs.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\n\ntype PullRequest = {\n number: number;\n title: string;\n state: string;\n draft: boolean;\n html_url: string;\n user: { login: string };\n created_at: string;\n updated_at: string;\n head: { ref: string };\n base: { ref: string };\n additions: number;\n deletions: number;\n changed_files: number;\n labels: Array<{ name: string }>;\n};\n\nexport default tool({\n id: \"list-prs\",\n description: \"List pull requests for a GitHub repository\",\n inputSchema: z.object({\n repo: z\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n state: z\n .enum([\"open\", \"closed\", \"all\"])\n .default(\"open\")\n .describe(\"State of pull requests to list\"),\n limit: z\n .number()\n .min(1)\n .max(100)\n .default(10)\n .describe(\"Maximum number of pull requests to return\"),\n }),\n execute: async ({ repo, state, limit }, context) => {\n const userId = context?.userId ?? \"current-user\";\n const [owner, repoName] = repo.split(\"/\");\n\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const prs = await github.listPullRequests(owner, repoName, {\n state,\n perPage: limit,\n });\n\n return {\n pullRequests: prs.map((pr: PullRequest) => ({\n number: pr.number,\n title: pr.title,\n state: pr.state,\n isDraft: pr.draft,\n url: pr.html_url,\n author: pr.user.login,\n createdAt: pr.created_at,\n updatedAt: pr.updated_at,\n sourceBranch: pr.head.ref,\n targetBranch: pr.base.ref,\n additions: pr.additions,\n deletions: pr.deletions,\n changedFiles: pr.changed_files,\n labels: pr.labels.map(({ name }) => name),\n })),\n count: prs.length,\n repository: repo,\n message: `Found ${prs.length} ${state} pull request(s) in ${repo}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
286
286
|
"lib/github-client.ts": "/**\n * GitHub API Client\n *\n * Provides a type-safe interface to GitHub API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n\n return undefined;\n}\n\nconst GITHUB_API_BASE = \"https://api.github.com\";\n\nexport interface GitHubRepo {\n id: number;\n name: string;\n full_name: string;\n description: string | null;\n private: boolean;\n html_url: string;\n default_branch: string;\n language: string | null;\n stargazers_count: number;\n forks_count: number;\n open_issues_count: number;\n updated_at: string;\n}\n\nexport interface GitHubPullRequest {\n id: number;\n number: number;\n title: string;\n body: string | null;\n state: \"open\" | \"closed\";\n html_url: string;\n user: { login: string; avatar_url: string };\n created_at: string;\n updated_at: string;\n head: { ref: string; sha: string };\n base: { ref: string };\n mergeable: boolean | null;\n additions: number;\n deletions: number;\n changed_files: number;\n draft: boolean;\n labels: Array<{ name: string; color: string }>;\n}\n\nexport interface GitHubIssue {\n id: number;\n number: number;\n title: string;\n body: string | null;\n state: \"open\" | \"closed\";\n html_url: string;\n user: { login: string };\n created_at: string;\n updated_at: string;\n labels: Array<{ name: string; color: string }>;\n assignees: Array<{ login: string }>;\n}\n\nexport interface GitHubCommit {\n sha: string;\n commit: {\n message: string;\n author: { name: string; date: string };\n };\n html_url: string;\n author: { login: string; avatar_url: string } | null;\n}\n\n/**\n * GitHub OAuth provider configuration\n */\nexport const githubOAuthProvider = {\n name: \"github\",\n authorizationUrl: \"https://github.com/login/oauth/authorize\",\n tokenUrl: \"https://github.com/login/oauth/access_token\",\n clientId: getEnv(\"GITHUB_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GITHUB_CLIENT_SECRET\") ?? \"\",\n scopes: [\"repo\", \"read:user\", \"read:org\"],\n callbackPath: \"/api/auth/github/callback\",\n};\n\nexport function createGitHubClient(userId: string): {\n listRepos(options?: {\n sort?: \"created\" | \"updated\" | \"pushed\" | \"full_name\";\n perPage?: number;\n type?: \"all\" | \"owner\" | \"public\" | \"private\" | \"member\";\n }): Promise<GitHubRepo[]>;\n listPullRequests(\n owner: string,\n repo: string,\n options?: { state?: \"open\" | \"closed\" | \"all\"; perPage?: number },\n ): Promise<GitHubPullRequest[]>;\n getPullRequest(owner: string, repo: string, pullNumber: number): Promise<GitHubPullRequest>;\n getPullRequestDiff(owner: string, repo: string, pullNumber: number): Promise<string>;\n createIssue(\n owner: string,\n repo: string,\n options: { title: string; body?: string; labels?: string[]; assignees?: string[] },\n ): Promise<GitHubIssue>;\n listIssues(\n owner: string,\n repo: string,\n options?: { state?: \"open\" | \"closed\" | \"all\"; perPage?: number },\n ): Promise<GitHubIssue[]>;\n listCommits(\n owner: string,\n repo: string,\n options?: { sha?: string; perPage?: number },\n ): Promise<GitHubCommit[]>;\n getUser(): Promise<{ login: string; name: string; email: string }>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(githubOAuthProvider, userId, \"github\");\n if (!token) throw new Error(\"GitHub not connected. Please connect your GitHub account first.\");\n return token;\n }\n\n async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n Accept: \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`GitHub API error: ${response.status} - ${error}`);\n }\n\n return response.json() as Promise<T>;\n }\n\n async function apiTextRequest(endpoint: string, accept: string): Promise<string> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {\n headers: {\n Authorization: `Bearer ${accessToken}`,\n Accept: accept,\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n },\n });\n\n if (!response.ok) throw new Error(`GitHub API error: ${response.status}`);\n\n return response.text();\n }\n\n function toQueryString(params: URLSearchParams): string {\n const query = params.toString();\n return query ? `?${query}` : \"\";\n }\n\n return {\n listRepos(options = {}): Promise<GitHubRepo[]> {\n const params = new URLSearchParams();\n if (options.sort) params.set(\"sort\", options.sort);\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n if (options.type) params.set(\"type\", options.type);\n\n return apiRequest<GitHubRepo[]>(`/user/repos${toQueryString(params)}`);\n },\n\n listPullRequests(owner, repo, options = {}): Promise<GitHubPullRequest[]> {\n const params = new URLSearchParams();\n params.set(\"state\", options.state ?? \"open\");\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return apiRequest<GitHubPullRequest[]>(\n `/repos/${owner}/${repo}/pulls${toQueryString(params)}`,\n );\n },\n\n getPullRequest(owner, repo, pullNumber): Promise<GitHubPullRequest> {\n return apiRequest<GitHubPullRequest>(`/repos/${owner}/${repo}/pulls/${pullNumber}`);\n },\n\n getPullRequestDiff(owner, repo, pullNumber): Promise<string> {\n return apiTextRequest(\n `/repos/${owner}/${repo}/pulls/${pullNumber}`,\n \"application/vnd.github.diff\",\n );\n },\n\n createIssue(owner, repo, options): Promise<GitHubIssue> {\n return apiRequest<GitHubIssue>(`/repos/${owner}/${repo}/issues`, {\n method: \"POST\",\n body: JSON.stringify(options),\n });\n },\n\n listIssues(owner, repo, options = {}): Promise<GitHubIssue[]> {\n const params = new URLSearchParams();\n params.set(\"state\", options.state ?? \"open\");\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return apiRequest<GitHubIssue[]>(`/repos/${owner}/${repo}/issues${toQueryString(params)}`);\n },\n\n listCommits(owner, repo, options = {}): Promise<GitHubCommit[]> {\n const params = new URLSearchParams();\n if (options.sha) params.set(\"sha\", options.sha);\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return apiRequest<GitHubCommit[]>(`/repos/${owner}/${repo}/commits${toQueryString(params)}`);\n },\n\n getUser(): Promise<{ login: string; name: string; email: string }> {\n return apiRequest(\"/user\");\n },\n };\n}\n\nexport type GitHubClient = ReturnType<typeof createGitHubClient>;\n",
|
|
287
|
-
"app/api/auth/github/callback/route.ts": "import { createOAuthCallbackHandler, githubConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
288
|
-
"app/api/auth/github/route.ts": "import { createOAuthInitHandler, githubConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(githubConfig, {
|
|
287
|
+
"app/api/auth/github/callback/route.ts": "import { createOAuthCallbackHandler, githubConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n async getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(githubConfig, { tokenStore: hybridTokenStore });\n",
|
|
288
|
+
"app/api/auth/github/route.ts": "import { createOAuthInitHandler, githubConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(githubConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
289
289
|
}
|
|
290
290
|
},
|
|
291
291
|
"integration:xero": {
|
|
@@ -297,8 +297,8 @@ export default {
|
|
|
297
297
|
"tools/get-contact.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { getContact } from \"../../lib/xero-client.ts\";\n\nexport default tool({\n id: \"get-contact\",\n description: \"Get details of a specific Xero contact by its ID.\",\n inputSchema: z.object({\n contactId: z.string().describe(\"The ID of the contact to retrieve\"),\n }),\n async execute({ contactId }) {\n const contact = await getContact(contactId);\n\n return {\n contactId: contact.ContactID,\n contactNumber: contact.ContactNumber,\n name: contact.Name,\n firstName: contact.FirstName,\n lastName: contact.LastName,\n emailAddress: contact.EmailAddress,\n isCustomer: contact.IsCustomer,\n isSupplier: contact.IsSupplier,\n addresses: contact.Addresses?.map((addr) => ({\n addressType: addr.AddressType,\n city: addr.City,\n region: addr.Region,\n postalCode: addr.PostalCode,\n country: addr.Country,\n attentionTo: addr.AttentionTo,\n })),\n phones: contact.Phones?.map((phone) => ({\n phoneType: phone.PhoneType,\n phoneNumber: phone.PhoneNumber,\n phoneAreaCode: phone.PhoneAreaCode,\n phoneCountryCode: phone.PhoneCountryCode,\n })),\n updatedDateUTC: contact.UpdatedDateUTC,\n };\n },\n});\n",
|
|
298
298
|
".env.example": "# Xero OAuth Configuration\n# Get your credentials from https://developer.xero.com/app/manage\nXERO_CLIENT_ID=your-client-id\nXERO_CLIENT_SECRET=your-client-secret\n",
|
|
299
299
|
"lib/xero-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst XERO_BASE_URL = \"https://api.xero.com/api.xro/2.0\";\n\ninterface XeroResponse<T> {\n Id: string;\n Status: string;\n ProviderName: string;\n DateTimeUTC: string;\n [key: string]: T | string;\n}\n\ninterface XeroInvoice {\n InvoiceID: string;\n InvoiceNumber: string;\n Type: \"ACCREC\" | \"ACCPAY\";\n Status: string;\n LineAmountTypes: string;\n Contact: {\n ContactID: string;\n Name: string;\n };\n LineItems: Array<{\n LineItemID?: string;\n Description: string;\n Quantity: number;\n UnitAmount: number;\n AccountCode?: string;\n TaxType?: string;\n LineAmount: number;\n }>;\n Date: string;\n DueDate: string;\n SubTotal: number;\n TotalTax: number;\n Total: number;\n AmountDue: number;\n AmountPaid: number;\n CurrencyCode: string;\n Reference?: string;\n UpdatedDateUTC: string;\n}\n\ninterface XeroContact {\n ContactID: string;\n ContactNumber?: string;\n Name: string;\n FirstName?: string;\n LastName?: string;\n EmailAddress?: string;\n Addresses?: Array<{\n AddressType: string;\n City?: string;\n Region?: string;\n PostalCode?: string;\n Country?: string;\n AttentionTo?: string;\n }>;\n Phones?: Array<{\n PhoneType: string;\n PhoneNumber?: string;\n PhoneAreaCode?: string;\n PhoneCountryCode?: string;\n }>;\n UpdatedDateUTC: string;\n IsSupplier: boolean;\n IsCustomer: boolean;\n}\n\ninterface XeroTenant {\n tenantId: string;\n tenantType: string;\n tenantName: string;\n}\n\nasync function requireAccessToken(): Promise<string> {\n const token = await getAccessToken();\n if (!token) throw new Error(\"Not authenticated with Xero. Please connect your account.\");\n return token;\n}\n\nasync function getTenantId(token: string): Promise<string> {\n const response = await fetch(\"https://api.xero.com/connections\", {\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n });\n\n if (!response.ok) throw new Error(`Failed to get Xero tenants: ${response.status}`);\n\n const tenants: XeroTenant[] = await response.json();\n const tenantId = tenants[0]?.tenantId;\n\n if (!tenantId) {\n throw new Error(\"No Xero organizations found. Please connect to a Xero organization.\");\n }\n\n return tenantId;\n}\n\nfunction getCollection<T extends Record<string, unknown>, K extends keyof T>(\n response: XeroResponse<T>,\n key: K,\n): T[K] {\n return response[key] as T[K];\n}\n\nasync function xeroFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await requireAccessToken();\n const tenantId = await getTenantId(token);\n\n const response = await fetch(`${XERO_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"xero-tenant-id\": tenantId,\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n ...(options.headers ?? {}),\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({}))) as {\n Message?: string;\n Detail?: string;\n };\n\n const message = error.Message ?? error.Detail ?? response.statusText;\n throw new Error(`Xero API error: ${response.status} ${message}`);\n }\n\n return response.json();\n}\n\nexport async function listInvoices(options?: {\n status?: \"DRAFT\" | \"SUBMITTED\" | \"AUTHORISED\" | \"PAID\" | \"VOIDED\";\n type?: \"ACCREC\" | \"ACCPAY\";\n contactId?: string;\n limit?: number;\n}): Promise<XeroInvoice[]> {\n const params = new URLSearchParams();\n const where: string[] = [];\n\n if (options?.status) where.push(`Status == \"${options.status}\"`);\n if (options?.type) where.push(`Type == \"${options.type}\"`);\n if (options?.contactId) where.push(`Contact.ContactID == Guid(\"${options.contactId}\")`);\n\n if (where.length) params.set(\"where\", where.join(\" AND \"));\n if (options?.limit) params.set(\"page\", \"1\");\n\n const queryString = params.toString();\n const endpoint = `/Invoices${queryString ? `?${queryString}` : \"\"}`;\n\n const response = await xeroFetch<XeroResponse<{ Invoices: XeroInvoice[] }>>(endpoint);\n const invoices = (getCollection(response, \"Invoices\") as XeroInvoice[] | undefined) ?? [];\n\n return options?.limit ? invoices.slice(0, options.limit) : invoices;\n}\n\nexport async function getInvoice(invoiceId: string): Promise<XeroInvoice> {\n const response = await xeroFetch<XeroResponse<{ Invoices: XeroInvoice[] }>>(\n `/Invoices/${invoiceId}`,\n );\n\n const invoices = getCollection(response, \"Invoices\") as XeroInvoice[] | undefined;\n const invoice = invoices?.[0];\n\n if (!invoice) throw new Error(`Invoice not found: ${invoiceId}`);\n return invoice;\n}\n\nexport async function createInvoice(options: {\n contactId: string;\n type: \"ACCREC\" | \"ACCPAY\";\n date: string;\n dueDate: string;\n lineItems: Array<{\n description: string;\n quantity: number;\n unitAmount: number;\n accountCode?: string;\n taxType?: string;\n }>;\n reference?: string;\n status?: \"DRAFT\" | \"SUBMITTED\" | \"AUTHORISED\";\n}): Promise<XeroInvoice> {\n const invoice = {\n Type: options.type,\n Contact: { ContactID: options.contactId },\n Date: options.date,\n DueDate: options.dueDate,\n LineItems: options.lineItems.map((item) => ({\n Description: item.description,\n Quantity: item.quantity,\n UnitAmount: item.unitAmount,\n AccountCode: item.accountCode,\n TaxType: item.taxType ?? \"NONE\",\n })),\n Reference: options.reference,\n Status: options.status ?? \"DRAFT\",\n LineAmountTypes: \"Exclusive\",\n };\n\n const response = await xeroFetch<XeroResponse<{ Invoices: XeroInvoice[] }>>(\"/Invoices\", {\n method: \"POST\",\n body: JSON.stringify({ Invoices: [invoice] }),\n });\n\n const invoices = getCollection(response, \"Invoices\") as XeroInvoice[] | undefined;\n const created = invoices?.[0];\n\n if (!created) throw new Error(\"Failed to create invoice\");\n return created;\n}\n\nexport async function listContacts(options?: {\n isCustomer?: boolean;\n isSupplier?: boolean;\n limit?: number;\n}): Promise<XeroContact[]> {\n const params = new URLSearchParams();\n const where: string[] = [];\n\n if (options?.isCustomer !== undefined) where.push(`IsCustomer == ${options.isCustomer}`);\n if (options?.isSupplier !== undefined) where.push(`IsSupplier == ${options.isSupplier}`);\n\n if (where.length) params.set(\"where\", where.join(\" AND \"));\n\n const queryString = params.toString();\n const endpoint = `/Contacts${queryString ? `?${queryString}` : \"\"}`;\n\n const response = await xeroFetch<XeroResponse<{ Contacts: XeroContact[] }>>(endpoint);\n const contacts = (getCollection(response, \"Contacts\") as XeroContact[] | undefined) ?? [];\n\n return options?.limit ? contacts.slice(0, options.limit) : contacts;\n}\n\nexport async function getContact(contactId: string): Promise<XeroContact> {\n const response = await xeroFetch<XeroResponse<{ Contacts: XeroContact[] }>>(\n `/Contacts/${contactId}`,\n );\n\n const contacts = getCollection(response, \"Contacts\") as XeroContact[] | undefined;\n const contact = contacts?.[0];\n\n if (!contact) throw new Error(`Contact not found: ${contactId}`);\n return contact;\n}\n\nexport async function getCurrentUser(): Promise<{\n userId: string;\n userName: string;\n email: string;\n}> {\n const token = await requireAccessToken();\n\n const response = await fetch(`${XERO_BASE_URL}/Organisation`, {\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n });\n\n if (!response.ok) throw new Error(`Failed to get user info: ${response.status}`);\n\n const data: { Organisations: Array<{ Name: string }> } = await response.json();\n\n return {\n userId: \"current-user\",\n userName: data.Organisations[0]?.Name ?? \"Xero User\",\n email: \"user@xero.com\",\n };\n}\n",
|
|
300
|
-
"app/api/auth/xero/callback/route.ts": "import { createOAuthCallbackHandler, xeroConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
301
|
-
"app/api/auth/xero/route.ts": "import { createOAuthInitHandler, xeroConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(xeroConfig, {
|
|
300
|
+
"app/api/auth/xero/callback/route.ts": "import { createOAuthCallbackHandler, xeroConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(xeroConfig, { tokenStore: hybridTokenStore });\n",
|
|
301
|
+
"app/api/auth/xero/route.ts": "import { createOAuthInitHandler, xeroConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(xeroConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
302
302
|
}
|
|
303
303
|
},
|
|
304
304
|
"integration:stripe": {
|
|
@@ -323,8 +323,8 @@ export default {
|
|
|
323
323
|
".env.example": "# Figma OAuth Integration\n# Get these credentials from https://www.figma.com/developers/apps\n\nFIGMA_CLIENT_ID=your_figma_client_id\nFIGMA_CLIENT_SECRET=your_figma_client_secret\n",
|
|
324
324
|
"lib/types.ts": "export type NodeType =\n | \"DOCUMENT\"\n | \"CANVAS\"\n | \"FRAME\"\n | \"GROUP\"\n | \"VECTOR\"\n | \"BOOLEAN_OPERATION\"\n | \"STAR\"\n | \"LINE\"\n | \"ELLIPSE\"\n | \"REGULAR_POLYGON\"\n | \"RECTANGLE\"\n | \"TEXT\"\n | \"SLICE\"\n | \"COMPONENT\"\n | \"COMPONENT_SET\"\n | \"INSTANCE\";\n\nexport type BlendMode =\n | \"NORMAL\"\n | \"DARKEN\"\n | \"MULTIPLY\"\n | \"LINEAR_BURN\"\n | \"COLOR_BURN\"\n | \"LIGHTEN\"\n | \"SCREEN\"\n | \"LINEAR_DODGE\"\n | \"COLOR_DODGE\"\n | \"OVERLAY\"\n | \"SOFT_LIGHT\"\n | \"HARD_LIGHT\"\n | \"DIFFERENCE\"\n | \"EXCLUSION\"\n | \"HUE\"\n | \"SATURATION\"\n | \"COLOR\"\n | \"LUMINOSITY\";\n\nexport type EasingType = \"EASE_IN\" | \"EASE_OUT\" | \"EASE_IN_AND_OUT\" | \"LINEAR\";\n\nexport interface Vector2D {\n x: number;\n y: number;\n}\n\nexport interface Rectangle {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\nexport interface Transform {\n /** 2D transformation matrix [[a, b, tx], [c, d, ty]] */\n matrix: [[number, number, number], [number, number, number]];\n}\n\nexport type PaintType =\n | \"SOLID\"\n | \"GRADIENT_LINEAR\"\n | \"GRADIENT_RADIAL\"\n | \"GRADIENT_ANGULAR\"\n | \"GRADIENT_DIAMOND\"\n | \"IMAGE\"\n | \"EMOJI\";\n\nexport interface Color {\n r: number;\n g: number;\n b: number;\n a: number;\n}\n\nexport interface ColorStop {\n position: number;\n color: Color;\n}\n\nexport interface Paint {\n type: PaintType;\n visible?: boolean;\n opacity?: number;\n color?: Color;\n blendMode?: BlendMode;\n gradientHandlePositions?: Vector2D[];\n gradientStops?: ColorStop[];\n scaleMode?: \"FILL\" | \"FIT\" | \"TILE\" | \"STRETCH\";\n imageTransform?: Transform;\n scalingFactor?: number;\n imageRef?: string;\n gifRef?: string;\n}\n\nexport type EffectType = \"INNER_SHADOW\" | \"DROP_SHADOW\" | \"LAYER_BLUR\" | \"BACKGROUND_BLUR\";\n\nexport interface Effect {\n type: EffectType;\n visible?: boolean;\n radius?: number;\n color?: Color;\n blendMode?: BlendMode;\n offset?: Vector2D;\n spread?: number;\n}\n\nexport type LayoutConstraintVertical = \"TOP\" | \"BOTTOM\" | \"CENTER\" | \"TOP_BOTTOM\" | \"SCALE\";\nexport type LayoutConstraintHorizontal = \"LEFT\" | \"RIGHT\" | \"CENTER\" | \"LEFT_RIGHT\" | \"SCALE\";\n\nexport interface LayoutConstraint {\n vertical: LayoutConstraintVertical;\n horizontal: LayoutConstraintHorizontal;\n}\n\nexport type LayoutAlign = \"MIN\" | \"CENTER\" | \"MAX\" | \"STRETCH\" | \"INHERIT\";\nexport type LayoutMode = \"NONE\" | \"HORIZONTAL\" | \"VERTICAL\";\n\nexport interface LayoutGrid {\n pattern: \"COLUMNS\" | \"ROWS\" | \"GRID\";\n sectionSize?: number;\n visible?: boolean;\n color?: Color;\n alignment?: \"MIN\" | \"MAX\" | \"CENTER\" | \"STRETCH\";\n gutterSize?: number;\n offset?: number;\n count?: number;\n}\n\nexport type TextAlignHorizontal = \"LEFT\" | \"CENTER\" | \"RIGHT\" | \"JUSTIFIED\";\nexport type TextAlignVertical = \"TOP\" | \"CENTER\" | \"BOTTOM\";\nexport type TextCase = \"ORIGINAL\" | \"UPPER\" | \"LOWER\" | \"TITLE\";\nexport type TextDecoration = \"NONE\" | \"STRIKETHROUGH\" | \"UNDERLINE\";\n\nexport interface TypeStyle {\n fontFamily: string;\n fontPostScriptName?: string;\n paragraphSpacing?: number;\n paragraphIndent?: number;\n italic?: boolean;\n fontWeight: number;\n fontSize: number;\n textAlignHorizontal?: TextAlignHorizontal;\n textAlignVertical?: TextAlignVertical;\n letterSpacing?: number;\n fills?: Paint[];\n lineHeightPx?: number;\n lineHeightPercent?: number;\n lineHeightPercentFontSize?: number;\n lineHeightUnit?: \"PIXELS\" | \"FONT_SIZE_%\" | \"INTRINSIC_%\";\n}\n\nexport interface Component {\n key: string;\n name: string;\n description: string;\n componentSetId?: string;\n documentationLinks: string[];\n remote?: boolean;\n}\n\nexport interface ComponentSet {\n key: string;\n name: string;\n description: string;\n documentationLinks: string[];\n remote?: boolean;\n}\n\nexport type StyleType = \"FILL\" | \"TEXT\" | \"EFFECT\" | \"GRID\";\n\nexport interface Style {\n key: string;\n name: string;\n description: string;\n styleType: StyleType;\n remote?: boolean;\n}\n\nexport type ExportFormat = \"JPG\" | \"PNG\" | \"SVG\" | \"PDF\";\n\nexport interface ExportSettings {\n suffix: string;\n format: ExportFormat;\n constraint?: {\n type: \"SCALE\" | \"WIDTH\" | \"HEIGHT\";\n value: number;\n };\n}\n\nexport interface Comment {\n id: string;\n file_key: string;\n parent_id?: string;\n user: User;\n created_at: string;\n resolved_at?: string;\n message: string;\n client_meta: CommentClientMeta;\n order_id: string;\n}\n\nexport interface CommentClientMeta {\n x?: number;\n y?: number;\n node_id?: string[];\n node_offset?: Vector2D;\n}\n\nexport interface User {\n id: string;\n handle: string;\n img_url: string;\n email?: string;\n}\n\nexport interface FileResponse {\n document: Node;\n components: Record<string, Component>;\n componentSets: Record<string, ComponentSet>;\n schemaVersion: number;\n styles: Record<string, Style>;\n name: string;\n lastModified: string;\n thumbnailUrl: string;\n version: string;\n role: \"owner\" | \"editor\" | \"viewer\";\n editorType: \"figma\" | \"figjam\";\n linkAccess: \"view\" | \"edit\" | \"org_view\" | \"org_edit\";\n}\n\nexport interface NodeBase {\n id: string;\n name: string;\n visible?: boolean;\n type: NodeType;\n pluginData?: unknown;\n sharedPluginData?: unknown;\n locked?: boolean;\n}\n\nexport interface NodeWithChildren extends NodeBase {\n children: Node[];\n}\n\nexport interface DocumentNode extends NodeWithChildren {\n type: \"DOCUMENT\";\n}\n\nexport interface CanvasNode extends NodeWithChildren {\n type: \"CANVAS\";\n backgroundColor: Color;\n prototypeStartNodeID?: string;\n prototypeDevice?: {\n type: string;\n rotation: \"NONE\" | \"CCW_90\";\n };\n exportSettings?: ExportSettings[];\n}\n\nexport interface FrameNode extends NodeWithChildren {\n type: \"FRAME\";\n absoluteBoundingBox?: Rectangle;\n absoluteRenderBounds?: Rectangle;\n constraints?: LayoutConstraint;\n clipsContent?: boolean;\n background: Paint[];\n backgroundColor?: Color;\n fills?: Paint[];\n strokes?: Paint[];\n strokeWeight?: number;\n strokeAlign?: \"INSIDE\" | \"OUTSIDE\" | \"CENTER\";\n strokeDashes?: number[];\n cornerRadius?: number;\n rectangleCornerRadii?: [number, number, number, number];\n exportSettings?: ExportSettings[];\n blendMode?: BlendMode;\n preserveRatio?: boolean;\n layoutAlign?: LayoutAlign;\n layoutGrow?: number;\n layoutMode?: LayoutMode;\n primaryAxisSizingMode?: \"FIXED\" | \"AUTO\";\n counterAxisSizingMode?: \"FIXED\" | \"AUTO\";\n primaryAxisAlignItems?: LayoutAlign;\n counterAxisAlignItems?: LayoutAlign;\n paddingLeft?: number;\n paddingRight?: number;\n paddingTop?: number;\n paddingBottom?: number;\n itemSpacing?: number;\n layoutGrids?: LayoutGrid[];\n effects?: Effect[];\n isMask?: boolean;\n isMaskOutline?: boolean;\n transitionNodeID?: string;\n transitionDuration?: number;\n transitionEasing?: EasingType;\n opacity?: number;\n}\n\nexport interface GroupNode extends NodeWithChildren {\n type: \"GROUP\";\n absoluteBoundingBox?: Rectangle;\n absoluteRenderBounds?: Rectangle;\n constraints?: LayoutConstraint;\n clipsContent?: boolean;\n blendMode?: BlendMode;\n effects?: Effect[];\n opacity?: number;\n}\n\nexport type VectorNodeType =\n | \"VECTOR\"\n | \"BOOLEAN_OPERATION\"\n | \"STAR\"\n | \"LINE\"\n | \"ELLIPSE\"\n | \"REGULAR_POLYGON\"\n | \"RECTANGLE\";\n\nexport interface VectorNode extends NodeBase {\n type: VectorNodeType;\n absoluteBoundingBox?: Rectangle;\n absoluteRenderBounds?: Rectangle;\n constraints?: LayoutConstraint;\n fills?: Paint[];\n fillGeometry?: unknown[];\n strokes?: Paint[];\n strokeWeight?: number;\n strokeCap?: \"NONE\" | \"ROUND\" | \"SQUARE\" | \"LINE_ARROW\" | \"TRIANGLE_ARROW\";\n strokeJoin?: \"MITER\" | \"BEVEL\" | \"ROUND\";\n strokeDashes?: number[];\n strokeAlign?: \"INSIDE\" | \"OUTSIDE\" | \"CENTER\";\n strokeGeometry?: unknown[];\n cornerRadius?: number;\n rectangleCornerRadii?: [number, number, number, number];\n exportSettings?: ExportSettings[];\n blendMode?: BlendMode;\n preserveRatio?: boolean;\n layoutAlign?: LayoutAlign;\n layoutGrow?: number;\n effects?: Effect[];\n isMask?: boolean;\n opacity?: number;\n}\n\nexport interface TextNode extends NodeBase {\n type: \"TEXT\";\n absoluteBoundingBox?: Rectangle;\n absoluteRenderBounds?: Rectangle;\n constraints?: LayoutConstraint;\n fills?: Paint[];\n strokes?: Paint[];\n strokeWeight?: number;\n strokeAlign?: \"INSIDE\" | \"OUTSIDE\" | \"CENTER\";\n strokeDashes?: number[];\n exportSettings?: ExportSettings[];\n blendMode?: BlendMode;\n preserveRatio?: boolean;\n layoutAlign?: LayoutAlign;\n layoutGrow?: number;\n effects?: Effect[];\n characters: string;\n style: TypeStyle;\n characterStyleOverrides?: number[];\n styleOverrideTable?: Record<number, TypeStyle>;\n opacity?: number;\n}\n\nexport interface ComponentNode extends FrameNode {\n type: \"COMPONENT\";\n}\n\nexport interface ComponentSetNode extends FrameNode {\n type: \"COMPONENT_SET\";\n}\n\nexport interface InstanceNode extends FrameNode {\n type: \"INSTANCE\";\n componentId: string;\n overrides?: unknown[];\n}\n\nexport type Node =\n | DocumentNode\n | CanvasNode\n | FrameNode\n | GroupNode\n | VectorNode\n | TextNode\n | ComponentNode\n | ComponentSetNode\n | InstanceNode;\n\nexport interface Project {\n id: string;\n name: string;\n}\n\nexport interface FileReference {\n key: string;\n name: string;\n thumbnail_url: string;\n last_modified: string;\n}\n\nexport interface ProjectFilesResponse {\n files: FileReference[];\n}\n\nexport interface TeamProjectsResponse {\n projects: Project[];\n}\n\nexport interface Version {\n id: string;\n created_at: string;\n label?: string;\n description?: string;\n user: User;\n thumbnail_url?: string;\n}\n\nexport interface VersionsResponse {\n versions: Version[];\n pagination?: {\n next_page?: number;\n };\n}\n",
|
|
325
325
|
"lib/figma-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst FIGMA_BASE_URL = \"https://api.figma.com/v1\";\n\nexport interface FigmaFile {\n document: FigmaNode;\n components: Record<string, FigmaComponent>;\n componentSets: Record<string, FigmaComponentSet>;\n schemaVersion: number;\n styles: Record<string, FigmaStyle>;\n name: string;\n lastModified: string;\n thumbnailUrl: string;\n version: string;\n role: string;\n editorType: string;\n linkAccess: string;\n}\n\nexport interface FigmaNode {\n id: string;\n name: string;\n type: string;\n children?: FigmaNode[];\n visible?: boolean;\n locked?: boolean;\n absoluteBoundingBox?: {\n x: number;\n y: number;\n width: number;\n height: number;\n };\n fills?: Array<{\n type: string;\n color?: {\n r: number;\n g: number;\n b: number;\n a: number;\n };\n }>;\n strokes?: unknown[];\n strokeWeight?: number;\n effects?: unknown[];\n cornerRadius?: number;\n rectangleCornerRadii?: number[];\n characters?: string;\n style?: {\n fontFamily?: string;\n fontSize?: number;\n fontWeight?: number;\n lineHeightPx?: number;\n };\n}\n\nexport interface FigmaComponent {\n key: string;\n name: string;\n description: string;\n componentSetId?: string;\n documentationLinks: unknown[];\n}\n\nexport interface FigmaComponentSet {\n key: string;\n name: string;\n description: string;\n documentationLinks: unknown[];\n}\n\nexport interface FigmaStyle {\n key: string;\n name: string;\n description: string;\n styleType: \"FILL\" | \"TEXT\" | \"EFFECT\" | \"GRID\";\n}\n\nexport interface FigmaComment {\n id: string;\n file_key: string;\n parent_id?: string;\n user: {\n id: string;\n handle: string;\n img_url: string;\n };\n created_at: string;\n resolved_at?: string;\n message: string;\n client_meta: {\n x?: number;\n y?: number;\n node_id?: string[];\n node_offset?: { x: number; y: number };\n };\n order_id: string;\n}\n\nexport interface FigmaProject {\n id: string;\n name: string;\n}\n\nexport interface FigmaTeamProject {\n id: string;\n name: string;\n}\n\nexport interface FigmaUser {\n id: string;\n handle: string;\n img_url: string;\n email?: string;\n}\n\nasync function figmaFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Figma. Please connect your account.\");\n }\n\n const response = await fetch(`${FIGMA_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (response.ok) {\n return response.json() as Promise<T>;\n }\n\n const error = (await response.json().catch(() => ({}))) as { message?: string; err?: string };\n throw new Error(\n `Figma API error: ${response.status} ${error.message ?? error.err ?? response.statusText}`,\n );\n}\n\nexport function getMe(): Promise<FigmaUser> {\n return figmaFetch<FigmaUser>(\"/me\");\n}\n\nexport function getFile(\n fileKey: string,\n options?: {\n version?: string;\n ids?: string[];\n depth?: number;\n geometry?: \"paths\" | \"bounds\";\n plugin_data?: string;\n branch_data?: boolean;\n },\n): Promise<FigmaFile> {\n const params = new URLSearchParams();\n\n if (options?.version) params.set(\"version\", options.version);\n if (options?.ids?.length) params.set(\"ids\", options.ids.join(\",\"));\n if (options?.depth) params.set(\"depth\", String(options.depth));\n if (options?.geometry) params.set(\"geometry\", options.geometry);\n if (options?.plugin_data) params.set(\"plugin_data\", options.plugin_data);\n if (options?.branch_data) params.set(\"branch_data\", \"true\");\n\n const query = params.toString();\n const url = query ? `/files/${fileKey}?${query}` : `/files/${fileKey}`;\n\n return figmaFetch<FigmaFile>(url);\n}\n\nexport function getFileNodes(\n fileKey: string,\n nodeIds: string[],\n): Promise<{\n name: string;\n lastModified: string;\n thumbnailUrl: string;\n version: string;\n nodes: Record<string, { document: FigmaNode; components: Record<string, FigmaComponent> }>;\n}> {\n const params = new URLSearchParams({ ids: nodeIds.join(\",\") });\n return figmaFetch(`/files/${fileKey}/nodes?${params.toString()}`);\n}\n\nexport function getFileImages(\n fileKey: string,\n nodeIds: string[],\n options?: {\n format?: \"jpg\" | \"png\" | \"svg\" | \"pdf\";\n scale?: number;\n svg_include_id?: boolean;\n svg_simplify_stroke?: boolean;\n use_absolute_bounds?: boolean;\n version?: string;\n },\n): Promise<{\n err?: string;\n images: Record<string, string | null>;\n status?: number;\n}> {\n const params = new URLSearchParams({\n ids: nodeIds.join(\",\"),\n format: options?.format ?? \"png\",\n });\n\n if (options?.scale) params.set(\"scale\", String(options.scale));\n if (options?.svg_include_id) params.set(\"svg_include_id\", \"true\");\n if (options?.svg_simplify_stroke) params.set(\"svg_simplify_stroke\", \"true\");\n if (options?.use_absolute_bounds) params.set(\"use_absolute_bounds\", \"true\");\n if (options?.version) params.set(\"version\", options.version);\n\n return figmaFetch(`/images/${fileKey}?${params.toString()}`);\n}\n\nexport function getComments(fileKey: string): Promise<{ comments: FigmaComment[] }> {\n return figmaFetch<{ comments: FigmaComment[] }>(`/files/${fileKey}/comments`);\n}\n\nexport function postComment(\n fileKey: string,\n message: string,\n options?: {\n client_meta?: { x?: number; y?: number; node_id?: string[] };\n parent_id?: string;\n },\n): Promise<FigmaComment> {\n return figmaFetch<FigmaComment>(`/files/${fileKey}/comments`, {\n method: \"POST\",\n body: JSON.stringify({\n message,\n client_meta: options?.client_meta ?? {},\n ...(options?.parent_id ? { parent_id: options.parent_id } : {}),\n }),\n });\n}\n\nexport function getTeamProjects(teamId: string): Promise<{ projects: FigmaTeamProject[] }> {\n return figmaFetch<{ projects: FigmaTeamProject[] }>(`/teams/${teamId}/projects`);\n}\n\nexport function getProjectFiles(projectId: string): Promise<{\n files: Array<{\n key: string;\n name: string;\n thumbnail_url: string;\n last_modified: string;\n }>;\n}> {\n return figmaFetch(`/projects/${projectId}/files`);\n}\n\nexport function getUserFiles(): Promise<{\n files: Array<{\n key: string;\n name: string;\n thumbnail_url: string;\n last_modified: string;\n }>;\n}> {\n throw new Error(\n \"Getting user files requires team ID. Use getTeamProjects and getProjectFiles instead.\",\n );\n}\n\nexport function extractComponents(file: FigmaFile): Array<{\n key: string;\n name: string;\n description: string;\n type: \"component\" | \"component_set\";\n}> {\n const components = Object.entries(file.components).map(([key, component]) => ({\n key,\n name: component.name,\n description: component.description,\n type: \"component\" as const,\n }));\n\n const componentSets = Object.entries(file.componentSets).map(([key, componentSet]) => ({\n key,\n name: componentSet.name,\n description: componentSet.description,\n type: \"component_set\" as const,\n }));\n\n return [...components, ...componentSets];\n}\n\nexport function extractStyles(file: FigmaFile): Array<{\n key: string;\n name: string;\n description: string;\n type: string;\n}> {\n return Object.entries(file.styles).map(([key, style]) => ({\n key,\n name: style.name,\n description: style.description,\n type: style.styleType,\n }));\n}\n\nexport function findNodesByType(node: FigmaNode, type: string): FigmaNode[] {\n const results: FigmaNode[] = [];\n\n if (node.type === type) {\n results.push(node);\n }\n\n for (const child of node.children ?? []) {\n results.push(...findNodesByType(child, type));\n }\n\n return results;\n}\n\nexport function getFileSummary(file: FigmaFile): {\n name: string;\n lastModified: string;\n componentCount: number;\n componentSetCount: number;\n styleCount: number;\n pageCount: number;\n} {\n return {\n name: file.name,\n lastModified: file.lastModified,\n componentCount: Object.keys(file.components).length,\n componentSetCount: Object.keys(file.componentSets).length,\n styleCount: Object.keys(file.styles).length,\n pageCount: file.document.children?.length ?? 0,\n };\n}\n",
|
|
326
|
-
"app/api/auth/figma/callback/route.ts": "import { createOAuthCallbackHandler, figmaConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
327
|
-
"app/api/auth/figma/route.ts": "import { createOAuthInitHandler, figmaConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(figmaConfig, {
|
|
326
|
+
"app/api/auth/figma/callback/route.ts": "import { createOAuthCallbackHandler, figmaConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(figmaConfig, { tokenStore: hybridTokenStore });\n",
|
|
327
|
+
"app/api/auth/figma/route.ts": "import { createOAuthInitHandler, figmaConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(figmaConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
328
328
|
}
|
|
329
329
|
},
|
|
330
330
|
"integration:linear": {
|
|
@@ -336,8 +336,8 @@ export default {
|
|
|
336
336
|
"tools/search-issues.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { searchIssues } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"search-issues\",\n description:\n \"Search for Linear issues by title or description. Returns matching issues with their details including status, assignee, and team.\",\n inputSchema: z.object({\n query: z.string().describe(\"Search query to find issues (searches in title and description)\"),\n limit: z.number().min(1).max(50).default(10).describe(\"Maximum number of results to return\"),\n includeArchived: z\n .boolean()\n .default(false)\n .describe(\"Whether to include archived issues in results\"),\n }),\n async execute({ query, limit, includeArchived }) {\n const issues = await searchIssues(query, { limit, includeArchived });\n\n return issues.map((issue) => {\n const assignee = issue.assignee\n ? { name: issue.assignee.name, email: issue.assignee.email }\n : null;\n\n const project = issue.project ? { name: issue.project.name } : null;\n\n return {\n id: issue.id,\n identifier: issue.identifier,\n title: issue.title,\n description: issue.description,\n priority: issue.priorityLabel,\n status: issue.state.name,\n statusType: issue.state.type,\n assignee,\n team: { name: issue.team.name, key: issue.team.key },\n project,\n labels: issue.labels.nodes.map((label) => ({\n name: label.name,\n color: label.color,\n })),\n url: issue.url,\n createdAt: issue.createdAt,\n updatedAt: issue.updatedAt,\n };\n });\n },\n});\n",
|
|
337
337
|
".env.example": "# Linear Integration\n# Create an OAuth application at https://linear.app/settings/api\n# Set the callback URL to: http://localhost:3000/api/auth/linear/callback (or your production URL)\n\nLINEAR_CLIENT_ID=your_client_id_here\nLINEAR_CLIENT_SECRET=your_client_secret_here\n",
|
|
338
338
|
"lib/linear-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst LINEAR_API_URL = \"https://api.linear.app/graphql\";\n\nexport interface LinearIssue {\n id: string;\n identifier: string;\n title: string;\n description?: string;\n priority: number;\n priorityLabel: string;\n state: {\n id: string;\n name: string;\n type: string;\n };\n assignee?: {\n id: string;\n name: string;\n email: string;\n };\n team: {\n id: string;\n name: string;\n key: string;\n };\n project?: {\n id: string;\n name: string;\n };\n labels: {\n nodes: Array<{\n id: string;\n name: string;\n color: string;\n }>;\n };\n createdAt: string;\n updatedAt: string;\n url: string;\n}\n\nexport interface LinearProject {\n id: string;\n name: string;\n description?: string;\n state: string;\n progress: number;\n url: string;\n lead?: {\n id: string;\n name: string;\n };\n teams: {\n nodes: Array<{\n id: string;\n name: string;\n key: string;\n }>;\n };\n createdAt: string;\n updatedAt: string;\n}\n\nexport interface LinearTeam {\n id: string;\n name: string;\n key: string;\n}\n\nexport interface LinearWorkflowState {\n id: string;\n name: string;\n type: string;\n}\n\ninterface GraphQLResponse<T> {\n data?: T;\n errors?: Array<{\n message: string;\n path?: string[];\n }>;\n}\n\nasync function linearFetch<T>(query: string, variables?: Record<string, unknown>): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Linear. Please connect your account.\");\n }\n\n const response = await fetch(LINEAR_API_URL, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ query, variables }),\n });\n\n if (!response.ok) {\n throw new Error(`Linear API error: ${response.status} ${response.statusText}`);\n }\n\n const json: GraphQLResponse<T> = await response.json();\n\n const errorMessage = json.errors?.[0]?.message;\n if (errorMessage) {\n throw new Error(`Linear GraphQL error: ${errorMessage}`);\n }\n\n if (!json.data) {\n throw new Error(\"Linear API returned no data\");\n }\n\n return json.data;\n}\n\nexport async function searchIssues(\n query: string,\n options?: {\n limit?: number;\n includeArchived?: boolean;\n },\n): Promise<LinearIssue[]> {\n const gqlQuery = `\n query SearchIssues($query: String!, $first: Int, $includeArchived: Boolean) {\n issueSearch(query: $query, first: $first, includeArchived: $includeArchived) {\n nodes {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const data = await linearFetch<{ issueSearch: { nodes: LinearIssue[] } }>(gqlQuery, {\n query,\n first: options?.limit ?? 10,\n includeArchived: options?.includeArchived ?? false,\n });\n\n return data.issueSearch.nodes;\n}\n\nexport async function getIssue(issueId: string): Promise<LinearIssue> {\n const query = `\n query GetIssue($id: String!) {\n issue(id: $id) {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n `;\n\n const data = await linearFetch<{ issue: LinearIssue }>(query, { id: issueId });\n return data.issue;\n}\n\nexport async function createIssue(options: {\n teamId: string;\n title: string;\n description?: string;\n priority?: number;\n stateId?: string;\n assigneeId?: string;\n projectId?: string;\n labelIds?: string[];\n}): Promise<LinearIssue> {\n const mutation = `\n mutation CreateIssue($input: IssueCreateInput!) {\n issueCreate(input: $input) {\n success\n issue {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const input: Record<string, unknown> = {\n teamId: options.teamId,\n title: options.title,\n };\n\n if (options.description) input.description = options.description;\n if (options.priority !== undefined) input.priority = options.priority;\n if (options.stateId) input.stateId = options.stateId;\n if (options.assigneeId) input.assigneeId = options.assigneeId;\n if (options.projectId) input.projectId = options.projectId;\n if (options.labelIds?.length) input.labelIds = options.labelIds;\n\n const data = await linearFetch<{ issueCreate: { success: boolean; issue: LinearIssue } }>(mutation, {\n input,\n });\n\n if (!data.issueCreate.success) {\n throw new Error(\"Failed to create issue\");\n }\n\n return data.issueCreate.issue;\n}\n\nexport async function updateIssue(\n issueId: string,\n options: {\n title?: string;\n description?: string;\n priority?: number;\n stateId?: string;\n assigneeId?: string;\n projectId?: string;\n labelIds?: string[];\n },\n): Promise<LinearIssue> {\n const mutation = `\n mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n success\n issue {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const input: Record<string, unknown> = {};\n\n if (options.title) input.title = options.title;\n if (options.description !== undefined) input.description = options.description;\n if (options.priority !== undefined) input.priority = options.priority;\n if (options.stateId) input.stateId = options.stateId;\n if (options.assigneeId) input.assigneeId = options.assigneeId;\n if (options.projectId) input.projectId = options.projectId;\n if (options.labelIds) input.labelIds = options.labelIds;\n\n const data = await linearFetch<{ issueUpdate: { success: boolean; issue: LinearIssue } }>(mutation, {\n id: issueId,\n input,\n });\n\n if (!data.issueUpdate.success) {\n throw new Error(\"Failed to update issue\");\n }\n\n return data.issueUpdate.issue;\n}\n\nexport async function listProjects(options?: {\n limit?: number;\n includeArchived?: boolean;\n}): Promise<LinearProject[]> {\n const query = `\n query ListProjects($first: Int, $includeArchived: Boolean) {\n projects(first: $first, includeArchived: $includeArchived) {\n nodes {\n id\n name\n description\n state\n progress\n url\n lead {\n id\n name\n }\n teams {\n nodes {\n id\n name\n key\n }\n }\n createdAt\n updatedAt\n }\n }\n }\n `;\n\n const data = await linearFetch<{ projects: { nodes: LinearProject[] } }>(query, {\n first: options?.limit ?? 20,\n includeArchived: options?.includeArchived ?? false,\n });\n\n return data.projects.nodes;\n}\n\nexport async function getTeams(): Promise<LinearTeam[]> {\n const query = `\n query GetTeams {\n teams {\n nodes {\n id\n name\n key\n }\n }\n }\n `;\n\n const data = await linearFetch<{ teams: { nodes: LinearTeam[] } }>(query);\n return data.teams.nodes;\n}\n\nexport async function getWorkflowStates(teamId: string): Promise<LinearWorkflowState[]> {\n const query = `\n query GetWorkflowStates($teamId: String!) {\n team(id: $teamId) {\n states {\n nodes {\n id\n name\n type\n }\n }\n }\n }\n `;\n\n const data = await linearFetch<{ team: { states: { nodes: LinearWorkflowState[] } } }>(query, {\n teamId,\n });\n\n return data.team.states.nodes;\n}\n",
|
|
339
|
-
"app/api/auth/linear/callback/route.ts": "/**\n * Linear OAuth Callback\n *\n * Handles the OAuth callback from Linear and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, linearConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
340
|
-
"app/api/auth/linear/route.ts": "import { createOAuthInitHandler, linearConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(linearConfig, {
|
|
339
|
+
"app/api/auth/linear/callback/route.ts": "/**\n * Linear OAuth Callback\n *\n * Handles the OAuth callback from Linear and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, linearConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(linearConfig, { tokenStore: hybridTokenStore });\n",
|
|
340
|
+
"app/api/auth/linear/route.ts": "import { createOAuthInitHandler, linearConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(linearConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
341
341
|
}
|
|
342
342
|
},
|
|
343
343
|
"integration:onedrive": {
|
|
@@ -348,8 +348,8 @@ export default {
|
|
|
348
348
|
"tools/list-files.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { formatFileSize, isFile, isFolder, listFiles } from \"../../lib/onedrive-client.ts\";\n\nexport default tool({\n id: \"list-files\",\n description:\n \"List files and folders in a OneDrive folder. Returns file/folder names, types, sizes, and modification dates.\",\n inputSchema: z.object({\n folderId: z\n .string()\n .default(\"root\")\n .describe('Folder ID or \"root\" for the root folder'),\n orderBy: z\n .string()\n .optional()\n .describe('Order by field (e.g., \"name\", \"lastModifiedDateTime desc\")'),\n limit: z\n .number()\n .min(1)\n .max(200)\n .default(100)\n .describe(\"Maximum number of items to return\"),\n }),\n async execute({ folderId, orderBy, limit }) {\n const result = await listFiles(folderId, { orderBy, top: limit });\n\n const items = result.value.map((item) => {\n const baseInfo = {\n id: item.id,\n name: item.name,\n webUrl: item.webUrl,\n createdDateTime: item.createdDateTime,\n lastModifiedDateTime: item.lastModifiedDateTime,\n };\n\n if (isFile(item)) {\n const size = item.size ?? 0;\n\n return {\n ...baseInfo,\n type: \"file\" as const,\n size,\n sizeFormatted: formatFileSize(size),\n mimeType: item.file?.mimeType,\n };\n }\n\n if (isFolder(item)) {\n return {\n ...baseInfo,\n type: \"folder\" as const,\n childCount: item.folder?.childCount ?? 0,\n };\n }\n\n return { ...baseInfo, type: \"unknown\" as const };\n });\n\n return {\n items,\n count: items.length,\n hasMore: Boolean(result[\"@odata.nextLink\"]),\n };\n },\n});\n",
|
|
349
349
|
".env.example": "# OneDrive Integration Environment Variables\n\n# Microsoft Azure App Client ID\n# Get this from https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\nMICROSOFT_CLIENT_ID=your_client_id_here\n\n# Microsoft Azure App Client Secret\n# Get this from https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\nMICROSOFT_CLIENT_SECRET=your_client_secret_here\n\n# Setup Instructions:\n# 1. Go to https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\n# 2. Create a new app registration or select an existing one\n# 3. Note the Application (client) ID\n# 4. Create a new client secret under \"Certificates & secrets\"\n# 5. Add the OAuth2 redirect URI under \"Authentication\": http://localhost:3000/api/auth/onedrive/callback\n# 6. Grant the following Microsoft Graph API permissions under \"API permissions\":\n# - Files.Read\n# - Files.ReadWrite\n# - Files.Read.All\n# - Files.ReadWrite.All\n# - offline_access\n# 7. Grant admin consent if required by your organization\n",
|
|
350
350
|
"lib/onedrive-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst GRAPH_API_URL = \"https://graph.microsoft.com/v1.0\";\n\nexport interface DriveItem {\n id: string;\n name: string;\n size?: number;\n createdDateTime: string;\n lastModifiedDateTime: string;\n webUrl: string;\n parentReference?: {\n driveId: string;\n id: string;\n path: string;\n };\n file?: {\n mimeType: string;\n hashes?: {\n quickXorHash?: string;\n sha1Hash?: string;\n sha256Hash?: string;\n };\n };\n folder?: {\n childCount: number;\n };\n \"@microsoft.graph.downloadUrl\"?: string;\n}\n\nexport interface FileMetadata {\n id: string;\n name: string;\n size: number;\n mimeType: string;\n createdDateTime: string;\n lastModifiedDateTime: string;\n webUrl: string;\n downloadUrl?: string;\n}\n\nexport interface FolderMetadata {\n id: string;\n name: string;\n childCount: number;\n createdDateTime: string;\n lastModifiedDateTime: string;\n webUrl: string;\n}\n\nexport interface ListFilesResult {\n value: DriveItem[];\n \"@odata.nextLink\"?: string;\n}\n\nexport interface SearchResult {\n value: DriveItem[];\n \"@odata.nextLink\"?: string;\n}\n\nasync function getTokenOrThrow(): Promise<string> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with OneDrive. Please connect your account.\");\n }\n return token;\n}\n\nasync function onedriveFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getTokenOrThrow();\n const url = endpoint.startsWith(\"http\") ? endpoint : `${GRAPH_API_URL}${endpoint}`;\n\n const response = await fetch(url, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(\n `OneDrive API error: ${response.status} ${error.error?.message ?? response.statusText}`,\n );\n }\n\n return response.json();\n}\n\nexport function listFiles(\n folderId: string = \"root\",\n options?: {\n orderBy?: string;\n top?: number;\n select?: string[];\n },\n): Promise<ListFilesResult> {\n const params = new URLSearchParams();\n\n if (options?.orderBy) params.set(\"$orderby\", options.orderBy);\n if (options?.top) params.set(\"$top\", options.top.toString());\n if (options?.select?.length) params.set(\"$select\", options.select.join(\",\"));\n\n const queryString = params.toString();\n const endpoint = `/me/drive/items/${folderId}/children${queryString ? `?${queryString}` : \"\"}`;\n\n return onedriveFetch<ListFilesResult>(endpoint);\n}\n\nexport function getFile(itemId: string): Promise<DriveItem> {\n return onedriveFetch<DriveItem>(`/me/drive/items/${itemId}`);\n}\n\nexport async function downloadFile(itemId: string): Promise<{\n content: string;\n metadata: FileMetadata;\n}> {\n const item = await getFile(itemId);\n\n if (!item.file) throw new Error(\"Item is not a file\");\n\n const downloadUrl = item[\"@microsoft.graph.downloadUrl\"];\n if (!downloadUrl) throw new Error(\"Download URL not available\");\n\n const response = await fetch(downloadUrl);\n if (!response.ok) throw new Error(`Failed to download file: ${response.statusText}`);\n\n const content = await response.text();\n\n return {\n content,\n metadata: {\n id: item.id,\n name: item.name,\n size: item.size ?? 0,\n mimeType: item.file.mimeType,\n createdDateTime: item.createdDateTime,\n lastModifiedDateTime: item.lastModifiedDateTime,\n webUrl: item.webUrl,\n downloadUrl,\n },\n };\n}\n\nexport async function uploadFile(\n fileName: string,\n content: string,\n parentFolderId: string = \"root\",\n): Promise<DriveItem> {\n const token = await getTokenOrThrow();\n const endpoint = `${GRAPH_API_URL}/me/drive/items/${parentFolderId}:/${fileName}:/content`;\n\n const response = await fetch(endpoint, {\n method: \"PUT\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/octet-stream\",\n },\n body: content,\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(`Failed to upload file: ${error.error?.message ?? response.statusText}`);\n }\n\n return response.json();\n}\n\nexport function createFolder(\n folderName: string,\n parentFolderId: string = \"root\",\n): Promise<DriveItem> {\n return onedriveFetch<DriveItem>(`/me/drive/items/${parentFolderId}/children`, {\n method: \"POST\",\n body: JSON.stringify({\n name: folderName,\n folder: {},\n \"@microsoft.graph.conflictBehavior\": \"rename\",\n }),\n });\n}\n\nexport function searchFiles(\n query: string,\n options?: {\n top?: number;\n },\n): Promise<SearchResult> {\n const params = new URLSearchParams({ q: query });\n if (options?.top) params.set(\"$top\", options.top.toString());\n\n return onedriveFetch<SearchResult>(\n `/me/drive/root/search(q='${encodeURIComponent(query)}')?${params.toString()}`,\n );\n}\n\nexport async function deleteFile(itemId: string): Promise<void> {\n const token = await getTokenOrThrow();\n\n const response = await fetch(`${GRAPH_API_URL}/me/drive/items/${itemId}`, {\n method: \"DELETE\",\n headers: { Authorization: `Bearer ${token}` },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(`Failed to delete item: ${error.error?.message ?? response.statusText}`);\n }\n}\n\nexport function moveFile(\n itemId: string,\n newParentId: string,\n newName?: string,\n): Promise<DriveItem> {\n const body: Record<string, unknown> = {\n parentReference: { id: newParentId },\n ...(newName ? { name: newName } : {}),\n };\n\n return onedriveFetch<DriveItem>(`/me/drive/items/${itemId}`, {\n method: \"PATCH\",\n body: JSON.stringify(body),\n });\n}\n\nexport function formatFileSize(bytes: number): string {\n const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n let size = bytes;\n let unitIndex = 0;\n\n while (size >= 1024 && unitIndex < units.length - 1) {\n size /= 1024;\n unitIndex++;\n }\n\n return `${size.toFixed(2)} ${units[unitIndex]}`;\n}\n\nexport function isFile(item: DriveItem): boolean {\n return item.file !== undefined;\n}\n\nexport function isFolder(item: DriveItem): boolean {\n return item.folder !== undefined;\n}\n",
|
|
351
|
-
"app/api/auth/onedrive/callback/route.ts": "/**\n * OneDrive OAuth Callback\n *\n * Handles the OAuth callback from Microsoft and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, oneDriveConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
352
|
-
"app/api/auth/onedrive/route.ts": "import { createOAuthInitHandler, oneDriveConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(oneDriveConfig, {\n tokenStore: oauthMemoryTokenStore,\n})
|
|
351
|
+
"app/api/auth/onedrive/callback/route.ts": "/**\n * OneDrive OAuth Callback\n *\n * Handles the OAuth callback from Microsoft and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, oneDriveConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n async getTokens(serviceId: string, userId: string): Promise<unknown> {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ): Promise<void> {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string): Promise<void> {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ): Promise<void> {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string): Promise<unknown> {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(oneDriveConfig, { tokenStore: hybridTokenStore });\n",
|
|
352
|
+
"app/api/auth/onedrive/route.ts": "import { createOAuthInitHandler, oneDriveConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(oneDriveConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
353
353
|
}
|
|
354
354
|
},
|
|
355
355
|
"integration:asana": {
|
|
@@ -361,8 +361,8 @@ export default {
|
|
|
361
361
|
"tools/create-task.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createTask } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"create-task\",\n description: \"Create a new task in an Asana project.\",\n inputSchema: z.object({\n projectGid: z.string().describe(\"The GID of the project to create the task in\"),\n name: z.string().describe(\"The name/title of the task\"),\n notes: z.string().optional().describe(\"Description or notes for the task\"),\n dueOn: z.string().optional().describe(\"Due date in YYYY-MM-DD format\"),\n assigneeGid: z.string().optional().describe(\"GID of the user to assign the task to\"),\n }),\n async execute({ projectGid, name, notes, dueOn, assigneeGid }) {\n const task = await createTask({\n projectGid,\n name,\n notes,\n dueOn,\n assigneeGid,\n });\n\n return {\n success: true,\n task: {\n gid: task.gid,\n name: task.name,\n dueOn: task.due_on,\n assignee: task.assignee?.name,\n },\n };\n },\n});\n",
|
|
362
362
|
".env.example": "# Asana OAuth Configuration\n# Get your credentials from https://app.asana.com/0/developer-console\nASANA_CLIENT_ID=your-client-id\nASANA_CLIENT_SECRET=your-client-secret\n",
|
|
363
363
|
"lib/asana-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst ASANA_BASE_URL = \"https://app.asana.com/api/1.0\";\n\ninterface AsanaResponse<T> {\n data: T;\n next_page?: { offset: string } | null;\n}\n\ninterface AsanaTask {\n gid: string;\n name: string;\n notes: string;\n completed: boolean;\n due_on: string | null;\n assignee: { gid: string; name: string } | null;\n projects: Array<{ gid: string; name: string }>;\n created_at: string;\n modified_at: string;\n}\n\ninterface AsanaProject {\n gid: string;\n name: string;\n notes: string;\n workspace: { gid: string; name: string };\n created_at: string;\n modified_at: string;\n}\n\ninterface AsanaWorkspace {\n gid: string;\n name: string;\n}\n\nasync function asanaFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Asana. Please connect your account.\");\n }\n\n const response = await fetch(`${ASANA_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (response.ok) {\n return response.json();\n }\n\n let error: unknown = {};\n try {\n error = await response.json();\n } catch {\n // ignore JSON parse errors\n }\n\n const message =\n (error as { errors?: Array<{ message?: string }> })?.errors?.[0]?.message ?? response.statusText;\n\n throw new Error(`Asana API error: ${response.status} ${message}`);\n}\n\nexport async function listWorkspaces(): Promise<AsanaWorkspace[]> {\n const { data } = await asanaFetch<AsanaResponse<AsanaWorkspace[]>>(\"/workspaces\");\n return data;\n}\n\nexport async function listProjects(workspaceGid: string): Promise<AsanaProject[]> {\n const { data } = await asanaFetch<AsanaResponse<AsanaProject[]>>(\n `/workspaces/${workspaceGid}/projects?opt_fields=name,notes,created_at,modified_at`,\n );\n return data;\n}\n\nexport async function listTasks(options: {\n projectGid?: string;\n assigneeGid?: string;\n workspaceGid?: string;\n completedSince?: string;\n}): Promise<AsanaTask[]> {\n const params = new URLSearchParams({\n opt_fields: \"name,notes,completed,due_on,assignee.name,projects.name,created_at,modified_at\",\n });\n\n if (options.completedSince) {\n params.set(\"completed_since\", options.completedSince);\n }\n\n let endpoint = \"/tasks\";\n if (options.projectGid) {\n endpoint = `/projects/${options.projectGid}/tasks`;\n } else if (options.assigneeGid && options.workspaceGid) {\n params.set(\"assignee\", options.assigneeGid);\n params.set(\"workspace\", options.workspaceGid);\n }\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask[]>>(`${endpoint}?${params}`);\n return data;\n}\n\nexport async function getTask(taskGid: string): Promise<AsanaTask> {\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(\n `/tasks/${taskGid}?opt_fields=name,notes,completed,due_on,assignee.name,projects.name,created_at,modified_at`,\n );\n return data;\n}\n\nexport async function createTask(options: {\n projectGid: string;\n name: string;\n notes?: string;\n dueOn?: string;\n assigneeGid?: string;\n}): Promise<AsanaTask> {\n const body: Record<string, unknown> = {\n name: options.name,\n projects: [options.projectGid],\n };\n\n if (options.notes) body.notes = options.notes;\n if (options.dueOn) body.due_on = options.dueOn;\n if (options.assigneeGid) body.assignee = options.assigneeGid;\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(\"/tasks\", {\n method: \"POST\",\n body: JSON.stringify({ data: body }),\n });\n\n return data;\n}\n\nexport async function updateTask(\n taskGid: string,\n updates: {\n name?: string;\n notes?: string;\n completed?: boolean;\n dueOn?: string;\n assigneeGid?: string;\n },\n): Promise<AsanaTask> {\n const body: Record<string, unknown> = {};\n\n if (updates.name !== undefined) body.name = updates.name;\n if (updates.notes !== undefined) body.notes = updates.notes;\n if (updates.completed !== undefined) body.completed = updates.completed;\n if (updates.dueOn !== undefined) body.due_on = updates.dueOn;\n if (updates.assigneeGid !== undefined) body.assignee = updates.assigneeGid;\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(`/tasks/${taskGid}`, {\n method: \"PUT\",\n body: JSON.stringify({ data: body }),\n });\n\n return data;\n}\n\nexport async function getMe(): Promise<{ gid: string; name: string; email: string }> {\n const { data } = await asanaFetch<AsanaResponse<{ gid: string; name: string; email: string }>>(\n \"/users/me\",\n );\n return data;\n}\n",
|
|
364
|
-
"app/api/auth/asana/callback/route.ts": "import { asanaConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
365
|
-
"app/api/auth/asana/route.ts": "import { asanaConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(asanaConfig, {
|
|
364
|
+
"app/api/auth/asana/callback/route.ts": "import { asanaConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(asanaConfig, { tokenStore: hybridTokenStore });\n",
|
|
365
|
+
"app/api/auth/asana/route.ts": "import { asanaConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(asanaConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
366
366
|
}
|
|
367
367
|
},
|
|
368
368
|
"integration:posthog": {
|
|
@@ -384,8 +384,8 @@ export default {
|
|
|
384
384
|
"tools/send-message.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { sendMessage } from \"../../lib/webex-client.ts\";\n\nexport default tool({\n id: \"send-message\",\n description:\n \"Send a text or markdown message to a Webex room/space or directly to a person.\",\n inputSchema: z\n .object({\n roomId: z\n .string()\n .optional()\n .describe(\"Room ID to send the message to (use this OR toPersonEmail)\"),\n toPersonEmail: z\n .string()\n .email()\n .optional()\n .describe(\"Email address to send a direct message (use this OR roomId)\"),\n text: z\n .string()\n .optional()\n .describe(\"Plain text message content (use this OR markdown)\"),\n markdown: z\n .string()\n .optional()\n .describe(\"Markdown formatted message content (use this OR text)\"),\n })\n .refine((data) => data.roomId || data.toPersonEmail, {\n message: \"Must specify either roomId or toPersonEmail\",\n })\n .refine((data) => data.text || data.markdown, {\n message: \"Must specify either text or markdown\",\n }),\n async execute({ roomId, toPersonEmail, text, markdown }) {\n const message = await sendMessage({ roomId, toPersonEmail, text, markdown });\n\n const destination = roomId ? `room ${roomId}` : toPersonEmail;\n\n return {\n id: message.id,\n roomId: message.roomId,\n text: message.text,\n markdown: message.markdown,\n personEmail: message.personEmail,\n created: message.created,\n message: `Message sent successfully to ${destination}`,\n };\n },\n});\n",
|
|
385
385
|
".env.example": "# Webex OAuth Configuration\n# Get your credentials from https://developer.webex.com/my-apps\nWEBEX_CLIENT_ID=your-client-id\nWEBEX_CLIENT_SECRET=your-client-secret\n",
|
|
386
386
|
"lib/webex-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst WEBEX_BASE_URL = \"https://webexapis.com/v1\";\n\ninterface WebexMeeting {\n id: string;\n title: string;\n agenda?: string;\n start: string;\n end: string;\n timezone: string;\n hostEmail: string;\n hostDisplayName: string;\n webLink: string;\n sipAddress?: string;\n meetingNumber?: string;\n state: string;\n enabledAutoRecordMeeting?: boolean;\n allowAnyUserToBeCoHost?: boolean;\n}\n\ninterface WebexRoom {\n id: string;\n title: string;\n type: \"direct\" | \"group\";\n isLocked: boolean;\n lastActivity: string;\n creatorId: string;\n created: string;\n}\n\ninterface WebexMessage {\n id: string;\n roomId: string;\n roomType: string;\n text?: string;\n markdown?: string;\n personId: string;\n personEmail: string;\n created: string;\n}\n\ninterface WebexPerson {\n id: string;\n emails: string[];\n displayName: string;\n firstName?: string;\n lastName?: string;\n avatar?: string;\n orgId: string;\n created: string;\n lastActivity?: string;\n status?: string;\n type: string;\n}\n\nasync function webexFetch<T>(\n endpoint: string,\n options: RequestInit = {},\n): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Webex. Please connect your account.\");\n }\n\n const response = await fetch(`${WEBEX_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({}))) as {\n message?: string;\n };\n\n throw new Error(\n `Webex API error: ${response.status} ${error.message ?? response.statusText}`,\n );\n }\n\n return response.json() as Promise<T>;\n}\n\nfunction setOptionalParam(\n params: URLSearchParams,\n key: string,\n value: string | number | undefined,\n): void {\n if (value === undefined) return;\n params.set(key, String(value));\n}\n\nexport async function getMe(): Promise<WebexPerson> {\n return webexFetch<WebexPerson>(\"/people/me\");\n}\n\n/**\n * List meetings for the authenticated user\n */\nexport async function listMeetings(options?: {\n max?: number;\n from?: string;\n to?: string;\n meetingType?: \"meeting\" | \"webinar\" | \"personalRoomMeeting\";\n state?: \"active\" | \"scheduled\" | \"ended\" | \"missed\" | \"inProgress\";\n}): Promise<WebexMeeting[]> {\n const params = new URLSearchParams();\n\n setOptionalParam(params, \"max\", options?.max);\n setOptionalParam(params, \"from\", options?.from);\n setOptionalParam(params, \"to\", options?.to);\n setOptionalParam(params, \"meetingType\", options?.meetingType);\n setOptionalParam(params, \"state\", options?.state);\n\n const response = await webexFetch<{ items?: WebexMeeting[] }>(\n `/meetings?${params}`,\n );\n\n return response.items ?? [];\n}\n\n/**\n * Get details of a specific meeting\n */\nexport async function getMeeting(meetingId: string): Promise<WebexMeeting> {\n return webexFetch<WebexMeeting>(`/meetings/${meetingId}`);\n}\n\n/**\n * Create a new Webex meeting\n */\nexport async function createMeeting(options: {\n title: string;\n agenda?: string;\n start: string;\n end: string;\n timezone?: string;\n enabledAutoRecordMeeting?: boolean;\n allowAnyUserToBeCoHost?: boolean;\n invitees?: Array<{ email: string; displayName?: string; coHost?: boolean }>;\n}): Promise<WebexMeeting> {\n const body: Record<string, unknown> = {\n title: options.title,\n start: options.start,\n end: options.end,\n timezone: options.timezone ?? \"UTC\",\n };\n\n if (options.agenda) body.agenda = options.agenda;\n if (options.enabledAutoRecordMeeting !== undefined) {\n body.enabledAutoRecordMeeting = options.enabledAutoRecordMeeting;\n }\n if (options.allowAnyUserToBeCoHost !== undefined) {\n body.allowAnyUserToBeCoHost = options.allowAnyUserToBeCoHost;\n }\n if (options.invitees?.length) body.invitees = options.invitees;\n\n return webexFetch<WebexMeeting>(\"/meetings\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n}\n\n/**\n * Update an existing meeting\n */\nexport async function updateMeeting(\n meetingId: string,\n updates: {\n title?: string;\n agenda?: string;\n start?: string;\n end?: string;\n timezone?: string;\n enabledAutoRecordMeeting?: boolean;\n },\n): Promise<WebexMeeting> {\n return webexFetch<WebexMeeting>(`/meetings/${meetingId}`, {\n method: \"PATCH\",\n body: JSON.stringify(updates),\n });\n}\n\n/**\n * Delete a meeting\n */\nexport async function deleteMeeting(meetingId: string): Promise<void> {\n await webexFetch<void>(`/meetings/${meetingId}`, { method: \"DELETE\" });\n}\n\n/**\n * List Webex rooms (spaces)\n */\nexport async function listRooms(options?: {\n max?: number;\n type?: \"direct\" | \"group\";\n sortBy?: \"id\" | \"lastactivity\" | \"created\";\n}): Promise<WebexRoom[]> {\n const params = new URLSearchParams();\n\n setOptionalParam(params, \"max\", options?.max);\n setOptionalParam(params, \"type\", options?.type);\n setOptionalParam(params, \"sortBy\", options?.sortBy);\n\n const response = await webexFetch<{ items?: WebexRoom[] }>(`/rooms?${params}`);\n return response.items ?? [];\n}\n\n/**\n * Get details of a specific room\n */\nexport async function getRoom(roomId: string): Promise<WebexRoom> {\n return webexFetch<WebexRoom>(`/rooms/${roomId}`);\n}\n\n/**\n * Create a new room\n */\nexport async function createRoom(options: {\n title: string;\n teamId?: string;\n}): Promise<WebexRoom> {\n return webexFetch<WebexRoom>(\"/rooms\", {\n method: \"POST\",\n body: JSON.stringify(options),\n });\n}\n\n/**\n * Send a message to a Webex room\n */\nexport async function sendMessage(options: {\n roomId?: string;\n toPersonId?: string;\n toPersonEmail?: string;\n text?: string;\n markdown?: string;\n files?: string[];\n}): Promise<WebexMessage> {\n if (!options.roomId && !options.toPersonId && !options.toPersonEmail) {\n throw new Error(\"Must specify roomId, toPersonId, or toPersonEmail\");\n }\n\n if (!options.text && !options.markdown && !options.files) {\n throw new Error(\"Must specify text, markdown, or files\");\n }\n\n return webexFetch<WebexMessage>(\"/messages\", {\n method: \"POST\",\n body: JSON.stringify(options),\n });\n}\n\n/**\n * List messages in a room\n */\nexport async function listMessages(options: {\n roomId: string;\n max?: number;\n before?: string;\n beforeMessage?: string;\n}): Promise<WebexMessage[]> {\n const params = new URLSearchParams({ roomId: options.roomId });\n\n setOptionalParam(params, \"max\", options.max);\n setOptionalParam(params, \"before\", options.before);\n setOptionalParam(params, \"beforeMessage\", options.beforeMessage);\n\n const response = await webexFetch<{ items?: WebexMessage[] }>(\n `/messages?${params}`,\n );\n\n return response.items ?? [];\n}\n\n/**\n * Delete a message\n */\nexport async function deleteMessage(messageId: string): Promise<void> {\n await webexFetch<void>(`/messages/${messageId}`, { method: \"DELETE\" });\n}\n",
|
|
387
|
-
"app/api/auth/webex/callback/route.ts": "import { createOAuthCallbackHandler, webexConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
388
|
-
"app/api/auth/webex/route.ts": "import { createOAuthInitHandler, webexConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(webexConfig, {
|
|
387
|
+
"app/api/auth/webex/callback/route.ts": "import { createOAuthCallbackHandler, webexConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n async getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(webexConfig, { tokenStore: hybridTokenStore });\n",
|
|
388
|
+
"app/api/auth/webex/route.ts": "import { createOAuthInitHandler, webexConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(webexConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
389
389
|
}
|
|
390
390
|
},
|
|
391
391
|
"integration:sentry": {
|
|
@@ -408,8 +408,8 @@ export default {
|
|
|
408
408
|
".env.example": "# Google Drive Integration\n# Create OAuth credentials at https://console.cloud.google.com/apis/credentials\n# Make sure to enable:\n# - Google Drive API: https://console.cloud.google.com/apis/library/drive.googleapis.com\n#\n# Note: These credentials are shared across all Google integrations (Gmail, Calendar, Sheets, Drive)\n\nGOOGLE_CLIENT_ID=your_client_id_here\nGOOGLE_CLIENT_SECRET=your_client_secret_here\n",
|
|
409
409
|
"lib/oauth.ts": "import { type OAuthToken, tokenStore } from \"./token-store.ts\";\n\nexport interface OAuthProvider {\n name: string;\n authorizationUrl: string;\n tokenUrl: string;\n clientId: string;\n clientSecret: string;\n scopes: string[];\n callbackPath: string;\n}\n\nfunction buildTokenRequest(\n provider: OAuthProvider,\n body: Record<string, string>,\n): RequestInit {\n return {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n ...body,\n }),\n };\n}\n\nasync function fetchToken(\n provider: OAuthProvider,\n body: Record<string, string>,\n errorPrefix: string,\n): Promise<any> {\n const response = await fetch(\n provider.tokenUrl,\n buildTokenRequest(provider, body),\n );\n\n if (response.ok) return response.json();\n\n const error = await response.text();\n throw new Error(`${errorPrefix}: ${response.status} - ${error}`);\n}\n\nfunction toOAuthToken(data: any, fallbackRefreshToken?: string): OAuthToken {\n const expiresIn = data.expires_in;\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token ?? fallbackRefreshToken,\n expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined,\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport function getAuthorizationUrl(\n provider: OAuthProvider,\n state: string,\n redirectUri: string,\n): string {\n const params = new URLSearchParams({\n client_id: provider.clientId,\n redirect_uri: redirectUri,\n response_type: \"code\",\n scope: provider.scopes.join(\" \"),\n state,\n access_type: \"offline\",\n prompt: \"consent\",\n });\n\n return `${provider.authorizationUrl}?${params.toString()}`;\n}\n\nexport async function exchangeCodeForTokens(\n provider: OAuthProvider,\n code: string,\n redirectUri: string,\n): Promise<OAuthToken> {\n const data = await fetchToken(\n provider,\n {\n code,\n grant_type: \"authorization_code\",\n redirect_uri: redirectUri,\n },\n \"Token exchange failed\",\n );\n\n return toOAuthToken(data);\n}\n\nexport async function refreshAccessToken(\n provider: OAuthProvider,\n refreshToken: string,\n): Promise<OAuthToken> {\n const data = await fetchToken(\n provider,\n {\n refresh_token: refreshToken,\n grant_type: \"refresh_token\",\n },\n \"Token refresh failed\",\n );\n\n return toOAuthToken(data, refreshToken);\n}\n\nexport async function getValidToken(\n provider: OAuthProvider,\n userId: string,\n service: string,\n): Promise<string | null> {\n const token = await tokenStore.getToken(userId, service);\n if (!token) return null;\n\n const isExpired = token.expiresAt\n ? token.expiresAt < Date.now() + 5 * 60 * 1000\n : false;\n\n if (!isExpired) return token.accessToken;\n if (!token.refreshToken) return token.accessToken;\n\n try {\n const newToken = await refreshAccessToken(provider, token.refreshToken);\n await tokenStore.setToken(userId, service, newToken);\n return newToken.accessToken;\n } catch {\n await tokenStore.revokeToken(userId, service);\n return null;\n }\n}\n",
|
|
410
410
|
"lib/drive-client.ts": "import { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n return undefined;\n}\n\nconst DRIVE_API_BASE = \"https://www.googleapis.com/drive/v3\";\n\nexport interface DriveFile {\n id: string;\n name: string;\n mimeType: string;\n kind: string;\n createdTime: string;\n modifiedTime: string;\n size?: string;\n webViewLink?: string;\n webContentLink?: string;\n iconLink?: string;\n thumbnailLink?: string;\n parents?: string[];\n starred?: boolean;\n trashed?: boolean;\n shared?: boolean;\n owners?: Array<{\n displayName: string;\n emailAddress: string;\n photoLink?: string;\n }>;\n lastModifyingUser?: {\n displayName: string;\n emailAddress: string;\n photoLink?: string;\n };\n capabilities?: {\n canEdit?: boolean;\n canComment?: boolean;\n canShare?: boolean;\n canDelete?: boolean;\n canDownload?: boolean;\n };\n}\n\nexport interface DriveFileList {\n files: DriveFile[];\n nextPageToken?: string;\n incompleteSearch?: boolean;\n}\n\nexport interface CreateFolderOptions {\n name: string;\n parentId?: string;\n description?: string;\n}\n\nexport interface UploadFileOptions {\n name: string;\n content: string;\n mimeType: string;\n parentId?: string;\n description?: string;\n}\n\nexport interface ListFilesOptions {\n folderId?: string;\n pageSize?: number;\n pageToken?: string;\n orderBy?: string;\n query?: string;\n}\n\nexport interface SearchFilesOptions {\n query: string;\n pageSize?: number;\n pageToken?: string;\n orderBy?: string;\n}\n\nexport const driveOAuthProvider = {\n name: \"drive\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/drive.readonly\",\n \"https://www.googleapis.com/auth/drive.file\",\n ],\n callbackPath: \"/api/auth/drive/callback\",\n};\n\nexport function createDriveClient(userId: string): {\n listFiles(options?: ListFilesOptions): Promise<DriveFileList>;\n getFile(fileId: string): Promise<DriveFile>;\n searchFiles(options: SearchFilesOptions): Promise<DriveFileList>;\n createFolder(options: CreateFolderOptions): Promise<DriveFile>;\n uploadFile(options: UploadFileOptions): Promise<DriveFile>;\n downloadFile(fileId: string): Promise<string>;\n deleteFile(fileId: string): Promise<void>;\n copyFile(fileId: string, name: string, parentId?: string): Promise<DriveFile>;\n updateFile(\n fileId: string,\n updates: { name?: string; description?: string; starred?: boolean },\n ): Promise<DriveFile>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(driveOAuthProvider, userId, \"drive\");\n if (!token) {\n throw new Error(\"Google Drive not connected. Please connect your Google account first.\");\n }\n return token;\n }\n\n async function driveApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${DRIVE_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Drive API error: ${response.status} - ${error}`);\n }\n\n if (response.status === 204) return undefined as T;\n return response.json();\n }\n\n function buildMetadata(options: {\n name: string;\n mimeType: string;\n parentId?: string;\n description?: string;\n }): Record<string, unknown> {\n const metadata: Record<string, unknown> = {\n name: options.name,\n mimeType: options.mimeType,\n };\n\n if (options.parentId) metadata.parents = [options.parentId];\n if (options.description) metadata.description = options.description;\n\n return metadata;\n }\n\n const fileFields =\n \"id,name,mimeType,kind,createdTime,modifiedTime,size,webViewLink,webContentLink,iconLink,thumbnailLink,parents,starred,trashed,shared,owners,lastModifyingUser,capabilities\";\n\n return {\n async listFiles(options: ListFilesOptions = {}): Promise<DriveFileList> {\n const params = new URLSearchParams({\n fields: `nextPageToken,incompleteSearch,files(${fileFields})`,\n pageSize: String(options.pageSize ?? 100),\n orderBy: options.orderBy ?? \"modifiedTime desc\",\n });\n\n let query = \"trashed=false\";\n if (options.folderId) query += ` and '${options.folderId}' in parents`;\n if (options.query) query += ` and ${options.query}`;\n\n params.append(\"q\", query);\n if (options.pageToken) params.append(\"pageToken\", options.pageToken);\n\n return driveApiRequest<DriveFileList>(`/files?${params.toString()}`);\n },\n\n async getFile(fileId: string): Promise<DriveFile> {\n const params = new URLSearchParams({ fields: fileFields });\n return driveApiRequest<DriveFile>(`/files/${fileId}?${params.toString()}`);\n },\n\n async searchFiles(options: SearchFilesOptions): Promise<DriveFileList> {\n const params = new URLSearchParams({\n fields:\n \"nextPageToken,incompleteSearch,files(id,name,mimeType,kind,createdTime,modifiedTime,size,webViewLink,webContentLink,iconLink,thumbnailLink,parents,starred,trashed)\",\n pageSize: String(options.pageSize ?? 100),\n q: `${options.query} and trashed=false`,\n orderBy: options.orderBy ?? \"modifiedTime desc\",\n });\n\n if (options.pageToken) params.append(\"pageToken\", options.pageToken);\n\n return driveApiRequest<DriveFileList>(`/files?${params.toString()}`);\n },\n\n async createFolder(options: CreateFolderOptions): Promise<DriveFile> {\n const metadata = buildMetadata({\n name: options.name,\n mimeType: \"application/vnd.google-apps.folder\",\n parentId: options.parentId,\n description: options.description,\n });\n\n return driveApiRequest<DriveFile>(\"/files\", {\n method: \"POST\",\n body: JSON.stringify(metadata),\n });\n },\n\n async uploadFile(options: UploadFileOptions): Promise<DriveFile> {\n const accessToken = await getAccessToken();\n\n const boundary = \"-------314159265358979323846\";\n const delimiter = `\\r\\n--${boundary}\\r\\n`;\n const closeDelimiter = `\\r\\n--${boundary}--`;\n\n const metadata = buildMetadata({\n name: options.name,\n mimeType: options.mimeType,\n parentId: options.parentId,\n description: options.description,\n });\n\n const multipartRequestBody =\n delimiter +\n \"Content-Type: application/json\\r\\n\\r\\n\" +\n JSON.stringify(metadata) +\n delimiter +\n `Content-Type: ${options.mimeType}\\r\\n\\r\\n` +\n options.content +\n closeDelimiter;\n\n const response = await fetch(\n \"https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,mimeType,kind,createdTime,modifiedTime,size,webViewLink,webContentLink\",\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": `multipart/related; boundary=${boundary}`,\n },\n body: multipartRequestBody,\n },\n );\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Drive upload error: ${response.status} - ${error}`);\n }\n\n return response.json();\n },\n\n async downloadFile(fileId: string): Promise<string> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${DRIVE_API_BASE}/files/${fileId}?alt=media`, {\n headers: { Authorization: `Bearer ${accessToken}` },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Drive download error: ${response.status} - ${error}`);\n }\n\n return response.text();\n },\n\n async deleteFile(fileId: string): Promise<void> {\n await driveApiRequest(`/files/${fileId}`, { method: \"DELETE\" });\n },\n\n async copyFile(fileId: string, name: string, parentId?: string): Promise<DriveFile> {\n const metadata: Record<string, unknown> = { name };\n if (parentId) metadata.parents = [parentId];\n\n return driveApiRequest<DriveFile>(`/files/${fileId}/copy`, {\n method: \"POST\",\n body: JSON.stringify(metadata),\n });\n },\n\n async updateFile(\n fileId: string,\n updates: { name?: string; description?: string; starred?: boolean },\n ): Promise<DriveFile> {\n return driveApiRequest<DriveFile>(`/files/${fileId}`, {\n method: \"PATCH\",\n body: JSON.stringify(updates),\n });\n },\n };\n}\n\nexport type DriveClient = ReturnType<typeof createDriveClient>;\n",
|
|
411
|
-
"app/api/auth/drive/callback/route.ts": "/**\n * Google Drive OAuth Callback\n *\n * Handles the OAuth callback from Google and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, driveConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
412
|
-
"app/api/auth/drive/route.ts": "import { createOAuthInitHandler, driveConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(driveConfig, {
|
|
411
|
+
"app/api/auth/drive/callback/route.ts": "/**\n * Google Drive OAuth Callback\n *\n * Handles the OAuth callback from Google and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, driveConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(driveConfig, { tokenStore: hybridTokenStore });\n",
|
|
412
|
+
"app/api/auth/drive/route.ts": "import { createOAuthInitHandler, driveConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(driveConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
413
413
|
}
|
|
414
414
|
},
|
|
415
415
|
"integration:bitbucket": {
|
|
@@ -420,8 +420,8 @@ export default {
|
|
|
420
420
|
"tools/create-pull-request.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createBitbucketClient } from \"../../lib/bitbucket-client.ts\";\n\nexport default tool({\n id: \"create-pull-request\",\n description: \"Create a new pull request in a Bitbucket repository\",\n inputSchema: z.object({\n workspace: z.string().describe(\"Workspace name or UUID\"),\n repoSlug: z.string().describe(\"Repository slug (e.g., 'my-repo')\"),\n title: z.string().min(1).describe(\"Pull request title\"),\n description: z\n .string()\n .optional()\n .describe(\"Pull request description (supports Markdown)\"),\n sourceBranch: z.string().describe(\"Source branch name\"),\n destinationBranch: z.string().describe(\"Destination branch name\"),\n closeSourceBranch: z\n .boolean()\n .optional()\n .default(false)\n .describe(\"Close source branch after merge\"),\n }),\n execute: async (\n {\n workspace,\n repoSlug,\n title,\n description,\n sourceBranch,\n destinationBranch,\n closeSourceBranch,\n },\n context,\n ) => {\n const userId = context?.userId ?? \"current-user\";\n\n try {\n const bitbucket = createBitbucketClient(userId);\n const pr = await bitbucket.createPullRequest(workspace, repoSlug, {\n title,\n description,\n sourceBranch,\n destinationBranch,\n closeSourceBranch,\n });\n\n return {\n success: true,\n pullRequest: {\n id: pr.id,\n title: pr.title,\n url: pr.links.html.href,\n state: pr.state,\n sourceBranch: pr.source.branch.name,\n destinationBranch: pr.destination.branch.name,\n author: {\n username: pr.author.username,\n displayName: pr.author.display_name,\n },\n },\n message: `Pull request #${pr.id} created successfully in ${workspace}/${repoSlug}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Bitbucket not connected. Please connect your Bitbucket account.\",\n connectUrl: \"/api/auth/bitbucket\",\n };\n }\n\n throw error;\n }\n },\n});\n",
|
|
421
421
|
".env.example": "# Bitbucket OAuth Configuration\n# Create a new OAuth consumer at: https://bitbucket.org/account/settings/app-passwords/\n# Or create an OAuth consumer at: https://bitbucket.org/{workspace}/workspace/settings/oauth-consumers/new\n# Set the callback URL to: http://localhost:3000/api/auth/bitbucket/callback\n# (Update the URL for production)\n# Required permissions: repository, pullrequest, issue, account\n\nBITBUCKET_CLIENT_ID=your_bitbucket_client_id\nBITBUCKET_CLIENT_SECRET=your_bitbucket_client_secret\n",
|
|
422
422
|
"lib/bitbucket-client.ts": "import { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n\n return undefined;\n}\n\nconst BITBUCKET_API_BASE = \"https://api.bitbucket.org/2.0\";\n\nexport interface BitbucketUser {\n uuid: string;\n username: string;\n display_name: string;\n account_id: string;\n links: {\n avatar: { href: string };\n html: { href: string };\n };\n}\n\nexport interface Repository {\n uuid: string;\n name: string;\n full_name: string;\n description: string | null;\n is_private: boolean;\n mainbranch: { name: string } | null;\n language: string;\n size: number;\n updated_on: string;\n created_on: string;\n links: {\n html: { href: string };\n clone: Array<{ href: string; name: string }>;\n };\n owner: {\n username: string;\n display_name: string;\n };\n}\n\nexport interface PullRequest {\n id: number;\n title: string;\n description: string;\n state: \"OPEN\" | \"MERGED\" | \"DECLINED\" | \"SUPERSEDED\";\n author: {\n username: string;\n display_name: string;\n };\n created_on: string;\n updated_on: string;\n source: {\n branch: { name: string };\n repository: { full_name: string };\n };\n destination: {\n branch: { name: string };\n repository: { full_name: string };\n };\n links: {\n html: { href: string };\n diff: { href: string };\n };\n comment_count: number;\n task_count: number;\n}\n\nexport interface Issue {\n id: number;\n title: string;\n content: {\n raw: string;\n } | null;\n state: \"new\" | \"open\" | \"resolved\" | \"on hold\" | \"invalid\" | \"duplicate\" | \"wontfix\" | \"closed\";\n kind: \"bug\" | \"enhancement\" | \"proposal\" | \"task\";\n priority: \"trivial\" | \"minor\" | \"major\" | \"critical\" | \"blocker\";\n created_on: string;\n updated_on: string;\n reporter: {\n username: string;\n display_name: string;\n };\n assignee: {\n username: string;\n display_name: string;\n } | null;\n links: {\n html: { href: string };\n };\n}\n\nexport const bitbucketOAuthProvider = {\n name: \"bitbucket\",\n authorizationUrl: \"https://bitbucket.org/site/oauth2/authorize\",\n tokenUrl: \"https://bitbucket.org/site/oauth2/access_token\",\n clientId: getEnv(\"BITBUCKET_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"BITBUCKET_CLIENT_SECRET\") ?? \"\",\n scopes: [\"repository\", \"pullrequest\", \"issue\", \"account\"],\n callbackPath: \"/api/auth/bitbucket/callback\",\n};\n\nfunction buildQuery(params: URLSearchParams): string {\n const query = params.toString();\n return query ? `?${query}` : \"\";\n}\n\nexport function createBitbucketClient(userId: string): {\n getCurrentUser(): Promise<BitbucketUser>;\n listRepositories(options?: { role?: \"owner\" | \"contributor\" | \"member\"; perPage?: number }): Promise<Repository[]>;\n getRepository(workspace: string, repoSlug: string): Promise<Repository>;\n listPullRequests(\n workspace: string,\n repoSlug: string,\n options?: { state?: \"OPEN\" | \"MERGED\" | \"DECLINED\" | \"SUPERSEDED\"; perPage?: number },\n ): Promise<PullRequest[]>;\n getPullRequest(workspace: string, repoSlug: string, pullRequestId: number): Promise<PullRequest>;\n createPullRequest(\n workspace: string,\n repoSlug: string,\n options: {\n title: string;\n description?: string;\n sourceBranch: string;\n destinationBranch: string;\n closeSourceBranch?: boolean;\n },\n ): Promise<PullRequest>;\n listIssues(\n workspace: string,\n repoSlug: string,\n options?: {\n state?:\n | \"new\"\n | \"open\"\n | \"resolved\"\n | \"on hold\"\n | \"invalid\"\n | \"duplicate\"\n | \"wontfix\"\n | \"closed\";\n kind?: \"bug\" | \"enhancement\" | \"proposal\" | \"task\";\n priority?: \"trivial\" | \"minor\" | \"major\" | \"critical\" | \"blocker\";\n perPage?: number;\n },\n ): Promise<Issue[]>;\n createIssue(\n workspace: string,\n repoSlug: string,\n options: {\n title: string;\n description?: string;\n kind?: \"bug\" | \"enhancement\" | \"proposal\" | \"task\";\n priority?: \"trivial\" | \"minor\" | \"major\" | \"critical\" | \"blocker\";\n },\n ): Promise<Issue>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(bitbucketOAuthProvider, userId, \"bitbucket\");\n if (!token) throw new Error(\"Bitbucket not connected. Please connect your Bitbucket account first.\");\n return token;\n }\n\n async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${BITBUCKET_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Bitbucket API error: ${response.status} - ${error}`);\n }\n\n return response.json();\n }\n\n return {\n getCurrentUser(): Promise<BitbucketUser> {\n return apiRequest(\"/user\");\n },\n\n async listRepositories(\n options: {\n role?: \"owner\" | \"contributor\" | \"member\";\n perPage?: number;\n } = {},\n ): Promise<Repository[]> {\n const params = new URLSearchParams();\n if (options.role) params.set(\"role\", options.role);\n if (options.perPage) params.set(\"pagelen\", String(options.perPage));\n\n const { values } = await apiRequest<{ values: Repository[] }>(`/repositories${buildQuery(params)}`);\n return values;\n },\n\n getRepository(workspace: string, repoSlug: string): Promise<Repository> {\n return apiRequest(`/repositories/${workspace}/${repoSlug}`);\n },\n\n async listPullRequests(\n workspace: string,\n repoSlug: string,\n options: {\n state?: \"OPEN\" | \"MERGED\" | \"DECLINED\" | \"SUPERSEDED\";\n perPage?: number;\n } = {},\n ): Promise<PullRequest[]> {\n const params = new URLSearchParams();\n if (options.state) params.set(\"state\", options.state);\n if (options.perPage) params.set(\"pagelen\", String(options.perPage));\n\n const { values } = await apiRequest<{ values: PullRequest[] }>(\n `/repositories/${workspace}/${repoSlug}/pullrequests${buildQuery(params)}`,\n );\n return values;\n },\n\n getPullRequest(workspace: string, repoSlug: string, pullRequestId: number): Promise<PullRequest> {\n return apiRequest(`/repositories/${workspace}/${repoSlug}/pullrequests/${pullRequestId}`);\n },\n\n createPullRequest(\n workspace: string,\n repoSlug: string,\n options: {\n title: string;\n description?: string;\n sourceBranch: string;\n destinationBranch: string;\n closeSourceBranch?: boolean;\n },\n ): Promise<PullRequest> {\n return apiRequest(`/repositories/${workspace}/${repoSlug}/pullrequests`, {\n method: \"POST\",\n body: JSON.stringify({\n title: options.title,\n description: options.description,\n source: { branch: { name: options.sourceBranch } },\n destination: { branch: { name: options.destinationBranch } },\n close_source_branch: options.closeSourceBranch,\n }),\n });\n },\n\n async listIssues(\n workspace: string,\n repoSlug: string,\n options: {\n state?:\n | \"new\"\n | \"open\"\n | \"resolved\"\n | \"on hold\"\n | \"invalid\"\n | \"duplicate\"\n | \"wontfix\"\n | \"closed\";\n kind?: \"bug\" | \"enhancement\" | \"proposal\" | \"task\";\n priority?: \"trivial\" | \"minor\" | \"major\" | \"critical\" | \"blocker\";\n perPage?: number;\n } = {},\n ): Promise<Issue[]> {\n const params = new URLSearchParams();\n if (options.state) params.set(\"q\", `state=\"${options.state}\"`);\n if (options.kind) params.set(\"kind\", options.kind);\n if (options.priority) params.set(\"priority\", options.priority);\n if (options.perPage) params.set(\"pagelen\", String(options.perPage));\n\n const { values } = await apiRequest<{ values: Issue[] }>(\n `/repositories/${workspace}/${repoSlug}/issues${buildQuery(params)}`,\n );\n return values;\n },\n\n createIssue(\n workspace: string,\n repoSlug: string,\n options: {\n title: string;\n description?: string;\n kind?: \"bug\" | \"enhancement\" | \"proposal\" | \"task\";\n priority?: \"trivial\" | \"minor\" | \"major\" | \"critical\" | \"blocker\";\n },\n ): Promise<Issue> {\n return apiRequest(`/repositories/${workspace}/${repoSlug}/issues`, {\n method: \"POST\",\n body: JSON.stringify({\n title: options.title,\n content: options.description ? { raw: options.description } : undefined,\n kind: options.kind ?? \"bug\",\n priority: options.priority ?? \"major\",\n }),\n });\n },\n };\n}\n\nexport type BitbucketClient = ReturnType<typeof createBitbucketClient>;\n",
|
|
423
|
-
"app/api/auth/bitbucket/callback/route.ts": "/**\n * Bitbucket OAuth Callback\n *\n * Handles the OAuth callback from Atlassian and stores the tokens.\n */\n\nimport { bitbucketConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
424
|
-
"app/api/auth/bitbucket/route.ts": "import { bitbucketConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(bitbucketConfig, {\n tokenStore: oauthMemoryTokenStore,\n})
|
|
423
|
+
"app/api/auth/bitbucket/callback/route.ts": "/**\n * Bitbucket OAuth Callback\n *\n * Handles the OAuth callback from Atlassian and stores the tokens.\n */\n\nimport { bitbucketConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(bitbucketConfig, { tokenStore: hybridTokenStore });\n",
|
|
424
|
+
"app/api/auth/bitbucket/route.ts": "import { bitbucketConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(bitbucketConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
425
425
|
}
|
|
426
426
|
},
|
|
427
427
|
"integration:pipedrive": {
|
|
@@ -433,8 +433,8 @@ export default {
|
|
|
433
433
|
"tools/list-persons.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { listPersons } from \"../../lib/pipedrive-client.ts\";\n\nexport default tool({\n id: \"list-persons\",\n description: \"List contacts/persons from Pipedrive. Can optionally search by name or email.\",\n inputSchema: z.object({\n searchTerm: z.string().optional().describe(\"Search term to filter persons by name or email\"),\n limit: z.number().min(1).max(100).default(20).describe(\"Maximum number of persons to return\"),\n }),\n async execute({ searchTerm, limit }) {\n const persons = await listPersons({ searchTerm, limit });\n\n return persons.map((person) => ({\n id: person.id,\n name: person.name,\n firstName: person.first_name,\n lastName: person.last_name,\n email: person.email?.[0]?.value ?? null,\n phone: person.phone?.[0]?.value ?? null,\n orgId: person.org_id,\n orgName: person.org_name,\n ownerName: person.owner_name,\n addTime: person.add_time,\n updateTime: person.update_time,\n }));\n },\n});\n",
|
|
434
434
|
".env.example": "# Pipedrive OAuth Configuration\n# Get your credentials from https://developers.pipedrive.com/docs/marketplace\nPIPEDRIVE_CLIENT_ID=your-client-id\nPIPEDRIVE_CLIENT_SECRET=your-client-secret\n",
|
|
435
435
|
"lib/pipedrive-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst PIPEDRIVE_BASE_URL = \"https://api.pipedrive.com/v1\";\n\ninterface PipedriveResponse<T> {\n success: boolean;\n data: T;\n additional_data?: {\n pagination?: {\n start: number;\n limit: number;\n more_items_in_collection: boolean;\n next_start?: number;\n };\n };\n}\n\ninterface PipedriveDeal {\n id: number;\n title: string;\n value: number;\n currency: string;\n status: string;\n stage_id: number;\n person_id: number | null;\n person_name: string | null;\n org_id: number | null;\n org_name: string | null;\n owner_name: string;\n expected_close_date: string | null;\n add_time: string;\n update_time: string;\n won_time: string | null;\n lost_time: string | null;\n close_time: string | null;\n}\n\ninterface PipedrivePerson {\n id: number;\n name: string;\n first_name: string;\n last_name: string;\n email: Array<{ value: string; primary: boolean }>;\n phone: Array<{ value: string; primary: boolean }>;\n org_id: number | null;\n org_name: string | null;\n owner_id: number;\n owner_name: string;\n add_time: string;\n update_time: string;\n}\n\ninterface PipedriveStage {\n id: number;\n name: string;\n order_nr: number;\n pipeline_id: number;\n pipeline_name: string;\n}\n\nasync function pipedriveFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Pipedrive. Please connect your account.\");\n }\n\n const url = new URL(`${PIPEDRIVE_BASE_URL}${endpoint}`);\n // SECURITY: Pipedrive's v1 API requires the token as a query parameter.\n // Tokens in query params may be recorded in browser history, server/proxy\n // access logs, and leaked via the Referer header. The Referrer-Policy\n // header (set by Veryfront's security middleware) mitigates the Referer leak.\n // This is an API design limitation — there is no Authorization header alternative.\n url.searchParams.set(\"api_token\", token);\n\n const response = await fetch(url.toString(), {\n ...options,\n headers: {\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({} as { error?: string }));\n throw new Error(`Pipedrive API error: ${response.status} ${error.error ?? response.statusText}`);\n }\n\n return response.json();\n}\n\nfunction buildEndpoint(path: string, params: URLSearchParams): string {\n const queryString = params.toString();\n return queryString ? `${path}?${queryString}` : path;\n}\n\nexport async function listDeals(options?: {\n status?: \"open\" | \"won\" | \"lost\" | \"all\";\n ownerId?: number;\n stageId?: number;\n limit?: number;\n}): Promise<PipedriveDeal[]> {\n const params = new URLSearchParams();\n\n if (options?.status) params.set(\"status\", options.status);\n if (options?.ownerId) params.set(\"user_id\", options.ownerId.toString());\n if (options?.stageId) params.set(\"stage_id\", options.stageId.toString());\n if (options?.limit) params.set(\"limit\", options.limit.toString());\n\n const response = await pipedriveFetch<PipedriveResponse<PipedriveDeal[]>>(\n buildEndpoint(\"/deals\", params),\n );\n\n return response.data || [];\n}\n\nexport async function getDeal(dealId: number): Promise<PipedriveDeal> {\n const response = await pipedriveFetch<PipedriveResponse<PipedriveDeal>>(`/deals/${dealId}`);\n return response.data;\n}\n\nexport async function createDeal(options: {\n title: string;\n value?: number;\n currency?: string;\n personId?: number;\n orgId?: number;\n stageId?: number;\n expectedCloseDate?: string;\n}): Promise<PipedriveDeal> {\n const body: Record<string, unknown> = { title: options.title };\n\n if (options.value !== undefined) body.value = options.value;\n if (options.currency) body.currency = options.currency;\n if (options.personId) body.person_id = options.personId;\n if (options.orgId) body.org_id = options.orgId;\n if (options.stageId) body.stage_id = options.stageId;\n if (options.expectedCloseDate) body.expected_close_date = options.expectedCloseDate;\n\n const response = await pipedriveFetch<PipedriveResponse<PipedriveDeal>>(\"/deals\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n\n return response.data;\n}\n\nexport async function updateDeal(\n dealId: number,\n updates: {\n title?: string;\n value?: number;\n status?: string;\n stageId?: number;\n personId?: number;\n orgId?: number;\n expectedCloseDate?: string;\n },\n): Promise<PipedriveDeal> {\n const body: Record<string, unknown> = {};\n\n if (updates.title !== undefined) body.title = updates.title;\n if (updates.value !== undefined) body.value = updates.value;\n if (updates.status !== undefined) body.status = updates.status;\n if (updates.stageId !== undefined) body.stage_id = updates.stageId;\n if (updates.personId !== undefined) body.person_id = updates.personId;\n if (updates.orgId !== undefined) body.org_id = updates.orgId;\n if (updates.expectedCloseDate !== undefined) body.expected_close_date = updates.expectedCloseDate;\n\n const response = await pipedriveFetch<PipedriveResponse<PipedriveDeal>>(`/deals/${dealId}`, {\n method: \"PUT\",\n body: JSON.stringify(body),\n });\n\n return response.data;\n}\n\nexport async function listPersons(options?: {\n searchTerm?: string;\n limit?: number;\n}): Promise<PipedrivePerson[]> {\n const params = new URLSearchParams();\n\n if (options?.searchTerm) params.set(\"term\", options.searchTerm);\n if (options?.limit) params.set(\"limit\", options.limit.toString());\n\n const response = await pipedriveFetch<PipedriveResponse<PipedrivePerson[]>>(\n buildEndpoint(\"/persons\", params),\n );\n\n return response.data || [];\n}\n\nexport async function getPerson(personId: number): Promise<PipedrivePerson> {\n const response = await pipedriveFetch<PipedriveResponse<PipedrivePerson>>(`/persons/${personId}`);\n return response.data;\n}\n\nexport async function createPerson(options: {\n name: string;\n email?: string;\n phone?: string;\n orgId?: number;\n}): Promise<PipedrivePerson> {\n const body: Record<string, unknown> = { name: options.name };\n\n if (options.email) body.email = [{ value: options.email, primary: true }];\n if (options.phone) body.phone = [{ value: options.phone, primary: true }];\n if (options.orgId) body.org_id = options.orgId;\n\n const response = await pipedriveFetch<PipedriveResponse<PipedrivePerson>>(\"/persons\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n\n return response.data;\n}\n\nexport async function listStages(): Promise<PipedriveStage[]> {\n const response = await pipedriveFetch<PipedriveResponse<PipedriveStage[]>>(\"/stages\");\n return response.data || [];\n}\n\nexport async function getCurrentUser(): Promise<{ id: number; name: string; email: string }> {\n const response = await pipedriveFetch<PipedriveResponse<{ id: number; name: string; email: string }>>(\n \"/users/me\",\n );\n return response.data;\n}\n",
|
|
436
|
-
"app/api/auth/pipedrive/callback/route.ts": "import { createOAuthCallbackHandler, pipedriveConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
437
|
-
"app/api/auth/pipedrive/route.ts": "import { createOAuthInitHandler, pipedriveConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(pipedriveConfig, {
|
|
436
|
+
"app/api/auth/pipedrive/callback/route.ts": "import { createOAuthCallbackHandler, pipedriveConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(pipedriveConfig, { tokenStore: hybridTokenStore });\n",
|
|
437
|
+
"app/api/auth/pipedrive/route.ts": "import { createOAuthInitHandler, pipedriveConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(pipedriveConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
438
438
|
}
|
|
439
439
|
},
|
|
440
440
|
"integration:servicenow": {
|
|
@@ -458,8 +458,8 @@ export default {
|
|
|
458
458
|
"tools/upload-file.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createFolder, uploadFile } from \"../../lib/sharepoint-client.ts\";\n\nexport default tool({\n id: \"upload-file\",\n description:\n \"Upload a file to a SharePoint document library. Can upload to root or a specific folder.\",\n inputSchema: z.object({\n siteId: z.string().describe(\"The ID of the SharePoint site\"),\n driveId: z.string().describe(\"The ID of the document library (drive) to upload to\"),\n fileName: z.string().describe(\"The name of the file to create (including extension)\"),\n content: z.string().describe(\"The content of the file to upload\"),\n folderId: z\n .string()\n .optional()\n .describe(\"Optional folder ID to upload into. If not provided, uploads to root.\"),\n createFolderIfNeeded: z\n .boolean()\n .default(false)\n .describe(\"If true and folderPath is provided, creates the folder if it does not exist\"),\n folderPath: z\n .string()\n .optional()\n .describe(\n 'Optional folder path (e.g., \"Documents/Projects\") to create if createFolderIfNeeded is true',\n ),\n }),\n async execute({\n siteId,\n driveId,\n fileName,\n content,\n folderId,\n createFolderIfNeeded,\n folderPath,\n }) {\n const targetFolderId = await resolveTargetFolderId({\n siteId,\n driveId,\n folderId,\n createFolderIfNeeded,\n folderPath,\n });\n\n const file = await uploadFile(siteId, driveId, fileName, content, targetFolderId);\n\n return {\n id: file.id,\n name: file.name,\n size: file.size,\n sizeFormatted: formatBytes(file.size),\n mimeType: file.file?.mimeType,\n url: file.webUrl,\n created: file.createdDateTime,\n lastModified: file.lastModifiedDateTime,\n parentPath: file.parentReference?.path,\n message: `Successfully uploaded \"${fileName}\" to SharePoint`,\n };\n },\n});\n\nasync function resolveTargetFolderId({\n siteId,\n driveId,\n folderId,\n createFolderIfNeeded,\n folderPath,\n}: {\n siteId: string;\n driveId: string;\n folderId?: string;\n createFolderIfNeeded: boolean;\n folderPath?: string;\n}): Promise<string | undefined> {\n if (!createFolderIfNeeded || !folderPath || folderId) return folderId;\n\n const folders = folderPath.split(\"/\").filter(Boolean);\n let currentFolderId: string | undefined;\n\n for (const folderName of folders) {\n try {\n const folder = await createFolder(siteId, driveId, folderName, currentFolderId);\n currentFolderId = folder.id;\n } catch (error) {\n console.warn(`Note: Could not create folder \"${folderName}\":`, error);\n }\n }\n\n return currentFolderId;\n}\n\nfunction formatBytes(bytes: number): string {\n if (bytes === 0) return \"0 Bytes\";\n\n const k = 1024;\n const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\", \"TB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;\n}\n",
|
|
459
459
|
"tools/list-files.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { listFiles, searchFiles } from \"../../lib/sharepoint-client.ts\";\n\nexport default tool({\n id: \"list-files\",\n description:\n \"List files and folders in a SharePoint document library. Can list root level or a specific folder, or search across the entire library.\",\n inputSchema: z.object({\n siteId: z.string().describe(\"The ID of the SharePoint site\"),\n driveId: z.string().describe(\"The ID of the document library (drive)\"),\n folderId: z\n .string()\n .optional()\n .describe(\n \"Optional folder ID to list contents from. If not provided, lists root level.\",\n ),\n search: z\n .string()\n .optional()\n .describe(\n \"Optional search query to find files by name or content instead of listing\",\n ),\n orderBy: z\n .enum([\"name\", \"lastModifiedDateTime\", \"size\"])\n .optional()\n .describe(\"Sort order for results\"),\n limit: z\n .number()\n .min(1)\n .max(100)\n .default(50)\n .describe(\"Maximum number of items to return\"),\n }),\n async execute({ siteId, driveId, folderId, search, orderBy, limit }) {\n const files = search\n ? await searchFiles(siteId, search, { limit })\n : await listFiles(siteId, driveId, folderId, { limit, orderBy });\n\n return files.map((file) => ({\n id: file.id,\n name: file.name,\n type: file.folder ? \"folder\" : \"file\",\n size: file.size,\n sizeFormatted: formatBytes(file.size),\n mimeType: file.file?.mimeType,\n url: file.webUrl,\n created: file.createdDateTime,\n lastModified: file.lastModifiedDateTime,\n createdBy: file.createdBy?.user?.displayName,\n lastModifiedBy: file.lastModifiedBy?.user?.displayName,\n parentPath: file.parentReference?.path,\n childCount: file.folder?.childCount,\n }));\n },\n});\n\nfunction formatBytes(bytes: number): string {\n if (bytes === 0) return \"0 Bytes\";\n\n const k = 1024;\n const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\", \"TB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;\n}\n",
|
|
460
460
|
"lib/sharepoint-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst GRAPH_BASE_URL = \"https://graph.microsoft.com/v1.0\";\n\nexport interface SharePointSite {\n id: string;\n name: string;\n displayName: string;\n description?: string;\n webUrl: string;\n createdDateTime: string;\n lastModifiedDateTime: string;\n siteCollection?: {\n hostname: string;\n };\n}\n\nexport interface SharePointDrive {\n id: string;\n name: string;\n description?: string;\n driveType: string;\n createdDateTime: string;\n lastModifiedDateTime: string;\n webUrl: string;\n quota?: {\n total: number;\n used: number;\n remaining: number;\n };\n}\n\nexport interface SharePointFile {\n id: string;\n name: string;\n size: number;\n createdDateTime: string;\n lastModifiedDateTime: string;\n webUrl: string;\n file?: {\n mimeType: string;\n hashes?: {\n sha1Hash?: string;\n quickXorHash?: string;\n };\n };\n folder?: {\n childCount: number;\n };\n parentReference?: {\n driveId: string;\n id: string;\n path: string;\n };\n createdBy?: {\n user?: {\n displayName: string;\n email?: string;\n };\n };\n lastModifiedBy?: {\n user?: {\n displayName: string;\n email?: string;\n };\n };\n}\n\ninterface GraphResponse<T> {\n value: T[];\n \"@odata.nextLink\"?: string;\n}\n\nasync function requireAccessToken(): Promise<string> {\n const token = await getAccessToken();\n if (!token) throw new Error(\"Not authenticated with Microsoft. Please connect your account.\");\n return token;\n}\n\nasync function graphFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await requireAccessToken();\n\n const response = await fetch(`${GRAPH_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...(options.headers ?? {}),\n },\n });\n\n if (response.ok) return response.json();\n\n const error = await response.json().catch(() => ({}));\n throw new Error(\n `Microsoft Graph API error: ${response.status} ${error.error?.message ?? response.statusText}`,\n );\n}\n\nexport async function listSites(options?: {\n search?: string;\n limit?: number;\n}): Promise<SharePointSite[]> {\n const endpoint = options?.search\n ? `/sites?search=${encodeURIComponent(options.search)}`\n : \"/sites?search=*\";\n\n const { value = [] } = await graphFetch<GraphResponse<SharePointSite>>(endpoint);\n return options?.limit ? value.slice(0, options.limit) : value;\n}\n\nexport function getSite(siteId: string): Promise<SharePointSite> {\n return graphFetch<SharePointSite>(`/sites/${siteId}`);\n}\n\nexport function getSiteByPath(hostname: string, sitePath: string): Promise<SharePointSite> {\n return graphFetch<SharePointSite>(`/sites/${hostname}:${sitePath}`);\n}\n\nexport async function listDrives(siteId: string): Promise<SharePointDrive[]> {\n const { value = [] } = await graphFetch<GraphResponse<SharePointDrive>>(`/sites/${siteId}/drives`);\n return value;\n}\n\nexport function getDefaultDrive(siteId: string): Promise<SharePointDrive> {\n return graphFetch<SharePointDrive>(`/sites/${siteId}/drive`);\n}\n\nexport async function listFiles(\n siteId: string,\n driveId: string,\n folderId?: string,\n options?: {\n limit?: number;\n orderBy?: string;\n },\n): Promise<SharePointFile[]> {\n const baseEndpoint = folderId\n ? `/sites/${siteId}/drives/${driveId}/items/${folderId}/children`\n : `/sites/${siteId}/drives/${driveId}/root/children`;\n\n const params = new URLSearchParams();\n if (options?.orderBy) params.set(\"$orderby\", options.orderBy);\n if (options?.limit) params.set(\"$top\", String(options.limit));\n\n const endpoint = params.size ? `${baseEndpoint}?${params.toString()}` : baseEndpoint;\n\n const { value = [] } = await graphFetch<GraphResponse<SharePointFile>>(endpoint);\n return value;\n}\n\nexport function getFile(siteId: string, driveId: string, itemId: string): Promise<SharePointFile> {\n return graphFetch<SharePointFile>(`/sites/${siteId}/drives/${driveId}/items/${itemId}`);\n}\n\nexport function getFileByPath(\n siteId: string,\n driveId: string,\n path: string,\n): Promise<SharePointFile> {\n const encodedPath = encodeURIComponent(path);\n return graphFetch<SharePointFile>(`/sites/${siteId}/drives/${driveId}/root:/${encodedPath}`);\n}\n\nexport async function downloadFile(\n siteId: string,\n driveId: string,\n itemId: string,\n): Promise<ArrayBuffer> {\n const token = await requireAccessToken();\n\n await getFile(siteId, driveId, itemId);\n\n const response = await fetch(\n `${GRAPH_BASE_URL}/sites/${siteId}/drives/${driveId}/items/${itemId}/content`,\n { headers: { Authorization: `Bearer ${token}` } },\n );\n\n if (!response.ok) throw new Error(`Failed to download file: ${response.statusText}`);\n\n return response.arrayBuffer();\n}\n\nexport async function downloadFileAsText(\n siteId: string,\n driveId: string,\n itemId: string,\n): Promise<string> {\n const buffer = await downloadFile(siteId, driveId, itemId);\n return new TextDecoder().decode(buffer);\n}\n\nexport async function uploadFile(\n siteId: string,\n driveId: string,\n fileName: string,\n content: string | ArrayBuffer | Blob,\n folderId?: string,\n): Promise<SharePointFile> {\n const token = await requireAccessToken();\n\n const encodedFileName = encodeURIComponent(fileName);\n const endpoint = folderId\n ? `/sites/${siteId}/drives/${driveId}/items/${folderId}:/${encodedFileName}:/content`\n : `/sites/${siteId}/drives/${driveId}/root:/${encodedFileName}:/content`;\n\n let body: ArrayBuffer;\n if (typeof content === \"string\") {\n body = new TextEncoder().encode(content);\n } else if (content instanceof Blob) {\n body = await content.arrayBuffer();\n } else {\n body = content;\n }\n\n const response = await fetch(`${GRAPH_BASE_URL}${endpoint}`, {\n method: \"PUT\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/octet-stream\",\n },\n body,\n });\n\n if (response.ok) return response.json();\n\n const error = await response.json().catch(() => ({}));\n throw new Error(`Failed to upload file: ${error.error?.message ?? response.statusText}`);\n}\n\nexport function createFolder(\n siteId: string,\n driveId: string,\n folderName: string,\n parentFolderId?: string,\n): Promise<SharePointFile> {\n const endpoint = parentFolderId\n ? `/sites/${siteId}/drives/${driveId}/items/${parentFolderId}/children`\n : `/sites/${siteId}/drives/${driveId}/root/children`;\n\n return graphFetch<SharePointFile>(endpoint, {\n method: \"POST\",\n body: JSON.stringify({\n name: folderName,\n folder: {},\n \"@microsoft.graph.conflictBehavior\": \"rename\",\n }),\n });\n}\n\nexport async function searchFiles(\n siteId: string,\n query: string,\n options?: {\n limit?: number;\n },\n): Promise<SharePointFile[]> {\n const baseEndpoint = `/sites/${siteId}/drive/root/search(q='${encodeURIComponent(query)}')`;\n const endpoint = options?.limit ? `${baseEndpoint}?$top=${options.limit}` : baseEndpoint;\n\n const { value = [] } = await graphFetch<GraphResponse<SharePointFile>>(endpoint);\n return value;\n}\n\nexport async function deleteItem(siteId: string, driveId: string, itemId: string): Promise<void> {\n await graphFetch<void>(`/sites/${siteId}/drives/${driveId}/items/${itemId}`, { method: \"DELETE\" });\n}\n\nexport function moveItem(\n siteId: string,\n driveId: string,\n itemId: string,\n newParentId: string,\n newName?: string,\n): Promise<SharePointFile> {\n const body: { parentReference: { id: string }; name?: string } = {\n parentReference: { id: newParentId },\n ...(newName ? { name: newName } : {}),\n };\n\n return graphFetch<SharePointFile>(`/sites/${siteId}/drives/${driveId}/items/${itemId}`, {\n method: \"PATCH\",\n body: JSON.stringify(body),\n });\n}\n\nexport async function copyItem(\n siteId: string,\n driveId: string,\n itemId: string,\n newParentId: string,\n newName?: string,\n): Promise<void> {\n const body: { parentReference: { driveId: string; id: string }; name?: string } = {\n parentReference: { driveId, id: newParentId },\n ...(newName ? { name: newName } : {}),\n };\n\n await graphFetch<void>(`/sites/${siteId}/drives/${driveId}/items/${itemId}/copy`, {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n}\n",
|
|
461
|
-
"app/api/auth/sharepoint/callback/route.ts": "import { createOAuthCallbackHandler, sharePointConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
462
|
-
"app/api/auth/sharepoint/route.ts": "import { createOAuthInitHandler, sharePointConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(sharePointConfig, {
|
|
461
|
+
"app/api/auth/sharepoint/callback/route.ts": "import { createOAuthCallbackHandler, sharePointConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(sharePointConfig, { tokenStore: hybridTokenStore });\n",
|
|
462
|
+
"app/api/auth/sharepoint/route.ts": "import { createOAuthInitHandler, sharePointConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(sharePointConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
463
463
|
}
|
|
464
464
|
},
|
|
465
465
|
"integration:gmail": {
|
|
@@ -468,9 +468,9 @@ export default {
|
|
|
468
468
|
"tools/search-emails.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createGmailClient, parseEmailHeaders } from \"../../lib/gmail-client.ts\";\n\nexport default tool({\n id: \"search-emails\",\n description:\n \"Search emails using Gmail's search syntax. Supports queries like 'from:person@email.com', 'subject:meeting', 'after:2024/01/01', etc.\",\n inputSchema: z.object({\n query: z\n .string()\n .min(1)\n .describe(\n \"Search query using Gmail search syntax (e.g., 'from:boss@company.com subject:urgent')\",\n ),\n maxResults: z\n .number()\n .min(1)\n .max(50)\n .default(10)\n .describe(\"Maximum number of results to return\"),\n }),\n execute: async ({ query, maxResults }, context) => {\n const userId = context?.userId ?? \"current-user\";\n const gmail = createGmailClient(userId);\n\n try {\n const list = await gmail.listMessages({ query, maxResults });\n\n if (!list.messages?.length) {\n return {\n emails: [],\n query,\n message: `No emails found matching: \"${query}\"`,\n searchTips: [\n \"from:email@example.com - Search by sender\",\n \"to:email@example.com - Search by recipient\",\n \"subject:keywords - Search in subject\",\n \"after:YYYY/MM/DD - Emails after date\",\n \"before:YYYY/MM/DD - Emails before date\",\n \"is:unread - Unread emails only\",\n \"has:attachment - Emails with attachments\",\n ],\n };\n }\n\n const emails = await Promise.all(\n list.messages.map(async ({ id }) => {\n const message = await gmail.getMessage(id, \"metadata\");\n const headers = parseEmailHeaders(message.payload?.headers ?? []);\n\n return {\n id: message.id,\n threadId: message.threadId,\n from: headers.from,\n to: headers.to,\n subject: headers.subject,\n date: headers.date,\n snippet: message.snippet,\n isUnread: message.labelIds?.includes(\"UNREAD\") ?? false,\n labels: message.labelIds,\n };\n }),\n );\n\n return {\n emails,\n query,\n count: emails.length,\n message: `Found ${emails.length} email(s) matching: \"${query}\"`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
469
469
|
"tools/send-email.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createGmailClient } from \"../../lib/gmail-client.ts\";\n\nfunction formatRecipients(value?: string | string[]): string | undefined {\n if (!value) return undefined;\n return Array.isArray(value) ? value.join(\", \") : value;\n}\n\nexport default tool({\n id: \"send-email\",\n description: \"Send an email via Gmail. Can send to multiple recipients with CC and BCC support.\",\n inputSchema: z.object({\n to: z.union([z.string().email(), z.array(z.string().email())]).describe(\"Email recipient(s)\"),\n subject: z.string().min(1).describe(\"Email subject line\"),\n body: z.string().min(1).describe(\"Email body content\"),\n cc: z\n .union([z.string().email(), z.array(z.string().email())])\n .optional()\n .describe(\"CC recipient(s)\"),\n bcc: z\n .union([z.string().email(), z.array(z.string().email())])\n .optional()\n .describe(\"BCC recipient(s)\"),\n isHtml: z.boolean().default(false).describe(\"Whether the body contains HTML\"),\n }),\n execute: async ({ to, subject, body, cc, bcc, isHtml }, context) => {\n const userId = context?.userId ?? \"current-user\";\n\n try {\n const gmail = createGmailClient(userId);\n\n const result = await gmail.sendEmail({ to, subject, body, cc, bcc, isHtml });\n\n const toFormatted = formatRecipients(to) ?? \"\";\n\n return {\n success: true,\n messageId: result.id,\n threadId: result.threadId,\n message: `Email sent successfully to ${toFormatted}.`,\n details: {\n to: toFormatted,\n subject,\n cc: formatRecipients(cc),\n bcc: formatRecipients(bcc),\n },\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
470
470
|
".env.example": "# =============================================================================\n# Gmail Integration Setup\n# =============================================================================\n#\n# STEP 1: Create a Google Cloud Project\n# Visit: https://console.cloud.google.com/projectcreate\n#\n# STEP 2: Enable the Gmail API\n# Visit: https://console.cloud.google.com/apis/library/gmail.googleapis.com\n# Click \"Enable\" to activate the Gmail API for your project\n#\n# STEP 3: Configure OAuth Consent Screen\n# Visit: https://console.cloud.google.com/apis/credentials/consent\n# - Choose \"External\" user type (or \"Internal\" for Workspace)\n# - Fill in app name, support email\n# - Add scopes: gmail.readonly, gmail.send, gmail.modify\n# - Add your email as a test user (required for development)\n#\n# STEP 4: Create OAuth Credentials\n# Visit: https://console.cloud.google.com/apis/credentials\n# - Click \"Create Credentials\" > \"OAuth client ID\"\n# - Application type: \"Web application\"\n# - Add Authorized redirect URI: http://localhost:3000/api/auth/gmail/callback\n# - Copy the Client ID and Client Secret below\n#\n# =============================================================================\n\nGOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com\nGOOGLE_CLIENT_SECRET=your-client-secret\n",
|
|
471
|
-
"lib/gmail-client.ts": "/**\n * Gmail API Client\n *\n * Provides a type-safe interface to Gmail API operations\n * using the veryfront/oauth module for authentication.\n */\n\nimport { gmailConfig, OAuthService } from \"veryfront/oauth\";\nimport { tokenStore } from \"./token-store.ts\";\n\nexport interface GmailMessage {\n id: string;\n threadId: string;\n labelIds: string[];\n snippet: string;\n payload?: {\n headers: Array<{ name: string; value: string }>;\n body?: { data?: string; size: number };\n parts?: Array<{\n mimeType: string;\n body?: { data?: string; size: number };\n }>;\n };\n internalDate: string;\n}\n\nexport interface GmailMessageList {\n messages: Array<{ id: string; threadId: string }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface SendEmailOptions {\n to: string | string[];\n subject: string;\n body: string;\n cc?: string | string[];\n bcc?: string | string[];\n replyTo?: string;\n isHtml?: boolean;\n}\n\nconst tokenStoreAdapter = {\n async getTokens(serviceId: string): Promise<unknown> {\n return tokenStore.getToken(
|
|
472
|
-
"app/api/auth/gmail/callback/route.ts": "import { createOAuthCallbackHandler, gmailConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
473
|
-
"app/api/auth/gmail/route.ts": "import { createOAuthInitHandler, gmailConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(gmailConfig, {
|
|
471
|
+
"lib/gmail-client.ts": "/**\n * Gmail API Client\n *\n * Provides a type-safe interface to Gmail API operations\n * using the veryfront/oauth module for authentication.\n */\n\nimport { gmailConfig, OAuthService } from \"veryfront/oauth\";\nimport { tokenStore } from \"./token-store.ts\";\n\nexport interface GmailMessage {\n id: string;\n threadId: string;\n labelIds: string[];\n snippet: string;\n payload?: {\n headers: Array<{ name: string; value: string }>;\n body?: { data?: string; size: number };\n parts?: Array<{\n mimeType: string;\n body?: { data?: string; size: number };\n }>;\n };\n internalDate: string;\n}\n\nexport interface GmailMessageList {\n messages: Array<{ id: string; threadId: string }>;\n nextPageToken?: string;\n resultSizeEstimate: number;\n}\n\nexport interface SendEmailOptions {\n to: string | string[];\n subject: string;\n body: string;\n cc?: string | string[];\n bcc?: string | string[];\n replyTo?: string;\n isHtml?: boolean;\n}\n\n// TokenStore adapter keyed by (serviceId, userId). All API calls must pass\n// the authenticated user's id — NEVER use a shared \"current-user\" constant\n// in production; that re-introduces VULN-AUTH-2.\nconst tokenStoreAdapter = {\n async getTokens(serviceId: string, userId: string): Promise<unknown> {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ): Promise<void> {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string): Promise<void> {\n await tokenStore.revokeToken(userId, serviceId);\n },\n async setState(): Promise<void> {},\n async consumeState(): Promise<null> {\n return null;\n },\n};\n\nconst gmailService = new OAuthService(gmailConfig, tokenStoreAdapter);\n\n/**\n * Create a Gmail client scoped to a specific user. Pass the authenticated\n * user's id (from your session). Tokens are looked up and stored per-user.\n */\nexport function createGmailClient(userId: string): {\n isConnected(): Promise<boolean>;\n listMessages(options?: {\n maxResults?: number;\n query?: string;\n labelIds?: string[];\n pageToken?: string;\n }): Promise<GmailMessageList>;\n getMessage(messageId: string, format?: \"full\" | \"metadata\" | \"minimal\"): Promise<GmailMessage>;\n sendEmail(options: SendEmailOptions): Promise<{ id: string; threadId: string }>;\n searchEmails(query: string, maxResults?: number): Promise<GmailMessage[]>;\n getUnreadEmails(maxResults?: number): Promise<GmailMessage[]>;\n markAsRead(messageId: string): Promise<void>;\n archiveEmail(messageId: string): Promise<void>;\n} {\n async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return gmailService.fetch<T>(userId, endpoint, options);\n }\n\n function formatAddresses(addresses: string | string[] | undefined): string {\n if (!addresses) return \"\";\n return Array.isArray(addresses) ? addresses.join(\", \") : addresses;\n }\n\n return {\n async isConnected(): Promise<boolean> {\n const token = await gmailService.getAccessToken(userId);\n return token !== null;\n },\n\n listMessages(\n options: {\n maxResults?: number;\n query?: string;\n labelIds?: string[];\n pageToken?: string;\n } = {},\n ): Promise<GmailMessageList> {\n const params = new URLSearchParams();\n\n if (options.maxResults != null) params.set(\"maxResults\", String(options.maxResults));\n if (options.query) params.set(\"q\", options.query);\n if (options.labelIds?.length) params.set(\"labelIds\", options.labelIds.join(\",\"));\n if (options.pageToken) params.set(\"pageToken\", options.pageToken);\n\n const query = params.toString();\n const url = query ? `/users/me/messages?${query}` : \"/users/me/messages\";\n\n return apiRequest<GmailMessageList>(url);\n },\n\n getMessage(messageId: string, format: \"full\" | \"metadata\" | \"minimal\" = \"full\"): Promise<GmailMessage> {\n return apiRequest<GmailMessage>(`/users/me/messages/${messageId}?format=${format}`);\n },\n\n sendEmail(options: SendEmailOptions): Promise<{ id: string; threadId: string }> {\n const toAddresses = formatAddresses(options.to);\n const ccAddresses = formatAddresses(options.cc);\n const bccAddresses = formatAddresses(options.bcc);\n\n const headers = [\n `To: ${toAddresses}`,\n `Subject: ${options.subject}`,\n options.isHtml ? \"Content-Type: text/html; charset=utf-8\" : \"Content-Type: text/plain; charset=utf-8\",\n ];\n\n if (ccAddresses) headers.push(`Cc: ${ccAddresses}`);\n if (bccAddresses) headers.push(`Bcc: ${bccAddresses}`);\n if (options.replyTo) headers.push(`Reply-To: ${options.replyTo}`);\n\n const email = `${headers.join(\"\\r\\n\")}\\r\\n\\r\\n${options.body}`;\n const encodedEmail = btoa(email).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n\n return apiRequest<{ id: string; threadId: string }>(\"/users/me/messages/send\", {\n method: \"POST\",\n body: JSON.stringify({ raw: encodedEmail }),\n });\n },\n\n async searchEmails(query: string, maxResults = 10): Promise<GmailMessage[]> {\n const list = await this.listMessages({ query, maxResults });\n if (!list.messages?.length) return [];\n return Promise.all(list.messages.map((m) => this.getMessage(m.id, \"metadata\")));\n },\n\n getUnreadEmails(maxResults = 10): Promise<GmailMessage[]> {\n return this.searchEmails(\"is:unread\", maxResults);\n },\n\n async markAsRead(messageId: string): Promise<void> {\n await apiRequest(`/users/me/messages/${messageId}/modify`, {\n method: \"POST\",\n body: JSON.stringify({ removeLabelIds: [\"UNREAD\"] }),\n });\n },\n\n async archiveEmail(messageId: string): Promise<void> {\n await apiRequest(`/users/me/messages/${messageId}/modify`, {\n method: \"POST\",\n body: JSON.stringify({ removeLabelIds: [\"INBOX\"] }),\n });\n },\n };\n}\n\nexport function parseEmailHeaders(\n headers: Array<{ name: string; value: string }>,\n): { from: string; to: string; subject: string; date: string } {\n function getHeader(name: string): string {\n return headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? \"\";\n }\n\n return {\n from: getHeader(\"From\"),\n to: getHeader(\"To\"),\n subject: getHeader(\"Subject\"),\n date: getHeader(\"Date\"),\n };\n}\n\nexport type GmailClient = ReturnType<typeof createGmailClient>;\n",
|
|
472
|
+
"app/api/auth/gmail/callback/route.ts": "import { createOAuthCallbackHandler, gmailConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(gmailConfig, { tokenStore: hybridTokenStore });\n",
|
|
473
|
+
"app/api/auth/gmail/route.ts": "import { createOAuthInitHandler, gmailConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(gmailConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
474
474
|
}
|
|
475
475
|
},
|
|
476
476
|
"integration:sheets": {
|
|
@@ -482,8 +482,8 @@ export default {
|
|
|
482
482
|
"tools/write-range.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"write-range\",\n description:\n \"Write data to a Google Sheets range. Overwrites existing content in the specified range. Provide data as a 2D array where each inner array is a row.\",\n inputSchema: z.object({\n spreadsheetId: z.string().describe(\"The ID of the spreadsheet\"),\n range: z\n .string()\n .describe(\n \"Range in A1 notation where to write data (e.g., 'Sheet1!A1', 'Sheet1!A1:D5')\",\n ),\n values: z\n .array(z.array(z.any()))\n .describe(\n \"2D array of values to write. Each inner array represents a row. Example: [['Name', 'Age'], ['John', 30], ['Jane', 25]]\",\n ),\n valueInputOption: z\n .enum([\"RAW\", \"USER_ENTERED\"])\n .default(\"USER_ENTERED\")\n .describe(\n \"RAW: Values are stored as-is. USER_ENTERED: Values are parsed as if typed by user (formulas, numbers, dates)\",\n ),\n }),\n async execute({ spreadsheetId, range, values, valueInputOption }) {\n const client = createSheetsClient(DEFAULT_USER_ID);\n return client.writeRange({ spreadsheetId, range, values, valueInputOption });\n },\n});\n",
|
|
483
483
|
".env.example": "# Google Sheets Integration\n# Create OAuth credentials at https://console.cloud.google.com/apis/credentials\n# Make sure to enable:\n# - Google Sheets API: https://console.cloud.google.com/apis/library/sheets.googleapis.com\n# - Google Drive API: https://console.cloud.google.com/apis/library/drive.googleapis.com\n\nGOOGLE_CLIENT_ID=your_client_id_here\nGOOGLE_CLIENT_SECRET=your_client_secret_here\n",
|
|
484
484
|
"lib/sheets-client.ts": "/**\n * Google Sheets API Client\n *\n * Provides a type-safe interface to Google Sheets API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n return undefined;\n}\n\nconst SHEETS_API_BASE = \"https://sheets.googleapis.com/v4\";\nconst DRIVE_API_BASE = \"https://www.googleapis.com/drive/v3\";\n\nexport interface Spreadsheet {\n spreadsheetId: string;\n properties: {\n title: string;\n locale: string;\n autoRecalc: string;\n timeZone: string;\n };\n sheets: Sheet[];\n spreadsheetUrl: string;\n}\n\nexport interface Sheet {\n properties: {\n sheetId: number;\n title: string;\n index: number;\n sheetType: \"GRID\" | \"OBJECT\";\n gridProperties?: {\n rowCount: number;\n columnCount: number;\n };\n };\n}\n\nexport interface SpreadsheetFile {\n id: string;\n name: string;\n mimeType: string;\n createdTime: string;\n modifiedTime: string;\n webViewLink: string;\n}\n\nexport interface CellData {\n values: unknown[][];\n range: string;\n}\n\nexport interface CreateSpreadsheetOptions {\n title: string;\n sheets?: Array<{\n title: string;\n rowCount?: number;\n columnCount?: number;\n }>;\n}\n\nexport interface WriteRangeOptions {\n spreadsheetId: string;\n range: string;\n values: unknown[][];\n valueInputOption?: \"RAW\" | \"USER_ENTERED\";\n}\n\nexport const sheetsOAuthProvider = {\n name: \"sheets\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/spreadsheets\",\n \"https://www.googleapis.com/auth/drive.readonly\",\n ],\n callbackPath: \"/api/auth/sheets/callback\",\n};\n\nexport function createSheetsClient(userId: string): {\n listSpreadsheets(options?: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n }): Promise<SpreadsheetFile[]>;\n getSpreadsheet(spreadsheetId: string): Promise<Spreadsheet>;\n readRange(spreadsheetId: string, range: string): Promise<CellData>;\n readRanges(spreadsheetId: string, ranges: string[]): Promise<CellData[]>;\n writeRange(options: WriteRangeOptions): Promise<{\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n }>;\n appendRange(\n spreadsheetId: string,\n range: string,\n values: unknown[][],\n valueInputOption?: \"RAW\" | \"USER_ENTERED\",\n ): Promise<{\n updates: {\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n };\n }>;\n clearRange(spreadsheetId: string, range: string): Promise<{ clearedRange: string }>;\n createSpreadsheet(options: CreateSpreadsheetOptions): Promise<Spreadsheet>;\n addSheet(\n spreadsheetId: string,\n title: string,\n options?: { rowCount?: number; columnCount?: number },\n ): Promise<Sheet>;\n deleteSheet(spreadsheetId: string, sheetId: number): Promise<void>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(sheetsOAuthProvider, userId, \"sheets\");\n if (token) return token;\n throw new Error(\"Google Sheets not connected. Please connect your Google account first.\");\n }\n\n async function apiRequest<T>(\n baseUrl: string,\n serviceName: \"Sheets\" | \"Drive\",\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${baseUrl}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`${serviceName} API error: ${response.status} - ${error}`);\n }\n\n return response.json();\n }\n\n function sheetsApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiRequest<T>(SHEETS_API_BASE, \"Sheets\", endpoint, options);\n }\n\n function driveApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiRequest<T>(DRIVE_API_BASE, \"Drive\", endpoint, options);\n }\n\n return {\n async listSpreadsheets(options: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n } = {}): Promise<SpreadsheetFile[]> {\n const params = new URLSearchParams({\n q: \"mimeType='application/vnd.google-apps.spreadsheet' and trashed=false\",\n fields: \"files(id,name,mimeType,createdTime,modifiedTime,webViewLink)\",\n pageSize: String(options.maxResults ?? 20),\n orderBy: `${options.orderBy ?? \"modifiedTime\"} desc`,\n });\n\n const result = await driveApiRequest<{ files?: SpreadsheetFile[] }>(`/files?${params.toString()}`);\n return result.files ?? [];\n },\n\n getSpreadsheet(spreadsheetId: string): Promise<Spreadsheet> {\n return sheetsApiRequest<Spreadsheet>(`/spreadsheets/${spreadsheetId}`);\n },\n\n async readRange(spreadsheetId: string, range: string): Promise<CellData> {\n const result = await sheetsApiRequest<{ values?: unknown[][]; range: string }>(\n `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}`,\n );\n\n return { values: result.values ?? [], range: result.range };\n },\n\n async readRanges(spreadsheetId: string, ranges: string[]): Promise<CellData[]> {\n const params = new URLSearchParams();\n ranges.forEach((range) => params.append(\"ranges\", range));\n\n const result = await sheetsApiRequest<{\n valueRanges: Array<{ values?: unknown[][]; range: string }>;\n }>(`/spreadsheets/${spreadsheetId}/values:batchGet?${params.toString()}`);\n\n return result.valueRanges.map((vr) => ({ values: vr.values ?? [], range: vr.range }));\n },\n\n writeRange(options: WriteRangeOptions): Promise<{\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n }> {\n const valueInputOption = options.valueInputOption ?? \"USER_ENTERED\";\n\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}/values/${encodeURIComponent(options.range)}?valueInputOption=${valueInputOption}`,\n {\n method: \"PUT\",\n body: JSON.stringify({ values: options.values }),\n },\n );\n },\n\n appendRange(\n spreadsheetId: string,\n range: string,\n values: unknown[][],\n valueInputOption: \"RAW\" | \"USER_ENTERED\" = \"USER_ENTERED\",\n ): Promise<{\n updates: {\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n };\n }> {\n return sheetsApiRequest(\n `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}:append?valueInputOption=${valueInputOption}`,\n {\n method: \"POST\",\n body: JSON.stringify({ values }),\n },\n );\n },\n\n clearRange(spreadsheetId: string, range: string): Promise<{ clearedRange: string }> {\n return sheetsApiRequest(`/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}:clear`, {\n method: \"POST\",\n });\n },\n\n createSpreadsheet(options: CreateSpreadsheetOptions): Promise<Spreadsheet> {\n const body: {\n properties: { title: string };\n sheets?: Array<{\n properties: {\n title: string;\n gridProperties?: { rowCount: number; columnCount: number };\n };\n }>;\n } = { properties: { title: options.title } };\n\n if (options.sheets?.length) {\n body.sheets = options.sheets.map((sheet) => ({\n properties: {\n title: sheet.title,\n gridProperties: {\n rowCount: sheet.rowCount ?? 1000,\n columnCount: sheet.columnCount ?? 26,\n },\n },\n }));\n }\n\n return sheetsApiRequest(\"/spreadsheets\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n },\n\n async addSheet(\n spreadsheetId: string,\n title: string,\n options?: { rowCount?: number; columnCount?: number },\n ): Promise<Sheet> {\n const result = await sheetsApiRequest<{\n replies: Array<{ addSheet?: { properties: Sheet[\"properties\"] } }>;\n }>(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [\n {\n addSheet: {\n properties: {\n title,\n gridProperties: {\n rowCount: options?.rowCount ?? 1000,\n columnCount: options?.columnCount ?? 26,\n },\n },\n },\n },\n ],\n }),\n });\n\n const properties = result.replies[0]?.addSheet?.properties;\n if (!properties) throw new Error(\"Failed to add sheet\");\n\n return { properties };\n },\n\n async deleteSheet(spreadsheetId: string, sheetId: number): Promise<void> {\n await sheetsApiRequest(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [{ deleteSheet: { sheetId } }],\n }),\n });\n },\n };\n}\n\nexport type SheetsClient = ReturnType<typeof createSheetsClient>;\n",
|
|
485
|
-
"app/api/auth/sheets/callback/route.ts": "import { createOAuthCallbackHandler, sheetsConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
486
|
-
"app/api/auth/sheets/route.ts": "import { createOAuthInitHandler, sheetsConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(sheetsConfig, {
|
|
485
|
+
"app/api/auth/sheets/callback/route.ts": "import { createOAuthCallbackHandler, sheetsConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(sheetsConfig, { tokenStore: hybridTokenStore });\n",
|
|
486
|
+
"app/api/auth/sheets/route.ts": "import { createOAuthInitHandler, sheetsConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(sheetsConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
487
487
|
}
|
|
488
488
|
},
|
|
489
489
|
"integration:salesforce": {
|
|
@@ -494,8 +494,8 @@ export default {
|
|
|
494
494
|
"tools/get-account.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { formatAddress, getAccount } from \"../../lib/salesforce-client.ts\";\n\nexport default tool({\n id: \"get-account\",\n description:\n \"Get detailed information about a specific account in Salesforce CRM by their account ID.\",\n inputSchema: z.object({\n accountId: z\n .string()\n .describe(\"The Salesforce account ID (e.g., 001XXXXXXXXXXXXXXX)\"),\n fields: z\n .array(z.string())\n .optional()\n .describe(\n \"Additional fields to retrieve (e.g., Description, Owner.Name, ParentId)\",\n ),\n }),\n async execute({ accountId, fields }) {\n const account = await getAccount(accountId, fields);\n\n const billingAddress =\n formatAddress(\n account.BillingStreet,\n account.BillingCity,\n account.BillingState,\n account.BillingPostalCode,\n account.BillingCountry,\n ) || undefined;\n\n const additionalFields = fields?.length\n ? Object.fromEntries(\n fields\n .filter((field) => account[field] !== undefined)\n .map((field) => [field, account[field]]),\n )\n : undefined;\n\n return {\n id: account.Id,\n name: account.Name,\n type: account.Type,\n industry: account.Industry,\n website: account.Website,\n phone: account.Phone,\n billingAddress,\n billingStreet: account.BillingStreet,\n billingCity: account.BillingCity,\n billingState: account.BillingState,\n billingPostalCode: account.BillingPostalCode,\n billingCountry: account.BillingCountry,\n numberOfEmployees: account.NumberOfEmployees,\n annualRevenue: account.AnnualRevenue,\n description: account.Description,\n createdDate: account.CreatedDate,\n lastModifiedDate: account.LastModifiedDate,\n additionalFields,\n };\n },\n});\n",
|
|
495
495
|
"tools/list-contacts.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { formatContactName, listContacts } from \"../../lib/salesforce-client.ts\";\n\nexport default tool({\n id: \"list-contacts\",\n description:\n \"List contacts from your Salesforce CRM. Returns contact information including name, email, phone, title, and account association.\",\n inputSchema: z.object({\n limit: z.number().min(1).max(100).default(10).describe(\"Maximum number of contacts to return\"),\n offset: z.number().min(0).default(0).describe(\"Number of records to skip for pagination\"),\n accountId: z.string().optional().describe(\"Filter contacts by Account ID\"),\n fields: z\n .array(z.string())\n .optional()\n .describe(\"Additional fields to retrieve (e.g., Account.Name, Owner.Name, LeadSource)\"),\n }),\n async execute({ limit, offset, accountId, fields }) {\n const response = await listContacts({ limit, offset, accountId, fields });\n\n return {\n contacts: response.records.map((contact) => {\n const additionalFields = fields\n ? Object.fromEntries(\n fields.flatMap((field) => {\n const value = contact[field];\n return value === undefined ? [] : [[field, value]];\n }),\n )\n : undefined;\n\n return {\n id: contact.Id,\n name: formatContactName(contact),\n firstName: contact.FirstName,\n lastName: contact.LastName,\n email: contact.Email,\n phone: contact.Phone,\n mobilePhone: contact.MobilePhone,\n title: contact.Title,\n department: contact.Department,\n accountId: contact.AccountId,\n mailingCity: contact.MailingCity,\n mailingState: contact.MailingState,\n mailingCountry: contact.MailingCountry,\n createdDate: contact.CreatedDate,\n lastModifiedDate: contact.LastModifiedDate,\n additionalFields,\n };\n }),\n totalSize: response.totalSize,\n hasMore: !response.done,\n };\n },\n});\n",
|
|
496
496
|
"lib/salesforce-client.ts": "import { getAccessToken, getInstanceUrl } from \"./token-store.ts\";\n\nconst API_VERSION = \"v59.0\";\n\ninterface SalesforceQueryResponse<T> {\n totalSize: number;\n done: boolean;\n records: T[];\n nextRecordsUrl?: string;\n}\n\ninterface SalesforceAccount {\n Id: string;\n Name: string;\n Type?: string;\n Industry?: string;\n Website?: string;\n Phone?: string;\n BillingStreet?: string;\n BillingCity?: string;\n BillingState?: string;\n BillingPostalCode?: string;\n BillingCountry?: string;\n NumberOfEmployees?: number;\n AnnualRevenue?: number;\n Description?: string;\n CreatedDate: string;\n LastModifiedDate: string;\n [key: string]: any;\n}\n\ninterface SalesforceContact {\n Id: string;\n FirstName?: string;\n LastName: string;\n Email?: string;\n Phone?: string;\n MobilePhone?: string;\n Title?: string;\n Department?: string;\n AccountId?: string;\n MailingStreet?: string;\n MailingCity?: string;\n MailingState?: string;\n MailingPostalCode?: string;\n MailingCountry?: string;\n Description?: string;\n CreatedDate: string;\n LastModifiedDate: string;\n [key: string]: any;\n}\n\ninterface SalesforceOpportunity {\n Id: string;\n Name: string;\n AccountId?: string;\n Amount?: number;\n StageName: string;\n Probability?: number;\n CloseDate: string;\n Type?: string;\n LeadSource?: string;\n Description?: string;\n NextStep?: string;\n IsClosed: boolean;\n IsWon: boolean;\n ForecastCategory?: string;\n CreatedDate: string;\n LastModifiedDate: string;\n [key: string]: any;\n}\n\ninterface SalesforceLead {\n Id: string;\n FirstName?: string;\n LastName: string;\n Company: string;\n Email?: string;\n Phone?: string;\n MobilePhone?: string;\n Title?: string;\n Status: string;\n LeadSource?: string;\n Industry?: string;\n Street?: string;\n City?: string;\n State?: string;\n PostalCode?: string;\n Country?: string;\n Website?: string;\n Description?: string;\n Rating?: string;\n CreatedDate: string;\n LastModifiedDate: string;\n [key: string]: any;\n}\n\n/** Validate a Salesforce record ID (15 or 18 character alphanumeric). */\nfunction validateSalesforceId(id: string, label: string): string {\n if (!/^[a-zA-Z0-9]{15,18}$/.test(id)) {\n throw new Error(`Invalid ${label}: must be a 15 or 18 character Salesforce ID`);\n }\n return id;\n}\n\n/** Escape a string value for use in SOQL single-quoted literals. */\nfunction escapeSoql(value: string): string {\n return value.replace(/\\\\/g, \"\\\\\\\\\").replace(/'/g, \"\\\\'\");\n}\n\n/** Validate a SOQL field name. */\nfunction validateFieldName(field: string): string {\n if (!/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(field)) {\n throw new Error(`Invalid SOQL field name: ${field}`);\n }\n return field;\n}\n\nasync function salesforceFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Salesforce. Please connect your account.\");\n }\n\n const instanceUrl = getInstanceUrl();\n if (!instanceUrl) {\n throw new Error(\"Salesforce instance URL not found. Please reconnect your account.\");\n }\n\n const url = endpoint.startsWith(\"http\")\n ? endpoint\n : `${instanceUrl}/services/data/${API_VERSION}${endpoint}`;\n\n const response = await fetch(url, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n const message = error?.[0]?.message ?? error?.message ?? response.statusText;\n throw new Error(`Salesforce API error: ${response.status} ${message}`);\n }\n\n return response.json();\n}\n\nexport function query<T = any>(soql: string): Promise<SalesforceQueryResponse<T>> {\n return salesforceFetch<SalesforceQueryResponse<T>>(`/query?q=${encodeURIComponent(soql)}`);\n}\n\nfunction buildListSoql(params: {\n object: string;\n fields: string[];\n where?: string;\n limit: number;\n offset: number;\n}): string {\n const { object, fields, where, limit, offset } = params;\n\n fields.forEach((f) => validateFieldName(f));\n let soql = `SELECT ${fields.join(\", \")} FROM ${object}`;\n if (where) soql += ` WHERE ${where}`;\n soql += ` ORDER BY LastModifiedDate DESC LIMIT ${limit} OFFSET ${offset}`;\n\n return soql;\n}\n\nasync function getSingleRecord<T>(params: {\n object: string;\n id: string;\n fields: string[];\n notFoundMessage: string;\n}): Promise<T> {\n const { object, id, fields, notFoundMessage } = params;\n fields.forEach((f) => validateFieldName(f));\n validateSalesforceId(id, `${object} ID`);\n const soql = `SELECT ${fields.join(\", \")} FROM ${object} WHERE Id = '${id}'`;\n const result = await query<T>(soql);\n\n if (result.totalSize === 0) throw new Error(notFoundMessage);\n return result.records[0];\n}\n\n// ============================================================================\n// ACCOUNTS\n// ============================================================================\n\nexport function listAccounts(options?: {\n limit?: number;\n offset?: number;\n fields?: string[];\n}): Promise<SalesforceQueryResponse<SalesforceAccount>> {\n const limit = options?.limit ?? 10;\n const offset = options?.offset ?? 0;\n const fields = options?.fields ?? [\n \"Id\",\n \"Name\",\n \"Type\",\n \"Industry\",\n \"Website\",\n \"Phone\",\n \"BillingCity\",\n \"BillingState\",\n \"BillingCountry\",\n \"NumberOfEmployees\",\n \"AnnualRevenue\",\n \"CreatedDate\",\n \"LastModifiedDate\",\n ];\n\n return query<SalesforceAccount>(buildListSoql({ object: \"Account\", fields, limit, offset }));\n}\n\nexport function getAccount(accountId: string, fields?: string[]): Promise<SalesforceAccount> {\n const selectedFields = fields ?? [\n \"Id\",\n \"Name\",\n \"Type\",\n \"Industry\",\n \"Website\",\n \"Phone\",\n \"BillingStreet\",\n \"BillingCity\",\n \"BillingState\",\n \"BillingPostalCode\",\n \"BillingCountry\",\n \"NumberOfEmployees\",\n \"AnnualRevenue\",\n \"Description\",\n \"CreatedDate\",\n \"LastModifiedDate\",\n ];\n\n return getSingleRecord<SalesforceAccount>({\n object: \"Account\",\n id: accountId,\n fields: selectedFields,\n notFoundMessage: `Account with ID ${accountId} not found`,\n });\n}\n\nexport function createAccount(data: {\n Name: string;\n Type?: string;\n Industry?: string;\n Website?: string;\n Phone?: string;\n BillingStreet?: string;\n BillingCity?: string;\n BillingState?: string;\n BillingPostalCode?: string;\n BillingCountry?: string;\n NumberOfEmployees?: number;\n AnnualRevenue?: number;\n Description?: string;\n [key: string]: any;\n}): Promise<{ id: string; success: boolean; errors: any[] }> {\n return salesforceFetch(\"/sobjects/Account\", {\n method: \"POST\",\n body: JSON.stringify(data),\n });\n}\n\n// ============================================================================\n// CONTACTS\n// ============================================================================\n\nexport function listContacts(options?: {\n limit?: number;\n offset?: number;\n fields?: string[];\n accountId?: string;\n}): Promise<SalesforceQueryResponse<SalesforceContact>> {\n const limit = options?.limit ?? 10;\n const offset = options?.offset ?? 0;\n const fields = options?.fields ?? [\n \"Id\",\n \"FirstName\",\n \"LastName\",\n \"Email\",\n \"Phone\",\n \"Title\",\n \"Department\",\n \"AccountId\",\n \"MailingCity\",\n \"MailingState\",\n \"MailingCountry\",\n \"CreatedDate\",\n \"LastModifiedDate\",\n ];\n\n const where = options?.accountId\n ? (validateSalesforceId(options.accountId, \"accountId\"), `AccountId = '${options.accountId}'`)\n : undefined;\n\n return query<SalesforceContact>(buildListSoql({ object: \"Contact\", fields, where, limit, offset }));\n}\n\nexport function getContact(contactId: string, fields?: string[]): Promise<SalesforceContact> {\n const selectedFields = fields ?? [\n \"Id\",\n \"FirstName\",\n \"LastName\",\n \"Email\",\n \"Phone\",\n \"MobilePhone\",\n \"Title\",\n \"Department\",\n \"AccountId\",\n \"MailingStreet\",\n \"MailingCity\",\n \"MailingState\",\n \"MailingPostalCode\",\n \"MailingCountry\",\n \"Description\",\n \"CreatedDate\",\n \"LastModifiedDate\",\n ];\n\n return getSingleRecord<SalesforceContact>({\n object: \"Contact\",\n id: contactId,\n fields: selectedFields,\n notFoundMessage: `Contact with ID ${contactId} not found`,\n });\n}\n\nexport function createContact(data: {\n LastName: string;\n FirstName?: string;\n Email?: string;\n Phone?: string;\n MobilePhone?: string;\n Title?: string;\n Department?: string;\n AccountId?: string;\n MailingStreet?: string;\n MailingCity?: string;\n MailingState?: string;\n MailingPostalCode?: string;\n MailingCountry?: string;\n Description?: string;\n [key: string]: any;\n}): Promise<{ id: string; success: boolean; errors: any[] }> {\n return salesforceFetch(\"/sobjects/Contact\", {\n method: \"POST\",\n body: JSON.stringify(data),\n });\n}\n\n// ============================================================================\n// OPPORTUNITIES\n// ============================================================================\n\nexport function listOpportunities(options?: {\n limit?: number;\n offset?: number;\n fields?: string[];\n accountId?: string;\n}): Promise<SalesforceQueryResponse<SalesforceOpportunity>> {\n const limit = options?.limit ?? 10;\n const offset = options?.offset ?? 0;\n const fields = options?.fields ?? [\n \"Id\",\n \"Name\",\n \"AccountId\",\n \"Amount\",\n \"StageName\",\n \"Probability\",\n \"CloseDate\",\n \"Type\",\n \"LeadSource\",\n \"IsClosed\",\n \"IsWon\",\n \"ForecastCategory\",\n \"CreatedDate\",\n \"LastModifiedDate\",\n ];\n\n const where = options?.accountId\n ? (validateSalesforceId(options.accountId, \"accountId\"), `AccountId = '${options.accountId}'`)\n : undefined;\n\n return query<SalesforceOpportunity>(\n buildListSoql({ object: \"Opportunity\", fields, where, limit, offset }),\n );\n}\n\nexport function getOpportunity(opportunityId: string, fields?: string[]): Promise<SalesforceOpportunity> {\n const selectedFields = fields ?? [\n \"Id\",\n \"Name\",\n \"AccountId\",\n \"Amount\",\n \"StageName\",\n \"Probability\",\n \"CloseDate\",\n \"Type\",\n \"LeadSource\",\n \"Description\",\n \"NextStep\",\n \"IsClosed\",\n \"IsWon\",\n \"ForecastCategory\",\n \"CreatedDate\",\n \"LastModifiedDate\",\n ];\n\n return getSingleRecord<SalesforceOpportunity>({\n object: \"Opportunity\",\n id: opportunityId,\n fields: selectedFields,\n notFoundMessage: `Opportunity with ID ${opportunityId} not found`,\n });\n}\n\nexport function createOpportunity(data: {\n Name: string;\n StageName: string;\n CloseDate: string;\n AccountId?: string;\n Amount?: number;\n Probability?: number;\n Type?: string;\n LeadSource?: string;\n Description?: string;\n NextStep?: string;\n [key: string]: any;\n}): Promise<{ id: string; success: boolean; errors: any[] }> {\n return salesforceFetch(\"/sobjects/Opportunity\", {\n method: \"POST\",\n body: JSON.stringify(data),\n });\n}\n\n// ============================================================================\n// LEADS\n// ============================================================================\n\nexport function listLeads(options?: {\n limit?: number;\n offset?: number;\n fields?: string[];\n status?: string;\n}): Promise<SalesforceQueryResponse<SalesforceLead>> {\n const limit = options?.limit ?? 10;\n const offset = options?.offset ?? 0;\n const fields = options?.fields ?? [\n \"Id\",\n \"FirstName\",\n \"LastName\",\n \"Company\",\n \"Email\",\n \"Phone\",\n \"Title\",\n \"Status\",\n \"LeadSource\",\n \"Industry\",\n \"City\",\n \"State\",\n \"Country\",\n \"Rating\",\n \"CreatedDate\",\n \"LastModifiedDate\",\n ];\n\n const where = options?.status ? `Status = '${escapeSoql(options.status)}'` : undefined;\n\n return query<SalesforceLead>(buildListSoql({ object: \"Lead\", fields, where, limit, offset }));\n}\n\nexport function createLead(data: {\n LastName: string;\n Company: string;\n FirstName?: string;\n Email?: string;\n Phone?: string;\n MobilePhone?: string;\n Title?: string;\n Status?: string;\n LeadSource?: string;\n Industry?: string;\n Street?: string;\n City?: string;\n State?: string;\n PostalCode?: string;\n Country?: string;\n Website?: string;\n Description?: string;\n Rating?: string;\n [key: string]: any;\n}): Promise<{ id: string; success: boolean; errors: any[] }> {\n return salesforceFetch(\"/sobjects/Lead\", {\n method: \"POST\",\n body: JSON.stringify({ ...data, Status: data.Status ?? \"Open - Not Contacted\" }),\n });\n}\n\n// ============================================================================\n// HELPER FUNCTIONS\n// ============================================================================\n\nfunction formatPersonName(firstName?: string, lastName?: string, email?: string, fallback = \"Unnamed\"): string {\n const parts = [firstName, lastName].filter(Boolean);\n if (parts.length) return parts.join(\" \");\n return email ?? fallback;\n}\n\nexport function formatContactName(contact: SalesforceContact): string {\n return formatPersonName(contact.FirstName, contact.LastName, contact.Email, \"Unnamed Contact\");\n}\n\nexport function formatLeadName(lead: SalesforceLead): string {\n return formatPersonName(lead.FirstName, lead.LastName, lead.Email, \"Unnamed Lead\");\n}\n\nexport function formatAddress(\n street?: string,\n city?: string,\n state?: string,\n postalCode?: string,\n country?: string,\n): string {\n return [street, city, state, postalCode, country].filter(Boolean).join(\", \");\n}\n\nexport type {\n SalesforceAccount,\n SalesforceContact,\n SalesforceLead,\n SalesforceOpportunity,\n SalesforceQueryResponse,\n};\n",
|
|
497
|
-
"app/api/auth/salesforce/callback/route.ts": "import { createOAuthCallbackHandler, salesforceConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
498
|
-
"app/api/auth/salesforce/route.ts": "import { createOAuthInitHandler, salesforceConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(salesforceConfig, {\n tokenStore: oauthMemoryTokenStore,\n})
|
|
497
|
+
"app/api/auth/salesforce/callback/route.ts": "import { createOAuthCallbackHandler, salesforceConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(salesforceConfig, { tokenStore: hybridTokenStore });\n",
|
|
498
|
+
"app/api/auth/salesforce/route.ts": "import { createOAuthInitHandler, salesforceConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(salesforceConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
499
499
|
}
|
|
500
500
|
},
|
|
501
501
|
"integration:confluence": {
|
|
@@ -507,8 +507,8 @@ export default {
|
|
|
507
507
|
"tools/get-page.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { extractPlainText, getPageContent } from \"../../lib/confluence-client.ts\";\n\nexport default tool({\n id: \"get-page\",\n description:\n \"Get the content of a specific Confluence page. Returns the page title, content, and metadata.\",\n inputSchema: z.object({\n pageId: z.string().describe(\"The ID of the Confluence page to retrieve\"),\n }),\n async execute({ pageId }) {\n const page = await getPageContent(pageId);\n\n const htmlContent = page.body?.storage?.value ?? page.body?.view?.value ?? \"\";\n const content = extractPlainText(htmlContent);\n\n return {\n id: page.id,\n type: page.type,\n title: page.title,\n content,\n htmlContent,\n version: page.version.number,\n url: page._links.webui,\n spaceId: page.spaceId,\n parentId: page.parentId,\n };\n },\n});\n",
|
|
508
508
|
".env.example": "# Atlassian OAuth credentials\n# Get these from: https://developer.atlassian.com/console/myapps/\nATLASSIAN_CLIENT_ID=your_client_id_here\nATLASSIAN_CLIENT_SECRET=your_client_secret_here\n",
|
|
509
509
|
"lib/confluence-client.ts": "import { getAccessToken, getCloudId } from \"./token-store.ts\";\n\nconst CONFLUENCE_API_BASE = \"https://api.atlassian.com/ex/confluence\";\n\ninterface ConfluenceResponse<T> {\n results: T[];\n size: number;\n start?: number;\n limit?: number;\n _links?: {\n next?: string;\n base?: string;\n };\n}\n\nexport interface ConfluenceSpace {\n id: string;\n key: string;\n name: string;\n type: string;\n status: string;\n _links: {\n webui: string;\n };\n}\n\nexport interface ConfluencePage {\n id: string;\n type: \"page\" | \"blogpost\";\n status: string;\n title: string;\n spaceId?: string;\n parentId?: string;\n version: {\n number: number;\n message?: string;\n };\n body?: {\n storage?: {\n value: string;\n representation: \"storage\";\n };\n view?: {\n value: string;\n representation: \"view\";\n };\n };\n _links: {\n webui: string;\n tinyui?: string;\n };\n}\n\nexport interface ConfluenceSearchResult {\n content: {\n id: string;\n type: string;\n status: string;\n title: string;\n space?: {\n id: string;\n key: string;\n name: string;\n };\n history?: {\n lastUpdated: {\n when: string;\n };\n };\n _links: {\n webui: string;\n };\n };\n excerpt?: string;\n url: string;\n resultGlobalContainer?: {\n title: string;\n };\n}\n\nasync function confluenceFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const [token, cloudId] = await Promise.all([getAccessToken(), getCloudId()]);\n\n if (!token || !cloudId) {\n throw new Error(\"Not authenticated with Confluence. Please connect your Atlassian account.\");\n }\n\n const url = `${CONFLUENCE_API_BASE}/${cloudId}${endpoint}`;\n\n const response = await fetch(url, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({}))) as { message?: string };\n throw new Error(`Confluence API error: ${response.status} ${error.message ?? response.statusText}`);\n }\n\n return response.json() as Promise<T>;\n}\n\nfunction buildEndpoint(path: string, params?: URLSearchParams): string {\n const query = params?.toString();\n return `${path}${query ? `?${query}` : \"\"}`;\n}\n\nexport async function listSpaces(options?: {\n limit?: number;\n type?: \"global\" | \"personal\";\n}): Promise<ConfluenceSpace[]> {\n const params = new URLSearchParams();\n\n if (options?.limit) params.set(\"limit\", options.limit.toString());\n if (options?.type) params.set(\"type\", options.type);\n\n const response = await confluenceFetch<ConfluenceResponse<ConfluenceSpace>>(\n buildEndpoint(\"/wiki/rest/api/space\", params),\n );\n\n return response.results ?? [];\n}\n\nexport async function searchContent(\n query: string,\n options?: {\n cql?: string;\n limit?: number;\n spaceKey?: string;\n },\n): Promise<ConfluenceSearchResult[]> {\n const params = new URLSearchParams();\n\n let cqlQuery = options?.cql ?? `title ~ \"${query}\" OR text ~ \"${query}\"`;\n if (options?.spaceKey) cqlQuery += ` AND space = \"${options.spaceKey}\"`;\n\n params.set(\"cql\", cqlQuery);\n if (options?.limit) params.set(\"limit\", options.limit.toString());\n\n const response = await confluenceFetch<ConfluenceResponse<ConfluenceSearchResult>>(\n buildEndpoint(\"/wiki/rest/api/search\", params),\n );\n\n return response.results ?? [];\n}\n\nexport function getPage(pageId: string, expand?: string[]): Promise<ConfluencePage> {\n const params = new URLSearchParams();\n if (expand?.length) params.set(\"expand\", expand.join(\",\"));\n\n return confluenceFetch<ConfluencePage>(buildEndpoint(`/wiki/rest/api/content/${pageId}`, params));\n}\n\nexport function getPageContent(pageId: string): Promise<ConfluencePage> {\n return getPage(pageId, [\"body.storage\", \"body.view\", \"version\", \"space\"]);\n}\n\nexport function createPage(options: {\n spaceKey: string;\n title: string;\n content: string;\n parentId?: string;\n type?: \"page\" | \"blogpost\";\n}): Promise<ConfluencePage> {\n const body = {\n type: options.type ?? \"page\",\n title: options.title,\n space: { key: options.spaceKey },\n body: {\n storage: {\n value: options.content,\n representation: \"storage\" as const,\n },\n },\n ...(options.parentId ? { ancestors: [{ id: options.parentId }] } : {}),\n };\n\n return confluenceFetch<ConfluencePage>(\"/wiki/rest/api/content\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n}\n\nexport async function updatePage(\n pageId: string,\n options: {\n title?: string;\n content?: string;\n version: number;\n versionMessage?: string;\n },\n): Promise<ConfluencePage> {\n await getPage(pageId, [\"version\"]);\n\n const body: Record<string, unknown> = {\n version: {\n number: options.version,\n message: options.versionMessage,\n },\n type: \"page\",\n };\n\n if (options.title) body.title = options.title;\n\n if (options.content) {\n body.body = {\n storage: {\n value: options.content,\n representation: \"storage\",\n },\n };\n }\n\n return confluenceFetch<ConfluencePage>(`/wiki/rest/api/content/${pageId}`, {\n method: \"PUT\",\n body: JSON.stringify(body),\n });\n}\n\nexport function extractPlainText(storageHtml: string): string {\n return storageHtml\n .replace(/<[^>]*>/g, \" \")\n .replace(/ /g, \" \")\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/\\s+/g, \" \")\n .trim();\n}\n\nexport function formatAsStorage(text: string): string {\n const paragraphs = text.split(\"\\n\\n\").filter((p) => p.trim());\n return paragraphs.map((p) => `<p>${escapeHtml(p.trim())}</p>`).join(\"\\n\");\n}\n\nfunction escapeHtml(text: string): string {\n return text\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n",
|
|
510
|
-
"app/api/auth/confluence/callback/route.ts": "import { confluenceConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
511
|
-
"app/api/auth/confluence/route.ts": "import { confluenceConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(confluenceConfig, {\n tokenStore: oauthMemoryTokenStore,\n})
|
|
510
|
+
"app/api/auth/confluence/callback/route.ts": "import { confluenceConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(confluenceConfig, { tokenStore: hybridTokenStore });\n",
|
|
511
|
+
"app/api/auth/confluence/route.ts": "import { confluenceConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(confluenceConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
512
512
|
}
|
|
513
513
|
},
|
|
514
514
|
"integration:clickup": {
|
|
@@ -520,8 +520,8 @@ export default {
|
|
|
520
520
|
"tools/create-task.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createTask } from \"../../lib/clickup-client.ts\";\n\nexport default tool({\n id: \"create-task\",\n description: \"Create a new task in a ClickUp list.\",\n inputSchema: z.object({\n listId: z.string().describe(\"The ID of the list to create the task in\"),\n name: z.string().describe(\"The name/title of the task\"),\n description: z.string().optional().describe(\"Description or details for the task\"),\n assignees: z.array(z.number()).optional().describe(\"Array of user IDs to assign the task to\"),\n tags: z.array(z.string()).optional().describe(\"Array of tag names to add to the task\"),\n status: z.string().optional().describe(\"Status name for the task\"),\n priority: z\n .number()\n .min(1)\n .max(4)\n .optional()\n .describe(\"Priority level: 1 (urgent), 2 (high), 3 (normal), 4 (low)\"),\n dueDate: z.number().optional().describe(\"Due date in Unix timestamp (milliseconds)\"),\n startDate: z.number().optional().describe(\"Start date in Unix timestamp (milliseconds)\"),\n timeEstimate: z.number().optional().describe(\"Time estimate in milliseconds\"),\n notifyAll: z.boolean().default(false).describe(\"Notify all assignees when task is created\"),\n }),\n async execute(input) {\n const task = await createTask(input);\n const dueDate = task.due_date ? new Date(Number(task.due_date)).toISOString() : null;\n\n return {\n success: true,\n task: {\n id: task.id,\n name: task.name,\n status: task.status.status,\n dueDate,\n priority: task.priority?.priority ?? \"none\",\n assignees: task.assignees.map((assignee) => assignee.username),\n list: task.list.name,\n url: `https://app.clickup.com/t/${task.id}`,\n },\n };\n },\n});\n",
|
|
521
521
|
".env.example": "# ClickUp OAuth Configuration\n# Get your credentials from https://app.clickup.com/settings/apps\nCLICKUP_CLIENT_ID=your-client-id\nCLICKUP_CLIENT_SECRET=your-client-secret\n",
|
|
522
522
|
"lib/clickup-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst CLICKUP_BASE_URL = \"https://api.clickup.com/api/v2\";\n\ninterface ClickUpTask {\n id: string;\n name: string;\n description: string;\n status: {\n status: string;\n color: string;\n type: string;\n };\n date_created: string;\n date_updated: string;\n date_closed: string | null;\n creator: {\n id: number;\n username: string;\n email: string;\n };\n assignees: Array<{\n id: number;\n username: string;\n email: string;\n }>;\n tags: Array<{\n name: string;\n tag_fg: string;\n tag_bg: string;\n }>;\n due_date: string | null;\n start_date: string | null;\n priority: {\n id: string;\n priority: string;\n color: string;\n } | null;\n list: {\n id: string;\n name: string;\n };\n folder: {\n id: string;\n name: string;\n };\n space: {\n id: string;\n name: string;\n };\n}\n\ninterface ClickUpList {\n id: string;\n name: string;\n orderindex: number;\n content: string;\n status: {\n status: string;\n color: string;\n };\n priority: {\n priority: string;\n color: string;\n } | null;\n assignee: {\n id: number;\n username: string;\n email: string;\n } | null;\n task_count: number;\n due_date: string | null;\n start_date: string | null;\n folder: {\n id: string;\n name: string;\n hidden: boolean;\n access: boolean;\n };\n space: {\n id: string;\n name: string;\n access: boolean;\n };\n archived: boolean;\n}\n\ninterface ClickUpFolder {\n id: string;\n name: string;\n orderindex: number;\n override_statuses: boolean;\n hidden: boolean;\n space: {\n id: string;\n name: string;\n };\n task_count: string;\n lists: ClickUpList[];\n}\n\ninterface ClickUpSpace {\n id: string;\n name: string;\n private: boolean;\n statuses: Array<{\n status: string;\n type: string;\n orderindex: number;\n color: string;\n }>;\n multiple_assignees: boolean;\n features: {\n due_dates: {\n enabled: boolean;\n start_date: boolean;\n remap_due_dates: boolean;\n remap_closed_due_date: boolean;\n };\n time_tracking: {\n enabled: boolean;\n };\n tags: {\n enabled: boolean;\n };\n time_estimates: {\n enabled: boolean;\n };\n checklists: {\n enabled: boolean;\n };\n custom_fields: {\n enabled: boolean;\n };\n remap_dependencies: {\n enabled: boolean;\n };\n dependency_warning: {\n enabled: boolean;\n };\n portfolios: {\n enabled: boolean;\n };\n };\n}\n\nfunction buildCustomTaskParams(options?: { customTaskIds?: boolean; teamId?: string }): URLSearchParams {\n const params = new URLSearchParams();\n\n if (!options?.customTaskIds) return params;\n\n params.set(\"custom_task_ids\", \"true\");\n if (options.teamId) params.set(\"team_id\", options.teamId);\n\n return params;\n}\n\nfunction withQuery(endpoint: string, params: URLSearchParams): string {\n const queryString = params.toString();\n return queryString ? `${endpoint}?${queryString}` : endpoint;\n}\n\nasync function clickupFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) throw new Error(\"Not authenticated with ClickUp. Please connect your account.\");\n\n const response = await fetch(`${CLICKUP_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: token,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (response.ok) return response.json();\n\n const error = await response.json().catch(() => ({} as { err?: string; error?: string }));\n const message = error.err ?? error.error ?? response.statusText;\n\n throw new Error(`ClickUp API error: ${response.status} ${message}`);\n}\n\nexport async function listSpaces(teamId: string): Promise<ClickUpSpace[]> {\n const response = await clickupFetch<{ spaces: ClickUpSpace[] }>(`/team/${teamId}/space`);\n return response.spaces;\n}\n\nexport async function listFolders(spaceId: string): Promise<ClickUpFolder[]> {\n const response = await clickupFetch<{ folders: ClickUpFolder[] }>(`/space/${spaceId}/folder`);\n return response.folders;\n}\n\nexport async function listLists(folderId: string): Promise<ClickUpList[]> {\n const response = await clickupFetch<{ lists: ClickUpList[] }>(`/folder/${folderId}/list`);\n return response.lists;\n}\n\nexport async function listFolderlessLists(spaceId: string): Promise<ClickUpList[]> {\n const response = await clickupFetch<{ lists: ClickUpList[] }>(`/space/${spaceId}/list`);\n return response.lists;\n}\n\nexport async function listTasks(options: {\n listId?: string;\n spaceId?: string;\n folderId?: string;\n assignees?: number[];\n statuses?: string[];\n includeClosed?: boolean;\n orderBy?: string;\n subtasks?: boolean;\n}): Promise<ClickUpTask[]> {\n const params = new URLSearchParams();\n\n options.assignees?.forEach((assignee) => params.append(\"assignees[]\", assignee.toString()));\n options.statuses?.forEach((status) => params.append(\"statuses[]\", status));\n\n if (options.includeClosed !== undefined) params.set(\"include_closed\", options.includeClosed.toString());\n if (options.orderBy) params.set(\"order_by\", options.orderBy);\n if (options.subtasks !== undefined) params.set(\"subtasks\", options.subtasks.toString());\n\n let endpoint = \"/task\";\n if (options.listId) endpoint = `/list/${options.listId}/task`;\n else if (options.folderId) endpoint = `/folder/${options.folderId}/task`;\n else if (options.spaceId) endpoint = `/space/${options.spaceId}/task`;\n\n const response = await clickupFetch<{ tasks: ClickUpTask[] }>(withQuery(endpoint, params));\n return response.tasks;\n}\n\nexport async function getTask(\n taskId: string,\n options?: {\n customTaskIds?: boolean;\n teamId?: string;\n includeSubtasks?: boolean;\n },\n): Promise<ClickUpTask> {\n const params = buildCustomTaskParams(options);\n if (options?.includeSubtasks) params.set(\"include_subtasks\", \"true\");\n\n return clickupFetch<ClickUpTask>(withQuery(`/task/${taskId}`, params));\n}\n\nexport async function createTask(options: {\n listId: string;\n name: string;\n description?: string;\n assignees?: number[];\n tags?: string[];\n status?: string;\n priority?: number;\n dueDate?: number;\n dueDateTime?: boolean;\n timeEstimate?: number;\n startDate?: number;\n startDateTime?: boolean;\n notifyAll?: boolean;\n parent?: string;\n linksTo?: string;\n checkRequired?: boolean;\n customTaskIds?: boolean;\n teamId?: string;\n}): Promise<ClickUpTask> {\n const body: Record<string, unknown> = { name: options.name };\n\n if (options.description) body.description = options.description;\n if (options.assignees) body.assignees = options.assignees;\n if (options.tags) body.tags = options.tags;\n if (options.status) body.status = options.status;\n if (options.priority !== undefined) body.priority = options.priority;\n\n if (options.dueDate) {\n body.due_date = options.dueDate;\n if (options.dueDateTime !== undefined) body.due_date_time = options.dueDateTime;\n }\n\n if (options.timeEstimate) body.time_estimate = options.timeEstimate;\n\n if (options.startDate) {\n body.start_date = options.startDate;\n if (options.startDateTime !== undefined) body.start_date_time = options.startDateTime;\n }\n\n if (options.notifyAll !== undefined) body.notify_all = options.notifyAll;\n if (options.parent) body.parent = options.parent;\n if (options.linksTo) body.links_to = options.linksTo;\n\n const url = withQuery(`/list/${options.listId}/task`, buildCustomTaskParams(options));\n\n return clickupFetch<ClickUpTask>(url, {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n}\n\nexport async function updateTask(\n taskId: string,\n updates: {\n name?: string;\n description?: string;\n status?: string;\n priority?: number | null;\n dueDate?: number | null;\n dueDateTime?: boolean;\n timeEstimate?: number | null;\n startDate?: number | null;\n startDateTime?: boolean;\n assignees?: {\n add?: number[];\n rem?: number[];\n };\n archived?: boolean;\n },\n options?: {\n customTaskIds?: boolean;\n teamId?: string;\n },\n): Promise<ClickUpTask> {\n const body: Record<string, unknown> = {};\n\n if (updates.name !== undefined) body.name = updates.name;\n if (updates.description !== undefined) body.description = updates.description;\n if (updates.status !== undefined) body.status = updates.status;\n if (updates.priority !== undefined) body.priority = updates.priority;\n\n if (updates.dueDate !== undefined) {\n body.due_date = updates.dueDate;\n if (updates.dueDateTime !== undefined) body.due_date_time = updates.dueDateTime;\n }\n\n if (updates.timeEstimate !== undefined) body.time_estimate = updates.timeEstimate;\n\n if (updates.startDate !== undefined) {\n body.start_date = updates.startDate;\n if (updates.startDateTime !== undefined) body.start_date_time = updates.startDateTime;\n }\n\n if (updates.assignees) body.assignees = updates.assignees;\n if (updates.archived !== undefined) body.archived = updates.archived;\n\n const url = withQuery(`/task/${taskId}`, buildCustomTaskParams(options));\n\n return clickupFetch<ClickUpTask>(url, {\n method: \"PUT\",\n body: JSON.stringify(body),\n });\n}\n\nexport async function getAuthorizedUser(): Promise<{\n user: {\n id: number;\n username: string;\n email: string;\n color: string;\n profilePicture: string;\n };\n}> {\n return clickupFetch<{\n user: {\n id: number;\n username: string;\n email: string;\n color: string;\n profilePicture: string;\n };\n }>(\"/user\");\n}\n\nexport async function getTeams(): Promise<\n Array<{\n id: string;\n name: string;\n color: string;\n avatar: string | null;\n members: Array<{\n user: {\n id: number;\n username: string;\n email: string;\n };\n }>;\n }>\n> {\n const response = await clickupFetch<{\n teams: Array<{\n id: string;\n name: string;\n color: string;\n avatar: string | null;\n members: Array<{\n user: {\n id: number;\n username: string;\n email: string;\n };\n }>;\n }>;\n }>(\"/team\");\n\n return response.teams;\n}\n",
|
|
523
|
-
"app/api/auth/clickup/callback/route.ts": "import { clickupConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
524
|
-
"app/api/auth/clickup/route.ts": "import { clickupConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(clickupConfig, {
|
|
523
|
+
"app/api/auth/clickup/callback/route.ts": "import { clickupConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(clickupConfig, { tokenStore: hybridTokenStore });\n",
|
|
524
|
+
"app/api/auth/clickup/route.ts": "import { clickupConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(clickupConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
525
525
|
}
|
|
526
526
|
},
|
|
527
527
|
"integration:jira": {
|
|
@@ -532,8 +532,8 @@ export default {
|
|
|
532
532
|
"tools/get-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { extractDescriptionText, getIssue } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"get-issue\",\n description:\n \"Get detailed information about a specific Jira issue by its key (e.g., PROJ-123) or ID. Returns all fields including description, comments, history, etc.\",\n inputSchema: z.object({\n issueKey: z.string().describe('The issue key (e.g., \"PROJ-123\") or ID'),\n }),\n async execute({ issueKey }) {\n const issue = await getIssue(issueKey);\n const { fields } = issue;\n\n const priority = fields.priority\n ? { name: fields.priority.name, iconUrl: fields.priority.iconUrl }\n : null;\n\n const assignee = fields.assignee\n ? {\n displayName: fields.assignee.displayName,\n email: fields.assignee.emailAddress,\n accountId: fields.assignee.accountId,\n }\n : null;\n\n const reporter = fields.reporter\n ? {\n displayName: fields.reporter.displayName,\n email: fields.reporter.emailAddress,\n accountId: fields.reporter.accountId,\n }\n : null;\n\n return {\n key: issue.key,\n id: issue.id,\n summary: fields.summary,\n description: extractDescriptionText(fields.description),\n status: fields.status.name,\n statusCategory: fields.status.statusCategory.name,\n type: {\n name: fields.issuetype.name,\n iconUrl: fields.issuetype.iconUrl,\n },\n priority,\n assignee,\n reporter,\n project: {\n key: fields.project.key,\n name: fields.project.name,\n id: fields.project.id,\n },\n created: fields.created,\n updated: fields.updated,\n labels: fields.labels ?? [],\n url: issue.self,\n };\n },\n});\n",
|
|
533
533
|
"tools/search-issues.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { extractDescriptionText, searchIssues } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"search-issues\",\n description:\n 'Search for Jira issues using JQL (Jira Query Language). Returns matching issues with key details. Common JQL examples: \"assignee = currentUser() AND status != Done\", \"project = PROJ AND type = Bug\", \"created >= -7d\".',\n inputSchema: z.object({\n jql: z\n .string()\n .describe(\n 'JQL query string to search issues. Examples: \"assignee = currentUser()\", \"project = PROJ\", \"status = Open\"',\n ),\n maxResults: z\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of results to return\"),\n fields: z\n .array(z.string())\n .optional()\n .describe(\n 'Specific fields to include (e.g., [\"summary\", \"status\", \"assignee\"])',\n ),\n }),\n async execute({ jql, maxResults, fields }) {\n const result = await searchIssues(jql, { maxResults, fields });\n\n return {\n total: result.total,\n issues: result.issues.map((issue) => {\n const issueFields = issue.fields;\n\n return {\n key: issue.key,\n id: issue.id,\n summary: issueFields.summary,\n description: extractDescriptionText(issueFields.description),\n status: issueFields.status.name,\n statusCategory: issueFields.status.statusCategory.name,\n type: issueFields.issuetype.name,\n priority: issueFields.priority?.name,\n assignee: issueFields.assignee?.displayName,\n reporter: issueFields.reporter?.displayName,\n project: {\n key: issueFields.project.key,\n name: issueFields.project.name,\n },\n created: issueFields.created,\n updated: issueFields.updated,\n labels: issueFields.labels ?? [],\n };\n }),\n };\n },\n});\n",
|
|
534
534
|
"lib/jira-client.ts": "import { getAccessToken, getCloudId } from \"./token-store.ts\";\n\nconst JIRA_API_VERSION = \"3\";\n\ninterface JiraResponse<T> {\n expand?: string;\n startAt?: number;\n maxResults?: number;\n total?: number;\n issues?: T[];\n values?: T[];\n}\n\nexport interface JiraIssue {\n id: string;\n key: string;\n self: string;\n fields: {\n summary: string;\n description?:\n | {\n type: string;\n content: unknown[];\n }\n | string;\n status: {\n name: string;\n statusCategory: {\n key: string;\n name: string;\n };\n };\n issuetype: {\n id: string;\n name: string;\n iconUrl: string;\n };\n priority?: {\n name: string;\n iconUrl: string;\n };\n assignee?: {\n displayName: string;\n emailAddress: string;\n accountId: string;\n };\n reporter?: {\n displayName: string;\n emailAddress: string;\n accountId: string;\n };\n created: string;\n updated: string;\n project: {\n id: string;\n key: string;\n name: string;\n };\n labels?: string[];\n [key: string]: unknown;\n };\n}\n\nexport interface JiraProject {\n id: string;\n key: string;\n name: string;\n projectTypeKey: string;\n self: string;\n avatarUrls?: Record<string, string>;\n lead?: {\n displayName: string;\n accountId: string;\n };\n}\n\nexport interface JiraIssueType {\n id: string;\n name: string;\n description: string;\n iconUrl: string;\n subtask: boolean;\n}\n\nexport interface JiraTransition {\n id: string;\n name: string;\n to: {\n id: string;\n name: string;\n };\n}\n\nfunction buildAdfDescription(text: string): Record<string, unknown> {\n return {\n type: \"doc\",\n version: 1,\n content: [\n {\n type: \"paragraph\",\n content: [\n {\n type: \"text\",\n text,\n },\n ],\n },\n ],\n };\n}\n\nasync function jiraFetch<T>(\n endpoint: string,\n options: RequestInit = {},\n): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Jira. Please connect your account.\");\n }\n\n const cloudId = await getCloudId();\n if (!cloudId) {\n throw new Error(\"Jira cloud ID not found. Please reconnect your account.\");\n }\n\n const baseUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/${JIRA_API_VERSION}`;\n const url = endpoint.startsWith(\"http\") ? endpoint : `${baseUrl}${endpoint}`;\n\n const response = await fetch(url, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({} as unknown));\n const message =\n (error as any)?.errorMessages?.join(\", \") ||\n (error as any)?.message ||\n response.statusText;\n\n throw new Error(`Jira API error: ${response.status} ${message}`);\n }\n\n if (response.status === 204) {\n return {} as T;\n }\n\n return response.json();\n}\n\nexport async function searchIssues(\n jql: string,\n options?: {\n fields?: string[];\n maxResults?: number;\n startAt?: number;\n },\n): Promise<{ issues: JiraIssue[]; total: number }> {\n const params = new URLSearchParams({\n jql,\n maxResults: String(options?.maxResults ?? 50),\n startAt: String(options?.startAt ?? 0),\n });\n\n if (options?.fields?.length) {\n params.set(\"fields\", options.fields.join(\",\"));\n }\n\n const response = await jiraFetch<JiraResponse<JiraIssue>>(\n `/search?${params.toString()}`,\n );\n\n return {\n issues: response.issues ?? [],\n total: response.total ?? 0,\n };\n}\n\nexport function getIssue(issueIdOrKey: string): Promise<JiraIssue> {\n return jiraFetch<JiraIssue>(`/issue/${issueIdOrKey}`);\n}\n\nexport async function createIssue(options: {\n projectKey: string;\n summary: string;\n description?: string;\n issueType: string;\n priority?: string;\n assigneeId?: string;\n labels?: string[];\n}): Promise<JiraIssue> {\n const fields: Record<string, unknown> = {\n project: { key: options.projectKey },\n summary: options.summary,\n issuetype: { name: options.issueType },\n };\n\n if (options.description) {\n fields.description = buildAdfDescription(options.description);\n }\n\n if (options.priority) {\n fields.priority = { name: options.priority };\n }\n\n if (options.assigneeId) {\n fields.assignee = { id: options.assigneeId };\n }\n\n if (options.labels?.length) {\n fields.labels = options.labels;\n }\n\n const response = await jiraFetch<{ id: string; key: string; self: string }>(\n \"/issue\",\n {\n method: \"POST\",\n body: JSON.stringify({ fields }),\n },\n );\n\n return getIssue(response.key);\n}\n\nexport function updateIssue(\n issueIdOrKey: string,\n updates: {\n summary?: string;\n description?: string;\n priority?: string;\n assigneeId?: string;\n labels?: string[];\n },\n): Promise<void> {\n const fields: Record<string, unknown> = {};\n\n if (updates.summary) {\n fields.summary = updates.summary;\n }\n\n if (updates.description) {\n fields.description = buildAdfDescription(updates.description);\n }\n\n if (updates.priority) {\n fields.priority = { name: updates.priority };\n }\n\n if (updates.assigneeId) {\n fields.assignee = { id: updates.assigneeId };\n }\n\n if (updates.labels) {\n fields.labels = updates.labels;\n }\n\n return jiraFetch<void>(`/issue/${issueIdOrKey}`, {\n method: \"PUT\",\n body: JSON.stringify({ fields }),\n });\n}\n\nexport async function transitionIssue(\n issueIdOrKey: string,\n transitionId: string,\n): Promise<void> {\n await jiraFetch<void>(`/issue/${issueIdOrKey}/transitions`, {\n method: \"POST\",\n body: JSON.stringify({ transition: { id: transitionId } }),\n });\n}\n\nexport async function getIssueTransitions(\n issueIdOrKey: string,\n): Promise<JiraTransition[]> {\n const response = await jiraFetch<{ transitions: JiraTransition[] }>(\n `/issue/${issueIdOrKey}/transitions`,\n );\n return response.transitions ?? [];\n}\n\nexport async function listProjects(): Promise<JiraProject[]> {\n return jiraFetch<JiraProject[]>(\"/project\");\n}\n\nexport function getProject(projectIdOrKey: string): Promise<JiraProject> {\n return jiraFetch<JiraProject>(`/project/${projectIdOrKey}`);\n}\n\nexport async function getProjectIssueTypes(\n projectIdOrKey: string,\n): Promise<JiraIssueType[]> {\n return jiraFetch<JiraIssueType[]>(`/project/${projectIdOrKey}/statuses`);\n}\n\nexport function extractDescriptionText(description: unknown): string {\n if (typeof description === \"string\") {\n return description;\n }\n\n if (!description || typeof description !== \"object\") {\n return \"\";\n }\n\n const content = (description as { content?: unknown[] }).content;\n if (!Array.isArray(content)) {\n return \"\";\n }\n\n const texts: string[] = [];\n\n function extractText(node: any): void {\n if (node?.type === \"text\" && node.text) {\n texts.push(node.text);\n }\n\n if (Array.isArray(node?.content)) {\n node.content.forEach(extractText);\n }\n }\n\n content.forEach(extractText);\n return texts.join(\" \");\n}\n",
|
|
535
|
-
"app/api/auth/jira/callback/route.ts": "import { createOAuthCallbackHandler, jiraConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
536
|
-
"app/api/auth/jira/route.ts": "import { createOAuthInitHandler, jiraConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(jiraConfig, {
|
|
535
|
+
"app/api/auth/jira/callback/route.ts": "import { createOAuthCallbackHandler, jiraConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n async getTokens(serviceId: string, userId: string): Promise<unknown> {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ): Promise<void> {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string): Promise<void> {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ): Promise<void> {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string): Promise<unknown> {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(jiraConfig, { tokenStore: hybridTokenStore });\n",
|
|
536
|
+
"app/api/auth/jira/route.ts": "import { createOAuthInitHandler, jiraConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(jiraConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
537
537
|
}
|
|
538
538
|
},
|
|
539
539
|
"integration:zendesk": {
|
|
@@ -557,8 +557,8 @@ export default {
|
|
|
557
557
|
"tools/create-card.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createCard } from \"../../lib/trello-client.ts\";\n\nexport default tool({\n id: \"create-card\",\n description: \"Create a new card in a Trello list.\",\n inputSchema: z.object({\n listId: z.string().describe(\"The ID of the list to create the card in\"),\n name: z.string().describe(\"The name/title of the card\"),\n desc: z.string().optional().describe(\"Description or details for the card\"),\n due: z\n .string()\n .optional()\n .describe(\"Due date in ISO 8601 format (e.g., 2024-12-31T23:59:59.000Z)\"),\n pos: z\n .union([z.string(), z.number()])\n .optional()\n .describe('Position of the card: \"top\", \"bottom\", or a positive number'),\n idMembers: z\n .array(z.string())\n .optional()\n .describe(\"Array of member IDs to assign to the card\"),\n idLabels: z\n .array(z.string())\n .optional()\n .describe(\"Array of label IDs to add to the card\"),\n }),\n async execute({ listId, name, desc, due, pos, idMembers, idLabels }) {\n const card = await createCard({\n listId,\n name,\n desc,\n due,\n pos,\n idMembers,\n idLabels,\n });\n\n return {\n success: true,\n card: {\n id: card.id,\n name: card.name,\n desc: card.desc,\n url: card.url,\n idList: card.idList,\n due: card.due,\n labels: card.labels.map((label) => ({\n id: label.id,\n name: label.name,\n color: label.color,\n })),\n },\n };\n },\n});\n",
|
|
558
558
|
".env.example": "# Trello OAuth Configuration\n# Get your credentials from https://trello.com/app-key\nTRELLO_CLIENT_ID=your-api-key\nTRELLO_CLIENT_SECRET=your-oauth-secret\n",
|
|
559
559
|
"lib/trello-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst TRELLO_BASE_URL = \"https://api.trello.com/1\";\n\ninterface TrelloBoard {\n id: string;\n name: string;\n desc: string;\n closed: boolean;\n url: string;\n prefs: {\n background: string;\n backgroundColor: string;\n };\n dateLastActivity: string;\n}\n\ninterface TrelloList {\n id: string;\n name: string;\n closed: boolean;\n idBoard: string;\n pos: number;\n}\n\ninterface TrelloCard {\n id: string;\n name: string;\n desc: string;\n closed: boolean;\n idBoard: string;\n idList: string;\n idMembers: string[];\n labels: Array<{\n id: string;\n name: string;\n color: string;\n }>;\n due: string | null;\n dueComplete: boolean;\n url: string;\n dateLastActivity: string;\n}\n\ninterface TrelloMember {\n id: string;\n fullName: string;\n username: string;\n avatarUrl: string;\n}\n\nasync function trelloFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Trello. Please connect your account.\");\n }\n\n const clientId = process.env.TRELLO_CLIENT_ID;\n if (!clientId) {\n throw new Error(\"TRELLO_CLIENT_ID environment variable is not set.\");\n }\n\n const url = new URL(`${TRELLO_BASE_URL}${endpoint}`);\n // SECURITY: Trello's REST API requires key and token as query parameters.\n // Tokens in query params may be recorded in browser history, server/proxy\n // access logs, and leaked via the Referer header. The Referrer-Policy\n // header (set by Veryfront's security middleware) mitigates the Referer leak.\n // This is an API design limitation — there is no Authorization header alternative.\n url.searchParams.set(\"key\", clientId);\n url.searchParams.set(\"token\", token);\n\n const response = await fetch(url.toString(), {\n ...options,\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const errorText = await response.text().catch(() => \"\");\n const message = errorText || response.statusText;\n throw new Error(`Trello API error: ${response.status} ${message}`);\n }\n\n return response.json();\n}\n\nexport async function listBoards(): Promise<TrelloBoard[]> {\n return trelloFetch<TrelloBoard[]>(\n \"/members/me/boards?fields=name,desc,closed,url,prefs,dateLastActivity\",\n );\n}\n\nexport async function getBoard(boardId: string): Promise<TrelloBoard> {\n return trelloFetch<TrelloBoard>(\n `/boards/${boardId}?fields=name,desc,closed,url,prefs,dateLastActivity`,\n );\n}\n\nexport async function listLists(boardId: string): Promise<TrelloList[]> {\n return trelloFetch<TrelloList[]>(\n `/boards/${boardId}/lists?fields=name,closed,idBoard,pos`,\n );\n}\n\nexport async function listCards(options: {\n boardId?: string;\n listId?: string;\n limit?: number;\n}): Promise<TrelloCard[]> {\n const { boardId, listId, limit = 50 } = options;\n\n const fields =\n \"name,desc,closed,idBoard,idList,idMembers,labels,due,dueComplete,url,dateLastActivity\";\n\n if (listId) {\n return trelloFetch<TrelloCard[]>(\n `/lists/${listId}/cards?fields=${fields}&limit=${limit}`,\n );\n }\n\n if (boardId) {\n return trelloFetch<TrelloCard[]>(\n `/boards/${boardId}/cards?fields=${fields}&limit=${limit}`,\n );\n }\n\n throw new Error(\"Either boardId or listId must be provided\");\n}\n\nexport async function getCard(cardId: string): Promise<TrelloCard> {\n return trelloFetch<TrelloCard>(\n \"/cards/\" +\n `${cardId}?fields=name,desc,closed,idBoard,idList,idMembers,labels,due,dueComplete,url,dateLastActivity`,\n );\n}\n\nexport async function createCard(options: {\n listId: string;\n name: string;\n desc?: string;\n due?: string;\n pos?: string | number;\n idMembers?: string[];\n idLabels?: string[];\n}): Promise<TrelloCard> {\n const params = new URLSearchParams({\n idList: options.listId,\n name: options.name,\n });\n\n if (options.desc) params.set(\"desc\", options.desc);\n if (options.due) params.set(\"due\", options.due);\n if (options.pos !== undefined) params.set(\"pos\", String(options.pos));\n if (options.idMembers) params.set(\"idMembers\", options.idMembers.join(\",\"));\n if (options.idLabels) params.set(\"idLabels\", options.idLabels.join(\",\"));\n\n return trelloFetch<TrelloCard>(`/cards?${params}`, { method: \"POST\" });\n}\n\nexport async function updateCard(\n cardId: string,\n updates: {\n name?: string;\n desc?: string;\n closed?: boolean;\n idList?: string;\n due?: string | null;\n dueComplete?: boolean;\n idMembers?: string[];\n idLabels?: string[];\n pos?: string | number;\n },\n): Promise<TrelloCard> {\n const params = new URLSearchParams();\n\n if (updates.name !== undefined) params.set(\"name\", updates.name);\n if (updates.desc !== undefined) params.set(\"desc\", updates.desc);\n if (updates.closed !== undefined) params.set(\"closed\", String(updates.closed));\n if (updates.idList !== undefined) params.set(\"idList\", updates.idList);\n if (updates.due !== undefined) params.set(\"due\", updates.due ?? \"\");\n if (updates.dueComplete !== undefined) params.set(\"dueComplete\", String(updates.dueComplete));\n if (updates.idMembers !== undefined) params.set(\"idMembers\", updates.idMembers.join(\",\"));\n if (updates.idLabels !== undefined) params.set(\"idLabels\", updates.idLabels.join(\",\"));\n if (updates.pos !== undefined) params.set(\"pos\", String(updates.pos));\n\n return trelloFetch<TrelloCard>(`/cards/${cardId}?${params}`, { method: \"PUT\" });\n}\n\nexport async function getMe(): Promise<TrelloMember> {\n return trelloFetch<TrelloMember>(\"/members/me?fields=fullName,username,avatarUrl\");\n}\n",
|
|
560
|
-
"app/api/auth/trello/callback/route.ts": "import { createOAuthCallbackHandler, trelloConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
561
|
-
"app/api/auth/trello/route.ts": "import { createOAuthInitHandler, trelloConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(trelloConfig, {
|
|
560
|
+
"app/api/auth/trello/callback/route.ts": "import { createOAuthCallbackHandler, trelloConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(trelloConfig, { tokenStore: hybridTokenStore });\n",
|
|
561
|
+
"app/api/auth/trello/route.ts": "import { createOAuthInitHandler, trelloConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(trelloConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
562
562
|
}
|
|
563
563
|
},
|
|
564
564
|
"integration:mixpanel": {
|
|
@@ -581,8 +581,8 @@ export default {
|
|
|
581
581
|
"tools/delete-meeting.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { deleteMeeting } from \"../../lib/zoom-client.ts\";\n\nexport default tool({\n id: \"delete-meeting\",\n description: \"Delete a scheduled Zoom meeting.\",\n inputSchema: z.object({\n meetingId: z.union([z.string(), z.number()]).describe(\"The meeting ID to delete\"),\n occurrenceId: z\n .string()\n .optional()\n .describe(\"The meeting occurrence ID for recurring meetings\"),\n scheduleForReminder: z\n .boolean()\n .default(false)\n .describe(\"Whether to send a reminder email to participants\"),\n }),\n async execute({ meetingId, occurrenceId, scheduleForReminder }) {\n await deleteMeeting(meetingId, { occurrenceId, scheduleForReminder });\n\n return {\n success: true,\n message: `Meeting ${meetingId} deleted successfully`,\n };\n },\n});\n",
|
|
582
582
|
".env.example": "# Zoom OAuth Configuration\n# Get your credentials from https://marketplace.zoom.us/develop/create\nZOOM_CLIENT_ID=your-client-id\nZOOM_CLIENT_SECRET=your-client-secret\n",
|
|
583
583
|
"lib/zoom-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst ZOOM_BASE_URL = \"https://api.zoom.us/v2\";\n\ninterface ZoomMeeting {\n id: number;\n uuid: string;\n topic: string;\n type: number;\n start_time: string;\n duration: number;\n timezone: string;\n agenda: string;\n created_at: string;\n join_url: string;\n password?: string;\n host_id: string;\n host_email: string;\n status: string;\n settings?: {\n host_video: boolean;\n participant_video: boolean;\n join_before_host: boolean;\n mute_upon_entry: boolean;\n watermark: boolean;\n audio: string;\n auto_recording: string;\n };\n}\n\ninterface ZoomUser {\n id: string;\n first_name: string;\n last_name: string;\n email: string;\n type: number;\n pmi: number;\n timezone: string;\n verified: number;\n created_at: string;\n last_login_time: string;\n pic_url: string;\n}\n\ninterface ZoomMeetingList {\n meetings: ZoomMeeting[];\n page_count: number;\n page_number: number;\n page_size: number;\n total_records: number;\n}\n\ninterface MeetingSettingsInput {\n hostVideo?: boolean;\n participantVideo?: boolean;\n joinBeforeHost?: boolean;\n muteUponEntry?: boolean;\n watermark?: boolean;\n audio?: \"both\" | \"telephony\" | \"voip\";\n autoRecording?: \"local\" | \"cloud\" | \"none\";\n}\n\nfunction toZoomSettings(\n settings?: MeetingSettingsInput,\n): Record<string, unknown> | undefined {\n if (!settings) return undefined;\n\n return {\n host_video: settings.hostVideo,\n participant_video: settings.participantVideo,\n join_before_host: settings.joinBeforeHost,\n mute_upon_entry: settings.muteUponEntry,\n watermark: settings.watermark,\n audio: settings.audio,\n auto_recording: settings.autoRecording,\n };\n}\n\nasync function zoomFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Zoom. Please connect your account.\");\n }\n\n const response = await fetch(`${ZOOM_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({}))) as { message?: string };\n throw new Error(`Zoom API error: ${response.status} ${error.message ?? response.statusText}`);\n }\n\n return response.json();\n}\n\nexport async function getUser(): Promise<ZoomUser> {\n return zoomFetch<ZoomUser>(\"/users/me\");\n}\n\nexport async function listMeetings(\n options: {\n userId?: string;\n type?: \"scheduled\" | \"live\" | \"upcoming\" | \"upcoming_meetings\" | \"previous_meetings\";\n pageSize?: number;\n pageNumber?: number;\n } = {},\n): Promise<ZoomMeeting[]> {\n const userId = options.userId ?? \"me\";\n const params = new URLSearchParams({\n type: options.type ?? \"scheduled\",\n page_size: String(options.pageSize ?? 30),\n page_number: String(options.pageNumber ?? 1),\n });\n\n const response = await zoomFetch<ZoomMeetingList>(`/users/${userId}/meetings?${params}`);\n return response.meetings;\n}\n\nexport async function getMeeting(meetingId: string | number): Promise<ZoomMeeting> {\n return zoomFetch<ZoomMeeting>(`/meetings/${meetingId}`);\n}\n\nexport async function createMeeting(options: {\n userId?: string;\n topic: string;\n type?: 1 | 2 | 3 | 8; // 1=Instant, 2=Scheduled, 3=Recurring with no fixed time, 8=Recurring with fixed time\n startTime?: string; // ISO 8601 format\n duration?: number; // In minutes\n timezone?: string;\n password?: string;\n agenda?: string;\n settings?: MeetingSettingsInput;\n}): Promise<ZoomMeeting> {\n const userId = options.userId ?? \"me\";\n const body: Record<string, unknown> = {\n topic: options.topic,\n type: options.type ?? 2,\n };\n\n if (options.startTime) body.start_time = options.startTime;\n if (options.duration) body.duration = options.duration;\n if (options.timezone) body.timezone = options.timezone;\n if (options.password) body.password = options.password;\n if (options.agenda) body.agenda = options.agenda;\n\n const settings = toZoomSettings(options.settings);\n if (settings) body.settings = settings;\n\n return zoomFetch<ZoomMeeting>(`/users/${userId}/meetings`, {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n}\n\nexport async function updateMeeting(\n meetingId: string | number,\n updates: {\n topic?: string;\n type?: 1 | 2 | 3 | 8;\n startTime?: string;\n duration?: number;\n timezone?: string;\n password?: string;\n agenda?: string;\n settings?: MeetingSettingsInput;\n },\n): Promise<void> {\n const body: Record<string, unknown> = {};\n\n if (updates.topic !== undefined) body.topic = updates.topic;\n if (updates.type !== undefined) body.type = updates.type;\n if (updates.startTime !== undefined) body.start_time = updates.startTime;\n if (updates.duration !== undefined) body.duration = updates.duration;\n if (updates.timezone !== undefined) body.timezone = updates.timezone;\n if (updates.password !== undefined) body.password = updates.password;\n if (updates.agenda !== undefined) body.agenda = updates.agenda;\n\n const settings = toZoomSettings(updates.settings);\n if (settings) body.settings = settings;\n\n await zoomFetch<void>(`/meetings/${meetingId}`, {\n method: \"PATCH\",\n body: JSON.stringify(body),\n });\n}\n\nexport async function deleteMeeting(\n meetingId: string | number,\n options?: {\n occurrenceId?: string;\n scheduleForReminder?: boolean;\n },\n): Promise<void> {\n const params = new URLSearchParams();\n if (options?.occurrenceId) params.set(\"occurrence_id\", options.occurrenceId);\n if (options?.scheduleForReminder !== undefined) {\n params.set(\"schedule_for_reminder\", String(options.scheduleForReminder));\n }\n\n const queryString = params.toString();\n await zoomFetch<void>(`/meetings/${meetingId}${queryString ? `?${queryString}` : \"\"}`, {\n method: \"DELETE\",\n });\n}\n",
|
|
584
|
-
"app/api/auth/zoom/callback/route.ts": "import { createOAuthCallbackHandler, zoomConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
585
|
-
"app/api/auth/zoom/route.ts": "import { createOAuthInitHandler, zoomConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(zoomConfig, {
|
|
584
|
+
"app/api/auth/zoom/callback/route.ts": "import { createOAuthCallbackHandler, zoomConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(zoomConfig, { tokenStore: hybridTokenStore });\n",
|
|
585
|
+
"app/api/auth/zoom/route.ts": "import { createOAuthInitHandler, zoomConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(zoomConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
586
586
|
}
|
|
587
587
|
},
|
|
588
588
|
"integration:airtable": {
|
|
@@ -593,8 +593,8 @@ export default {
|
|
|
593
593
|
"tools/get-base.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { getBase } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"get-base\",\n description:\n \"Get the schema and structure of an Airtable base, including all tables, fields, and views. Useful for understanding the data model before querying or creating records.\",\n inputSchema: z.object({\n baseId: z.string().describe('The ID of the Airtable base (starts with \"app\")'),\n }),\n async execute({ baseId }) {\n const { tables } = await getBase(baseId);\n\n return {\n tables: tables.map((table) => ({\n id: table.id,\n name: table.name,\n primaryFieldId: table.primaryFieldId,\n fields: table.fields.map((field) => ({\n id: field.id,\n name: field.name,\n type: field.type,\n options: field.options,\n })),\n views: table.views.map((view) => ({\n id: view.id,\n name: view.name,\n type: view.type,\n })),\n })),\n };\n },\n});\n",
|
|
594
594
|
"tools/list-records.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { listRecords } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"list-records\",\n description:\n \"List records from an Airtable table. Supports filtering with formulas, sorting, and limiting results. Returns record IDs, creation times, and all field values.\",\n inputSchema: z.object({\n baseId: z.string().describe('The ID of the Airtable base (starts with \"app\")'),\n tableIdOrName: z.string().describe(\"The ID or name of the table\"),\n fields: z\n .array(z.string())\n .optional()\n .describe(\"Specific field names to return (returns all fields if not specified)\"),\n filterByFormula: z\n .string()\n .optional()\n .describe('Airtable formula to filter records (e.g., \"{Status} = \\'Done\\'\")'),\n maxRecords: z.number().min(1).max(100).optional().describe(\"Maximum number of records to return\"),\n sort: z\n .array(\n z.object({\n field: z.string().describe(\"Field name to sort by\"),\n direction: z.enum([\"asc\", \"desc\"]).describe(\"Sort direction\"),\n }),\n )\n .optional()\n .describe(\"Array of sort specifications\"),\n view: z.string().optional().describe(\"Name of a view to use for filtering and sorting\"),\n }),\n async execute({ baseId, tableIdOrName, fields, filterByFormula, maxRecords, sort, view }) {\n const { records, offset } = await listRecords(baseId, tableIdOrName, {\n fields,\n filterByFormula,\n maxRecords,\n pageSize: maxRecords,\n sort,\n view,\n });\n\n return {\n records: records.map((record) => ({\n id: record.id,\n createdTime: record.createdTime,\n fields: record.fields,\n })),\n count: records.length,\n hasMore: Boolean(offset),\n };\n },\n});\n",
|
|
595
595
|
"lib/airtable-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst AIRTABLE_BASE_URL = \"https://api.airtable.com/v0\";\nconst AIRTABLE_META_BASE_URL = \"https://api.airtable.com/v0/meta\";\n\ninterface AirtableResponse<T> {\n records?: T[];\n offset?: string;\n}\n\ninterface AirtableBase {\n id: string;\n name: string;\n permissionLevel: string;\n}\n\ninterface AirtableBaseSchema {\n tables: Array<{\n id: string;\n name: string;\n primaryFieldId: string;\n fields: Array<{\n id: string;\n name: string;\n type: string;\n options?: Record<string, unknown>;\n }>;\n views: Array<{\n id: string;\n name: string;\n type: string;\n }>;\n }>;\n}\n\nexport interface AirtableRecord {\n id: string;\n createdTime: string;\n fields: Record<string, unknown>;\n}\n\nfunction getTokenOrThrow(): string {\n const token = getAccessToken();\n if (token) return token;\n throw new Error(\"Not authenticated with Airtable. Please connect your account.\");\n}\n\nasync function apiFetch<T>(\n baseUrl: string,\n endpoint: string,\n options: RequestInit,\n errorPrefix: string,\n): Promise<T> {\n const token = getTokenOrThrow();\n\n const response = await fetch(`${baseUrl}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(\n `${errorPrefix}: ${response.status} ${error?.error?.message ?? response.statusText}`,\n );\n }\n\n return response.json() as Promise<T>;\n}\n\nfunction airtableFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiFetch<T>(AIRTABLE_BASE_URL, endpoint, options, \"Airtable API error\");\n}\n\nfunction metaFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiFetch<T>(AIRTABLE_META_BASE_URL, endpoint, options, \"Airtable Meta API error\");\n}\n\nexport async function listBases(): Promise<AirtableBase[]> {\n const response = await metaFetch<{ bases: AirtableBase[] }>(\"/bases\");\n return response.bases ?? [];\n}\n\nexport function getBase(baseId: string): Promise<AirtableBaseSchema> {\n return metaFetch<AirtableBaseSchema>(`/bases/${baseId}/tables`);\n}\n\nexport async function listRecords(\n baseId: string,\n tableIdOrName: string,\n options?: {\n fields?: string[];\n filterByFormula?: string;\n maxRecords?: number;\n pageSize?: number;\n sort?: Array<{ field: string; direction: \"asc\" | \"desc\" }>;\n view?: string;\n offset?: string;\n },\n): Promise<{ records: AirtableRecord[]; offset?: string }> {\n const params = new URLSearchParams();\n\n options?.fields?.forEach((field) => params.append(\"fields[]\", field));\n if (options?.filterByFormula) params.append(\"filterByFormula\", options.filterByFormula);\n if (options?.maxRecords) params.append(\"maxRecords\", String(options.maxRecords));\n if (options?.pageSize) params.append(\"pageSize\", String(options.pageSize));\n options?.sort?.forEach((s, i) => {\n params.append(`sort[${i}][field]`, s.field);\n params.append(`sort[${i}][direction]`, s.direction);\n });\n if (options?.view) params.append(\"view\", options.view);\n if (options?.offset) params.append(\"offset\", options.offset);\n\n const queryString = params.toString();\n const endpoint = `/${baseId}/${encodeURIComponent(tableIdOrName)}${\n queryString ? `?${queryString}` : \"\"\n }`;\n\n const response = await airtableFetch<AirtableResponse<AirtableRecord>>(endpoint);\n\n return { records: response.records ?? [], offset: response.offset };\n}\n\nexport function getRecord(\n baseId: string,\n tableIdOrName: string,\n recordId: string,\n): Promise<AirtableRecord> {\n return airtableFetch<AirtableRecord>(\n `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}`,\n );\n}\n\nexport function createRecord(\n baseId: string,\n tableIdOrName: string,\n fields: Record<string, unknown>,\n): Promise<AirtableRecord> {\n return airtableFetch<AirtableRecord>(`/${baseId}/${encodeURIComponent(tableIdOrName)}`, {\n method: \"POST\",\n body: JSON.stringify({ fields }),\n });\n}\n\nexport async function createRecords(\n baseId: string,\n tableIdOrName: string,\n records: Array<{ fields: Record<string, unknown> }>,\n): Promise<AirtableRecord[]> {\n const response = await airtableFetch<{ records: AirtableRecord[] }>(\n `/${baseId}/${encodeURIComponent(tableIdOrName)}`,\n {\n method: \"POST\",\n body: JSON.stringify({ records }),\n },\n );\n\n return response.records;\n}\n\nexport function updateRecord(\n baseId: string,\n tableIdOrName: string,\n recordId: string,\n fields: Record<string, unknown>,\n options?: { destructive?: boolean },\n): Promise<AirtableRecord> {\n return airtableFetch<AirtableRecord>(\n `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}`,\n {\n method: options?.destructive ? \"PUT\" : \"PATCH\",\n body: JSON.stringify({ fields }),\n },\n );\n}\n\nexport function deleteRecord(\n baseId: string,\n tableIdOrName: string,\n recordId: string,\n): Promise<{ id: string; deleted: boolean }> {\n return airtableFetch<{ id: string; deleted: boolean }>(\n `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}`,\n { method: \"DELETE\" },\n );\n}\n\nexport function formatFieldValue(value: unknown): string {\n if (value == null) return \"\";\n if (Array.isArray(value)) return value.map((v) => formatFieldValue(v)).join(\", \");\n if (typeof value === \"object\") return JSON.stringify(value);\n return String(value);\n}\n",
|
|
596
|
-
"app/api/auth/airtable/callback/route.ts": "import { airtableConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
597
|
-
"app/api/auth/airtable/route.ts": "import { airtableConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(airtableConfig, {\n tokenStore: oauthMemoryTokenStore,\n})
|
|
596
|
+
"app/api/auth/airtable/callback/route.ts": "import { airtableConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(airtableConfig, { tokenStore: hybridTokenStore });\n",
|
|
597
|
+
"app/api/auth/airtable/route.ts": "import { airtableConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(airtableConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
598
598
|
}
|
|
599
599
|
},
|
|
600
600
|
"integration:quickbooks": {
|
|
@@ -606,8 +606,8 @@ export default {
|
|
|
606
606
|
"tools/get-customer.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { getCustomer } from \"../../lib/quickbooks-client.ts\";\n\nexport default tool({\n id: \"get-customer\",\n description: \"Get details of a specific QuickBooks customer by their ID.\",\n inputSchema: z.object({\n customerId: z.string().describe(\"The ID of the customer to retrieve\"),\n }),\n async execute({ customerId }) {\n const customer = await getCustomer(customerId);\n const billAddr = customer.BillAddr;\n\n return {\n id: customer.Id,\n displayName: customer.DisplayName,\n companyName: customer.CompanyName,\n givenName: customer.GivenName,\n familyName: customer.FamilyName,\n email: customer.PrimaryEmailAddr?.Address,\n phone: customer.PrimaryPhone?.FreeFormNumber,\n address: billAddr\n ? {\n line1: billAddr.Line1,\n city: billAddr.City,\n state: billAddr.CountrySubDivisionCode,\n postalCode: billAddr.PostalCode,\n }\n : undefined,\n balance: customer.Balance,\n active: customer.Active,\n metadata: {\n createTime: customer.MetaData?.CreateTime,\n lastUpdatedTime: customer.MetaData?.LastUpdatedTime,\n },\n };\n },\n});\n",
|
|
607
607
|
".env.example": "# QuickBooks OAuth Configuration\n# Get your credentials from https://developer.intuit.com/app/developer/myapps\nQUICKBOOKS_CLIENT_ID=your-client-id\nQUICKBOOKS_CLIENT_SECRET=your-client-secret\n",
|
|
608
608
|
"lib/quickbooks-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst QUICKBOOKS_BASE_URL = \"https://quickbooks.api.intuit.com/v3\";\n\nfunction getRealmId(): string {\n const realmId = process.env.QUICKBOOKS_REALM_ID;\n if (!realmId) {\n throw new Error(\"QUICKBOOKS_REALM_ID environment variable is required\");\n }\n return realmId;\n}\n\ninterface QuickBooksResponse<T> {\n QueryResponse?: {\n [key: string]: T[] | number | undefined;\n maxResults?: number;\n startPosition?: number;\n };\n Invoice?: T;\n Customer?: T;\n time?: string;\n}\n\ninterface QuickBooksInvoice {\n Id: string;\n DocNumber: string;\n TxnDate: string;\n DueDate?: string;\n TotalAmt: number;\n Balance: number;\n CustomerRef: {\n value: string;\n name: string;\n };\n Line: Array<{\n Id: string;\n LineNum: number;\n Description?: string;\n Amount: number;\n DetailType: string;\n SalesItemLineDetail?: {\n ItemRef: {\n value: string;\n name: string;\n };\n Qty?: number;\n UnitPrice?: number;\n };\n }>;\n EmailStatus?: string;\n BillEmail?: {\n Address: string;\n };\n TxnStatus?: string;\n MetaData?: {\n CreateTime: string;\n LastUpdatedTime: string;\n };\n}\n\ninterface QuickBooksCustomer {\n Id: string;\n DisplayName: string;\n CompanyName?: string;\n GivenName?: string;\n FamilyName?: string;\n PrimaryEmailAddr?: {\n Address: string;\n };\n PrimaryPhone?: {\n FreeFormNumber: string;\n };\n BillAddr?: {\n Line1?: string;\n City?: string;\n CountrySubDivisionCode?: string;\n PostalCode?: string;\n };\n Balance: number;\n Active: boolean;\n MetaData?: {\n CreateTime: string;\n LastUpdatedTime: string;\n };\n}\n\nasync function quickbooksFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with QuickBooks. Please connect your account.\");\n }\n\n const url = `${QUICKBOOKS_BASE_URL}/company/${getRealmId()}${endpoint}`;\n\n const response = await fetch(url, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n const message = error.Fault?.Error?.[0]?.Message ?? response.statusText;\n throw new Error(`QuickBooks API error: ${response.status} ${message}`);\n }\n\n return response.json();\n}\n\nexport async function listInvoices(options?: {\n customerId?: string;\n maxResults?: number;\n}): Promise<QuickBooksInvoice[]> {\n const maxResults = options?.maxResults ?? 100;\n\n if (options?.customerId && !/^[a-zA-Z0-9_\\-:.]+$/.test(options.customerId)) {\n throw new Error('Invalid customerId: must contain only alphanumeric characters, underscores, hyphens, colons, or periods');\n }\n\n let query = `SELECT * FROM Invoice MAXRESULTS ${maxResults}`;\n if (options?.customerId) {\n query = `SELECT * FROM Invoice WHERE CustomerRef = '${options.customerId}' MAXRESULTS ${maxResults}`;\n }\n\n const response = await quickbooksFetch<QuickBooksResponse<QuickBooksInvoice>>(\n `/query?query=${encodeURIComponent(query)}`,\n );\n\n return response.QueryResponse?.Invoice ?? [];\n}\n\nexport async function getInvoice(invoiceId: string): Promise<QuickBooksInvoice> {\n const response = await quickbooksFetch<QuickBooksResponse<QuickBooksInvoice>>(\n `/invoice/${invoiceId}`,\n );\n\n const invoice = response.Invoice;\n if (!invoice) {\n throw new Error(`Invoice ${invoiceId} not found`);\n }\n\n return invoice;\n}\n\nexport async function createInvoice(options: {\n customerId: string;\n lineItems: Array<{\n description?: string;\n amount: number;\n itemId?: string;\n quantity?: number;\n unitPrice?: number;\n }>;\n txnDate?: string;\n dueDate?: string;\n customerMemo?: string;\n}): Promise<QuickBooksInvoice> {\n const lines = options.lineItems.map((item, index) => {\n const line: Record<string, unknown> = {\n LineNum: index + 1,\n Amount: item.amount,\n DetailType: \"SalesItemLineDetail\",\n };\n\n if (item.description) {\n line.Description = item.description;\n }\n\n if (item.itemId) {\n line.SalesItemLineDetail = {\n ItemRef: { value: item.itemId },\n Qty: item.quantity ?? 1,\n UnitPrice: item.unitPrice ?? item.amount,\n };\n }\n\n return line;\n });\n\n const invoiceData: Record<string, unknown> = {\n CustomerRef: { value: options.customerId },\n Line: lines,\n };\n\n if (options.txnDate) invoiceData.TxnDate = options.txnDate;\n if (options.dueDate) invoiceData.DueDate = options.dueDate;\n if (options.customerMemo) invoiceData.CustomerMemo = { value: options.customerMemo };\n\n const response = await quickbooksFetch<QuickBooksResponse<QuickBooksInvoice>>(\"/invoice\", {\n method: \"POST\",\n body: JSON.stringify(invoiceData),\n });\n\n const invoice = response.Invoice;\n if (!invoice) {\n throw new Error(\"Failed to create invoice\");\n }\n\n return invoice;\n}\n\nexport async function listCustomers(options?: {\n maxResults?: number;\n active?: boolean;\n}): Promise<QuickBooksCustomer[]> {\n const maxResults = options?.maxResults ?? 100;\n\n let query = `SELECT * FROM Customer MAXRESULTS ${maxResults}`;\n if (options?.active !== undefined) {\n query = `SELECT * FROM Customer WHERE Active = ${options.active} MAXRESULTS ${maxResults}`;\n }\n\n const response = await quickbooksFetch<QuickBooksResponse<QuickBooksCustomer>>(\n `/query?query=${encodeURIComponent(query)}`,\n );\n\n return response.QueryResponse?.Customer ?? [];\n}\n\nexport async function getCustomer(customerId: string): Promise<QuickBooksCustomer> {\n const response = await quickbooksFetch<QuickBooksResponse<QuickBooksCustomer>>(\n `/customer/${customerId}`,\n );\n\n const customer = response.Customer;\n if (!customer) {\n throw new Error(`Customer ${customerId} not found`);\n }\n\n return customer;\n}\n",
|
|
609
|
-
"app/api/auth/quickbooks/callback/route.ts": "import { createOAuthCallbackHandler, quickbooksConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
610
|
-
"app/api/auth/quickbooks/route.ts": "import { createOAuthInitHandler, quickbooksConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(quickbooksConfig, {
|
|
609
|
+
"app/api/auth/quickbooks/callback/route.ts": "import { createOAuthCallbackHandler, quickbooksConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(quickbooksConfig, { tokenStore: hybridTokenStore });\n",
|
|
610
|
+
"app/api/auth/quickbooks/route.ts": "import { createOAuthInitHandler, quickbooksConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(quickbooksConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
611
611
|
}
|
|
612
612
|
},
|
|
613
613
|
"integration:outlook": {
|
|
@@ -619,8 +619,8 @@ export default {
|
|
|
619
619
|
"tools/send-email.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { sendEmail } from \"../../lib/outlook-client.ts\";\n\nexport default tool({\n id: \"send-email\",\n description:\n \"Send a new email message. Supports multiple recipients, CC, BCC, and importance levels.\",\n inputSchema: z.object({\n to: z.array(z.string().email()).min(1).describe(\"Email addresses of recipients\"),\n subject: z.string().min(1).describe(\"Email subject line\"),\n body: z.string().min(1).describe(\"Email body content\"),\n cc: z.array(z.string().email()).optional().describe(\"Email addresses to CC\"),\n bcc: z.array(z.string().email()).optional().describe(\"Email addresses to BCC\"),\n importance: z\n .enum([\"low\", \"normal\", \"high\"])\n .default(\"normal\")\n .describe(\"Email importance level\"),\n bodyType: z\n .enum([\"text\", \"html\"])\n .default(\"text\")\n .describe(\"Body content type (text or html)\"),\n }),\n async execute({ to, subject, body, cc, bcc, importance, bodyType }) {\n await sendEmail({ to, subject, body, cc, bcc, importance, bodyType });\n\n return {\n success: true,\n message: `Email sent successfully to ${to.join(\", \")}`,\n recipients: { to, cc, bcc },\n };\n },\n});\n",
|
|
620
620
|
".env.example": "# Microsoft Outlook Integration\n# Get these from: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\n\n# Your Microsoft Azure App Client ID (Application ID)\nMICROSOFT_CLIENT_ID=your_client_id_here\n\n# Your Microsoft Azure App Client Secret\nMICROSOFT_CLIENT_SECRET=your_client_secret_here\n",
|
|
621
621
|
"lib/outlook-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst GRAPH_BASE_URL = \"https://graph.microsoft.com/v1.0\";\n\ninterface GraphResponse<T> {\n value?: T[];\n \"@odata.nextLink\"?: string;\n}\n\nexport interface OutlookMessage {\n id: string;\n subject: string;\n bodyPreview: string;\n body: {\n contentType: \"text\" | \"html\";\n content: string;\n };\n from: {\n emailAddress: {\n name: string;\n address: string;\n };\n };\n toRecipients: Array<{\n emailAddress: {\n name: string;\n address: string;\n };\n }>;\n ccRecipients?: Array<{\n emailAddress: {\n name: string;\n address: string;\n };\n }>;\n receivedDateTime: string;\n sentDateTime: string;\n isRead: boolean;\n hasAttachments: boolean;\n importance: \"low\" | \"normal\" | \"high\";\n conversationId: string;\n webLink: string;\n}\n\nexport interface OutlookFolder {\n id: string;\n displayName: string;\n parentFolderId: string;\n childFolderCount: number;\n unreadItemCount: number;\n totalItemCount: number;\n}\n\nexport interface SendEmailOptions {\n to: string[];\n subject: string;\n body: string;\n cc?: string[];\n bcc?: string[];\n importance?: \"low\" | \"normal\" | \"high\";\n bodyType?: \"text\" | \"html\";\n}\n\nasync function graphFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Microsoft. Please connect your account.\");\n }\n\n const response = await fetch(`${GRAPH_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(\n `Microsoft Graph API error: ${response.status} ${error.error?.message ?? response.statusText}`,\n );\n }\n\n return response.json();\n}\n\nexport async function listEmails(options?: {\n folderId?: string;\n top?: number;\n skip?: number;\n filter?: string;\n orderBy?: string;\n}): Promise<OutlookMessage[]> {\n const params = new URLSearchParams();\n\n if (options?.top != null) params.set(\"$top\", options.top.toString());\n if (options?.skip != null) params.set(\"$skip\", options.skip.toString());\n if (options?.filter) params.set(\"$filter\", options.filter);\n if (options?.orderBy) params.set(\"$orderby\", options.orderBy);\n\n const folderPath = options?.folderId\n ? `/mailFolders/${options.folderId}/messages`\n : \"/messages\";\n\n const queryString = params.toString();\n const endpoint = queryString ? `${folderPath}?${queryString}` : folderPath;\n\n const response = await graphFetch<GraphResponse<OutlookMessage>>(endpoint);\n return response.value ?? [];\n}\n\nexport function getEmail(messageId: string): Promise<OutlookMessage> {\n return graphFetch<OutlookMessage>(`/messages/${messageId}`);\n}\n\nexport async function sendEmail(options: SendEmailOptions): Promise<void> {\n const message = {\n subject: options.subject,\n body: {\n contentType: options.bodyType ?? \"text\",\n content: options.body,\n },\n toRecipients: options.to.map((email) => ({\n emailAddress: { address: email },\n })),\n ccRecipients: options.cc?.map((email) => ({\n emailAddress: { address: email },\n })),\n bccRecipients: options.bcc?.map((email) => ({\n emailAddress: { address: email },\n })),\n importance: options.importance ?? \"normal\",\n };\n\n await graphFetch(\"/sendMail\", {\n method: \"POST\",\n body: JSON.stringify({ message }),\n });\n}\n\nexport async function searchEmails(options: {\n query: string;\n top?: number;\n skip?: number;\n}): Promise<OutlookMessage[]> {\n const params = new URLSearchParams({ $search: `\"${options.query}\"` });\n\n if (options.top != null) params.set(\"$top\", options.top.toString());\n if (options.skip != null) params.set(\"$skip\", options.skip.toString());\n\n const response = await graphFetch<GraphResponse<OutlookMessage>>(`/messages?${params.toString()}`);\n return response.value ?? [];\n}\n\nexport async function listFolders(): Promise<OutlookFolder[]> {\n const response = await graphFetch<GraphResponse<OutlookFolder>>(\"/mailFolders\");\n return response.value ?? [];\n}\n\nasync function setReadState(messageId: string, isRead: boolean): Promise<void> {\n await graphFetch(`/messages/${messageId}`, {\n method: \"PATCH\",\n body: JSON.stringify({ isRead }),\n });\n}\n\nexport async function markAsRead(messageId: string): Promise<void> {\n await setReadState(messageId, true);\n}\n\nexport async function markAsUnread(messageId: string): Promise<void> {\n await setReadState(messageId, false);\n}\n\nexport async function deleteEmail(messageId: string): Promise<void> {\n await graphFetch(`/messages/${messageId}`, { method: \"DELETE\" });\n}\n\nexport async function moveEmail(messageId: string, destinationFolderId: string): Promise<void> {\n await graphFetch(`/messages/${messageId}/move`, {\n method: \"POST\",\n body: JSON.stringify({ destinationId: destinationFolderId }),\n });\n}\n\nexport function formatEmail(message: OutlookMessage): string {\n const from = message.from.emailAddress.name || message.from.emailAddress.address;\n const to = message.toRecipients.map((r) => r.emailAddress.address).join(\", \");\n const date = new Date(message.receivedDateTime).toLocaleString();\n const read = message.isRead ? \"Yes\" : \"No\";\n\n return `From: ${from}\nTo: ${to}\nSubject: ${message.subject}\nDate: ${date}\nRead: ${read}\n\n${message.bodyPreview}`;\n}\n",
|
|
622
|
-
"app/api/auth/outlook/callback/route.ts": "import { createOAuthCallbackHandler, outlookConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
623
|
-
"app/api/auth/outlook/route.ts": "import { createOAuthInitHandler, outlookConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(outlookConfig, {\n tokenStore: oauthMemoryTokenStore,\n})
|
|
622
|
+
"app/api/auth/outlook/callback/route.ts": "import { createOAuthCallbackHandler, outlookConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(outlookConfig, { tokenStore: hybridTokenStore });\n",
|
|
623
|
+
"app/api/auth/outlook/route.ts": "import { createOAuthInitHandler, outlookConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(outlookConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
624
624
|
}
|
|
625
625
|
},
|
|
626
626
|
"integration:dropbox": {
|
|
@@ -632,8 +632,8 @@ export default {
|
|
|
632
632
|
"tools/list-files.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { formatFileSize, isFile, listFolder } from \"../../lib/dropbox-client.ts\";\n\nexport default tool({\n id: \"list-files\",\n description:\n \"List files and folders in a Dropbox folder. Returns file/folder names, types, sizes, and modification dates.\",\n inputSchema: z.object({\n path: z\n .string()\n .default(\"\")\n .describe(\n 'Path to the folder to list (empty string for root, or \"/FolderName\" for specific folder)',\n ),\n recursive: z.boolean().default(false).describe(\"Whether to list files recursively in subfolders\"),\n limit: z.number().min(1).max(500).default(100).describe(\"Maximum number of items to return\"),\n }),\n async execute({ path, recursive, limit }) {\n const result = await listFolder(path, { recursive, limit });\n\n const items = result.entries.map((entry) => {\n const baseInfo = {\n name: entry.name,\n path: entry.path_display ?? entry.path_lower ?? \"\",\n id: entry.id,\n type: entry[\".tag\"],\n };\n\n if (!isFile(entry)) {\n return baseInfo;\n }\n\n return {\n ...baseInfo,\n size: entry.size,\n sizeFormatted: formatFileSize(entry.size),\n modified: entry.server_modified,\n clientModified: entry.client_modified,\n isDownloadable: entry.is_downloadable,\n rev: entry.rev,\n };\n });\n\n return {\n items,\n count: items.length,\n hasMore: result.has_more,\n cursor: result.cursor,\n };\n },\n});\n",
|
|
633
633
|
".env.example": "# Dropbox Integration Environment Variables\n\n# Dropbox App Key (Client ID)\n# Get this from https://www.dropbox.com/developers/apps\nDROPBOX_APP_KEY=your_app_key_here\n\n# Dropbox App Secret\n# Get this from https://www.dropbox.com/developers/apps\nDROPBOX_APP_SECRET=your_app_secret_here\n\n# Setup Instructions:\n# 1. Go to https://www.dropbox.com/developers/apps\n# 2. Create a new app or select an existing one\n# 3. Choose \"Scoped access\" as the API type\n# 4. Select \"Full Dropbox\" or \"App folder\" access\n# 5. Copy the App Key and App Secret\n# 6. Add the OAuth2 redirect URI: http://localhost:3000/api/auth/dropbox/callback\n# 7. Enable the following permissions in the Permissions tab:\n# - files.content.read\n# - files.content.write\n# - files.metadata.read\n# - files.metadata.write\n# - account_info.read\n# 8. Submit the app for production use if needed\n",
|
|
634
634
|
"lib/dropbox-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst DROPBOX_API_URL = \"https://api.dropboxapi.com/2\";\nconst DROPBOX_CONTENT_URL = \"https://content.dropboxapi.com/2\";\n\nexport interface DropboxMetadata {\n \".tag\": \"file\" | \"folder\" | \"deleted\";\n name: string;\n path_lower?: string;\n path_display?: string;\n id: string;\n}\n\nexport interface DropboxFileMetadata extends DropboxMetadata {\n \".tag\": \"file\";\n client_modified: string;\n server_modified: string;\n rev: string;\n size: number;\n is_downloadable: boolean;\n content_hash?: string;\n}\n\nexport interface DropboxFolderMetadata extends DropboxMetadata {\n \".tag\": \"folder\";\n}\n\nexport interface ListFolderResult {\n entries: Array<DropboxFileMetadata | DropboxFolderMetadata>;\n cursor: string;\n has_more: boolean;\n}\n\nexport interface SearchResult {\n matches: Array<{\n match_type: {\n \".tag\": \"filename\" | \"content\" | \"both\";\n };\n metadata: {\n \".tag\": \"metadata\";\n metadata: DropboxFileMetadata | DropboxFolderMetadata;\n };\n }>;\n has_more: boolean;\n cursor?: string;\n}\n\nexport interface AccountInfo {\n account_id: string;\n name: {\n given_name: string;\n surname: string;\n familiar_name: string;\n display_name: string;\n };\n email: string;\n email_verified: boolean;\n disabled: boolean;\n country: string;\n locale: string;\n account_type: {\n \".tag\": \"basic\" | \"pro\" | \"business\";\n };\n}\n\nexport interface SpaceUsage {\n used: number;\n allocation: {\n \".tag\": \"individual\" | \"team\";\n allocated?: number;\n };\n}\n\nexport interface SharedLinkMetadata {\n url: string;\n id: string;\n name: string;\n path_lower?: string;\n link_permissions: {\n can_revoke: boolean;\n resolved_visibility?: {\n \".tag\": \"public\" | \"team_only\" | \"password\";\n };\n };\n}\n\nasync function requireAccessToken(): Promise<string> {\n const token = await getAccessToken();\n if (token) return token;\n throw new Error(\"Not authenticated with Dropbox. Please connect your account.\");\n}\n\nasync function parseDropboxError(response: Response): Promise<any> {\n return response.json().catch(() => ({}));\n}\n\nfunction throwDropboxError(response: Response, error: any): never {\n throw new Error(\n `Dropbox API error: ${response.status} ${error?.error_summary ?? response.statusText}`,\n );\n}\n\nasync function dropboxRPC<T>(\n endpoint: string,\n body: Record<string, unknown> = {},\n): Promise<T> {\n const token = await requireAccessToken();\n\n const response = await fetch(`${DROPBOX_API_URL}${endpoint}`, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n });\n\n if (!response.ok) {\n throwDropboxError(response, await parseDropboxError(response));\n }\n\n return response.json();\n}\n\nasync function dropboxContent<T>(\n endpoint: string,\n args: Record<string, unknown>,\n content?: string | Uint8Array,\n): Promise<T> {\n const token = await requireAccessToken();\n\n const headers: Record<string, string> = {\n Authorization: `Bearer ${token}`,\n \"Dropbox-API-Arg\": JSON.stringify(args),\n };\n\n if (content != null) {\n headers[\"Content-Type\"] = \"application/octet-stream\";\n }\n\n const response = await fetch(`${DROPBOX_CONTENT_URL}${endpoint}`, {\n method: \"POST\",\n headers,\n body: content,\n });\n\n if (!response.ok) {\n throwDropboxError(response, await parseDropboxError(response));\n }\n\n return response.json();\n}\n\nexport function getCurrentAccount(): Promise<AccountInfo> {\n return dropboxRPC<AccountInfo>(\"/users/get_current_account\");\n}\n\nexport function getSpaceUsage(): Promise<SpaceUsage> {\n return dropboxRPC<SpaceUsage>(\"/users/get_space_usage\");\n}\n\nexport function listFolder(\n path: string = \"\",\n options?: {\n recursive?: boolean;\n includeDeleted?: boolean;\n includeHasExplicitSharedMembers?: boolean;\n limit?: number;\n },\n): Promise<ListFolderResult> {\n return dropboxRPC<ListFolderResult>(\"/files/list_folder\", {\n path: path || \"\",\n recursive: options?.recursive ?? false,\n include_deleted: options?.includeDeleted ?? false,\n include_has_explicit_shared_members: options?.includeHasExplicitSharedMembers ?? false,\n limit: options?.limit ?? 100,\n });\n}\n\nexport function listFolderContinue(cursor: string): Promise<ListFolderResult> {\n return dropboxRPC<ListFolderResult>(\"/files/list_folder/continue\", { cursor });\n}\n\nexport function getMetadata(\n path: string,\n options?: {\n includeMediaInfo?: boolean;\n includeDeleted?: boolean;\n },\n): Promise<DropboxFileMetadata | DropboxFolderMetadata> {\n return dropboxRPC<DropboxFileMetadata | DropboxFolderMetadata>(\"/files/get_metadata\", {\n path,\n include_media_info: options?.includeMediaInfo ?? false,\n include_deleted: options?.includeDeleted ?? false,\n });\n}\n\nexport async function downloadFile(path: string): Promise<{\n content: string;\n metadata: DropboxFileMetadata;\n}> {\n const token = await requireAccessToken();\n\n const response = await fetch(`${DROPBOX_CONTENT_URL}/files/download`, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Dropbox-API-Arg\": JSON.stringify({ path }),\n },\n });\n\n if (!response.ok) {\n throwDropboxError(response, await parseDropboxError(response));\n }\n\n const content = await response.text();\n const metadataHeader = response.headers.get(\"Dropbox-API-Result\");\n const metadata = metadataHeader ? JSON.parse(metadataHeader) : {};\n\n return { content, metadata };\n}\n\nexport function uploadFile(\n path: string,\n content: string | Uint8Array,\n options?: {\n mode?: \"add\" | \"overwrite\" | \"update\";\n autorename?: boolean;\n mute?: boolean;\n },\n): Promise<DropboxFileMetadata> {\n return dropboxContent<DropboxFileMetadata>(\n \"/files/upload\",\n {\n path,\n mode: options?.mode ?? \"add\",\n autorename: options?.autorename ?? false,\n mute: options?.mute ?? false,\n },\n content,\n );\n}\n\nexport function deleteFile(\n path: string,\n): Promise<DropboxFileMetadata | DropboxFolderMetadata> {\n return dropboxRPC<{ metadata: DropboxFileMetadata | DropboxFolderMetadata }>(\n \"/files/delete_v2\",\n { path },\n ).then((result) => result.metadata);\n}\n\nexport function moveFile(\n fromPath: string,\n toPath: string,\n options?: {\n allowSharedFolder?: boolean;\n autorename?: boolean;\n allowOwnershipTransfer?: boolean;\n },\n): Promise<DropboxFileMetadata | DropboxFolderMetadata> {\n return dropboxRPC<{ metadata: DropboxFileMetadata | DropboxFolderMetadata }>(\n \"/files/move_v2\",\n {\n from_path: fromPath,\n to_path: toPath,\n allow_shared_folder: options?.allowSharedFolder ?? false,\n autorename: options?.autorename ?? false,\n allow_ownership_transfer: options?.allowOwnershipTransfer ?? false,\n },\n ).then((result) => result.metadata);\n}\n\nexport function copyFile(\n fromPath: string,\n toPath: string,\n options?: {\n allowSharedFolder?: boolean;\n autorename?: boolean;\n allowOwnershipTransfer?: boolean;\n },\n): Promise<DropboxFileMetadata | DropboxFolderMetadata> {\n return dropboxRPC<{ metadata: DropboxFileMetadata | DropboxFolderMetadata }>(\n \"/files/copy_v2\",\n {\n from_path: fromPath,\n to_path: toPath,\n allow_shared_folder: options?.allowSharedFolder ?? false,\n autorename: options?.autorename ?? false,\n allow_ownership_transfer: options?.allowOwnershipTransfer ?? false,\n },\n ).then((result) => result.metadata);\n}\n\nexport function createFolder(\n path: string,\n autorename?: boolean,\n): Promise<DropboxFolderMetadata> {\n return dropboxRPC<{ metadata: DropboxFolderMetadata }>(\"/files/create_folder_v2\", {\n path,\n autorename: autorename ?? false,\n }).then((result) => result.metadata);\n}\n\nexport function searchFiles(\n query: string,\n options?: {\n path?: string;\n maxResults?: number;\n fileCategories?: Array<\n | \"image\"\n | \"document\"\n | \"pdf\"\n | \"spreadsheet\"\n | \"presentation\"\n | \"audio\"\n | \"video\"\n | \"folder\"\n | \"paper\"\n | \"others\"\n >;\n fileExtensions?: string[];\n },\n): Promise<SearchResult> {\n return dropboxRPC<SearchResult>(\"/files/search_v2\", {\n query,\n options: {\n path: options?.path ?? \"\",\n max_results: options?.maxResults ?? 20,\n file_categories: options?.fileCategories,\n filename_only: false,\n },\n });\n}\n\nexport async function createSharedLink(\n path: string,\n settings?: {\n requestedVisibility?: \"public\" | \"team_only\" | \"password\";\n linkPassword?: string;\n expires?: string;\n },\n): Promise<SharedLinkMetadata> {\n try {\n return await dropboxRPC<SharedLinkMetadata>(\"/sharing/create_shared_link_with_settings\", {\n path,\n settings: settings ?? {},\n });\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"shared_link_already_exists\")) {\n const links = await listSharedLinks(path);\n if (links.length > 0) return links[0];\n }\n throw error;\n }\n}\n\nexport async function listSharedLinks(path?: string): Promise<SharedLinkMetadata[]> {\n const result = await dropboxRPC<{ links: SharedLinkMetadata[] }>(\"/sharing/list_shared_links\", {\n path: path ?? \"\",\n });\n return result.links;\n}\n\nexport function formatFileSize(bytes: number): string {\n const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n let size = bytes;\n let unitIndex = 0;\n\n while (size >= 1024 && unitIndex < units.length - 1) {\n size /= 1024;\n unitIndex++;\n }\n\n return `${size.toFixed(2)} ${units[unitIndex]}`;\n}\n\nexport function isFile(metadata: DropboxMetadata): metadata is DropboxFileMetadata {\n return metadata[\".tag\"] === \"file\";\n}\n\nexport function isFolder(metadata: DropboxMetadata): metadata is DropboxFolderMetadata {\n return metadata[\".tag\"] === \"folder\";\n}\n",
|
|
635
|
-
"app/api/auth/dropbox/callback/route.ts": "import { createOAuthCallbackHandler, dropboxConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
636
|
-
"app/api/auth/dropbox/route.ts": "import { createOAuthInitHandler, dropboxConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(dropboxConfig, {
|
|
635
|
+
"app/api/auth/dropbox/callback/route.ts": "import { createOAuthCallbackHandler, dropboxConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(dropboxConfig, { tokenStore: hybridTokenStore });\n",
|
|
636
|
+
"app/api/auth/dropbox/route.ts": "import { createOAuthInitHandler, dropboxConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(dropboxConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
637
637
|
}
|
|
638
638
|
},
|
|
639
639
|
"integration:slack": {
|
|
@@ -642,8 +642,8 @@ export default {
|
|
|
642
642
|
"tools/list-channels.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createSlackClient } from \"../../lib/slack-client.ts\";\n\ntype SlackChannel = {\n id: string;\n name: string;\n is_private: boolean;\n is_member: boolean;\n topic?: { value: string };\n purpose?: { value: string };\n};\n\nexport default tool({\n id: \"list-channels\",\n description: \"List Slack channels the user is a member of\",\n inputSchema: z.object({\n limit: z\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of channels to return\"),\n excludeArchived: z\n .boolean()\n .default(true)\n .describe(\"Exclude archived channels\"),\n }),\n execute: async ({ limit, excludeArchived }, context) => {\n const userId = context?.userId ?? \"current-user\";\n\n try {\n const slack = createSlackClient(userId);\n const channels = await slack.listChannels({ limit, excludeArchived });\n const count = channels.length;\n\n return {\n channels: channels.map((ch: SlackChannel) => ({\n id: ch.id,\n name: ch.name,\n isPrivate: ch.is_private,\n isMember: ch.is_member,\n topic: ch.topic?.value ?? null,\n purpose: ch.purpose?.value ?? null,\n })),\n count,\n message: `Found ${count} channel(s).`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Slack not connected. Please connect your Slack account.\",\n connectUrl: \"/api/auth/slack\",\n };\n }\n\n throw error;\n }\n },\n});\n",
|
|
643
643
|
"tools/send-message.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { createSlackClient } from \"../../lib/slack-client.ts\";\n\nexport default tool({\n id: \"send-message\",\n description: \"Send a message to a Slack channel\",\n inputSchema: z.object({\n channel: z\n .string()\n .describe(\"Channel ID or name (e.g., 'C1234567890' or '#general')\"),\n text: z.string().min(1).describe(\"Message text to send\"),\n threadTs: z\n .string()\n .optional()\n .describe(\"Thread timestamp to reply to (for threaded messages)\"),\n }),\n execute: async ({ channel, text, threadTs }, context) => {\n const userId = context?.userId ?? \"current-user\";\n\n try {\n const slack = createSlackClient(userId);\n const result = await slack.sendMessage(channel, text, { threadTs });\n\n return {\n success: true,\n messageTs: result.ts,\n channel: result.channel,\n message: threadTs\n ? `Reply sent to thread in ${channel}.`\n : `Message sent to ${channel}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Slack not connected. Please connect your Slack account.\",\n connectUrl: \"/api/auth/slack\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
644
644
|
"lib/slack-client.ts": "/**\n * Slack API Client\n *\n * Provides a type-safe interface to Slack API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") {\n // @ts-ignore - Deno global\n return Deno.env.get(key);\n }\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) {\n // @ts-ignore - process global\n return process.env[key];\n }\n\n return undefined;\n}\n\nconst SLACK_API_BASE = \"https://slack.com/api\";\n\nexport interface SlackChannel {\n id: string;\n name: string;\n is_channel: boolean;\n is_private: boolean;\n is_member: boolean;\n topic?: { value: string };\n purpose?: { value: string };\n}\n\nexport interface SlackMessage {\n type: string;\n user?: string;\n text: string;\n ts: string;\n thread_ts?: string;\n reply_count?: number;\n reactions?: Array<{ name: string; count: number }>;\n}\n\nexport interface SlackUser {\n id: string;\n name: string;\n real_name: string;\n profile: {\n display_name: string;\n email?: string;\n image_48?: string;\n };\n}\n\n/**\n * Slack OAuth provider configuration\n */\nexport const slackOAuthProvider = {\n name: \"slack\",\n authorizationUrl: \"https://slack.com/oauth/v2/authorize\",\n tokenUrl: \"https://slack.com/api/oauth.v2.access\",\n clientId: getEnv(\"SLACK_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"SLACK_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"channels:history\",\n \"channels:read\",\n \"chat:write\",\n \"users:read\",\n \"im:history\",\n \"im:read\",\n ],\n callbackPath: \"/api/auth/slack/callback\",\n};\n\nexport interface SlackClient {\n listChannels(options?: {\n limit?: number;\n excludeArchived?: boolean;\n }): Promise<SlackChannel[]>;\n getMessages(\n channelId: string,\n options?: { limit?: number; oldest?: string },\n ): Promise<SlackMessage[]>;\n sendMessage(\n channelId: string,\n text: string,\n options?: { threadTs?: string; unfurlLinks?: boolean },\n ): Promise<{ ts: string; channel: string }>;\n getUser(userId: string): Promise<SlackUser>;\n getThread(channelId: string, threadTs: string): Promise<SlackMessage[]>;\n searchMessages(\n query: string,\n options?: { count?: number },\n ): Promise<SlackMessage[]>;\n}\n\n/**\n * Create a Slack client for a specific user\n */\nexport function createSlackClient(userId: string): SlackClient {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(slackOAuthProvider, userId, \"slack\");\n if (!token) {\n throw new Error(\n \"Slack not connected. Please connect your Slack account first.\",\n );\n }\n return token;\n }\n\n async function apiRequest<T>(\n method: string,\n params: Record<string, unknown> = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${SLACK_API_BASE}/${method}`, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json; charset=utf-8\",\n },\n body: JSON.stringify(params),\n });\n\n const data = await response.json();\n\n if (!data.ok) {\n throw new Error(`Slack API error: ${data.error}`);\n }\n\n return data as T;\n }\n\n return {\n /**\n * List channels the user is a member of\n */\n async listChannels(options = {}): Promise<SlackChannel[]> {\n const result = await apiRequest<{ channels: SlackChannel[] }>(\n \"conversations.list\",\n {\n limit: options.limit ?? 100,\n exclude_archived: options.excludeArchived ?? true,\n types: \"public_channel,private_channel\",\n },\n );\n return result.channels;\n },\n\n /**\n * Get messages from a channel\n */\n async getMessages(\n channelId: string,\n options = {},\n ): Promise<SlackMessage[]> {\n const result = await apiRequest<{ messages: SlackMessage[] }>(\n \"conversations.history\",\n {\n channel: channelId,\n limit: options.limit ?? 20,\n oldest: options.oldest,\n },\n );\n return result.messages;\n },\n\n /**\n * Send a message to a channel\n */\n async sendMessage(\n channelId: string,\n text: string,\n options = {},\n ): Promise<{ ts: string; channel: string }> {\n return apiRequest<{ ts: string; channel: string }>(\"chat.postMessage\", {\n channel: channelId,\n text,\n thread_ts: options.threadTs,\n unfurl_links: options.unfurlLinks ?? true,\n });\n },\n\n /**\n * Get user info\n */\n async getUser(userId: string): Promise<SlackUser> {\n const result = await apiRequest<{ user: SlackUser }>(\"users.info\", {\n user: userId,\n });\n return result.user;\n },\n\n /**\n * Get thread replies\n */\n async getThread(\n channelId: string,\n threadTs: string,\n ): Promise<SlackMessage[]> {\n const result = await apiRequest<{ messages: SlackMessage[] }>(\n \"conversations.replies\",\n {\n channel: channelId,\n ts: threadTs,\n },\n );\n return result.messages;\n },\n\n /**\n * Search messages\n */\n async searchMessages(\n query: string,\n options = {},\n ): Promise<SlackMessage[]> {\n const result = await apiRequest<{\n messages: { matches: SlackMessage[] };\n }>(\"search.messages\", {\n query,\n count: options.count ?? 20,\n });\n return result.messages.matches;\n },\n };\n}\n",
|
|
645
|
-
"app/api/auth/slack/callback/route.ts": "import { createOAuthCallbackHandler, slackConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
646
|
-
"app/api/auth/slack/route.ts": "import { createOAuthInitHandler, slackConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(slackConfig, {
|
|
645
|
+
"app/api/auth/slack/callback/route.ts": "import { createOAuthCallbackHandler, slackConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(slackConfig, { tokenStore: hybridTokenStore });\n",
|
|
646
|
+
"app/api/auth/slack/route.ts": "import { createOAuthInitHandler, slackConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(slackConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
647
647
|
}
|
|
648
648
|
},
|
|
649
649
|
"integration:twilio": {
|
|
@@ -666,8 +666,8 @@ export default {
|
|
|
666
666
|
"tools/list-contacts.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { listContacts } from \"../../lib/freshdesk-client.ts\";\n\nexport default tool({\n id: \"list-contacts\",\n description:\n \"List customer contacts from Freshdesk. Can filter by email, phone, mobile, or company ID.\",\n inputSchema: z.object({\n email: z.string().optional().describe(\"Filter by contact email address\"),\n phone: z.string().optional().describe(\"Filter by contact phone number\"),\n mobile: z.string().optional().describe(\"Filter by contact mobile number\"),\n companyId: z.number().optional().describe(\"Filter by company ID\"),\n limit: z\n .number()\n .min(1)\n .max(100)\n .default(30)\n .describe(\"Maximum number of contacts to return\"),\n }),\n async execute({ email, phone, mobile, companyId, limit }) {\n const contacts = await listContacts({\n email,\n phone,\n mobile,\n companyId,\n perPage: limit,\n });\n\n return contacts.map((contact) => ({\n id: contact.id,\n name: contact.name,\n email: contact.email,\n phone: contact.phone,\n mobile: contact.mobile,\n companyId: contact.company_id,\n createdAt: contact.created_at,\n updatedAt: contact.updated_at,\n tags: contact.tags,\n }));\n },\n});\n",
|
|
667
667
|
".env.example": "# Freshdesk OAuth Configuration\n# Get your credentials from https://developers.freshdesk.com/apps/\nFRESHDESK_CLIENT_ID=your-client-id\nFRESHDESK_CLIENT_SECRET=your-client-secret\n",
|
|
668
668
|
"lib/freshdesk-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst FRESHDESK_BASE_URL = \"https://domain.freshdesk.com/api/v2\";\n\ninterface FreshdeskTicket {\n id: number;\n subject: string;\n description: string;\n description_text: string;\n status: number;\n priority: number;\n type: string;\n requester_id: number;\n responder_id: number | null;\n due_by: string;\n fr_due_by: string;\n created_at: string;\n updated_at: string;\n tags: string[];\n custom_fields: Record<string, unknown>;\n}\n\ninterface FreshdeskContact {\n id: number;\n name: string;\n email: string;\n phone: string | null;\n mobile: string | null;\n company_id: number | null;\n created_at: string;\n updated_at: string;\n tags: string[];\n custom_fields: Record<string, unknown>;\n}\n\nasync function freshdeskFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Freshdesk. Please connect your account.\");\n }\n\n const response = await fetch(`${FRESHDESK_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({}))) as { description?: string };\n throw new Error(\n `Freshdesk API error: ${response.status} ${error.description ?? response.statusText}`,\n );\n }\n\n return response.json() as Promise<T>;\n}\n\nfunction buildEndpoint(path: string, params: URLSearchParams): string {\n const queryString = params.toString();\n if (!queryString) return path;\n return `${path}?${queryString}`;\n}\n\nexport async function listTickets(\n options: {\n status?: number;\n priority?: number;\n type?: string;\n page?: number;\n perPage?: number;\n } = {},\n): Promise<FreshdeskTicket[]> {\n const params = new URLSearchParams();\n\n if (options.status !== undefined) params.set(\"status\", String(options.status));\n if (options.priority !== undefined) params.set(\"priority\", String(options.priority));\n if (options.type) params.set(\"type\", options.type);\n if (options.page) params.set(\"page\", String(options.page));\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return freshdeskFetch<FreshdeskTicket[]>(buildEndpoint(\"/tickets\", params));\n}\n\nexport async function getTicket(ticketId: number): Promise<FreshdeskTicket> {\n return freshdeskFetch<FreshdeskTicket>(`/tickets/${ticketId}`);\n}\n\nexport async function createTicket(options: {\n subject: string;\n description: string;\n email: string;\n priority?: number;\n status?: number;\n type?: string;\n tags?: string[];\n}): Promise<FreshdeskTicket> {\n const body: Record<string, unknown> = {\n subject: options.subject,\n description: options.description,\n email: options.email,\n priority: options.priority ?? 1,\n status: options.status ?? 2,\n };\n\n if (options.type) body.type = options.type;\n if (options.tags) body.tags = options.tags;\n\n return freshdeskFetch<FreshdeskTicket>(\"/tickets\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n}\n\nexport async function updateTicket(\n ticketId: number,\n updates: {\n subject?: string;\n description?: string;\n status?: number;\n priority?: number;\n type?: string;\n tags?: string[];\n },\n): Promise<FreshdeskTicket> {\n const body: Record<string, unknown> = {};\n\n if (updates.subject !== undefined) body.subject = updates.subject;\n if (updates.description !== undefined) body.description = updates.description;\n if (updates.status !== undefined) body.status = updates.status;\n if (updates.priority !== undefined) body.priority = updates.priority;\n if (updates.type !== undefined) body.type = updates.type;\n if (updates.tags !== undefined) body.tags = updates.tags;\n\n return freshdeskFetch<FreshdeskTicket>(`/tickets/${ticketId}`, {\n method: \"PUT\",\n body: JSON.stringify(body),\n });\n}\n\nexport async function listContacts(\n options: {\n email?: string;\n mobile?: string;\n phone?: string;\n companyId?: number;\n page?: number;\n perPage?: number;\n } = {},\n): Promise<FreshdeskContact[]> {\n const params = new URLSearchParams();\n\n if (options.email) params.set(\"email\", options.email);\n if (options.mobile) params.set(\"mobile\", options.mobile);\n if (options.phone) params.set(\"phone\", options.phone);\n if (options.companyId) params.set(\"company_id\", String(options.companyId));\n if (options.page) params.set(\"page\", String(options.page));\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return freshdeskFetch<FreshdeskContact[]>(buildEndpoint(\"/contacts\", params));\n}\n\nexport const TicketStatus = {\n OPEN: 2,\n PENDING: 3,\n RESOLVED: 4,\n CLOSED: 5,\n} as const;\n\nexport const TicketPriority = {\n LOW: 1,\n MEDIUM: 2,\n HIGH: 3,\n URGENT: 4,\n} as const;\n",
|
|
669
|
-
"app/api/auth/freshdesk/callback/route.ts": "/**\n * Freshdesk OAuth Callback\n *\n * Handles the OAuth callback from Freshdesk and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, freshdeskConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
670
|
-
"app/api/auth/freshdesk/route.ts": "import { createOAuthInitHandler, freshdeskConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(freshdeskConfig, {
|
|
669
|
+
"app/api/auth/freshdesk/callback/route.ts": "/**\n * Freshdesk OAuth Callback\n *\n * Handles the OAuth callback from Freshdesk and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, freshdeskConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\ntype Tokens = { accessToken: string; refreshToken?: string; expiresAt?: number };\ntype StateMeta = {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n};\n\n// Hybrid adapter: uses framework's oauthMemoryTokenStore for state (PKCE),\n// but user's tokenStore for actual token storage. Tokens are keyed by\n// (serviceId, userId) — NEVER share a single slot across users.\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string): Promise<Tokens | null> {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(serviceId: string, userId: string, tokens: Tokens): Promise<void> {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string): Promise<void> {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(state: string, meta: StateMeta): ReturnType<typeof oauthMemoryTokenStore.setState> {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string): ReturnType<typeof oauthMemoryTokenStore.consumeState> {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(freshdeskConfig, { tokenStore: hybridTokenStore });\n",
|
|
670
|
+
"app/api/auth/freshdesk/route.ts": "import { createOAuthInitHandler, freshdeskConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(freshdeskConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
671
671
|
}
|
|
672
672
|
},
|
|
673
673
|
"integration:teams": {
|
|
@@ -678,8 +678,8 @@ export default {
|
|
|
678
678
|
"tools/send-message.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { sendChannelMessage, sendChatMessage } from \"../../lib/teams-client.ts\";\n\nexport default tool({\n id: \"send-message\",\n description:\n \"Send a message to a Microsoft Teams chat or channel. For chats, use the chatId. For channels, use both teamId and channelId.\",\n inputSchema: z\n .object({\n chatId: z\n .string()\n .optional()\n .describe(\"The ID of the chat to send the message to (use this for direct/group chats)\"),\n teamId: z\n .string()\n .optional()\n .describe(\"The ID of the team (use with channelId for channel messages)\"),\n channelId: z\n .string()\n .optional()\n .describe(\"The ID of the channel (use with teamId for channel messages)\"),\n content: z.string().min(1).describe(\"The message content to send\"),\n contentType: z.enum([\"text\", \"html\"]).default(\"text\").describe(\"Content format: text or html\"),\n subject: z.string().optional().describe(\"Subject line (only for channel messages)\"),\n })\n .refine(\n (data) =>\n (data.chatId && !data.teamId && !data.channelId) ||\n (!data.chatId && data.teamId && data.channelId),\n { message: \"Either provide chatId OR both teamId and channelId\" },\n ),\n async execute({ chatId, teamId, channelId, content, contentType, subject }) {\n if (chatId) {\n const message = await sendChatMessage(chatId, content, contentType);\n return {\n success: true,\n messageId: message.id,\n type: \"chat\",\n chatId,\n createdAt: message.createdDateTime,\n content: message.body.content,\n };\n }\n\n if (!teamId || !channelId) {\n throw new Error(\"Invalid parameters: provide either chatId or both teamId and channelId\");\n }\n\n const message = await sendChannelMessage(teamId, channelId, content, contentType, subject);\n return {\n success: true,\n messageId: message.id,\n type: \"channel\",\n teamId,\n channelId,\n subject,\n createdAt: message.createdDateTime,\n content: message.body.content,\n };\n },\n});\n",
|
|
679
679
|
"tools/list-teams.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { listTeams } from \"../../lib/teams-client.ts\";\n\nexport default tool({\n id: \"list-teams\",\n description:\n \"List all Microsoft Teams that the authenticated user is a member of. Returns team IDs, names, descriptions, and metadata.\",\n inputSchema: z.object({\n limit: z\n .number()\n .min(1)\n .max(50)\n .default(25)\n .describe(\"Maximum number of teams to return (1-50)\"),\n }),\n async execute({ limit }) {\n const teams = await listTeams({ limit });\n\n return teams.map((team) => ({\n id: team.id,\n name: team.displayName,\n description: team.description,\n visibility: team.visibility,\n isArchived: team.isArchived,\n createdAt: team.createdDateTime,\n webUrl: team.webUrl,\n }));\n },\n});\n",
|
|
680
680
|
"lib/teams-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst GRAPH_API_BASE = \"https://graph.microsoft.com/v1.0\";\n\ninterface GraphResponse<T> {\n \"@odata.context\"?: string;\n \"@odata.nextLink\"?: string;\n value?: T[];\n}\n\nexport interface TeamsChat {\n id: string;\n topic: string | null;\n createdDateTime: string;\n lastUpdatedDateTime: string;\n chatType: \"oneOnOne\" | \"group\" | \"meeting\";\n webUrl?: string;\n members?: ChatMember[];\n}\n\nexport interface ChatMember {\n \"@odata.type\": string;\n id: string;\n displayName?: string;\n userId?: string;\n email?: string;\n}\n\nexport interface ChatMessage {\n id: string;\n messageType: \"message\" | \"chatEvent\" | \"typing\";\n createdDateTime: string;\n lastModifiedDateTime?: string;\n deletedDateTime?: string;\n subject?: string | null;\n summary?: string | null;\n importance: \"normal\" | \"high\" | \"urgent\";\n locale?: string;\n from: {\n user?: {\n id: string;\n displayName?: string;\n userIdentityType?: string;\n };\n };\n body: {\n contentType: \"text\" | \"html\";\n content: string;\n };\n attachments?: Array<{\n id: string;\n contentType: string;\n contentUrl?: string;\n content?: string;\n name?: string;\n }>;\n mentions?: Array<{\n id: number;\n mentionText: string;\n mentioned: {\n user: {\n id: string;\n displayName?: string;\n };\n };\n }>;\n reactions?: Array<{\n reactionType: string;\n createdDateTime: string;\n user: {\n id: string;\n displayName?: string;\n };\n }>;\n}\n\nexport interface Team {\n id: string;\n displayName: string;\n description?: string;\n createdDateTime?: string;\n webUrl?: string;\n isArchived?: boolean;\n visibility?: \"private\" | \"public\";\n}\n\nexport interface Channel {\n id: string;\n displayName: string;\n description?: string;\n email?: string;\n webUrl?: string;\n membershipType?: \"standard\" | \"private\" | \"shared\";\n createdDateTime?: string;\n}\n\nfunction buildEndpoint(path: string, params?: URLSearchParams): string {\n const queryString = params?.toString();\n return queryString ? `${path}?${queryString}` : path;\n}\n\nasync function graphFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Microsoft Teams. Please connect your account.\");\n }\n\n const url = endpoint.startsWith(\"http\") ? endpoint : `${GRAPH_API_BASE}${endpoint}`;\n\n const response = await fetch(url, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(\n `Microsoft Graph API error: ${response.status} ${error?.error?.message ?? response.statusText}`,\n );\n }\n\n return response.json();\n}\n\nexport async function listChats(options?: { limit?: number; expand?: string[] }): Promise<TeamsChat[]> {\n const params = new URLSearchParams();\n if (options?.limit) params.set(\"$top\", options.limit.toString());\n if (options?.expand?.length) params.set(\"$expand\", options.expand.join(\",\"));\n\n const response = await graphFetch<GraphResponse<TeamsChat>>(buildEndpoint(\"/me/chats\", params));\n return response.value ?? [];\n}\n\nexport async function getChatMessages(\n chatId: string,\n options?: { limit?: number; orderBy?: string },\n): Promise<ChatMessage[]> {\n const params = new URLSearchParams();\n if (options?.limit) params.set(\"$top\", options.limit.toString());\n params.set(\"$orderby\", options?.orderBy ?? \"createdDateTime desc\");\n\n const response = await graphFetch<GraphResponse<ChatMessage>>(\n buildEndpoint(`/me/chats/${chatId}/messages`, params),\n );\n return response.value ?? [];\n}\n\nexport function sendChatMessage(\n chatId: string,\n content: string,\n contentType: \"text\" | \"html\" = \"text\",\n): Promise<ChatMessage> {\n return graphFetch<ChatMessage>(`/me/chats/${chatId}/messages`, {\n method: \"POST\",\n body: JSON.stringify({ body: { contentType, content } }),\n });\n}\n\nexport async function listTeams(options?: { limit?: number }): Promise<Team[]> {\n const params = new URLSearchParams();\n if (options?.limit) params.set(\"$top\", options.limit.toString());\n\n const response = await graphFetch<GraphResponse<Team>>(buildEndpoint(\"/me/joinedTeams\", params));\n return response.value ?? [];\n}\n\nexport async function listChannels(teamId: string, options?: { limit?: number }): Promise<Channel[]> {\n const params = new URLSearchParams();\n if (options?.limit) params.set(\"$top\", options.limit.toString());\n\n const response = await graphFetch<GraphResponse<Channel>>(\n buildEndpoint(`/teams/${teamId}/channels`, params),\n );\n return response.value ?? [];\n}\n\nexport function sendChannelMessage(\n teamId: string,\n channelId: string,\n content: string,\n contentType: \"text\" | \"html\" = \"text\",\n subject?: string,\n): Promise<ChatMessage> {\n const body: Record<string, unknown> = { body: { contentType, content } };\n if (subject) body.subject = subject;\n\n return graphFetch<ChatMessage>(`/teams/${teamId}/channels/${channelId}/messages`, {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n}\n\nexport async function getChannelMessages(\n teamId: string,\n channelId: string,\n options?: { limit?: number; orderBy?: string },\n): Promise<ChatMessage[]> {\n const params = new URLSearchParams();\n if (options?.limit) params.set(\"$top\", options.limit.toString());\n params.set(\"$orderby\", options?.orderBy ?? \"createdDateTime desc\");\n\n const response = await graphFetch<GraphResponse<ChatMessage>>(\n buildEndpoint(`/teams/${teamId}/channels/${channelId}/messages`, params),\n );\n return response.value ?? [];\n}\n\nexport function getCurrentUser(): Promise<{\n id: string;\n displayName: string;\n mail?: string;\n userPrincipalName?: string;\n}> {\n return graphFetch(\"/me\");\n}\n\nexport function getChatDisplayName(chat: TeamsChat): string {\n if (chat.topic) return chat.topic;\n\n const memberNames = chat.members?.flatMap((m) => (m.displayName ? [m.displayName] : [])).join(\", \");\n if (memberNames) return memberNames;\n\n return chat.chatType === \"oneOnOne\" ? \"Direct Chat\" : \"Group Chat\";\n}\n\nexport function getPlainTextContent(message: ChatMessage): string {\n if (message.body.contentType === \"text\") return message.body.content;\n\n return message.body.content\n .replace(/<[^>]*>/g, \"\")\n .replace(/ /g, \" \")\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/"/g, '\"')\n .trim();\n}\n",
|
|
681
|
-
"app/api/auth/teams/callback/route.ts": "/**\n * Teams OAuth Callback\n *\n * Handles the OAuth callback from Microsoft and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, teamsConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
682
|
-
"app/api/auth/teams/route.ts": "import { createOAuthInitHandler, teamsConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(teamsConfig, {
|
|
681
|
+
"app/api/auth/teams/callback/route.ts": "/**\n * Teams OAuth Callback\n *\n * Handles the OAuth callback from Microsoft and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, teamsConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(teamsConfig, { tokenStore: hybridTokenStore });\n",
|
|
682
|
+
"app/api/auth/teams/route.ts": "import { createOAuthInitHandler, teamsConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(teamsConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
683
683
|
}
|
|
684
684
|
},
|
|
685
685
|
"integration:box": {
|
|
@@ -691,8 +691,8 @@ export default {
|
|
|
691
691
|
"tools/list-files.ts": "import { tool } from \"veryfront/tool\";\nimport { z } from \"zod\";\nimport { listFiles } from \"../../lib/box-client.ts\";\n\nexport default tool({\n id: \"list-files\",\n description:\n \"List files and folders from a Box folder. Use folder ID '0' for the root folder.\",\n inputSchema: z.object({\n folderId: z\n .string()\n .default(\"0\")\n .describe(\"Folder ID to list files from (use '0' for root folder)\"),\n limit: z\n .number()\n .min(1)\n .max(100)\n .default(50)\n .describe(\"Maximum number of items to return\"),\n offset: z\n .number()\n .min(0)\n .default(0)\n .describe(\"Number of items to skip for pagination\"),\n }),\n async execute({ folderId, limit, offset }) {\n const items = await listFiles({ folderId, limit, offset });\n\n return items.map((item) => {\n const pathEntries = item.path_collection?.entries;\n\n return {\n id: item.id,\n type: item.type,\n name: item.name,\n size: item.type === \"file\" ? item.size : undefined,\n createdAt: item.created_at,\n modifiedAt: item.modified_at,\n createdBy: item.created_by?.name,\n modifiedBy: item.modified_by?.name,\n path: pathEntries ? pathEntries.map((e) => e.name).join(\"/\") : \"/\",\n };\n });\n },\n});\n",
|
|
692
692
|
".env.example": "# Box OAuth Configuration\n# Get your credentials from https://app.box.com/developers/console\nBOX_CLIENT_ID=your-client-id\nBOX_CLIENT_SECRET=your-client-secret\n",
|
|
693
693
|
"lib/box-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst BOX_BASE_URL = \"https://api.box.com/2.0\";\nconst BOX_UPLOAD_URL = \"https://upload.box.com/api/2.0/files/content\";\n\ninterface BoxItemCollection<T> {\n total_count: number;\n entries: T[];\n offset: number;\n limit: number;\n}\n\ninterface BoxFile {\n id: string;\n type: \"file\";\n name: string;\n size: number;\n created_at: string;\n modified_at: string;\n description: string;\n path_collection: {\n entries: Array<{ id: string; name: string }>;\n };\n created_by: {\n id: string;\n name: string;\n };\n modified_by: {\n id: string;\n name: string;\n };\n shared_link?: {\n url: string;\n };\n}\n\ninterface BoxFolder {\n id: string;\n type: \"folder\";\n name: string;\n created_at: string;\n modified_at: string;\n description: string;\n path_collection: {\n entries: Array<{ id: string; name: string }>;\n };\n created_by: {\n id: string;\n name: string;\n };\n modified_by: {\n id: string;\n name: string;\n };\n item_collection?: {\n total_count: number;\n };\n}\n\ntype BoxItem = BoxFile | BoxFolder;\n\ninterface BoxSearchResult {\n type: \"file\" | \"folder\";\n id: string;\n name: string;\n size?: number;\n created_at: string;\n modified_at: string;\n path_collection: {\n entries: Array<{ id: string; name: string }>;\n };\n}\n\nasync function requireAccessToken(): Promise<string> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Box. Please connect your account.\");\n }\n return token;\n}\n\nasync function parseErrorMessage(\n response: Response,\n fallback: string,\n): Promise<string> {\n const error = await response.json().catch(() => ({} as { message?: string }));\n return `${fallback}: ${response.status} ${error.message ?? response.statusText}`;\n}\n\nasync function boxFetch<T>(\n endpoint: string,\n options: RequestInit = {},\n): Promise<T> {\n const token = await requireAccessToken();\n\n const response = await fetch(`${BOX_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n throw new Error(await parseErrorMessage(response, \"Box API error\"));\n }\n\n return (await response.json()) as T;\n}\n\nfunction toUploadBlob(fileContent: string | Buffer | Blob): Blob {\n if (typeof fileContent === \"string\") {\n return new Blob([fileContent], { type: \"text/plain\" });\n }\n\n if (fileContent instanceof Buffer) {\n return new Blob([fileContent]);\n }\n\n return fileContent;\n}\n\n/**\n * List files and folders in a Box folder\n */\nexport async function listFiles(options: {\n folderId?: string;\n limit?: number;\n offset?: number;\n}): Promise<BoxItem[]> {\n const { folderId = \"0\", limit = 100, offset = 0 } = options;\n\n const params = new URLSearchParams({\n limit: limit.toString(),\n offset: offset.toString(),\n fields:\n \"id,type,name,size,created_at,modified_at,description,path_collection,created_by,modified_by\",\n });\n\n const response = await boxFetch<BoxItemCollection<BoxItem>>(\n `/folders/${folderId}/items?${params}`,\n );\n\n return response.entries;\n}\n\n/**\n * Get details of a specific file or folder\n */\nexport async function getFile(\n itemId: string,\n itemType: \"file\" | \"folder\" = \"file\",\n): Promise<BoxItem> {\n const endpoint = itemType === \"file\" ? `/files/${itemId}` : `/folders/${itemId}`;\n\n const params = new URLSearchParams({\n fields:\n \"id,type,name,size,created_at,modified_at,description,path_collection,created_by,modified_by,shared_link\",\n });\n\n return boxFetch<BoxItem>(`${endpoint}?${params}`);\n}\n\n/**\n * Upload a file to Box\n */\nexport async function uploadFile(options: {\n parentFolderId: string;\n fileName: string;\n fileContent: string | Buffer | Blob;\n}): Promise<BoxFile> {\n const { parentFolderId, fileName, fileContent } = options;\n\n const token = await requireAccessToken();\n\n const formData = new FormData();\n formData.append(\n \"attributes\",\n JSON.stringify({\n name: fileName,\n parent: { id: parentFolderId },\n }),\n );\n formData.append(\"file\", toUploadBlob(fileContent), fileName);\n\n const response = await fetch(BOX_UPLOAD_URL, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n },\n body: formData,\n });\n\n if (!response.ok) {\n throw new Error(await parseErrorMessage(response, \"Box upload error\"));\n }\n\n const result = (await response.json()) as { entries: BoxFile[] };\n return result.entries[0];\n}\n\n/**\n * Download a file from Box\n */\nexport async function downloadFile(fileId: string): Promise<{\n content: ArrayBuffer;\n fileName: string;\n mimeType: string;\n}> {\n const token = await requireAccessToken();\n const fileInfo = (await getFile(fileId, \"file\")) as BoxFile;\n\n const response = await fetch(`${BOX_BASE_URL}/files/${fileId}/content`, {\n headers: {\n Authorization: `Bearer ${token}`,\n },\n });\n\n if (!response.ok) {\n throw new Error(await parseErrorMessage(response, \"Box download error\"));\n }\n\n const content = await response.arrayBuffer();\n const mimeType = response.headers.get(\"content-type\") ?? \"application/octet-stream\";\n\n return { content, fileName: fileInfo.name, mimeType };\n}\n\n/**\n * Create a new folder in Box\n */\nexport async function createFolder(options: {\n parentFolderId: string;\n name: string;\n}): Promise<BoxFolder> {\n const { parentFolderId, name } = options;\n\n return boxFetch<BoxFolder>(\"/folders\", {\n method: \"POST\",\n body: JSON.stringify({\n name,\n parent: { id: parentFolderId },\n }),\n });\n}\n\n/**\n * Search for files and folders in Box\n */\nexport async function searchFiles(options: {\n query: string;\n limit?: number;\n offset?: number;\n contentTypes?: string[];\n}): Promise<BoxSearchResult[]> {\n const { query, limit = 100, offset = 0, contentTypes } = options;\n\n const params = new URLSearchParams({\n query,\n limit: limit.toString(),\n offset: offset.toString(),\n fields: \"id,type,name,size,created_at,modified_at,path_collection\",\n });\n\n if (contentTypes?.length) {\n params.set(\"content_types\", contentTypes.join(\",\"));\n }\n\n const response = await boxFetch<BoxItemCollection<BoxSearchResult>>(\n `/search?${params}`,\n );\n\n return response.entries;\n}\n\n/**\n * Get current user info\n */\nexport async function getMe(): Promise<{ id: string; name: string; login: string }> {\n return boxFetch<{ id: string; name: string; login: string }>(\"/users/me\");\n}\n",
|
|
694
|
-
"app/api/auth/box/callback/route.ts": "import { boxConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
695
|
-
"app/api/auth/box/route.ts": "import { boxConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(boxConfig, {
|
|
694
|
+
"app/api/auth/box/callback/route.ts": "import { boxConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n async getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(boxConfig, { tokenStore: hybridTokenStore });\n",
|
|
695
|
+
"app/api/auth/box/route.ts": "import { boxConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(boxConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
696
696
|
}
|
|
697
697
|
},
|
|
698
698
|
"integration:supabase": {
|
|
@@ -717,8 +717,8 @@ export default {
|
|
|
717
717
|
".env.example": "# Google Docs Integration\n# Create OAuth credentials at https://console.cloud.google.com/apis/credentials\n# Make sure to enable:\n# - Google Docs API: https://console.cloud.google.com/apis/library/docs.googleapis.com\n# - Google Drive API: https://console.cloud.google.com/apis/library/drive.googleapis.com\n\nGOOGLE_CLIENT_ID=your_client_id_here\nGOOGLE_CLIENT_SECRET=your_client_secret_here\n",
|
|
718
718
|
"lib/oauth.ts": "import { type OAuthToken, tokenStore } from \"./token-store.ts\";\n\nexport interface OAuthProvider {\n name: string;\n authorizationUrl: string;\n tokenUrl: string;\n clientId: string;\n clientSecret: string;\n scopes: string[];\n callbackPath: string;\n}\n\nfunction getExpiresAt(expiresIn: unknown): number | undefined {\n if (typeof expiresIn !== \"number\") return undefined;\n return Date.now() + expiresIn * 1000;\n}\n\nasync function postForm(url: string, body: Record<string, string>): Promise<any> {\n const response = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams(body),\n });\n\n if (response.ok) return response.json();\n\n throw new Error(\n `Token request failed: ${response.status} - ${await response.text()}`,\n );\n}\n\nexport function getAuthorizationUrl(\n provider: OAuthProvider,\n state: string,\n redirectUri: string,\n): string {\n const params = new URLSearchParams({\n client_id: provider.clientId,\n redirect_uri: redirectUri,\n response_type: \"code\",\n scope: provider.scopes.join(\" \"),\n state,\n access_type: \"offline\",\n prompt: \"consent\",\n });\n\n return `${provider.authorizationUrl}?${params.toString()}`;\n}\n\nexport async function exchangeCodeForTokens(\n provider: OAuthProvider,\n code: string,\n redirectUri: string,\n): Promise<OAuthToken> {\n const data = await postForm(provider.tokenUrl, {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n code,\n grant_type: \"authorization_code\",\n redirect_uri: redirectUri,\n });\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function refreshAccessToken(\n provider: OAuthProvider,\n refreshToken: string,\n): Promise<OAuthToken> {\n const data = await postForm(provider.tokenUrl, {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n refresh_token: refreshToken,\n grant_type: \"refresh_token\",\n });\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token ?? refreshToken,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function getValidToken(\n provider: OAuthProvider,\n userId: string,\n service: string,\n): Promise<string | null> {\n const token = await tokenStore.getToken(userId, service);\n if (!token) return null;\n\n const isExpired = token.expiresAt\n ? token.expiresAt < Date.now() + 5 * 60 * 1000\n : false;\n\n if (!isExpired || !token.refreshToken) return token.accessToken;\n\n try {\n const newToken = await refreshAccessToken(provider, token.refreshToken);\n await tokenStore.setToken(userId, service, newToken);\n return newToken.accessToken;\n } catch {\n await tokenStore.revokeToken(userId, service);\n return null;\n }\n}\n",
|
|
719
719
|
"lib/docs-client.ts": "/**\n * Google Docs API Client\n *\n * Provides a type-safe interface to Google Docs API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n return undefined;\n}\n\nconst DOCS_API_BASE = \"https://docs.googleapis.com/v1\";\nconst DRIVE_API_BASE = \"https://www.googleapis.com/drive/v3\";\n\nexport interface Document {\n documentId: string;\n title: string;\n body: {\n content: StructuralElement[];\n };\n revisionId: string;\n suggestionsViewMode: string;\n documentStyle: DocumentStyle;\n}\n\nexport interface StructuralElement {\n startIndex: number;\n endIndex: number;\n paragraph?: Paragraph;\n table?: Table;\n sectionBreak?: SectionBreak;\n}\n\nexport interface Paragraph {\n elements: ParagraphElement[];\n paragraphStyle?: ParagraphStyle;\n bullet?: Bullet;\n}\n\nexport interface ParagraphElement {\n startIndex: number;\n endIndex: number;\n textRun?: TextRun;\n inlineObjectElement?: InlineObjectElement;\n}\n\nexport interface TextRun {\n content: string;\n textStyle?: TextStyle;\n}\n\nexport interface TextStyle {\n bold?: boolean;\n italic?: boolean;\n underline?: boolean;\n strikethrough?: boolean;\n fontSize?: Dimension;\n foregroundColor?: Color;\n backgroundColor?: Color;\n fontFamily?: string;\n link?: Link;\n}\n\nexport interface Link {\n url?: string;\n bookmarkId?: string;\n headingId?: string;\n}\n\nexport interface Dimension {\n magnitude: number;\n unit: string;\n}\n\nexport interface Color {\n rgbColor?: RgbColor;\n}\n\nexport interface RgbColor {\n red: number;\n green: number;\n blue: number;\n}\n\nexport interface ParagraphStyle {\n headingId?: string;\n namedStyleType?: string;\n alignment?: string;\n lineSpacing?: number;\n direction?: string;\n spacingMode?: string;\n spaceAbove?: Dimension;\n spaceBelow?: Dimension;\n indentFirstLine?: Dimension;\n indentStart?: Dimension;\n indentEnd?: Dimension;\n}\n\nexport interface Bullet {\n listId: string;\n nestingLevel?: number;\n textStyle?: TextStyle;\n}\n\nexport interface Table {\n rows: number;\n columns: number;\n tableRows: TableRow[];\n tableStyle?: TableStyle;\n}\n\nexport interface TableRow {\n startIndex: number;\n endIndex: number;\n tableCells: TableCell[];\n}\n\nexport interface TableCell {\n startIndex: number;\n endIndex: number;\n content: StructuralElement[];\n tableCellStyle?: TableCellStyle;\n}\n\nexport interface TableCellStyle {\n rowSpan?: number;\n columnSpan?: number;\n backgroundColor?: Color;\n borderLeft?: TableCellBorder;\n borderRight?: TableCellBorder;\n borderTop?: TableCellBorder;\n borderBottom?: TableCellBorder;\n paddingLeft?: Dimension;\n paddingRight?: Dimension;\n paddingTop?: Dimension;\n paddingBottom?: Dimension;\n}\n\nexport interface TableCellBorder {\n color?: Color;\n width?: Dimension;\n dashStyle?: string;\n}\n\nexport interface TableStyle {\n tableColumnProperties?: TableColumnProperties[];\n}\n\nexport interface TableColumnProperties {\n width?: Dimension;\n widthType?: string;\n}\n\nexport interface SectionBreak {\n sectionStyle?: SectionStyle;\n}\n\nexport interface SectionStyle {\n columnSeparatorStyle?: string;\n contentDirection?: string;\n marginTop?: Dimension;\n marginBottom?: Dimension;\n marginRight?: Dimension;\n marginLeft?: Dimension;\n pageNumberStart?: number;\n}\n\nexport interface DocumentStyle {\n background?: Background;\n pageNumberStart?: number;\n marginTop?: Dimension;\n marginBottom?: Dimension;\n marginRight?: Dimension;\n marginLeft?: Dimension;\n pageSize?: Size;\n marginHeader?: Dimension;\n marginFooter?: Dimension;\n useFirstPageHeaderFooter?: boolean;\n}\n\nexport interface Background {\n color?: Color;\n}\n\nexport interface Size {\n height?: Dimension;\n width?: Dimension;\n}\n\nexport interface InlineObjectElement {\n inlineObjectId: string;\n textStyle?: TextStyle;\n}\n\nexport interface DocumentFile {\n id: string;\n name: string;\n mimeType: string;\n createdTime: string;\n modifiedTime: string;\n webViewLink: string;\n iconLink?: string;\n thumbnailLink?: string;\n}\n\nexport interface CreateDocumentOptions {\n title: string;\n}\n\nexport interface BatchUpdateRequest {\n requests: Request[];\n}\n\nexport interface Request {\n insertText?: InsertTextRequest;\n deleteContentRange?: DeleteContentRangeRequest;\n replaceAllText?: ReplaceAllTextRequest;\n updateTextStyle?: UpdateTextStyleRequest;\n updateParagraphStyle?: UpdateParagraphStyleRequest;\n insertPageBreak?: InsertPageBreakRequest;\n insertTable?: InsertTableRequest;\n deleteTableRow?: DeleteTableRowRequest;\n deleteTableColumn?: DeleteTableColumnRequest;\n createParagraphBullets?: CreateParagraphBulletsRequest;\n deleteParagraphBullets?: DeleteParagraphBulletsRequest;\n}\n\nexport interface InsertTextRequest {\n text: string;\n location: Location;\n}\n\nexport interface DeleteContentRangeRequest {\n range: Range;\n}\n\nexport interface ReplaceAllTextRequest {\n containsText: ContainsText;\n replaceText: string;\n}\n\nexport interface UpdateTextStyleRequest {\n range: Range;\n textStyle: TextStyle;\n fields: string;\n}\n\nexport interface UpdateParagraphStyleRequest {\n range: Range;\n paragraphStyle: ParagraphStyle;\n fields: string;\n}\n\nexport interface InsertPageBreakRequest {\n location: Location;\n}\n\nexport interface InsertTableRequest {\n rows: number;\n columns: number;\n location: Location;\n}\n\nexport interface DeleteTableRowRequest {\n tableCellLocation: TableCellLocation;\n}\n\nexport interface DeleteTableColumnRequest {\n tableCellLocation: TableCellLocation;\n}\n\nexport interface CreateParagraphBulletsRequest {\n range: Range;\n bulletPreset: string;\n}\n\nexport interface DeleteParagraphBulletsRequest {\n range: Range;\n}\n\nexport interface Location {\n index: number;\n segmentId?: string;\n}\n\nexport interface Range {\n startIndex: number;\n endIndex: number;\n segmentId?: string;\n}\n\nexport interface ContainsText {\n text: string;\n matchCase: boolean;\n}\n\nexport interface TableCellLocation {\n tableStartLocation: Location;\n rowIndex: number;\n columnIndex: number;\n}\n\nexport interface BatchUpdateResponse {\n documentId: string;\n replies: Reply[];\n writeControl?: WriteControl;\n}\n\nexport interface Reply {\n [key: string]: unknown;\n}\n\nexport interface WriteControl {\n requiredRevisionId: string;\n targetRevisionId: string;\n}\n\n/**\n * Google Docs OAuth provider configuration\n */\nexport const docsOAuthProvider = {\n name: \"docs-google\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/documents.readonly\",\n \"https://www.googleapis.com/auth/documents\",\n \"https://www.googleapis.com/auth/drive.readonly\",\n ],\n callbackPath: \"/api/auth/docs-google/callback\",\n};\n\nexport function createDocsClient(userId: string): {\n listDocuments(options?: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n }): Promise<DocumentFile[]>;\n getDocument(documentId: string): Promise<Document>;\n createDocument(options: CreateDocumentOptions): Promise<Document>;\n updateDocument(documentId: string, requests: Request[]): Promise<BatchUpdateResponse>;\n insertText(documentId: string, text: string, index: number): Promise<BatchUpdateResponse>;\n deleteContent(documentId: string, startIndex: number, endIndex: number): Promise<BatchUpdateResponse>;\n replaceAllText(\n documentId: string,\n searchText: string,\n replaceText: string,\n matchCase?: boolean,\n ): Promise<BatchUpdateResponse>;\n searchDocuments(query: string, maxResults?: number): Promise<DocumentFile[]>;\n extractText(document: Document): string;\n createDocumentWithContent(title: string, content: string): Promise<Document>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(docsOAuthProvider, userId, \"docs-google\");\n if (!token) throw new Error(\"Google Docs not connected. Please connect your Google account first.\");\n return token;\n }\n\n async function apiRequest<T>(\n baseUrl: string,\n label: string,\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${baseUrl}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`${label} API error: ${response.status} - ${error}`);\n }\n\n return response.json();\n }\n\n function docsApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiRequest<T>(DOCS_API_BASE, \"Docs\", endpoint, options);\n }\n\n function driveApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiRequest<T>(DRIVE_API_BASE, \"Drive\", endpoint, options);\n }\n\n function extractText(document: Document): string {\n const textParts: string[] = [];\n\n function processElement(element: StructuralElement): void {\n if (element.paragraph) {\n for (const el of element.paragraph.elements) {\n if (el.textRun) textParts.push(el.textRun.content);\n }\n return;\n }\n\n if (!element.table) return;\n\n for (const row of element.table.tableRows) {\n for (const cell of row.tableCells) {\n for (const child of cell.content) processElement(child);\n }\n }\n }\n\n for (const element of document.body.content) processElement(element);\n return textParts.join(\"\");\n }\n\n async function listDocuments(options: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n } = {}): Promise<DocumentFile[]> {\n const params = new URLSearchParams({\n q: \"mimeType='application/vnd.google-apps.document' and trashed=false\",\n fields: \"files(id,name,mimeType,createdTime,modifiedTime,webViewLink,iconLink,thumbnailLink)\",\n pageSize: String(options.maxResults ?? 20),\n orderBy: `${options.orderBy ?? \"modifiedTime\"} desc`,\n });\n\n const result = await driveApiRequest<{ files: DocumentFile[] }>(`/files?${params.toString()}`);\n return result.files ?? [];\n }\n\n async function searchDocuments(query: string, maxResults = 20): Promise<DocumentFile[]> {\n const params = new URLSearchParams({\n q: `mimeType='application/vnd.google-apps.document' and trashed=false and fullText contains '${query}'`,\n fields: \"files(id,name,mimeType,createdTime,modifiedTime,webViewLink,iconLink,thumbnailLink)\",\n pageSize: String(maxResults),\n orderBy: \"modifiedTime desc\",\n });\n\n const result = await driveApiRequest<{ files: DocumentFile[] }>(`/files?${params.toString()}`);\n return result.files ?? [];\n }\n\n function getDocument(documentId: string): Promise<Document> {\n return docsApiRequest<Document>(`/documents/${documentId}`);\n }\n\n function createDocument(options: CreateDocumentOptions): Promise<Document> {\n return docsApiRequest<Document>(\"/documents\", {\n method: \"POST\",\n body: JSON.stringify({ title: options.title }),\n });\n }\n\n function updateDocument(documentId: string, requests: Request[]): Promise<BatchUpdateResponse> {\n return docsApiRequest<BatchUpdateResponse>(`/documents/${documentId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({ requests }),\n });\n }\n\n function insertText(documentId: string, text: string, index: number): Promise<BatchUpdateResponse> {\n return updateDocument(documentId, [\n {\n insertText: {\n text,\n location: { index },\n },\n },\n ]);\n }\n\n function deleteContent(documentId: string, startIndex: number, endIndex: number): Promise<BatchUpdateResponse> {\n return updateDocument(documentId, [\n {\n deleteContentRange: {\n range: { startIndex, endIndex },\n },\n },\n ]);\n }\n\n function replaceAllText(\n documentId: string,\n searchText: string,\n replaceText: string,\n matchCase = false,\n ): Promise<BatchUpdateResponse> {\n return updateDocument(documentId, [\n {\n replaceAllText: {\n containsText: {\n text: searchText,\n matchCase,\n },\n replaceText,\n },\n },\n ]);\n }\n\n async function createDocumentWithContent(title: string, content: string): Promise<Document> {\n const doc = await createDocument({ title });\n await insertText(doc.documentId, content, 1);\n return getDocument(doc.documentId);\n }\n\n return {\n listDocuments,\n getDocument,\n createDocument,\n updateDocument,\n insertText,\n deleteContent,\n replaceAllText,\n searchDocuments,\n extractText,\n createDocumentWithContent,\n };\n}\n\nexport type DocsClient = ReturnType<typeof createDocsClient>;\n",
|
|
720
|
-
"app/api/auth/docs-google/callback/route.ts": "import { createOAuthCallbackHandler, docsGoogleConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\
|
|
721
|
-
"app/api/auth/docs-google/route.ts": "import { createOAuthInitHandler, docsGoogleConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nexport const GET = createOAuthInitHandler(docsGoogleConfig, {\n tokenStore: oauthMemoryTokenStore,\n})
|
|
720
|
+
"app/api/auth/docs-google/callback/route.ts": "import { createOAuthCallbackHandler, docsGoogleConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(docsGoogleConfig, { tokenStore: hybridTokenStore });\n",
|
|
721
|
+
"app/api/auth/docs-google/route.ts": "import { createOAuthInitHandler, docsGoogleConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\n// TODO: Replace with real user ID from your auth system (e.g., session cookie, JWT).\n// NEVER return a shared constant in production - it breaks per-user token isolation (VULN-AUTH-2).\nfunction getUserId(_request: Request): string {\n return \"current-user\";\n}\n\nexport const GET = createOAuthInitHandler(docsGoogleConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});"
|
|
722
722
|
}
|
|
723
723
|
}
|
|
724
724
|
}
|