diff --git a/apps/docs/content/docs/knowledgebase/connectors.mdx b/apps/docs/content/docs/knowledgebase/connectors.mdx index 31110c79c1c..e3624676668 100644 --- a/apps/docs/content/docs/knowledgebase/connectors.mdx +++ b/apps/docs/content/docs/knowledgebase/connectors.mdx @@ -16,11 +16,11 @@ Connectors continuously sync documents from external services into your knowledg The current Connect Source picker showing searchable connectors including Airtable, Asana, Ashby, Azure DevOps, Bitbucket, Box, and ClickUp -Sim ships with 64 built-in connectors: +Sim ships with 66 built-in connectors: | Category | Connectors | |----------|-----------| -| **Productivity** | Notion, Confluence, Asana, Linear, Jira, Jira Service Management, Monday, Trello, ClickUp, Google Calendar, Google Sheets, Google Forms, Microsoft Excel, Typeform | +| **Productivity** | Coda, Notion, Confluence, Asana, Linear, Jira, Jira Service Management, Monday, Trello, ClickUp, Google Calendar, Google Sheets, Google Forms, Microsoft Excel, Typeform | | **Cloud Storage** | Google Drive, Dropbox, OneDrive, SharePoint, Box, Amazon S3, SFTP | | **Documents** | Google Docs, Google Slides, Mintlify, WordPress, Webflow, DocuSign | | **Development** | GitHub, GitLab, Bitbucket, Azure DevOps, Sentry | @@ -31,7 +31,7 @@ Sim ships with 64 built-in connectors: | **Incident Management** | incident.io, Rootly, PagerDuty | | **Data** | Airtable, Databricks | | **Note-taking** | Obsidian | -| **Meetings** | Zoom, Google Meet, Gong, Grain, Granola, Fathom, Fireflies | +| **Meetings** | Zoom, Google Meet, Gong, Grain, Granola, Fathom, Fireflies, Circleback | | **Recruiting** | Greenhouse, Ashby | | **HR** | Workday Help | | **Compliance** | Google Vault | @@ -52,6 +52,7 @@ Other connectors use **API keys** or **personal access tokens** instead. The set | Connector | Where to get the key | |-----------|---------------------| | **Obsidian** | Install the [Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin, then copy the key from its settings | +| **Coda** | API token from Account settings → API connections, saved as a Coda service account credential | | **Fireflies** | Generate from the Integrations page in your Fireflies account | | **Typeform** | Personal access token from your Typeform account settings | | **Azure DevOps** | Personal access token with Wiki (Read), Work Items (Read), and Code (Read) scopes | diff --git a/apps/docs/content/docs/search/coda.mdx b/apps/docs/content/docs/search/coda.mdx new file mode 100644 index 00000000000..c2a276f7cf5 --- /dev/null +++ b/apps/docs/content/docs/search/coda.mdx @@ -0,0 +1,66 @@ +--- +title: Coda +description: Connect Coda documents while preserving each teammate's access +--- + +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +An organization admin connects Coda once. Teammates search using their verified Sim email address; they do not need a personal Coda connection. Their email must match the address Coda reports. + +## Connect Coda + + + + +### Create an API token + +Sign in to Coda with a dedicated integration account that can read the documents and their sharing permissions. Open **Account settings → API connections → Generate API token**, name the token, and copy it. Coda's current app may be labeled **Superhuman Docs**. + +Use a standard REST API token. Do not select the MCP restriction. + +Coda API connections with the Generate new token dialog + + + + +### Add the source + +In Sim, open **Settings → Sources → Add source → Coda**. Add or select a Coda service account credential and enter the token when prompted. + +| Field | What to enter | +|---|---| +| Documents | Select documents, or switch to **Document IDs** and enter up to 100 IDs separated by commas. For `https://coda.io/d/_dAbCDeFGH`, the ID is `AbCDeFGH`. | +| Enterprise organization ID | Leave blank for ordinary access. For organization-wide indexing, enter the ID from Coda's [List organizations API](https://coda.io/developers/apis/admin/v1#operation/listOrganizations) and use an Enterprise organization administrator's token. Workspace admin access alone is insufficient. | + +The picker shows accessible documents the token owner has opened. Use IDs for other accessible documents. With no selection, Sim discovers the token owner's opened documents, or the organization's documents in Enterprise mode. Use explicit IDs when deleted documents must reliably be removed from the index; discovery listings alone are not authoritative for deletion. + + + + +### Sync and verify + +Select **Connect & Sync**. Open the source's **Documents** and **Sync history** to check indexing. Content and permission changes appear after synchronization. + +To rotate the token, add the replacement credential, update the source, and verify a sync before revoking the old token. + + + + +## Content and access + +Ordinary connections index visible canvas text and base-table rows. Enterprise connections index the Admin API's plaintext page exports; tables are included only as represented in those exports. Attachments, comments, images, and remote embeds are not extracted. + +| Permission | Ordinary connection | Enterprise connection | +|---|---|---| +| Owner or direct email share | Matching verified Sim email | Matching active organization user | +| Group, workspace, or domain share | Not expanded | Resolved through the Admin API | +| Anyone with the link | Does not grant Search access | Does not grant Search access | + +Enterprise mode excludes deactivated and deleted users. Direct-share guests absent from the organization directory are excluded; guests explicitly listed in a group or workspace may receive that membership's access. Ordinary connections cannot check Coda organization deactivation, so manage departing users' Sim membership too. + +Sim refreshes permissions even when content is unchanged. Unverified permissions never become broad access, and Sim admin status does not override Coda sharing. For setup failures or missing results, check the token's access, Enterprise admin role if applicable, the teammate's verified email, and **Sync history**. + +In a regular knowledge base, **Workspace access** intentionally shares all indexed content with that Sim workspace and does not use the Enterprise organization ID. The permissions above apply to organization Search and **Admin access** sources. + +See Coda's [public API](https://coda.io/developers/apis/v1) and [Enterprise Admin API](https://coda.io/developers/apis/admin/v1). diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx index b82cdf817a2..6d741241fe1 100644 --- a/apps/docs/content/docs/search/connect-your-account.mdx +++ b/apps/docs/content/docs/search/connect-your-account.mdx @@ -60,6 +60,7 @@ For a source configured inside a workspace, join that workspace and connect thro | GitHub App installation | Connect GitHub once for this Sim organization. The App handles indexing; your account establishes which repositories you may search. | | Confluence service account | Connect Confluence to verify your identity; the service account handles the crawl. | | Google Workspace service account (Gmail, Calendar, Drive) | No personal connection is needed for that source. Your verified Sim email identifies your mailbox and calendar view, or is matched to Drive permissions. | +| [Coda](/search/coda) | No personal connection is needed. Your verified Sim email must match a supported Coda sharing grant. | | GitLab | No personal connection is needed. For an administrator token, your verified Sim email must match your confirmed primary GitLab email. For a non-admin token, it must match the uploaded user mapping and have a project grant. | Gmail, Calendar, and Drive are separate Search sources. Connecting one Google service does not connect all of them. Central Google and GitLab sources remain searchable without a personal account connection. diff --git a/apps/docs/content/docs/search/meta.json b/apps/docs/content/docs/search/meta.json index 473704f2d45..8a83d115762 100644 --- a/apps/docs/content/docs/search/meta.json +++ b/apps/docs/content/docs/search/meta.json @@ -3,6 +3,7 @@ "pages": [ "connect-your-account", "mcp", + "coda", "confluence", "github", "gitlab", diff --git a/apps/docs/public/static/search/coda-api-token.jpg b/apps/docs/public/static/search/coda-api-token.jpg new file mode 100644 index 00000000000..09aa99e08eb Binary files /dev/null and b/apps/docs/public/static/search/coda-api-token.jpg differ diff --git a/apps/sim/connectors/coda/README.md b/apps/sim/connectors/coda/README.md new file mode 100644 index 00000000000..1b2ae294e9b --- /dev/null +++ b/apps/sim/connectors/coda/README.md @@ -0,0 +1,79 @@ +# Coda connector decisions and verification + +## Precedent and authentication + +| Reference | Verified behavior | What it establishes for Sim | +| --- | --- | --- | +| [Glean Coda search](https://www.glean.com/connectors/coda) | Native document/page indexing with query-time source permissions. | The same access-control objective. The public page does not specify the crawler's credentials, endpoints, directory expansion, or deletion strategy; exact implementation parity is not established. | +| [Glean Coda agent service credential](https://docs.glean.com/administration/agent-identity/coda) | A dedicated Coda user and an MCP-restricted token for agent tools. | Dedicated identity and credential rotation are useful operational precedent. This is a separate MCP integration, not the REST indexing setup; its MCP token restriction must not be copied into Sim's connector instructions. | +| [Onyx Coda connector](https://github.com/onyx-dot-app/onyx/blob/5fe6573c3c155e1a75b51de32d4988ee6c82164e/backend/onyx/connectors/coda/connector.py) | A bearer token, optional workspace scope, page content, and table rows through the public REST API. | Direct precedent for ordinary content ingestion. Sim uses bounded deferred hydration rather than collecting the entire source before indexing. | + +Onyx's [test setup](https://github.com/onyx-dot-app/onyx/blob/5fe6573c3c155e1a75b51de32d4988ee6c82164e/backend/tests/daily/connectors/coda/README.md) also uses an API token generated in account settings. Its reviewed Coda implementation has no source ACL or directory calls, and its [permission-sync registry](https://github.com/onyx-dot-app/onyx/blob/5fe6573c3c155e1a75b51de32d4988ee6c82164e/backend/ee/onyx/external_permissions/sync_params.py) has no Coda entry. It therefore does not establish Enterprise permission parity. Coda's own API documentation is the authority for Sim's Enterprise path. + +This connector reuses Sim's existing `coda-service-account` token credential and its selector. The metadata's `oauth` discriminator means “use a stored credential” in the shared connector framework. The actual credential is an API token, with no OAuth authorization or refresh flow. Personal credential-group enrollment is not exposed because that flow currently requires OAuth. Admin indexing matches provider-reported grants to verified Sim emails instead. + +## API contract audit + +All connector requests are GET requests authenticated with `Authorization: Bearer`. The following operation IDs were checked against the OpenAPI specifications embedded in Coda's [public reference](https://coda.io/developers/apis/v1) and [Admin reference](https://coda.io/developers/apis/admin/v1), including query parameters, response fields, pagination, and allowed formats. None of these operations is deprecated. + +| API | Operations used | +| --- | --- | +| Public identity and discovery | `whoami`, `listDocs`, `getDoc` | +| Public content | `listPages`, `getPage`, `listPageContent`, `listTables`, `getTable`, `listColumns`, `listRows` | +| Public permissions | `getPermissions` | +| Admin discovery | `getOrganization`, `listDocs` with `docIds` or `docAvailabilityState` and `fetchPermissionsMode=none` | +| Admin content and permissions | `listPagesV2`, `getPageV2` with `outputFormat=LossyPlainText`, `listDocPermissionsV2` | +| Admin directory | `listOrgUsers`, `listGroups`, `listGroupMembers`, `listWorkspaces`, `listWorkspaceUsers` | + +The Admin API requires an [Enterprise organization administrator](https://help.superhuman.com/hc/en-us/articles/46210125237901-Intro-to-Superhuman-Docs-Admin-API). Ordinary workspace admin access does not satisfy that requirement. All network calls use the shared secure fetch/retry path and fixed API origins; the connector uses no browser session, private endpoint, credential impersonation, or provider permission mutation. Synthetic directory groups are Sim's internal representation of documented grants, not invented Coda API objects. Size and crawl limits below are Sim safeguards, not claimed Coda service limits. + +## Access tradeoffs + +- Without an organization ID, only ownership and direct email grants establish individual access. Workspace, domain, group, and unknown principals are not expanded. Link possession never grants Search access. The public API does not provide organization account status; this mode cannot independently detect organization-level deactivation while a direct grant remains. Sim membership lifecycle must also be managed. +- With an Enterprise organization ID, the Admin API supplies organization discovery and directory membership. Direct users and domains use synthetic groups so deactivated/deleted organization users cannot resolve grants. External direct-share guests absent from the organization directory are conservatively excluded. Explicit group/workspace guests remain eligible unless the directory reports them inactive. +- Opaque group/workspace IDs are hex encoded before shared case-folding. Tenant IDs remain source-exact. This avoids merging case-sensitive Coda IDs. +- Every listed document's complete ACL is read on every sync, independently of content hashes. Partial/failed ACLs are omitted so the shared engine fails closed. Directory snapshots are collected under the shared directory lease and never reported complete after a truncated response. +- Permission freshness, query authorization, and directory persistence remain in the existing shared engines. Workspace access mode deliberately grants workspace-wide access and is separate from organization Search. + +## Content and pagination tradeoffs + +The [public API](https://coda.io/developers/apis/v1) exposes canvas plaintext and base-table rows; the [Admin API](https://coda.io/developers/apis/admin/v1) exposes `LossyPlainText` page exports. Admin exports are not equivalent to independently fetching every table. Hidden/embed/synced pages are excluded on the public path; the Admin API does not expose those public page-type flags. Neither path extracts arbitrary attachments or remote embeds. + +Admin document reads use the current workspace-qualified endpoints, resolving the workspace with the documented organization `docIds` filter. Metadata queries request `fetchPermissionsMode=none`; permissions come only from the complete paginated ACL endpoint. + +Listing defers content and retains only the current parent document metadata in the per-run context, avoiding one redundant parent fetch per child. Resource content and permissions are still read from the provider; ACLs never use that metadata cache. Discovery fetches ten parent documents at a time. Compound cursors persist that bounded queue, the current document, child phase, and provider token for durable replay, under the shared 512 KiB cursor limit. Only `pageToken` is sent on continuation requests, as Coda requires. No response URL or redirect receives the credential. Responses, text, list lengths, ACLs, directory entries, and pagination loops are bounded and fail rather than silently truncating. Responses are limited to 4 MiB, indexed text to 12 MiB, and hydration concurrency to two. The stubs advertise the text bound to the shared byte-budget scheduler. Directory entries are limited to 100,000, email lengths to 254, and document permissions to 5,000. Domain memberships are indexed once instead of scanning every user for every domain. + +Hashes use the parent document revision because table-list references do not guarantee a row-sensitive timestamp. A document edit invalidates all its children, trading extra reads for correctness. Explicit full resyncs also refresh rendered dependencies. When the Admin API omits its optional revision timestamp, the shared durable sync-generation ID forces one refresh per crawl, following the Slack/GitLab pattern. It remains stable across retries within that generation. + +Discovery order is mutable and the public list omits never-opened documents. Discovery is therefore non-authoritative for deletion reconciliation. Explicit document IDs provide authoritative scope; confirmed 404/410 documents reconcile, while 403s remain errors. Up to 100 explicit IDs are supported per source. Setup probes one document and one ACL page to keep request latency bounded; sync checks the remaining scope. + +## Reproducible checks + +From `apps/sim`: + +```sh +bunx vitest run connectors/coda/coda.test.ts connectors/coda/permissions.test.ts lib/selectors/server/providers/coda.test.ts lib/credentials/token-service-accounts/validators/coda.test.ts lib/selectors/manifest.test.ts +CODA_CONNECTOR_LIVE_TOKEN_FILE=/path/to/token bunx vitest run connectors/coda/coda.live.test.ts +``` + +The provider test creates and deletes its own document. `CODA_CONNECTOR_LIVE_ORGANIZATION_ID` enables additional Enterprise export/ACL/directory checks against that fixture when the token belongs to an Enterprise organization. Set `CODA_CONNECTOR_LIVE_FIXTURE_FILE=/path/to/fixture.json` to retain it for the application test, then from the repository root: + +```sh +CODA_CONNECTOR_LIVE_TOKEN_FILE=/path/to/token \ +CODA_CONNECTOR_LIVE_FIXTURE_FILE=/path/to/fixture.json \ +CODA_CONNECTOR_LIVE_SECOND_EMAIL=second-user@example.com \ +bun scripts/test-knowledge-acls.ts coda-live.integration.ts +``` + +The application test uses disposable PostgreSQL/Redis, real credentials, Coda, source creation, ingestion, storage, and authorization. Set `CODA_CONNECTOR_LIVE_SCOPE=organization` to exercise organization credential creation and organization Search instead of a workspace knowledge base. Only embeddings are deterministic substitutes. It changes only the retained fixture's share, with notifications suppressed, and removes that share afterward. `CODA_CONNECTOR_LIVE_ALLOW_SHARING=false` explicitly skips the live grant/revoke cases when source policy prohibits sharing. `CODA_CONNECTOR_LIVE_UI_FIXTURE_FILE` optionally retains isolated rows and writes a temporary signed session for browser verification; use only with the runner's disposable-database retention options and remove both afterward. + +## Verification coverage + +- Focused connector, selector, credential, navigation, configuration, and integration-availability tests passed. App and deployment-config type checks, Biome, and the strict API-contract audit passed. +- Live provider tests cover page/table hydration, page edits, and row edits. Coda's real initialization `409` led to an explicit retry path. +- Real application setup and ingestion passed in both workspace and organization scope (the live sharing cases require a workspace that allows the intended cross-domain share). Organization scope creates the token credential through the authorized application use case. Owner search/chunks were allowed; another verified user, unverified owner, and workspace API key were denied. Embeddings were substituted, so external embedding-provider behavior was not tested. +- Sim's browser showed the Coda source Active, both indexed documents, the live document picker, the saved organization credential, and preserved selection across connection/input modes. Browser checks caught and fixed missing Search URL registration, scope loss when switching modes, and deployment metadata rejecting Coda's token credential. Coda's official browser app showed the fixture content. +- Live sharing to the requested second account was rejected by the source policy: “Cross domain sharing is prohibited.” Grant/revoke tests remain unverified live, with unit coverage for ACL changes and incomplete responses. +- The available test account returned no Enterprise organizations; the official UI offers sales-assisted Enterprise setup rather than a self-service trial. Admin API behavior is checked against published schemas and mocked responses; organization-wide crawling, deactivation, and group/workspace/domain permission parity still require an Enterprise tenant test before being considered release-verified. + +These remaining provider prerequisites prevent claiming complete live verification. diff --git a/apps/sim/connectors/coda/client.ts b/apps/sim/connectors/coda/client.ts new file mode 100644 index 00000000000..b6a16206ebd --- /dev/null +++ b/apps/sim/connectors/coda/client.ts @@ -0,0 +1,129 @@ +import { z } from 'zod' +import { fetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server' +import { isRetryableError, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { ConnectorSourceError } from '@/connectors/source-error' +import { readBodyWithLimit } from '@/connectors/utils' +import { buildCodaUrl, codaHeaders } from '@/tools/coda/utils' + +export const CODA_RESPONSE_BYTES = 4 * 1024 * 1024 +export const codaIdSchema = z.string().regex(/^[a-zA-Z0-9_-]{1,512}$/) +export const codaEmailSchema = z.string().email().max(254) +export const codaPageTokenSchema = z.string().min(1).max(4096) +const timestampSchema = z.string().datetime({ offset: true }) +const sourceUrlSchema = z.string().url().max(4096) + +export const codaDocSchema = z.object({ + id: codaIdSchema, + name: z.string().max(4096), + browserLink: sourceUrlSchema, + updatedAt: timestampSchema.optional(), + owner: codaEmailSchema.optional(), + workspaceId: codaIdSchema.optional(), + isDeleted: z.boolean().optional(), + keyAccessRevoked: z.boolean().optional(), +}) + +export const codaAdminDocSchema = codaDocSchema.required({ workspaceId: true }) + +export const codaResourceSchema = z.object({ + id: codaIdSchema, + name: z.string().max(4096), + browserLink: sourceUrlSchema, +}) + +export const codaPageSchema = codaResourceSchema.extend({ + contentType: z.string(), + isHidden: z.boolean(), + isEffectivelyHidden: z.boolean(), +}) + +export const codaTableSchema = codaResourceSchema.extend({ tableType: z.string() }) +export type CodaDoc = z.infer +export type CodaResource = z.infer + +export function codaListSchema(item: T) { + return z.object({ + items: z.array(item).max(1000), + nextPageToken: codaPageTokenSchema.optional(), + }) +} + +/** Provider URLs are constructed locally; response links never receive the credential. */ +export async function codaJson( + token: string, + path: string, + schema: T, + query?: Record, + validating = false, + admin = false +): Promise> { + const response = await fetchWithRetry( + buildCodaUrl(path, query, admin ? 'admin' : 'public'), + { headers: codaHeaders(token), redirect: 'error' }, + { + ...(validating ? VALIDATE_RETRY_OPTIONS : {}), + /** A newly created Coda doc returns 409 until its API snapshot is ready. */ + fetcher: async (input, init, transport) => { + const response = await transport(input, init) + if (response.status === 409) { + await response.body?.cancel() + throw new ConnectorSourceError( + 'Coda document is still initializing', + 409, + 'provider_unavailable' + ) + } + return response + }, + retryCondition: (error) => + (error instanceof ConnectorSourceError && error.status === 409) || isRetryableError(error), + } + ) + if (!response.ok) { + await response.body?.cancel() + throw new ConnectorSourceError( + `Coda request failed (${response.status})`, + response.status, + response.status === 401 || response.status === 403 ? 'authorization' : undefined + ) + } + const body = await readBodyWithLimit(response, CODA_RESPONSE_BYTES) + if (!body) throw new Error('Coda response exceeded the 4MB limit') + let data: unknown + try { + data = JSON.parse(body.toString('utf8')) + } catch { + throw new Error('Coda returned invalid JSON') + } + const parsed = schema.safeParse(data) + if (!parsed.success) throw new Error('Coda returned an invalid response') + return parsed.data +} + +/** Drains bounded collections without retaining their response bodies or following nextPageLink. */ +export async function* codaPages( + token: string, + path: string, + schema: T, + query?: Record, + admin = false +) { + let pageToken: string | undefined + const seen = new Set() + for (let page = 0; page < 1000; page++) { + const result = await codaJson( + token, + path, + codaListSchema(schema), + { limit: 100, ...query, pageToken }, + false, + admin + ) + yield result.items + pageToken = result.nextPageToken + if (!pageToken) return + if (seen.has(pageToken)) throw new Error('Coda repeated a pagination token') + seen.add(pageToken) + } + throw new Error('Coda exceeded the 1000-page safety limit') +} diff --git a/apps/sim/connectors/coda/coda.live.test.ts b/apps/sim/connectors/coda/coda.live.test.ts new file mode 100644 index 00000000000..a2c9d8aa881 --- /dev/null +++ b/apps/sim/connectors/coda/coda.live.test.ts @@ -0,0 +1,225 @@ +/** + * Opt-in provider tests. Only documents created here are mutated or deleted. + * Set CODA_CONNECTOR_LIVE_TOKEN_FILE to a local token file. Optionally set + * CODA_CONNECTOR_LIVE_FIXTURE_FILE to retain the fixture for UI and access tests. + * @vitest-environment node + */ +import { readFileSync, writeFileSync } from 'node:fs' +import { sleep } from '@sim/utils/helpers' +import { generateShortId } from '@sim/utils/id' +import { afterAll, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { fetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server' +import { codaConnector } from '@/connectors/coda/coda' +import type { ExternalDocument } from '@/connectors/types' +import { buildCodaUrl, codaDocPath, codaHeaders } from '@/tools/coda/utils' + +const tokenPath = process.env.CODA_CONNECTOR_LIVE_TOKEN_FILE +const fixturePath = process.env.CODA_CONNECTOR_LIVE_FIXTURE_FILE +const token = tokenPath ? readFileSync(tokenPath, 'utf8').trim() : '' +const resourceSchema = z.object({ id: z.string(), browserLink: z.string() }) +const mutationSchema = z.object({ requestId: z.string() }) + +async function request( + path: string, + schema: T, + method = 'GET', + body?: unknown +) { + const response = await fetchWithRetry(buildCodaUrl(path), { + method, + headers: codaHeaders(token, body !== undefined), + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + redirect: 'error', + }) + if (!response.ok) throw new Error(`Live Coda ${method} failed (${response.status})`) + return schema.parse(await response.json()) +} + +async function waitFor(read: () => Promise): Promise { + for (let attempt = 0; attempt < 60; attempt++) { + const value = await read() + if (value !== undefined) return value + await sleep(1000) + } + throw new Error('Coda did not converge within 60 seconds') +} + +describe.skipIf(!token).sequential('Coda connector live', () => { + let docId = '' + let pageId = '' + let before: ExternalDocument[] = [] + const marker = `SimConnector-${generateShortId()}` + + async function list() { + const documents: ExternalDocument[] = [] + let cursor: string | undefined + for (let count = 0; count < 10; count++) { + const result = await codaConnector.listDocuments(token, { docIds: [docId] }, cursor) + documents.push(...result.documents) + cursor = result.nextCursor + if (!result.hasMore) return documents + } + throw new Error('Unexpected fixture pagination') + } + + afterAll(async () => { + if (docId && !fixturePath) { + await request(codaDocPath(docId), z.object({}), 'DELETE') + } + }) + + it('creates a disposable page and table, then lists and hydrates both through the connector', async () => { + const created = await request('/docs', resourceSchema, 'POST', { + title: `Sim Coda connector verification ${marker}`, + initialPage: { + name: 'Connector verification', + pageContent: { + type: 'canvas', + canvasContent: { + format: 'html', + content: `

Connector verification

${marker} initial content

NameStatus
Verification rowReady
`, + }, + }, + }, + }) + docId = created.id + if (fixturePath) + writeFileSync( + fixturePath, + JSON.stringify({ docId, browserLink: created.browserLink, marker }) + ) + await waitFor(async () => { + const result = await codaConnector.validateConfig(token, { docIds: [docId] }) + return result.valid ? true : undefined + }) + before = await waitFor(async () => { + const documents = await list() + if (documents.length < 2) return undefined + for (const listed of documents) { + const hydrated = await codaConnector.getDocument( + token, + { docIds: [docId] }, + listed.externalId + ) + if (hydrated?.contentHash !== listed.contentHash) return undefined + } + return documents + }) + expect(before.every((item) => item.contentDeferred && item.content === '')).toBe(true) + const page = before.find((item) => item.externalId.includes('/pages/'))! + pageId = page.externalId.split('/')[2] + const table = before.find((item) => item.externalId.includes('/tables/'))! + const pageContent = await codaConnector.getDocument(token, { docIds: [docId] }, page.externalId) + const tableContent = await codaConnector.getDocument( + token, + { docIds: [docId] }, + table.externalId + ) + expect(pageContent?.content).toContain(`${marker} initial content`) + expect(pageContent?.contentHash).toBe(page.contentHash) + expect(tableContent?.content).toContain('Name: Verification row') + expect(tableContent?.content).toContain('Status: Ready') + expect(tableContent?.contentHash).toBe(table.contentHash) + if (fixturePath) + writeFileSync( + fixturePath, + JSON.stringify({ + docId, + pageId, + tableId: table.externalId.split('/')[2], + browserLink: created.browserLink, + marker, + }) + ) + }, 180_000) + + it('detects page edits and fetches the updated body', async () => { + const mutation = await request( + codaDocPath(docId, 'pages', [pageId, 'pageId']), + mutationSchema, + 'PUT', + { + contentUpdate: { + insertionMode: 'append', + canvasContent: { format: 'markdown', content: `${marker} updated content` }, + }, + } + ) + await waitFor(async () => { + const result = await request( + `/mutationStatus/${encodeURIComponent(mutation.requestId)}`, + z.object({ completed: z.boolean() }) + ) + return result.completed ? true : undefined + }) + const updated = await waitFor(async () => { + const documents = await list() + return documents[0].contentHash !== before[0].contentHash ? documents : undefined + }) + const full = await codaConnector.getDocument(token, { docIds: [docId] }, updated[0].externalId) + expect(full?.content).toContain(`${marker} updated content`) + expect(full?.contentHash).toBe(updated[0].contentHash) + }, 180_000) + it('detects table row edits through the parent revision and hydrates current rows', async () => { + const table = (await list()).find((item) => item.externalId.includes('/tables/'))! + const tableId = table.externalId.split('/')[2] + const columns = await request( + codaDocPath(docId, 'tables', [tableId, 'tableId'], 'columns'), + z.object({ items: z.array(z.object({ id: z.string(), name: z.string() })) }) + ) + const nameColumn = columns.items.find((column) => column.name === 'Name')! + const mutation = await request( + codaDocPath(docId, 'tables', [tableId, 'tableId'], 'rows'), + mutationSchema, + 'POST', + { + rows: [{ cells: [{ column: nameColumn.id, value: 'Second verification row' }] }], + } + ) + await waitFor(async () => { + const status = await request( + `/mutationStatus/${encodeURIComponent(mutation.requestId)}`, + z.object({ completed: z.boolean() }) + ) + return status.completed ? true : undefined + }) + const updated = await waitFor(async () => { + const current = (await list()).find((item) => item.externalId === table.externalId) + return current?.contentHash !== table.contentHash ? current : undefined + }) + const full = await codaConnector.getDocument(token, { docIds: [docId] }, updated.externalId) + expect(full?.content).toContain('Name: Second verification row') + expect(full?.contentHash).toBe(updated.contentHash) + }, 180_000) + + it.skipIf(!process.env.CODA_CONNECTOR_LIVE_ORGANIZATION_ID)( + 'reads the fixture through the Enterprise Admin API and directory', + async () => { + const config = { + docIds: [docId], + organizationId: process.env.CODA_CONNECTOR_LIVE_ORGANIZATION_ID, + } + const context = { mirrorsSourceAcls: true, syncRunId: 'live-enterprise-verification' } + expect(await codaConnector.validateConfig(token, config, context)).toEqual({ valid: true }) + const listed = await codaConnector.listDocuments(token, config, undefined, context) + expect(listed.documents.length).toBeGreaterThan(0) + const full = await codaConnector.getDocument( + token, + config, + listed.documents[0].externalId, + context + ) + expect(full?.content).toContain(marker) + const acl = await codaConnector.getDocumentAcls!(token, config, listed.documents, context) + expect(acl[listed.documents[0].externalId]?.length).toBeGreaterThan(0) + const directory = await codaConnector.openDirectory!(token, config, context) + const groups = await directory!.listGroups() + expect(groups.length).toBeGreaterThan(0) + for (const group of groups.slice(0, 3)) { + expect((await directory!.listGroupMembers(group)).complete).toBe(true) + } + }, + 180_000 + ) +}) diff --git a/apps/sim/connectors/coda/coda.test.ts b/apps/sim/connectors/coda/coda.test.ts new file mode 100644 index 00000000000..df43c9ee634 --- /dev/null +++ b/apps/sim/connectors/coda/coda.test.ts @@ -0,0 +1,288 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { codaConnector } from '@/connectors/coda/coda' + +const doc = { + id: 'doc-1', + name: 'Handbook', + browserLink: 'https://coda.io/d/_ddoc-1', + updatedAt: '2026-09-16T00:00:00Z', +} +const page = { + id: 'canvas-1', + name: 'Welcome', + browserLink: 'https://coda.io/d/_ddoc-1/Welcome_s1', + contentType: 'canvas', + isHidden: false, + isEffectivelyHidden: false, +} +const table = { + id: 'grid-1', + name: 'Tasks', + browserLink: 'https://coda.io/d/_ddoc-1/Tasks_t1', + tableType: 'table', +} +const config = { docIds: ['doc-1'] } +const line = (content: string) => ({ type: 'line', itemContent: { format: 'plainText', content } }) + +describe('Coda connector', () => { + beforeEach(() => vi.restoreAllMocks()) + + it('reconciles a deleted explicit document but never treats authorization failure as deletion', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json({}, { status: 404 })) + .mockResolvedValueOnce(Response.json({}, { status: 403 })) + expect(await codaConnector.listDocuments('token', config)).toMatchObject({ + documents: [], + hasMore: false, + reconciliationSafe: true, + }) + await expect(codaConnector.listDocuments('token', config)).rejects.toMatchObject({ + status: 403, + }) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('rejects persisted document cursors after the configured scope changes', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json(doc)) + .mockResolvedValueOnce(Response.json({ items: [page] })) + const first = await codaConnector.listDocuments('token', config) + await expect( + codaConnector.listDocuments('token', { docIds: ['other'] }, first.nextCursor) + ).rejects.toThrow('different document scope') + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('validates one document and one ACL page using a bounded setup probe', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json({ loginId: 'owner@example.com' })) + .mockResolvedValueOnce(Response.json(doc)) + .mockResolvedValueOnce(Response.json({ items: [], nextPageToken: 'more' })) + expect( + await codaConnector.validateConfig( + 'token', + { docIds: ['doc-1', 'doc-2'] }, + { mirrorsSourceAcls: true } + ) + ).toEqual({ valid: true }) + expect(fetch).toHaveBeenCalledTimes(3) + expect(String(fetch.mock.calls[2][0])).toContain('/acl/permissions?limit=1') + }) + + it('lists deferred pages then base tables and resumes without fetching content', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json(doc)) + .mockResolvedValueOnce(Response.json({ items: [page], nextPageToken: 'pages-2' })) + .mockResolvedValueOnce(Response.json({ items: [] })) + .mockResolvedValueOnce( + Response.json({ items: [table, { ...table, id: 'view', tableType: 'view' }] }) + ) + const first = await codaConnector.listDocuments('token', config) + expect(first.documents[0]).toMatchObject({ + externalId: 'doc-1/pages/canvas-1', + contentDeferred: true, + content: '', + estimatedBytes: 12 * 1024 * 1024, + }) + const second = await codaConnector.listDocuments('token', config, first.nextCursor) + const third = await codaConnector.listDocuments('token', config, second.nextCursor) + expect(third.documents.map((item) => item.externalId)).toEqual(['doc-1/tables/grid-1']) + expect(third.hasMore).toBe(false) + expect(String(fetch.mock.calls[2][0])).toBe( + 'https://coda.io/apis/v1/docs/doc-1/pages?pageToken=pages-2' + ) + expect(fetch.mock.calls.every(([url]) => !String(url).endsWith('/content'))).toBe(true) + }) + + it('reuses only the current listing document metadata during deferred hydration', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json(doc)) + .mockResolvedValueOnce(Response.json({ items: [page] })) + .mockResolvedValueOnce(Response.json(page)) + .mockResolvedValueOnce(Response.json({ items: [line('Cached parent')] })) + const context = {} + const listed = await codaConnector.listDocuments('token', config, undefined, context) + const full = await codaConnector.getDocument( + 'token', + config, + listed.documents[0].externalId, + context + ) + expect(full?.content).toContain('Cached parent') + expect(full?.contentHash).toBe(listed.documents[0].contentHash) + expect( + fetch.mock.calls.filter(([url]) => String(url) === 'https://coda.io/apis/v1/docs/doc-1') + ).toHaveLength(1) + }) + + it('keeps document discovery separate from child pagination and marks unstable discovery', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json({ items: [doc], nextPageToken: 'docs-2' })) + .mockResolvedValueOnce(Response.json({ items: [page] })) + .mockResolvedValueOnce(Response.json({ items: [] })) + .mockResolvedValueOnce(Response.json({ items: [{ ...doc, id: 'doc-2' }] })) + .mockResolvedValueOnce(Response.json({ items: [page] })) + const first = await codaConnector.listDocuments('token', {}) + const tables = await codaConnector.listDocuments('token', {}, first.nextCursor) + const nextDoc = await codaConnector.listDocuments('token', {}, tables.nextCursor) + expect(first.reconciliationSafe).toBe(false) + expect(nextDoc.documents[0].externalId).toBe('doc-2/pages/canvas-1') + expect(String(fetch.mock.calls[3][0])).toBe('https://coda.io/apis/v1/docs?pageToken=docs-2') + }) + + it('resumes a bounded discovery batch without listing the parent collection per document', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json({ items: [doc, { ...doc, id: 'doc-2' }] })) + .mockResolvedValueOnce(Response.json({ items: [page] })) + .mockResolvedValueOnce(Response.json({ items: [] })) + .mockResolvedValueOnce(Response.json({ items: [page] })) + const first = await codaConnector.listDocuments('token', {}) + const tables = await codaConnector.listDocuments('token', {}, first.nextCursor) + const second = await codaConnector.listDocuments('token', {}, tables.nextCursor) + expect(second.documents[0].externalId).toBe('doc-2/pages/canvas-1') + expect(fetch.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://coda.io/apis/v1/docs?limit=10', + 'https://coda.io/apis/v1/docs/doc-1/pages?limit=100', + 'https://coda.io/apis/v1/docs/doc-1/tables?limit=100', + 'https://coda.io/apis/v1/docs/doc-2/pages?limit=100', + ]) + }) + + it('omits hidden, inherited-hidden, embedded and synced pages', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json(doc)) + .mockResolvedValueOnce( + Response.json({ + items: [ + page, + { ...page, isHidden: true }, + { ...page, isEffectivelyHidden: true }, + { ...page, contentType: 'embed' }, + { ...page, contentType: 'syncPage' }, + ], + }) + ) + expect((await codaConnector.listDocuments('token', config)).documents).toHaveLength(1) + }) + + it('uses the same metadata hash for listing and complete paginated hydration', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json(doc)) + .mockResolvedValueOnce(Response.json({ items: [page] })) + .mockResolvedValueOnce(Response.json(doc)) + .mockResolvedValueOnce(Response.json(page)) + .mockResolvedValueOnce(Response.json({ items: [line('First')], nextPageToken: 'content-2' })) + .mockResolvedValueOnce(Response.json({ items: [line('Second')] })) + const listed = await codaConnector.listDocuments('token', config) + const hydrated = await codaConnector.getDocument( + 'token', + config, + listed.documents[0].externalId + ) + expect(hydrated).toMatchObject({ + contentHash: listed.documents[0].contentHash, + contentDeferred: false, + }) + expect(hydrated?.content).toContain('First\n\nSecond') + }) + + it('retains column identities when names duplicate and fetches every row page', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json(doc)) + .mockResolvedValueOnce(Response.json(table)) + .mockResolvedValueOnce( + Response.json({ + items: [ + { id: 'col-1', name: 'Status' }, + { id: 'col-2', name: 'Status' }, + ], + }) + ) + .mockResolvedValueOnce( + Response.json({ + items: [{ name: 'One', values: { 'col-1': false, 'col-2': ['A', 'B'] } }], + nextPageToken: 'rows-2', + }) + ) + .mockResolvedValueOnce(Response.json({ items: [{ name: 'Two', values: { 'col-1': 0 } }] })) + const result = await codaConnector.getDocument('token', config, 'doc-1/tables/grid-1') + expect(result?.content).toContain('Status: false\nStatus: A, B') + expect(result?.content).toContain('Two\nStatus: 0') + }) + + it('fails incomplete hydration without swallowing authorization errors', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json(doc)) + .mockResolvedValueOnce(Response.json(page)) + .mockResolvedValueOnce(Response.json({}, { status: 403 })) + await expect( + codaConnector.getDocument('token', config, 'doc-1/pages/canvas-1') + ).rejects.toMatchObject({ status: 403 }) + }) + + it.each([404, 410])('returns null for an unavailable resource (%s)', async (status) => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(Response.json({}, { status })) + await expect( + codaConnector.getDocument('token', config, 'doc-1/pages/canvas-1') + ).resolves.toBeNull() + }) + + it('rejects malformed provider lists rather than reconciling them as empty', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(Response.json({})) + await expect(codaConnector.listDocuments('token', {})).rejects.toThrow('invalid response') + }) + + it('rejects repeated content cursors without indexing a partial document', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json(doc)) + .mockResolvedValueOnce(Response.json(page)) + .mockResolvedValueOnce(Response.json({ items: [line('One')], nextPageToken: 'same' })) + .mockResolvedValueOnce(Response.json({ items: [line('Two')], nextPageToken: 'same' })) + await expect( + codaConnector.getDocument('token', config, 'doc-1/pages/canvas-1') + ).rejects.toThrow('repeated') + }) + + it('bounds provider response bytes even without a content-length header', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(' '.repeat(4 * 1024 * 1024 + 1)) + ) + await expect(codaConnector.listDocuments('token', {})).rejects.toThrow('4MB') + }) + + it('rejects unsafe identifiers and out-of-scope hydration before making requests', async () => { + const fetch = vi.spyOn(globalThis, 'fetch') + expect(await codaConnector.validateConfig('token', { docIds: '../whoami' })).toMatchObject({ + valid: false, + }) + await expect(codaConnector.getDocument('token', config, '../pages/canvas-1')).rejects.toThrow() + await expect( + codaConnector.getDocument('token', config, 'other/pages/canvas-1') + ).resolves.toBeNull() + expect(fetch).not.toHaveBeenCalled() + }) + + it('does not follow provider links or redirects with the token', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + Response.json({ items: [doc], nextPageLink: 'https://evil.example/steal' }) + ) + .mockResolvedValueOnce(Response.json({ items: [page] })) + await codaConnector.listDocuments('token', {}) + expect( + fetch.mock.calls.every( + ([url, options]) => + String(url).startsWith('https://coda.io/apis/v1/') && options?.redirect === 'error' + ) + ).toBe(true) + }) +}) diff --git a/apps/sim/connectors/coda/coda.ts b/apps/sim/connectors/coda/coda.ts new file mode 100644 index 00000000000..13922b47a9b --- /dev/null +++ b/apps/sim/connectors/coda/coda.ts @@ -0,0 +1,437 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { z } from 'zod' +import { + type CodaDoc, + type CodaResource, + codaAdminDocSchema, + codaDocSchema, + codaEmailSchema, + codaIdSchema, + codaJson, + codaListSchema, + codaPageSchema, + codaPages, + codaPageTokenSchema, + codaResourceSchema, + codaTableSchema, +} from '@/connectors/coda/client' +import { codaOrganizationPath, codaSourceConfig } from '@/connectors/coda/config' +import { codaConnectorMeta } from '@/connectors/coda/meta' +import { + openCodaDirectory, + resolveCodaAcls, + validateCodaAdminAccess, + validateCodaDocPermissions, +} from '@/connectors/coda/permissions' +import { codaDocumentPath, readCodaDoc } from '@/connectors/coda/source' +import { ConnectorSourceError } from '@/connectors/source-error' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { CONNECTOR_TEXT_DOCUMENT_MAX_BYTES, parseTagDate } from '@/connectors/utils' + +const DOC_PAGE_SIZE = 10 +const MAX_CURSOR_LENGTH = 512 * 1024 +const cursorSchema = z.object({ + version: z.literal(1), + docIndex: z.number().int().min(0).max(100), + docToken: codaPageTokenSchema.optional(), + nextDocToken: codaPageTokenSchema.optional(), + doc: codaDocSchema.optional(), + pendingDocs: z + .array(codaDocSchema) + .max(DOC_PAGE_SIZE - 1) + .optional(), + phase: z.enum(['pages', 'tables']), + pageToken: codaPageTokenSchema.optional(), + organizationId: codaIdSchema.optional(), +}) +type CodaCursor = z.infer +type ResourceKind = 'pages' | 'tables' +const listedDocSchema = z.object({ + doc: codaDocSchema, + organizationId: codaIdSchema.optional(), +}) + +function encodeCursor(cursor: CodaCursor): string { + const encoded = Buffer.from(JSON.stringify(cursor)).toString('base64url') + if (encoded.length > MAX_CURSOR_LENGTH) throw new Error('Coda cursor exceeded its size limit') + return encoded +} + +function decodeCursor(value?: string): CodaCursor { + if (!value) return { version: 1, docIndex: 0, phase: 'pages' } + if (value.length > MAX_CURSOR_LENGTH) throw new Error('Invalid Coda cursor') + return cursorSchema.parse(JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))) +} + +/** The shared generation ID refreshes exports whose optional Admin revision is absent. */ +function listingToken(context?: Record): string { + if (typeof context?.syncRunId === 'string') return context.syncRunId + if (typeof context?._codaListingToken === 'string') return context._codaListingToken + const token = generateId() + if (context) context._codaListingToken = token + return token +} + +/** Doc revisions also invalidate tables when row edits do not update table metadata. */ +function toStub( + doc: CodaDoc, + resource: CodaResource, + kind: ResourceKind, + context?: Record +): ExternalDocument { + return { + externalId: `${doc.id}/${kind}/${resource.id}`, + title: `${doc.name} / ${resource.name}`, + content: '', + contentDeferred: true, + estimatedBytes: CONNECTOR_TEXT_DOCUMENT_MAX_BYTES, + mimeType: 'text/plain', + sourceUrl: resource.browserLink, + contentHash: `coda:${JSON.stringify([doc.id, kind, resource.id, doc.updatedAt ?? `run:${listingToken(context)}`, doc.name, resource.name])}`, + metadata: { + docId: doc.id, + document: doc.name, + resourceType: kind, + lastModified: doc.updatedAt, + }, + } +} + +function parseExternalId(externalId: string): { + docId: string + kind: ResourceKind + resourceId: string +} { + const [docId, kind, resourceId, extra] = externalId.split('/') + if (extra !== undefined || (kind !== 'pages' && kind !== 'tables')) { + throw new Error('Invalid Coda document ID') + } + return { docId: codaIdSchema.parse(docId), kind, resourceId: codaIdSchema.parse(resourceId) } +} + +function nextDocument(state: CodaCursor, docIds: string[]): CodaCursor | undefined { + if (docIds.length > 0) { + return state.docIndex + 1 < docIds.length + ? { version: 1, docIndex: state.docIndex + 1, phase: 'pages' } + : undefined + } + if (state.pendingDocs?.length) { + return { + version: 1, + docIndex: 0, + phase: 'pages', + doc: state.pendingDocs[0], + pendingDocs: state.pendingDocs.slice(1), + nextDocToken: state.nextDocToken, + } + } + return state.nextDocToken + ? { version: 1, docIndex: 0, phase: 'pages', docToken: state.nextDocToken } + : undefined +} + +async function listDocuments( + token: string, + sourceConfig: Record, + cursor?: string, + context?: Record +): Promise { + const { docIds, organizationId } = codaSourceConfig(sourceConfig, context) + const admin = Boolean(organizationId) + const root = organizationId ? codaOrganizationPath(organizationId, 'docs') : '/docs' + const state = decodeCursor(cursor) + if (cursor && state.organizationId !== organizationId) + throw new Error('Coda cursor belongs to a different source') + state.organizationId = organizationId + if (admin && state.phase === 'tables') throw new Error('Invalid Coda organization cursor') + if (state.doc && docIds.length && state.doc.id !== docIds[state.docIndex]) { + throw new Error('Coda cursor belongs to a different document scope') + } + if (!state.doc) { + if (docIds.length) { + const docId = docIds[state.docIndex] + if (!docId) throw new Error('Invalid Coda document cursor') + try { + state.doc = await readCodaDoc(token, docId, organizationId) + } catch (error) { + if (!(error instanceof ConnectorSourceError && [404, 410].includes(error.status))) + throw error + const next = nextDocument(state, docIds) + return { + documents: [], + hasMore: Boolean(next), + nextCursor: next && encodeCursor({ ...next, organizationId }), + reconciliationSafe: true, + } + } + } else { + const listed = await codaJson( + token, + root, + codaListSchema(admin ? codaAdminDocSchema : codaDocSchema), + { + limit: DOC_PAGE_SIZE, + pageToken: state.docToken, + ...(admin ? { docAvailabilityState: 'online', fetchPermissionsMode: 'none' } : {}), + }, + false, + admin + ) + if (listed.items.length > DOC_PAGE_SIZE) + throw new Error('Coda ignored the document page size') + if (listed.nextPageToken && listed.nextPageToken === state.docToken) { + throw new Error('Coda repeated a document pagination token') + } + state.doc = listed.items[0] + state.pendingDocs = listed.items.slice(1) + state.nextDocToken = listed.nextPageToken + if (!state.doc) { + const next = nextDocument(state, docIds) + return { + documents: [], + hasMore: Boolean(next), + nextCursor: next && encodeCursor({ ...next, organizationId }), + reconciliationSafe: false, + } + } + } + } + + const currentCursor = encodeCursor(state) + if (state.doc.isDeleted || state.doc.keyAccessRevoked) { + const next = nextDocument(state, docIds) + return { + documents: [], + hasMore: Boolean(next), + nextCursor: next && encodeCursor({ ...next, organizationId }), + reconciliationSafe: docIds.length > 0, + } + } + if (context) context._codaListedDoc = { doc: state.doc, organizationId } + const path = `${codaDocumentPath(state.doc, organizationId)}/${state.phase}` + const schema = admin + ? codaResourceSchema + : state.phase === 'pages' + ? codaPageSchema + : codaTableSchema + const listed = await codaJson( + token, + path, + codaListSchema(schema), + { + limit: 100, + pageToken: state.pageToken, + }, + false, + admin + ) + if (listed.nextPageToken && listed.nextPageToken === state.pageToken) { + throw new Error('Coda repeated a resource pagination token') + } + const doc = state.doc + const documents = listed.items + .filter( + (item) => + admin || + ('contentType' in item + ? item.contentType === 'canvas' && + 'isHidden' in item && + item.isHidden === false && + 'isEffectivelyHidden' in item && + item.isEffectivelyHidden === false + : 'tableType' in item && item.tableType === 'table') + ) + .map((item) => toStub(doc, item, state.phase, context)) + const next: CodaCursor | undefined = listed.nextPageToken + ? { ...state, pageToken: listed.nextPageToken } + : state.phase === 'pages' && !admin + ? { ...state, phase: 'tables', pageToken: undefined } + : nextDocument(state, docIds) + return { + documents, + currentCursor, + hasMore: Boolean(next), + nextCursor: next && encodeCursor({ ...next, organizationId }), + /** Coda sorts discovered docs by user activity and does not promise a snapshot. */ + reconciliationSafe: docIds.length > 0, + } +} + +const lineSchema = z.object({ + type: z.literal('line'), + itemContent: z.object({ format: z.literal('plainText'), content: z.string() }).optional(), +}) +const columnSchema = z.object({ id: codaIdSchema, name: z.string().max(4096) }) +const cellSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]) +const rowSchema = z.object({ + name: z.string(), + values: z.record(z.string(), z.union([cellSchema, z.array(cellSchema)])), +}) + +/** Throws instead of indexing a truncated page or table as though it were complete. */ +class CodaText { + private readonly parts: string[] = [] + private bytes = 0 + + append(text: string): void { + this.bytes += Buffer.byteLength(text, 'utf8') + 2 + if (this.bytes > CONNECTOR_TEXT_DOCUMENT_MAX_BYTES) { + throw new Error('Coda content exceeded the 12MB limit') + } + this.parts.push(text) + } + + toString(): string { + return this.parts.join('\n\n') + } +} + +async function getDocument( + token: string, + sourceConfig: Record, + externalId: string, + context?: Record +) { + const { docIds, organizationId } = codaSourceConfig(sourceConfig, context) + const { docId, kind, resourceId } = parseExternalId(externalId) + if (docIds.length > 0 && !docIds.includes(docId)) return null + try { + const cached = listedDocSchema.safeParse(context?._codaListedDoc) + const doc = + cached.success && + cached.data.organizationId === organizationId && + cached.data.doc.id === docId + ? cached.data.doc + : await readCodaDoc(token, docId, organizationId) + const path = `${codaDocumentPath(doc, organizationId)}/${kind}/${encodeURIComponent(resourceId)}` + if (doc.isDeleted || doc.keyAccessRevoked) return null + if (organizationId) { + if (kind !== 'pages') return null + const page = await codaJson( + token, + path, + codaResourceSchema.extend({ + pageContent: z.object({ content: z.string() }), + }), + { outputFormat: 'LossyPlainText' }, + false, + true + ) + if (page.id !== resourceId) throw new Error('Coda returned a different page') + const text = new CodaText() + text.append(`${doc.name}\n${page.name}`) + text.append(page.pageContent.content) + return { + ...toStub(doc, page, kind, context), + content: text.toString(), + contentDeferred: false, + } + } + const resource = await codaJson( + token, + path, + kind === 'pages' ? codaPageSchema : codaTableSchema + ) + if (resource.id !== resourceId) throw new Error('Coda returned a different resource') + if ('contentType' in resource) { + if (resource.contentType !== 'canvas' || resource.isHidden || resource.isEffectivelyHidden) { + return null + } + } else if (resource.tableType !== 'table') return null + const text = new CodaText() + text.append(`${doc.name}\n${resource.name}`) + if (kind === 'pages') { + for await (const items of codaPages(token, `${path}/content`, lineSchema, { + contentFormat: 'plainText', + })) { + for (const item of items) if (item.itemContent) text.append(item.itemContent.content) + } + } else { + const columns = new Map() + for await (const items of codaPages(token, `${path}/columns`, columnSchema)) { + for (const column of items) { + if (columns.size >= 1000) throw new Error('Coda table exceeded the 1000-column limit') + columns.set(column.id, column.name) + } + } + for await (const items of codaPages(token, `${path}/rows`, rowSchema, { + valueFormat: 'simple', + })) { + for (const row of items) { + text.append( + [ + row.name, + ...Object.entries(row.values).map(([id, value]) => { + const rendered = Array.isArray(value) ? value.join(', ') : String(value ?? '') + return `${columns.get(id) ?? id}: ${rendered}` + }), + ].join('\n') + ) + } + } + } + return { + ...toStub(doc, resource, kind, context), + content: text.toString(), + contentDeferred: false, + } + } catch (error) { + if (error instanceof ConnectorSourceError && (error.status === 404 || error.status === 410)) { + return null + } + throw error + } +} + +export const codaConnector: ConnectorConfig = { + ...codaConnectorMeta, + contentConcurrency: 2, + listDocuments, + getDocument, + getDocumentAcls: resolveCodaAcls, + openDirectory: async (token, config, context) => { + const { organizationId } = codaSourceConfig(config, context) + return organizationId ? openCodaDirectory(token, organizationId) : null + }, + validateConfig: async (token, sourceConfig, context) => { + try { + const { docIds, organizationId } = codaSourceConfig(sourceConfig, context) + await codaJson(token, '/whoami', z.object({ loginId: codaEmailSchema }), undefined, true) + if (organizationId) await validateCodaAdminAccess(token, organizationId) + const root = organizationId ? codaOrganizationPath(organizationId, 'docs') : '/docs' + let probeDoc: CodaDoc | undefined + const firstDocId = docIds[0] + if (firstDocId) { + probeDoc = await readCodaDoc(token, firstDocId, organizationId, true) + } else { + const docs = await codaJson( + token, + root, + codaListSchema(organizationId ? codaAdminDocSchema : codaDocSchema), + { + limit: 1, + ...(organizationId + ? { fetchPermissionsMode: 'none', docAvailabilityState: 'online' } + : {}), + }, + true, + Boolean(organizationId) + ) + probeDoc = docs.items[0] + } + if (context?.mirrorsSourceAcls === true && probeDoc) + await validateCodaDocPermissions(token, probeDoc, organizationId) + return { valid: true } + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Unable to connect to Coda') } + } + }, + isCredentialInvalidError: (error) => + error instanceof ConnectorSourceError && error.status === 401, + mapTags: (metadata) => ({ + document: metadata.document, + resourceType: metadata.resourceType, + lastModified: parseTagDate(metadata.lastModified), + }), +} diff --git a/apps/sim/connectors/coda/config.ts b/apps/sim/connectors/coda/config.ts new file mode 100644 index 00000000000..ed3fef080b7 --- /dev/null +++ b/apps/sim/connectors/coda/config.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' +import { codaIdSchema } from '@/connectors/coda/client' +import { parseMultiValue } from '@/connectors/utils' +import { codaPath } from '@/tools/coda/utils' + +const configSchema = z.object({ + docIds: z.union([z.string(), z.array(z.string())]).optional(), + organizationId: z.string().optional(), +}) + +export function codaSourceConfig( + config: Record, + context?: Record +) { + const parsed = configSchema.parse(config) + const docIds = z.array(codaIdSchema).max(100).parse(parseMultiValue(parsed.docIds)) + const organizationId = + context?.mirrorsSourceAcls === true && parsed.organizationId?.trim() + ? codaIdSchema.parse(parsed.organizationId.trim()) + : undefined + return { docIds, organizationId } +} + +export function codaOrganizationPath( + organizationId: string, + ...segments: Array +) { + return codaPath('organizations', [organizationId, 'organizationId'], ...segments) +} diff --git a/apps/sim/connectors/coda/index.ts b/apps/sim/connectors/coda/index.ts new file mode 100644 index 00000000000..eefd5354bf2 --- /dev/null +++ b/apps/sim/connectors/coda/index.ts @@ -0,0 +1 @@ +export { codaConnector } from '@/connectors/coda/coda' diff --git a/apps/sim/connectors/coda/meta.ts b/apps/sim/connectors/coda/meta.ts new file mode 100644 index 00000000000..4c0ae001dd0 --- /dev/null +++ b/apps/sim/connectors/coda/meta.ts @@ -0,0 +1,68 @@ +import { CodaIcon } from '@/components/icons' +import type { ConnectorMeta } from '@/connectors/types' + +export const codaConnectorMeta: ConnectorMeta = { + id: 'coda', + name: 'Coda', + description: 'Sync Coda pages and tables', + version: '1.0.0', + icon: CodaIcon, + search: true, + searchDocsUrl: 'https://docs.sim.ai/search/coda', + supportedAccessModes: ['admin', 'workspace'], + auth: { + mode: 'oauth', + provider: 'coda', + requiredScopes: [], + adminCredentialType: 'service_account', + }, + mirrorsSourceAcls: true, + adminSetupHint: + 'Use a Coda API token that can read the documents and their sharing permissions. Direct shares match verified Sim email addresses. An Enterprise org-admin token and organization ID are needed for organization-wide discovery and group, workspace, or domain grants. Link-only shares do not grant search access.', + rehydrateOnFullSync: true, + configFields: [ + { + id: 'docSelector', + title: 'Documents', + type: 'selector', + selectorKey: 'coda.docs', + canonicalParamId: 'docIds', + mode: 'basic', + multi: true, + required: false, + preserveValueOnModeChange: true, + placeholder: 'All accessible, previously opened documents', + descriptionInAdminMode: + 'The picker shows documents the token owner can access and has opened. Switch to document IDs for other Enterprise organization documents.', + }, + { + id: 'docIds', + title: 'Document IDs', + type: 'short-input', + canonicalParamId: 'docIds', + mode: 'advanced', + multi: true, + required: false, + preserveValueOnModeChange: true, + placeholder: 'Leave empty to discover documents', + description: 'Explicit IDs also include accessible documents the token owner has not opened.', + descriptionInAdminMode: + 'Leave empty for discovery, or enter document IDs. Without an organization ID, discovery only includes documents the token owner has opened.', + }, + { + id: 'organizationId', + title: 'Enterprise organization ID', + type: 'short-input', + showInAdminModeOnly: true, + required: false, + placeholder: 'org-… (optional)', + description: + 'Requires a Coda Enterprise organization administrator. Enables organization-wide page indexing and directory permissions. Leave empty for token-accessible pages and tables with direct email shares.', + }, + ], + tagDefinitions: [ + { id: 'document', displayName: 'Document', fieldType: 'text' }, + { id: 'resourceType', displayName: 'Resource Type', fieldType: 'text' }, + { id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' }, + ], +} diff --git a/apps/sim/connectors/coda/permissions.test.ts b/apps/sim/connectors/coda/permissions.test.ts new file mode 100644 index 00000000000..8a99b84d544 --- /dev/null +++ b/apps/sim/connectors/coda/permissions.test.ts @@ -0,0 +1,287 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { codaConnector } from '@/connectors/coda/coda' +import { + codaPermissionTokens, + openCodaDirectory, + resolveCodaAcls, +} from '@/connectors/coda/permissions' +import type { ExternalDocument } from '@/connectors/types' + +const doc = { + id: 'doc-1', + name: 'Doc', + browserLink: 'https://coda.io/d/_ddoc-1', + updatedAt: '2026-09-16T00:00:00Z', + owner: 'owner@example.com', + workspaceId: 'ws-1', +} +const stub: ExternalDocument = { + externalId: 'doc-1/pages/canvas-1', + title: 'Page', + content: '', + contentHash: 'same', + mimeType: 'text/plain', +} +let admin: Record = { mirrorsSourceAcls: true } + +describe('Coda source permissions', () => { + beforeEach(() => { + vi.restoreAllMocks() + admin = { mirrorsSourceAcls: true } + }) + + it('keeps case-sensitive Coda group IDs distinct after shared group normalization', () => { + const tokens = codaPermissionTokens( + [ + { access: 'readonly', principal: { type: 'group', groupId: 'grp-X' } }, + { access: 'readonly', principal: { type: 'group', groupId: 'grp-x' } }, + ], + undefined, + 'org-1' + ) + expect(tokens).toEqual(['g:coda:org-1:group:6772702d58', 'g:coda:org-1:group:6772702d78']) + }) + + it('maps direct grants and ownership but never broadens link, domain, workspace, or unknown access', () => { + expect( + codaPermissionTokens( + [ + { access: 'readonly', principal: { type: 'email', email: 'Reader@Example.com' } }, + { access: 'comment', principal: { type: 'email', email: 'comment@example.com' } }, + { access: 'none', principal: { type: 'email', email: 'denied@example.com' } }, + { access: 'future-access', principal: { type: 'email', email: 'unknown@example.com' } }, + { access: 'write', principal: { type: 'anyone' } }, + { access: 'readonly', principal: { type: 'domain', domain: 'example.com' } }, + { access: 'readonly', principal: { type: 'workspace', workspaceId: 'ws-1' } }, + { access: 'readonly', principal: { type: 'group', groupId: 'grp-1' } }, + { access: 'readonly', principal: { type: 'internalAccess' } }, + ], + doc.owner + ) + ).toEqual(['link', 'u:comment@example.com', 'u:owner@example.com', 'u:reader@example.com']) + }) + + it('namespaces Enterprise grants and gates registered users through directory status', () => { + expect( + codaPermissionTokens( + [ + { access: 'write', principal: { type: 'email', email: 'Reader@Example.com' } }, + { access: 'readonly', principal: { type: 'domain', domain: 'Example.COM' } }, + { access: 'readonly', principal: { type: 'workspace', workspaceId: 'ws-1' } }, + { access: 'readonly', principal: { type: 'group', groupId: 'grp-1' } }, + ], + undefined, + 'org-1' + ) + ).toEqual([ + 'g:coda:org-1:domain:example.com', + 'g:coda:org-1:group:6772702d31', + 'g:coda:org-1:user:reader@example.com', + 'g:coda:org-1:workspace:77732d31', + ]) + }) + + it('refreshes sharing for unchanged pages and tables, including every ACL page', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json(doc)) + .mockResolvedValueOnce( + Response.json({ + items: [ + { access: 'readonly', principal: { type: 'email', email: 'reader@example.com' } }, + ], + nextPageToken: 'next', + }) + ) + .mockResolvedValueOnce(Response.json({ items: [] })) + .mockResolvedValueOnce(Response.json(doc)) + .mockResolvedValueOnce(Response.json({ items: [] })) + const documents = [stub, { ...stub, externalId: 'doc-1/tables/grid-1' }] + const before = await resolveCodaAcls('token', {}, documents, admin) + expect(before[stub.externalId]).toContain('u:reader@example.com') + expect(before[documents[1].externalId]).toEqual(before[stub.externalId]) + const after = await resolveCodaAcls('token', {}, documents, admin) + expect(after[stub.externalId]).toEqual(['u:owner@example.com']) + expect(fetch).toHaveBeenCalledTimes(5) + }) + + it('fails closed on an incomplete ACL instead of retaining the successfully read prefix', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json(doc)) + .mockResolvedValueOnce( + Response.json({ + items: [ + { access: 'readonly', principal: { type: 'email', email: 'reader@example.com' } }, + ], + nextPageToken: 'next', + }) + ) + .mockResolvedValueOnce(Response.json({}, { status: 403 })) + expect(await resolveCodaAcls('token', {}, [stub], admin)).toEqual({}) + }) + + it('ignores untrusted metadata doc IDs and refuses documents outside the selected scope', async () => { + const fetch = vi.spyOn(globalThis, 'fetch') + expect( + await resolveCodaAcls( + 'token', + { docIds: ['other'] }, + [{ ...stub, metadata: { docId: 'other' } }], + admin + ) + ).toEqual({}) + expect(fetch).not.toHaveBeenCalled() + }) + + it('opens the Enterprise directory lazily and excludes deactivated or deleted users', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + Response.json({ + items: [ + { email: 'active@example.com', status: 'Active' }, + { email: 'disabled@example.com', status: 'Deactivated' }, + { email: 'deleted@example.com', status: 'Deleted' }, + ], + }) + ) + .mockResolvedValueOnce(Response.json({ items: [{ id: 'grp-X' }] })) + .mockResolvedValueOnce(Response.json({ items: [{ id: 'ws-X' }] })) + .mockResolvedValueOnce( + Response.json({ + items: [{ email: 'active@example.com' }, { email: 'disabled@example.com' }], + nextPageToken: 'members-2', + }) + ) + .mockResolvedValueOnce(Response.json({ items: [{ email: 'guest@external.com' }] })) + const directory = openCodaDirectory('token', 'org-1') + expect(fetch).not.toHaveBeenCalled() + const groups = await directory.listGroups() + expect(groups).toContainEqual({ id: 'domain:example.com' }) + expect( + (await directory.listGroupMembers({ id: 'user:disabled@example.com' })).memberTokens + ).toEqual([]) + expect((await directory.listGroupMembers({ id: 'domain:example.com' })).memberTokens).toEqual([ + 'u:active@example.com', + ]) + expect((await directory.listGroupMembers({ id: 'group:6772702d58' })).memberTokens).toEqual([ + 'u:active@example.com', + 'u:guest@external.com', + ]) + expect(String(fetch.mock.calls[4][0])).toBe( + 'https://coda.io/apis/admin/v1/organizations/org-1/groups/grp-X/members?pageToken=members-2' + ) + }) + + it('throws on malformed or partial directories instead of replacing memberships', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + Response.json({ items: [{ email: 'bad' }] }) + ) + await expect(openCodaDirectory('token', 'org-1').listGroups()).rejects.toThrow( + 'invalid response' + ) + }) + + it('uses the Admin API only for mirrored access with an explicit organization', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json({ items: [doc] })) + .mockResolvedValueOnce( + Response.json({ + items: [{ id: 'canvas-1', name: 'Page', browserLink: 'https://coda.io/d/page' }], + }) + ) + + .mockResolvedValueOnce( + Response.json({ + id: 'canvas-1', + name: 'Page', + browserLink: 'https://coda.io/d/page', + pageContent: { content: 'Admin page body' }, + }) + ) + const config = { docIds: ['doc-1'], organizationId: 'org-1' } + const listed = await codaConnector.listDocuments('token', config, undefined, admin) + expect(listed.hasMore).toBe(false) + const full = await codaConnector.getDocument( + 'token', + config, + listed.documents[0].externalId, + admin + ) + expect(full?.content).toContain('Admin page body') + expect(full?.contentHash).toBe(listed.documents[0].contentHash) + expect(fetch.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://coda.io/apis/admin/v1/organizations/org-1/docs?docIds=doc-1&limit=1&fetchPermissionsMode=none', + 'https://coda.io/apis/admin/v1/organizations/org-1/workspaces/ws-1/docs/doc-1/pages?limit=100', + 'https://coda.io/apis/admin/v1/organizations/org-1/workspaces/ws-1/docs/doc-1/pages/canvas-1?outputFormat=LossyPlainText', + ]) + }) + + it('refreshes unversioned Enterprise pages once per durable sync generation', async () => { + const { updatedAt: _revision, ...unversionedDoc } = doc + const page = { id: 'canvas-1', name: 'Page', browserLink: 'https://coda.io/d/page' } + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = new URL(String(input)) + if (url.searchParams.has('docIds')) return Response.json({ items: [unversionedDoc] }) + if (url.pathname.endsWith('/pages')) return Response.json({ items: [page] }) + return Response.json({ ...page, pageContent: { content: 'Unversioned body' } }) + }) + const config = { docIds: ['doc-1'], organizationId: 'org-1' } + const context = { ...admin, syncRunId: 'first' } + const first = await codaConnector.listDocuments('token', config, undefined, context) + const hydrated = await codaConnector.getDocument( + 'token', + config, + first.documents[0].externalId, + context + ) + expect(hydrated?.contentHash).toBe(first.documents[0].contentHash) + const next = await codaConnector.listDocuments('token', config, undefined, { + ...admin, + syncRunId: 'next', + }) + expect(next.documents[0].contentHash).not.toBe(first.documents[0].contentHash) + }) + + it('uses current workspace-qualified permissions and rejects ambiguous metadata lookups', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json({ items: [doc] })) + .mockResolvedValueOnce( + Response.json({ + items: [{ access: 'readonly', principal: { type: 'workspace', workspaceId: 'ws-1' } }], + }) + ) + .mockResolvedValueOnce(Response.json({ items: [doc], nextPageToken: 'unexpected' })) + const config = { docIds: ['doc-1'], organizationId: 'org-1' } + expect((await resolveCodaAcls('token', config, [stub], admin))[stub.externalId]).toContain( + 'g:coda:org-1:workspace:77732d31' + ) + expect(String(fetch.mock.calls[1][0])).toBe( + 'https://coda.io/apis/admin/v1/organizations/org-1/workspaces/ws-1/docs/doc-1/acl/permissions?limit=100' + ) + expect(await resolveCodaAcls('token', config, [stub], admin)).toEqual({}) + }) + + it('rejects organization cursors after a scope change', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json({ items: [doc] })) + .mockResolvedValueOnce(Response.json({ items: [], nextPageToken: 'more' })) + const listed = await codaConnector.listDocuments( + 'token', + { docIds: ['doc-1'], organizationId: 'org-1' }, + undefined, + admin + ) + await expect( + codaConnector.listDocuments( + 'token', + { docIds: ['doc-1'], organizationId: 'org-2' }, + listed.nextCursor, + admin + ) + ).rejects.toThrow('different source') + }) +}) diff --git a/apps/sim/connectors/coda/permissions.ts b/apps/sim/connectors/coda/permissions.ts new file mode 100644 index 00000000000..9676c760a3c --- /dev/null +++ b/apps/sim/connectors/coda/permissions.ts @@ -0,0 +1,251 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { z } from 'zod' +import { groupToken, sortAccessTokens, userToken } from '@/lib/knowledge/access/tokens' +import { LINK_ACCESS_TOKEN } from '@/lib/knowledge/access/types' +import { + type CodaDoc, + codaEmailSchema, + codaIdSchema, + codaJson, + codaListSchema, + codaPages, +} from '@/connectors/coda/client' +import { codaOrganizationPath, codaSourceConfig } from '@/connectors/coda/config' +import { codaDocumentPath, readCodaDoc } from '@/connectors/coda/source' +import type { + ConnectorConfig, + ConnectorDirectory, + ConnectorDirectoryGroup, +} from '@/connectors/types' + +const logger = createLogger('CodaPermissions') +const MAX_DIRECTORY_ENTRIES = 100_000 +const permissionSchema = z.object({ + access: z.string().max(64), + principal: z.object({ + type: z.string().max(64), + email: codaEmailSchema.optional(), + groupId: codaIdSchema.optional(), + workspaceId: codaIdSchema.optional(), + domain: z.string().max(253).optional(), + }), +}) +const namedSchema = z.object({ id: codaIdSchema }) +const memberSchema = z.object({ email: codaEmailSchema }) +const orgUserSchema = memberSchema.extend({ status: z.enum(['Active', 'Deactivated', 'Deleted']) }) + +function directoryToken(organizationId: string, id: string): string { + const token = groupToken({ providerId: 'coda', tenantId: organizationId, groupId: id }) + if (!token) throw new Error('Coda returned an invalid permission principal') + return token +} + +/** + * Link possession and unsupported audiences never become workspace-wide grants. + * Opaque Coda IDs use hex so shared email-oriented group normalization cannot merge case-distinct IDs. + */ +export function codaPermissionTokens( + permissions: readonly z.infer[], + owner?: string, + organizationId?: string +): string[] { + const tokens = new Set() + const addPerson = (email?: string) => { + const token = userToken(email) + if (token) + tokens.add(organizationId ? directoryToken(organizationId, `user:${token.slice(2)}`) : token) + } + addPerson(owner) + for (const { access, principal } of permissions) { + if (!['readonly', 'comment', 'write'].includes(access)) continue + if (principal.type === 'email') addPerson(principal.email) + else if (principal.type === 'anyone') tokens.add(LINK_ACCESS_TOKEN) + else if (organizationId) { + const id = + principal.type === 'group' && principal.groupId + ? `group:${Buffer.from(principal.groupId).toString('hex')}` + : principal.type === 'workspace' && principal.workspaceId + ? `workspace:${Buffer.from(principal.workspaceId).toString('hex')}` + : principal.type === 'domain' && principal.domain + ? `domain:${principal.domain.toLowerCase()}` + : undefined + if (id) tokens.add(directoryToken(organizationId, id)) + } + } + return sortAccessTokens(tokens) +} + +/** Reads the entire ACL afresh even when the document body has not changed. */ +export async function readCodaDocAcl(token: string, docId: string, organizationId?: string) { + const doc = await readCodaDoc(token, docId, organizationId) + const path = codaDocumentPath(doc, organizationId) + if (doc.isDeleted || doc.keyAccessRevoked) return [] + const permissions: z.infer[] = [] + for await (const page of codaPages( + token, + `${path}/acl/permissions`, + permissionSchema, + undefined, + Boolean(organizationId) + )) { + if (permissions.length + page.length > 5000) + throw new Error('Coda document exceeded the permission limit') + permissions.push(...page) + } + return codaPermissionTokens(permissions, doc.owner, organizationId) +} + +export const resolveCodaAcls: NonNullable = async ( + token, + config, + documents, + context +) => { + const { docIds, organizationId } = codaSourceConfig(config, context) + const groups = new Map() + for (const document of documents) { + const docId = document.externalId.split('/')[0] + if (!codaIdSchema.safeParse(docId).success || (docIds.length && !docIds.includes(docId))) + continue + const entries = groups.get(docId) ?? [] + entries.push(document.externalId) + groups.set(docId, entries) + } + const result: Record = {} + for (const [docId, externalIds] of groups) { + try { + const acl = await readCodaDocAcl(token, docId, organizationId) + for (const externalId of externalIds) result[externalId] = acl + } catch (error) { + logger.warn('Coda document permissions could not be verified', { + docId, + error: getErrorMessage(error), + }) + } + } + return result +} + +/** Admin-only preflight keeps source setup from silently falling back to personal discovery. */ +export async function validateCodaAdminAccess(token: string, organizationId: string) { + const root = codaOrganizationPath(organizationId) + const organization = await codaJson(token, root, namedSchema, undefined, true, true) + if (organization.id !== organizationId) throw new Error('Coda returned a different organization') + for (const collection of ['groups', 'workspaces', 'users'] as const) { + await codaJson( + token, + `${root}/${collection}`, + codaListSchema(collection === 'users' ? orgUserSchema : namedSchema), + { limit: 1 }, + true, + true + ) + } +} + +/** Opens without reading memberships; snapshots are collected only under the directory lease. */ +export function openCodaDirectory(token: string, organizationId: string): ConnectorDirectory { + const root = codaOrganizationPath(organizationId) + let people: Map | undefined + const domains = new Map() + + async function readPeople() { + const result = new Map() + for await (const page of codaPages(token, `${root}/users`, orgUserSchema, undefined, true)) { + for (const person of page) { + if (result.size >= MAX_DIRECTORY_ENTRIES) + throw new Error('Coda directory exceeded its user limit') + const email = person.email.trim().toLowerCase() + /** A duplicate identity with conflicting status is never activated by response order. */ + result.set(email, (result.get(email) ?? true) && person.status === 'Active') + } + } + domains.clear() + for (const [email, active] of result) { + const domain = email.split('@')[1] + const members = domains.get(domain) ?? [] + if (active) members.push(email) + domains.set(domain, members) + } + people = result + return result + } + + return { + providerId: 'coda', + tenantId: organizationId, + listGroups: async () => { + const users = await readPeople() + const groups: ConnectorDirectoryGroup[] = [] + const append = (id: string) => { + if (groups.length >= MAX_DIRECTORY_ENTRIES) + throw new Error('Coda directory exceeded its group limit') + groups.push({ id }) + } + for (const email of users.keys()) append(`user:${email}`) + for (const domain of domains.keys()) append(`domain:${domain}`) + for (const [collection, prefix] of [ + ['groups', 'group'], + ['workspaces', 'workspace'], + ] as const) { + for await (const page of codaPages( + token, + `${root}/${collection}`, + namedSchema, + undefined, + true + )) { + for (const group of page) append(`${prefix}:${Buffer.from(group.id).toString('hex')}`) + } + } + return groups + }, + listGroupMembers: async (group) => { + const users = people ?? (await readPeople()) + const memberTokens = new Set() + const add = (email: string) => { + const normalized = email.trim().toLowerCase() + if (users.get(normalized) === false) return + const token = userToken(normalized) + if (!token) throw new Error('Coda returned an invalid directory member') + if (memberTokens.size >= MAX_DIRECTORY_ENTRIES) + throw new Error('Coda group exceeded its member limit') + memberTokens.add(token) + } + const separator = group.id.indexOf(':') + const kind = group.id.slice(0, separator) + const id = group.id.slice(separator + 1) + if (kind === 'user') { + if (users.get(id) === true) add(id) + } else if (kind === 'domain') { + for (const email of domains.get(id) ?? []) add(email) + } else if (kind === 'group' || kind === 'workspace') { + if (!/^(?:[0-9a-f]{2})+$/.test(id)) throw new Error('Invalid Coda directory ID') + const resourceId = codaIdSchema.parse(Buffer.from(id, 'hex').toString('utf8')) + const path = `${root}/${kind === 'group' ? 'groups' : 'workspaces'}/${encodeURIComponent(resourceId)}/${kind === 'group' ? 'members' : 'users'}` + for await (const page of codaPages(token, path, memberSchema, undefined, true)) { + for (const member of page) add(member.email) + } + } else throw new Error('Unrecognized Coda directory group') + return { group, memberTokens: sortAccessTokens(memberTokens), complete: true } + }, + } +} + +/** Setup probes endpoint access; synchronization separately drains the complete ACL. */ +export async function validateCodaDocPermissions( + token: string, + doc: CodaDoc, + organizationId?: string +) { + const path = codaDocumentPath(doc, organizationId) + await codaJson( + token, + `${path}/acl/permissions`, + codaListSchema(permissionSchema), + { limit: 1 }, + true, + Boolean(organizationId) + ) +} diff --git a/apps/sim/connectors/coda/source.ts b/apps/sim/connectors/coda/source.ts new file mode 100644 index 00000000000..ca1bcfaa179 --- /dev/null +++ b/apps/sim/connectors/coda/source.ts @@ -0,0 +1,50 @@ +import { + type CodaDoc, + codaAdminDocSchema, + codaDocSchema, + codaJson, + codaListSchema, +} from '@/connectors/coda/client' +import { codaOrganizationPath } from '@/connectors/coda/config' +import { ConnectorSourceError } from '@/connectors/source-error' +import { codaDocPath } from '@/tools/coda/utils' + +/** Uses current workspace-qualified Admin endpoints; organization-only doc endpoints are deprecated. */ +export function codaDocumentPath(doc: CodaDoc, organizationId?: string): string { + if (!organizationId) return codaDocPath(doc.id) + const { workspaceId } = codaAdminDocSchema.parse(doc) + return codaOrganizationPath(organizationId, 'workspaces', [workspaceId, 'workspaceId'], 'docs', [ + doc.id, + 'docId', + ]) +} + +/** Resolves the current workspace from the documented organization document filter. */ +export async function readCodaDoc( + token: string, + docId: string, + organizationId?: string, + validating = false +): Promise { + let doc: CodaDoc + if (organizationId) { + const result = await codaJson( + token, + codaOrganizationPath(organizationId, 'docs'), + codaListSchema(codaAdminDocSchema), + { docIds: docId, limit: 1, fetchPermissionsMode: 'none' }, + validating, + true + ) + if (result.nextPageToken || result.items.length > 1) + throw new Error('Coda returned an ambiguous document lookup') + const found = result.items[0] + if (!found) + throw new ConnectorSourceError('Coda document was not found in the organization', 404) + doc = found + } else { + doc = await codaJson(token, codaDocPath(docId), codaDocSchema, undefined, validating) + } + if (doc.id !== docId) throw new Error('Coda returned a different document') + return doc +} diff --git a/apps/sim/connectors/permission-scoped-listing.test.ts b/apps/sim/connectors/permission-scoped-listing.test.ts index 5d1b5764f66..32dbbc4d62e 100644 --- a/apps/sim/connectors/permission-scoped-listing.test.ts +++ b/apps/sim/connectors/permission-scoped-listing.test.ts @@ -41,6 +41,7 @@ describe('permission-scoped connector listings', () => { it('offers Search only for reviewed source and permission capabilities', () => { const search = Object.values(CONNECTOR_META_REGISTRY).filter((meta) => meta.search) expect(search.map((meta) => meta.id).sort()).toEqual([ + 'coda', 'confluence', 'github', 'gitlab', diff --git a/apps/sim/connectors/registry.server.ts b/apps/sim/connectors/registry.server.ts index c351ad50d5d..a07a0ffef53 100644 --- a/apps/sim/connectors/registry.server.ts +++ b/apps/sim/connectors/registry.server.ts @@ -6,6 +6,7 @@ import { bitbucketConnector } from '@/connectors/bitbucket' import { boxConnector } from '@/connectors/box' import { circlebackConnector } from '@/connectors/circleback' import { clickupConnector } from '@/connectors/clickup' +import { codaConnector } from '@/connectors/coda' import { confluenceConnector } from '@/connectors/confluence' import { databricksConnector } from '@/connectors/databricks' import { discordConnector } from '@/connectors/discord' @@ -81,6 +82,7 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { box: boxConnector, circleback: circlebackConnector, clickup: clickupConnector, + coda: codaConnector, confluence: confluenceConnector, databricks: databricksConnector, discord: discordConnector, diff --git a/apps/sim/connectors/registry.ts b/apps/sim/connectors/registry.ts index 705995174e3..8e5a5d452f3 100644 --- a/apps/sim/connectors/registry.ts +++ b/apps/sim/connectors/registry.ts @@ -6,6 +6,7 @@ import { bitbucketConnectorMeta } from '@/connectors/bitbucket/meta' import { boxConnectorMeta } from '@/connectors/box/meta' import { circlebackConnectorMeta } from '@/connectors/circleback/meta' import { clickupConnectorMeta } from '@/connectors/clickup/meta' +import { codaConnectorMeta } from '@/connectors/coda/meta' import { confluenceConnectorMeta } from '@/connectors/confluence/meta' import { databricksConnectorMeta } from '@/connectors/databricks/meta' import { discordConnectorMeta } from '@/connectors/discord/meta' @@ -81,6 +82,7 @@ export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { box: boxConnectorMeta, circleback: circlebackConnectorMeta, clickup: clickupConnectorMeta, + coda: codaConnectorMeta, confluence: confluenceConnectorMeta, databricks: databricksConnectorMeta, discord: discordConnectorMeta, diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index 7a1c35fc2a2..a9c8fc6b419 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -418,6 +418,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'platform/workspaces.mdx', 'quick-reference.mdx', 'search.mdx', + 'search/coda.mdx', 'search/confluence.mdx', 'search/connect-your-account.mdx', 'search/github.mdx', diff --git a/apps/sim/lib/integrations/credential-display.test.ts b/apps/sim/lib/integrations/credential-display.test.ts index 69dbcfff521..60c7ae3e5b8 100644 --- a/apps/sim/lib/integrations/credential-display.test.ts +++ b/apps/sim/lib/integrations/credential-display.test.ts @@ -49,7 +49,7 @@ const EXPECTED_COVERAGE: Record = { 'calcom-service-account': ['cal-com'], 'claude-platform-service-account': [], 'clickup-service-account': ['clickup'], - 'coda-service-account': [], + 'coda-service-account': ['coda'], 'github-app-installation': ['github'], 'google-service-account': [ 'gmail', diff --git a/apps/sim/lib/integrations/credential-visibility.server.test.ts b/apps/sim/lib/integrations/credential-visibility.server.test.ts index 30f9e271bed..7dee212ccc5 100644 --- a/apps/sim/lib/integrations/credential-visibility.server.test.ts +++ b/apps/sim/lib/integrations/credential-visibility.server.test.ts @@ -17,6 +17,7 @@ vi.mock('@/lib/integrations/availability.server', () => ({ isOAuthServiceDeploymentAvailable: vi.fn(() => true), })) +import { resolveIntegrationAvailability } from '@/lib/integrations/availability' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' const SERVICES: readonly OAuthServiceMetadata[] = [ @@ -71,6 +72,41 @@ describe('integration credential visibility', () => { ]) }) + it('exposes Coda token credentials without OAuth while honoring integration policy and visibility', () => { + const catalog = resolveIntegrationAvailability({}) + expect(catalog.find((entry) => entry.type === 'coda')).toMatchObject({ + state: 'ready', + oauthAvailable: false, + serviceAccountAvailable: true, + }) + getIntegrationAvailabilityMock.mockReturnValue(catalog) + const service: OAuthServiceMetadata = { + serviceId: 'coda', + providerId: 'coda', + serviceAccountProviderId: 'coda-service-account', + authType: 'service_account', + name: 'Coda', + description: 'Coda token', + baseProvider: 'coda', + } + const identity = { providerId: 'coda-service-account', type: 'service_account' } as const + const visibility = (allowed: ReadonlySet | null, disabled: boolean) => + createIntegrationCredentialVisibility({ + allowedIntegrationTypes: allowed, + oauthServices: [service], + blockVisibility: { + revealed: new Set(), + previewTagged: new Set(), + disabled: new Set(disabled ? ['coda'] : []), + }, + }) + expect(visibility(new Set(['coda']), false).isCredentialVisible(identity)).toBe(true) + expect(visibility(new Set(['slack_v2']), false).isCredentialVisible(identity)).toBe(false) + expect(visibility(null, true).isCredentialVisible(identity)).toBe(false) + getBlockMock.mockReturnValue({ type: 'coda', preview: true }) + expect(visibility(null, false).isCredentialVisible(identity)).toBe(false) + }) + it('applies the integration allowlist to OAuth and service-account credentials', () => { const visibility = createIntegrationCredentialVisibility({ allowedIntegrationTypes: new Set(['slack_v2']), diff --git a/apps/sim/lib/knowledge/__integration__/coda-live.integration.ts b/apps/sim/lib/knowledge/__integration__/coda-live.integration.ts new file mode 100644 index 00000000000..a83f45d0c3b --- /dev/null +++ b/apps/sim/lib/knowledge/__integration__/coda-live.integration.ts @@ -0,0 +1,385 @@ +/** + * Opt-in test of real Coda, credential resolution, admin setup, storage, indexing, + * and application authorization. Only embeddings are substituted. Run with + * CODA_CONNECTOR_LIVE_TOKEN_FILE and CODA_CONNECTOR_LIVE_FIXTURE_FILE produced by + * connectors/coda/coda.live.test.ts, plus CODA_CONNECTOR_LIVE_SECOND_EMAIL. + * Only the disposable fixture's sharing is changed; notifications are suppressed. + */ +import { readFileSync, writeFileSync } from 'node:fs' +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { + credential, + document, + knowledgeBase, + knowledgeConnector, + member, + organization, + session, + user, + workspace, +} from '@sim/db/schema' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { serializeSignedCookie } from 'better-call' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' + +const metrics = vi.hoisted(() => ({ embeddingCalls: 0 })) +vi.mock('@/lib/embeddings', async () => ({ + ...(await import('@/lib/embeddings/client')), + assertKnowledgeEmbeddingCapacity: async () => {}, + embedKnowledge: async (texts: string[]) => { + metrics.embeddingCalls++ + return { + embeddings: texts.map(() => [1, ...Array(1535).fill(0)]), + totalTokens: texts.length, + billableTokens: 0, + isBYOK: true, + modelName: 'text-embedding-3-small', + pricingId: 'text-embedding-3-small', + } + }, +})) + +import { + resolveBillingAttribution, + resolveOrganizationBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { encryptSecret } from '@/lib/core/security/encryption' +import { createOrganizationCredential } from '@/lib/credentials/application/organization-credentials' +import { seedKnowledgeAclFixture } from '@/lib/knowledge/__integration__/seed-source-access-fixture' +import { listKnowledgeChunks } from '@/lib/knowledge/application/chunks' +import { createKnowledgeConnector } from '@/lib/knowledge/application/connectors' +import { readKnowledgeDocument } from '@/lib/knowledge/application/documents' +import { searchKnowledge } from '@/lib/knowledge/application/search' +import { executeSync } from '@/lib/knowledge/connectors/sync-engine' +import { deleteFile } from '@/lib/uploads/core/storage-service' +import { codaJson } from '@/connectors/coda/client' +import { readCodaDocAcl } from '@/connectors/coda/permissions' +import { buildCodaUrl, codaDocPath, codaHeaders } from '@/tools/coda/utils' + +const tokenPath = process.env.CODA_CONNECTOR_LIVE_TOKEN_FILE +const fixturePath = process.env.CODA_CONNECTOR_LIVE_FIXTURE_FILE +const secondEmail = process.env.CODA_CONNECTOR_LIVE_SECOND_EMAIL +const allowSharing = process.env.CODA_CONNECTOR_LIVE_ALLOW_SHARING !== 'false' +const organizationScope = process.env.CODA_CONNECTOR_LIVE_SCOPE === 'organization' +const uiFixturePath = process.env.CODA_CONNECTOR_LIVE_UI_FIXTURE_FILE +const fixtureSchema = z.object({ + docId: z.string(), + pageId: z.string(), + marker: z.string().startsWith('SimConnector-'), +}) +const permissionListSchema = z.object({ + items: z.array( + z.object({ id: z.string(), principal: z.object({ email: z.string().optional() }) }) + ), + nextPageToken: z.string().optional(), +}) + +describe + .skipIf(!tokenPath || !fixturePath || !secondEmail) + .sequential('live Coda ingestion and source access', () => { + let ids: Awaited> + let connectorId: string + let documentId: string + let token: string + let fixture: z.infer + let fixtureValidated = false + let credentialId = generateId() + const principal = (userId: string): Principal => ({ + kind: 'session', + userId, + sessionId: 'coda-live', + }) + + async function mutation(path: string, method: string, body?: unknown) { + const response = await fetch(buildCodaUrl(path), { + method, + redirect: 'error', + headers: codaHeaders(token, body !== undefined), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) + await response.body?.cancel() + if (!response.ok) throw new Error(`Coda fixture mutation failed (${response.status})`) + } + + async function revokeShare() { + const path = codaDocPath(fixture.docId, 'acl', 'permissions') + const permissions = await codaJson(token, path, permissionListSchema) + if (permissions.nextPageToken) throw new Error('Disposable fixture has unexpected sharing') + for (const item of permissions.items) { + if (item.principal.email?.toLowerCase() === secondEmail!.toLowerCase()) { + await mutation(`${path}/${encodeURIComponent(item.id)}`, 'DELETE') + } + } + } + + async function waitForAcl(shared: boolean) { + for (let attempt = 0; attempt < 30; attempt++) { + const acl = await readCodaDocAcl(token, fixture.docId) + if (acl.includes(`u:${secondEmail!.toLowerCase()}`) === shared) return + await sleep(1000) + } + throw new Error('Coda sharing did not converge') + } + + async function search(as: Principal) { + const result = await searchKnowledge.execute({ + principal: as, + input: { + ...(organizationScope + ? { organizationId: ids.organizationId } + : { workspaceId: ids.workspaceId }), + knowledgeBaseIds: [ids.knowledgeBaseId], + query: fixture.marker, + searchMode: 'hybrid', + topK: 10, + }, + }) + return result.results.map((row) => row.documentId) + } + + async function sync() { + const result = await executeSync(connectorId, { + fullSync: false, + billingAttribution: organizationScope + ? await resolveOrganizationBillingAttribution({ + actorUserId: ids.aliceId, + organizationId: ids.organizationId, + }) + : await resolveBillingAttribution({ + actorUserId: ids.aliceId, + workspaceId: ids.workspaceId, + }), + }) + expect(result.error).toBeUndefined() + expect(result.skipReason).toBeUndefined() + expect(result.docsFailed).toBe(0) + return result + } + + beforeAll(async () => { + token = readFileSync(tokenPath!, 'utf8').trim() + fixture = fixtureSchema.parse(JSON.parse(readFileSync(fixturePath!, 'utf8'))) + const source = await codaJson( + token, + codaDocPath(fixture.docId), + z.object({ name: z.string(), owner: z.string().email() }) + ) + if ( + source.name !== `Sim Coda connector verification ${fixture.marker}` || + source.owner === secondEmail + ) { + throw new Error('Refusing to change sharing on a non-fixture document') + } + fixtureValidated = true + await revokeShare() + await waitForAcl(false) + ids = await seedKnowledgeAclFixture() + await db.update(user).set({ email: source.owner }).where(eq(user.id, ids.aliceId)) + await db.update(user).set({ email: secondEmail! }).where(eq(user.id, ids.bobId)) + if (organizationScope) { + await db.insert(member).values([ + { + id: generateId(), + organizationId: ids.organizationId, + userId: ids.aliceId, + role: 'owner', + createdAt: new Date(), + }, + { + id: generateId(), + organizationId: ids.organizationId, + userId: ids.bobId, + role: 'member', + createdAt: new Date(), + }, + ]) + await db + .update(knowledgeBase) + .set({ workspaceId: null, organizationId: ids.organizationId, isSearchIndex: true }) + .where(eq(knowledgeBase.id, ids.knowledgeBaseId)) + const createdCredential = await createOrganizationCredential.execute({ + principal: principal(ids.aliceId), + input: { + organizationId: ids.organizationId, + type: 'service_account', + providerId: 'coda-service-account', + displayName: 'Disposable Coda live fixture', + apiToken: token, + }, + }) + credentialId = createdCredential.credential.id + } else + await db.insert(credential).values({ + id: credentialId, + workspaceId: ids.workspaceId, + createdBy: ids.aliceId, + type: 'service_account', + providerId: 'coda-service-account', + displayName: 'Disposable Coda live fixture', + encryptedServiceAccountKey: ( + await encryptSecret( + JSON.stringify({ + type: 'token_service_account', + providerId: 'coda-service-account', + apiToken: token, + }) + ) + ).encrypted, + }) + const created = await createKnowledgeConnector.execute({ + principal: principal(ids.aliceId), + input: { + knowledgeBaseId: ids.knowledgeBaseId, + ...(organizationScope + ? { assertedOrganizationId: ids.organizationId } + : { assertedWorkspaceId: ids.workspaceId }), + connectorType: 'coda', + credentialId, + accessMode: 'admin', + sourceConfig: { docIds: [fixture.docId] }, + syncIntervalMinutes: 60, + }, + }) + connectorId = created.connector.id + for (let attempt = 0; attempt < 120; attempt++) { + const [source] = await db + .select() + .from(knowledgeConnector) + .where(eq(knowledgeConnector.id, connectorId)) + if (source.status === 'error') + throw new Error(source.lastSyncError ?? 'Initial Coda sync failed') + if (source.lastSyncAt && source.status === 'active') break + await sleep(1000) + } + const stored = await db.select().from(document).where(eq(document.connectorId, connectorId)) + expect(stored.length).toBeGreaterThanOrEqual(2) + expect(stored.every((row) => row.processingStatus === 'completed')).toBe(true) + documentId = stored.find( + (row) => row.externalId === `${fixture.docId}/pages/${fixture.pageId}` + )!.id + }, 180_000) + + afterAll(async () => { + try { + if (fixtureValidated && allowSharing) { + await revokeShare() + await waitForAcl(false) + } + } finally { + if (ids && !uiFixturePath) { + const rows = await db + .select() + .from(document) + .where(eq(document.knowledgeBaseId, ids.knowledgeBaseId)) + for (const row of rows) + if (row.storageKey) await deleteFile({ key: row.storageKey, context: 'knowledge-base' }) + await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) + await db.delete(organization).where(eq(organization.id, ids.organizationId)) + await db.delete(user).where(eq(user.id, ids.aliceId)) + await db.delete(user).where(eq(user.id, ids.bobId)) + } + if (ids && uiFixturePath) { + const sessionToken = generateId() + await db.insert(session).values({ + id: generateId(), + token: sessionToken, + userId: ids.aliceId, + expiresAt: new Date(Date.now() + 3_600_000), + createdAt: new Date(), + updatedAt: new Date(), + }) + const cookie = await serializeSignedCookie( + 'better-auth.session_token', + sessionToken, + process.env.BETTER_AUTH_SECRET! + ) + writeFileSync( + uiFixturePath, + JSON.stringify({ ...ids, connectorId, cookie, databaseUrl: process.env.DATABASE_URL }), + { mode: 0o600 } + ) + } + await db.$client.end() + } + }, 60_000) + + it('indexes through admin setup and restricts private content to its owner', async () => { + expect(metrics.embeddingCalls).toBeGreaterThan(0) + expect(await search(principal(ids.aliceId))).toContain(documentId) + expect(await search(principal(ids.bobId))).not.toContain(documentId) + const keySearch = search({ + kind: 'workspace_api_key', + workspaceId: ids.workspaceId, + keyId: 'fixture', + }) + if (organizationScope) await expect(keySearch).rejects.toThrow() + else expect(await keySearch).not.toContain(documentId) + const chunks = await listKnowledgeChunks.execute({ + principal: principal(ids.aliceId), + input: { knowledgeBaseId: ids.knowledgeBaseId, documentId }, + }) + expect(chunks.chunks.map((row) => row.content).join('\n')).toContain( + `${fixture.marker} updated content` + ) + }) + + it('does not re-embed unchanged pages or tables during a permission refresh', async () => { + const before = metrics.embeddingCalls + const result = await sync() + expect(result.docsUnchanged).toBeGreaterThanOrEqual(2) + expect(metrics.embeddingCalls).toBe(before) + expect(await search(principal(ids.aliceId))).toContain(documentId) + expect(await search(principal(ids.bobId))).not.toContain(documentId) + }) + + it.skipIf(!allowSharing)( + 'propagates a real Coda email share to search and document reads', + async () => { + await mutation(codaDocPath(fixture.docId, 'acl', 'permissions'), 'POST', { + access: 'readonly', + principal: { type: 'email', email: secondEmail }, + suppressEmail: true, + }) + await waitForAcl(true) + await sync() + expect(await search(principal(ids.bobId))).toContain(documentId) + const result = await readKnowledgeDocument.execute({ + principal: principal(ids.bobId), + input: { knowledgeBaseId: ids.knowledgeBaseId, documentId }, + }) + expect(result.document.id).toBe(documentId) + }, + 120_000 + ) + + it('denies an unverified Sim email even for the Coda document owner', async () => { + await db.update(user).set({ emailVerified: false }).where(eq(user.id, ids.aliceId)) + try { + expect(await search(principal(ids.aliceId))).not.toContain(documentId) + } finally { + await db.update(user).set({ emailVerified: true }).where(eq(user.id, ids.aliceId)) + } + }) + + it.skipIf(!allowSharing)( + 'removes search and read access after source sharing is revoked without editing content', + async () => { + await revokeShare() + await waitForAcl(false) + await sync() + expect(await search(principal(ids.bobId))).not.toContain(documentId) + await expect( + readKnowledgeDocument.execute({ + principal: principal(ids.bobId), + input: { knowledgeBaseId: ids.knowledgeBaseId, documentId }, + }) + ).rejects.toThrow() + expect(await search(principal(ids.aliceId))).toContain(documentId) + }, + 120_000 + ) + }) diff --git a/apps/sim/lib/sim-search/search-params.test.ts b/apps/sim/lib/sim-search/search-params.test.ts new file mode 100644 index 00000000000..39a109dea40 --- /dev/null +++ b/apps/sim/lib/sim-search/search-params.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { searchSetupParam, searchSetupReturnParam } from '@/lib/sim-search/search-params' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' + +describe('Search setup navigation', () => { + it('can open and resume every source advertised by the connector registry', () => { + for (const [type, meta] of Object.entries(CONNECTOR_META_REGISTRY)) { + if (!meta.search) continue + expect(searchSetupParam.parser.parse(type), type).toBe(type) + expect(searchSetupReturnParam.parser.parse(type), type).toBe(type) + } + }) + + it('rejects arbitrary redirects and keeps the empty picker value', () => { + expect(searchSetupParam.parser.parse('')).toBe('') + expect(searchSetupParam.parser.parse('https://example.com')).toBeNull() + expect(searchSetupReturnParam.parser.parse('https://example.com')).toBeNull() + }) +}) diff --git a/apps/sim/lib/sim-search/search-params.ts b/apps/sim/lib/sim-search/search-params.ts index 3d0116eb784..408892987ee 100644 --- a/apps/sim/lib/sim-search/search-params.ts +++ b/apps/sim/lib/sim-search/search-params.ts @@ -1,6 +1,7 @@ import { parseAsString, parseAsStringLiteral } from 'nuqs/server' const SEARCH_SETUP_SOURCES = [ + 'coda', 'confluence', 'github', 'gitlab', diff --git a/apps/sim/tools/coda/utils.ts b/apps/sim/tools/coda/utils.ts index bf44386e11a..ff665e5541d 100644 --- a/apps/sim/tools/coda/utils.ts +++ b/apps/sim/tools/coda/utils.ts @@ -16,6 +16,7 @@ import type { OutputProperty, ToolConfig, ToolRetryConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const CODA_API_BASE = 'https://coda.io/apis/v1' +const CODA_ADMIN_API_BASE = 'https://coda.io/apis/admin/v1' type QueryValue = string | number | boolean | null | undefined @@ -25,8 +26,12 @@ type QueryValue = string | number | boolean | null | undefined * already encodes the original query, and Coda rejects any other parameter sent with it * (for example `limit` on pages, or `useColumnNames` on rows), so only the token is sent. */ -export function buildCodaUrl(path: string, query?: Record): string { - const url = new URL(`${CODA_API_BASE}${path}`) +export function buildCodaUrl( + path: string, + query?: Record, + api: 'public' | 'admin' = 'public' +): string { + const url = new URL(`${api === 'admin' ? CODA_ADMIN_API_BASE : CODA_API_BASE}${path}`) const pageToken = query?.pageToken const effectiveQuery = typeof pageToken === 'string' && pageToken.trim() !== '' ? { pageToken } : (query ?? {}) diff --git a/packages/deployment-config/src/integration-availability.ts b/packages/deployment-config/src/integration-availability.ts index 86a5e923257..0be1458546a 100644 --- a/packages/deployment-config/src/integration-availability.ts +++ b/packages/deployment-config/src/integration-availability.ts @@ -29,6 +29,12 @@ const deploymentGatedIntegrationTypes = new Set( const integrationTypesByOAuthServiceId = new Map() /** Search authorization shares GitHub's integration policy while its workflow tools retain PAT auth. */ integrationTypesByOAuthServiceId.set('github-repositories', ['github_v2']) +/** Coda's stored API-token credential is available without a deployment OAuth client. */ +const tokenCredentialIntegrationTypes = new Map([['coda', 'coda']]) +for (const [serviceId, integrationType] of tokenCredentialIntegrationTypes) { + integrationTypesByOAuthServiceId.set(serviceId, [integrationType]) +} +const tokenCredentialTypes = new Set(tokenCredentialIntegrationTypes.values()) const previewServiceAccountProvidersByIntegrationType = new Map() for (const integration of INTEGRATION_METADATA) { if (integration.authType !== 'oauth' || !integration.oauthServiceId) continue @@ -144,7 +150,7 @@ export function resolveIntegrationAvailability( name: integration.name, state: 'ready', oauthAvailable: false, - serviceAccountAvailable: false, + serviceAccountAvailable: tokenCredentialTypes.has(integration.type), missingFields: [], } })