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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ jobs:
lib/auth/oauth-provider-lifecycle.postgres.test.ts
app/api/auth/oauth2/token/route.postgres.test.ts
lib/auth/sim-auth-adapter.test.ts
lib/auth/sim-auth-adapter.postgres.test.ts
ee/scim/lib/managed-membership.postgres.test.ts
lib/auth/sso/application/admit-sso-user.postgres.test.ts

Expand Down
78 changes: 78 additions & 0 deletions apps/sim/lib/auth/sim-auth-adapter.postgres.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* @vitest-environment node
*/
import * as schema from '@sim/db/schema'
import { withUtcTimestamps } from '@sim/db/timestamps'
import { generateId } from '@sim/utils/id'
import type { BetterAuthOptions } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { organization } from 'better-auth/plugins'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { describe, expect, it, vi } from 'vitest'
import { createSimAuthAdapter } from '@/lib/auth/sim-auth-adapter'

vi.unmock('@sim/db/schema')
vi.unmock('drizzle-orm')

/** The guard suites cover authorization; this exercises adapter SQL against the real table shape. */
vi.mock('@/lib/auth/oauth-provider-adapter-guard', () => ({
guardOAuthProviderWrites: (adapter: object) => adapter,
}))
vi.mock('@/lib/auth/stripe-adapter-guard', () => ({
guardSubscriptionPlanWrites: (adapter: object) => adapter,
}))

const OPTIONS: BetterAuthOptions = { plugins: [organization()] }
const databaseUrl = process.env.OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL
type AdapterSurface = Omit<ReturnType<typeof createSimAuthAdapter>, 'transaction'>

async function exerciseOrganization(adapter: AdapterSurface) {
const id = generateId()
const data = { id, name: 'Example', slug: id, createdAt: new Date('2026-01-01T00:00:00Z') }
const where = [{ field: 'id', value: id }]

await expect(
adapter.create({ model: 'organization', data, forceAllowId: true })
).resolves.toMatchObject(data)
await expect(adapter.findOne({ model: 'organization', where })).resolves.toMatchObject(data)
await expect(adapter.findMany({ model: 'organization', where })).resolves.toEqual([
expect.objectContaining(data),
])
await expect(
adapter.update({ model: 'organization', where, update: { name: 'Renamed' } })
).resolves.toMatchObject({ id, name: 'Renamed' })
await adapter.delete({ model: 'organization', where })
await expect(adapter.findOne({ model: 'organization', where })).resolves.toBeNull()
}

describe.skipIf(!databaseUrl)('Better Auth across the organization column drop', () => {
it.each([false, true])('preserves CRUD with transaction=%s', async (transaction) => {
const client = postgres(
databaseUrl!,
withUtcTimestamps({ max: 1, prepare: false, fetch_types: false })
)
try {
/** The connection-local copy lets other integration suites keep using the public table. */
await client`CREATE TEMP TABLE organization (LIKE public.organization INCLUDING ALL)`
const database = drizzle(client, { schema })
const adapter = createSimAuthAdapter(OPTIONS, database)
const exercise = () =>
transaction
? adapter.transaction((tx) => exerciseOrganization(tx))
: exerciseOrganization(adapter)

await exercise()
await client`ALTER TABLE pg_temp.organization DROP COLUMN departed_member_usage`

const unprojected = drizzleAdapter(database, { provider: 'pg', schema })(OPTIONS)
await expect(
unprojected.findOne({ model: 'organization', where: [{ field: 'id', value: 'missing' }] })
).rejects.toMatchObject({ cause: { code: '42703' } })

await exercise()
} finally {
await client.end()
}
})
})
82 changes: 82 additions & 0 deletions apps/sim/lib/auth/sim-auth-adapter.sql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @vitest-environment node
*/
import * as schema from '@sim/db/schema'
import type { BetterAuthOptions } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { organization } from 'better-auth/plugins'
import { drizzle } from 'drizzle-orm/pg-proxy'
import { describe, expect, it, vi } from 'vitest'
import type { AuthDatabase } from '@/lib/auth/oauth-provider-adapter-guard'
import { createSimAuthAdapter } from '@/lib/auth/sim-auth-adapter'

vi.unmock('@sim/db/schema')
vi.unmock('drizzle-orm')

/** Guard behavior is covered separately; these tests exercise the real adapter and SQL builder. */
vi.mock('@/lib/auth/oauth-provider-adapter-guard', () => ({
guardOAuthProviderWrites: (adapter: object) => adapter,
}))
vi.mock('@/lib/auth/stripe-adapter-guard', () => ({
guardSubscriptionPlanWrites: (adapter: object) => adapter,
}))

const OPTIONS: BetterAuthOptions = { plugins: [organization()] }
type Adapter = ReturnType<typeof createSimAuthAdapter>
type AdapterSurface = Omit<Adapter, 'transaction'>
const WHERE = [{ field: 'id', value: 'organization-1' }]
const OPERATIONS: { name: string; run: (adapter: AdapterSurface) => Promise<unknown> }[] = [
{
name: 'findOne',
run: (adapter) => adapter.findOne({ model: 'organization', where: WHERE }),
},
{
name: 'findMany',
run: (adapter) => adapter.findMany({ model: 'organization' }),
},
{
name: 'create',
run: (adapter) =>
adapter.create({
model: 'organization',
data: { name: 'Example', slug: 'example', createdAt: new Date('2026-01-01T00:00:00Z') },
}),
},
{
name: 'update',
run: (adapter) =>
adapter.update({ model: 'organization', where: WHERE, update: { name: 'Updated' } }),
},
{
name: 'delete',
run: (adapter) => adapter.delete({ model: 'organization', where: WHERE }),
},
]

describe('Better Auth organization SQL', () => {
it.each(OPERATIONS)('excludes retired columns from $name', async (operation) => {
const execute = vi.fn(async (_query: string) => ({ rows: [] }))
const database = drizzle(execute)
const adapter = createSimAuthAdapter(OPTIONS, database as unknown as AuthDatabase)

await operation.run(adapter)

const queries = execute.mock.calls.map(([query]) => query)
expect(
queries.some((query) => query.includes('"organization"')),
operation.name
).toBe(true)
for (const query of queries) {
expect(query, operation.name).not.toContain('"departed_member_usage"')
}
})

it('retains the full migration schema while the unprojected adapter remains incompatible', async () => {
const execute = vi.fn(async (_query: string) => ({ rows: [] }))
const adapter = drizzleAdapter(drizzle(execute), { provider: 'pg', schema })(OPTIONS)

await adapter.findOne({ model: 'organization', where: WHERE })

expect(execute.mock.calls[0][0]).toContain('"departed_member_usage"')
})
})
9 changes: 8 additions & 1 deletion apps/sim/lib/auth/sim-auth-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { db } from '@sim/db'
import { withInsertColumns } from '@sim/db/insert-columns'
import * as schema from '@sim/db/schema'
import type { BetterAuthOptions } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
Expand All @@ -11,6 +12,12 @@ import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard'

type BetterAuthAdapter = ReturnType<ReturnType<typeof drizzleAdapter>>

/** Better Auth's implicit reads, INSERT defaults, and RETURNING must use live columns. */
const AUTH_SCHEMA = {
...schema,
organization: withInsertColumns(schema.organization, schema.organizationColumns),
}

/**
* Builds every Better Auth adapter surface, including transactional callbacks,
* with Sim's write invariants applied to the actual Drizzle connection in use.
Expand All @@ -22,7 +29,7 @@ export function createSimAuthAdapter(
): BetterAuthAdapter {
const base = drizzleAdapter(database, {
provider: 'pg',
schema,
schema: AUTH_SCHEMA,
transaction: false,
})(options)
const guarded = guardSubscriptionPlanWrites(guardOAuthProviderWrites(base, database))
Expand Down
30 changes: 0 additions & 30 deletions apps/sim/lib/copilot/chat/process-contents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,36 +205,6 @@ describe('processContextsServer - block contexts', () => {
isIntegrationDeploymentAvailable.mockReturnValue(true)
})

it('resolves integration mentions through the same metadata and access policy as blocks', async () => {
const contexts = await processContextsServer(
[
{ kind: 'integration', blockType: 'slack', label: 'Slack' },
{ kind: 'blocks', blockIds: ['slack'], label: 'Slack' },
{ kind: 'integration', blockType: 'notion', label: 'Notion' },
{ kind: 'integration', blockType: 'missing', label: 'Missing' },
],
'user-1',
'',
'workspace-1'
)

expect(contexts).toEqual([
{ type: 'blocks', tag: '@Slack', content: '', path: 'components/blocks/slack.json' },
{ type: 'blocks', tag: '@Slack', content: '', path: 'components/blocks/slack.json' },
])
expect(
await resolveActiveResourceContext('integration', 'slack', 'workspace-1', 'user-1')
).toEqual({
type: 'active_resource',
tag: '@active_resource',
content: '',
path: 'components/blocks/slack.json',
})
expect(
await resolveActiveResourceContext('integration', 'notion', 'workspace-1', 'user-1')
).toBeNull()
})

it('keeps access-control-exempt blocks while filtering non-exempt integrations', async () => {
const result = await processContextsServer(
[
Expand Down
10 changes: 6 additions & 4 deletions packages/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1718,10 +1718,12 @@ export const organization = pgTable('organization', {
.notNull()
.default({}),
/**
* contract-pending(after #7134 and #7774 are fully deployed):
* DROP departed_member_usage. Reads and inserts use organizationColumns;
* #7134 removed the v1 admin exposure and cycle-close resets. The pending-drop
* audit enforces the same read and insert constraints as user_stats.
* contract-pending(after #7134, #7774, and the Better Auth schema projection are fully deployed):
* DROP departed_member_usage. Application reads and inserts use
* organizationColumns; createSimAuthAdapter also projects the table for Better
* Auth's implicit reads, INSERT defaults, and RETURNING. Its projection must
* already be deployed before the drop; the pending-drop audit cannot inspect
* queries generated inside the auth dependency.
*/
/** @deprecated No readers or writers; a departed member's ledger rows stay stamped to the org's period, so nothing needs capturing. */
departedMemberUsage: decimal('departed_member_usage').notNull().default('0'),
Expand Down
Loading