Skip to content
Open
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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@
],
"scripts/generate-jwt-keys.sh": [
"bash -c 'cp scripts/generate-jwt-keys.sh create-agents-template/scripts/generate-jwt-keys.sh && git add create-agents-template/scripts/generate-jwt-keys.sh'"
],
"packages/agents-core/src/**/*.{ts,tsx}": [
"bash scripts/lint-data-access-boundary.sh"
]
},
"packageManager": "pnpm@10.10.0"
Expand Down
41 changes: 10 additions & 31 deletions packages/agents-core/src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,18 @@ import { type BetterAuthAdvancedOptions, betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { bearer, deviceAuthorization, oAuthProxy, organization } from 'better-auth/plugins';
import type { GoogleOptions } from 'better-auth/social-providers';
import { eq } from 'drizzle-orm';
import {
createSSOProvider,
getInitialOrganizationForUser,
getSSOProviderByProviderId,
} from '../data-access/runtime/auth';
import type { AgentsRunDatabaseClient } from '../db/runtime/runtime-client';
import { env } from '../env';
import { generateId } from '../utils';
import * as authSchema from './auth-schema';
import { type OrgRole, OrgRoles } from './authz/types';
import { setPasswordResetLink } from './password-reset-link-store';
import { ac, adminRole, memberRole, ownerRole } from './permissions';

/**
* Get the user's initial organization for a new session.
* Returns the oldest organization the user is a member of.
* See: https://www.better-auth.com/docs/plugins/organization#active-organization
*/
async function getInitialOrganization(
dbClient: AgentsRunDatabaseClient,
userId: string
): Promise<{ id: string } | null> {
const [membership] = await dbClient
.select({ organizationId: authSchema.member.organizationId })
.from(authSchema.member)
.where(eq(authSchema.member.userId, userId))
.orderBy(authSchema.member.createdAt)
.limit(1);

return membership ? { id: membership.organizationId } : null;
}

export interface OIDCProviderConfig {
clientId: string;
clientSecret: string;
Expand Down Expand Up @@ -155,28 +139,23 @@ async function registerSSOProvider(
provider: SSOProviderConfig
): Promise<void> {
try {
const existing = await dbClient
.select()
.from(authSchema.ssoProvider)
.where(eq(authSchema.ssoProvider.providerId, provider.providerId))
.limit(1);
const existing = await getSSOProviderByProviderId(dbClient)(provider.providerId);

if (existing.length > 0) {
if (existing) {
return;
}

if (!provider.domain) {
throw new Error(`SSO provider '${provider.providerId}' must have a domain`);
}

await dbClient.insert(authSchema.ssoProvider).values({
await createSSOProvider(dbClient)({
id: generateId(),
providerId: provider.providerId,
issuer: provider.issuer,
domain: provider.domain,
oidcConfig: provider.oidcConfig ? JSON.stringify(provider.oidcConfig) : null,
samlConfig: provider.samlConfig ? JSON.stringify(provider.samlConfig) : null,
userId: null,
organizationId: provider.organizationId || null,
});
} catch (error) {
Expand Down Expand Up @@ -217,11 +196,11 @@ export function createAuth(config: BetterAuthConfig) {
session: {
create: {
before: async (session) => {
const organization = await getInitialOrganization(config.dbClient, session.userId);
const membership = await getInitialOrganizationForUser(config.dbClient)(session.userId);
return {
data: {
...session,
activeOrganizationId: organization?.id ?? null,
activeOrganizationId: membership?.organizationId ?? null,
},
};
},
Expand Down
4 changes: 2 additions & 2 deletions packages/agents-core/src/data-access/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ export * from './manage/subAgents';
export * from './manage/subAgentTeamAgentRelations';
export * from './manage/tools';
export * from './manage/triggers';

// Runtime data access (Postgres - not versioned)
export * from './runtime/apiKeys';
// Runtime data access (Postgres - not versioned)
export * from './runtime/auth';
Comment on lines 29 to +31
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor Comment placement inconsistent with established pattern

Issue: The section comment // Runtime data access (Postgres - not versioned) was moved to between apiKeys and auth exports, but the established pattern places section comments before the first export of that section.

Why: Minor readability impact — section comments demarcate where a group starts.

Fix: (1-click apply)

Suggested change
export * from './runtime/apiKeys';
// Runtime data access (Postgres - not versioned)
export * from './runtime/auth';
// Runtime data access (Postgres - not versioned)
export * from './runtime/apiKeys';
export * from './runtime/auth';

Refs:

export * from './runtime/cascade-delete';
export * from './runtime/contextCache';
export * from './runtime/conversations';
Expand Down
51 changes: 51 additions & 0 deletions packages/agents-core/src/data-access/runtime/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { eq } from 'drizzle-orm';
import { member, ssoProvider } from '../../auth/auth-schema';
import type { AgentsRunDatabaseClient } from '../../db/runtime/runtime-client';

export const getInitialOrganizationForUser =
(db: AgentsRunDatabaseClient) =>
async (userId: string): Promise<{ organizationId: string } | null> => {
const [result] = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
.orderBy(member.createdAt)
.limit(1);

return result ?? null;
};

export const getSSOProviderByProviderId =
(db: AgentsRunDatabaseClient) =>
async (providerId: string): Promise<{ id: string } | null> => {
const [result] = await db
.select({ id: ssoProvider.id })
.from(ssoProvider)
.where(eq(ssoProvider.providerId, providerId))
.limit(1);

return result ?? null;
};

export const createSSOProvider =
(db: AgentsRunDatabaseClient) =>
async (data: {
id: string;
providerId: string;
issuer: string;
domain: string;
oidcConfig?: string | null;
samlConfig?: string | null;
organizationId?: string | null;
}): Promise<void> => {
await db.insert(ssoProvider).values({
id: data.id,
providerId: data.providerId,
issuer: data.issuer,
domain: data.domain,
oidcConfig: data.oidcConfig ?? null,
samlConfig: data.samlConfig ?? null,
userId: null,
organizationId: data.organizationId ?? null,
});
};
37 changes: 37 additions & 0 deletions scripts/lint-data-access-boundary.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Enforces that drizzle-orm imports only appear in allowed directories.
# Designed to run via lint-staged on staged files only.
#
# Usage: scripts/lint-data-access-boundary.sh file1.ts file2.ts ...

set -euo pipefail

VIOLATIONS=()

for file in "$@"; do
# Skip allowed directories
case "$file" in
*/data-access/* | */db/* | */dolt/* | */__tests__/* | *.test.ts | *.spec.ts | */test-*)
continue
;;
esac
Comment on lines +13 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR Allowlist missing auth/ and validation/ directories

Issue: The allowlist doesn't include auth/ or validation/ directories, but existing files in these directories have legitimate drizzle-orm imports for schema definitions and type helpers. When any staged change touches these files, commits will be blocked.

Why:

  • packages/agents-core/src/auth/auth-schema.ts imports relations from drizzle-orm (line 1)
  • packages/agents-core/src/validation/drizzle-schema-helpers.ts imports from drizzle-orm/sqlite-core (line 2)

These are schema/type definition files, not query logic that should be encapsulated in data-access.

Fix: (1-click apply)

Suggested change
case "$file" in
*/data-access/* | */db/* | */dolt/* | */__tests__/* | *.test.ts | *.spec.ts | */test-*)
continue
;;
esac
case "$file" in
*/data-access/* | */db/* | */dolt/* | */__tests__/* | *.test.ts | *.spec.ts | */test-* | */auth/*-schema* | */validation/drizzle-*)
continue
;;
esac

Refs:


# Check for drizzle-orm imports
if grep -qE "from ['\"]drizzle-orm" "$file" 2>/dev/null; then
VIOLATIONS+=("$file")
fi
done

if [ ${#VIOLATIONS[@]} -gt 0 ]; then
echo ""
echo "ERROR: drizzle-orm imports found outside the data-access layer:"
echo ""
for v in "${VIOLATIONS[@]}"; do
echo " $v"
done
echo ""
echo "Move database queries to packages/agents-core/src/data-access/ and import"
echo "the data-access functions instead. See CLAUDE.md for architecture guidelines."
echo ""
exit 1
fi
Loading