diff --git a/packages/backend/package.json b/packages/backend/package.json index 8329fbbb..f1466c0e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -40,6 +40,7 @@ "argparse": "^2.0.1", "azure-devops-node-api": "^15.1.1", "bullmq": "^5.34.10", + "chokidar": "^4.0.3", "cross-fetch": "^4.0.0", "dotenv": "^16.4.5", "express": "^4.21.2", diff --git a/packages/backend/src/configManager.ts b/packages/backend/src/configManager.ts new file mode 100644 index 00000000..14c6b0de --- /dev/null +++ b/packages/backend/src/configManager.ts @@ -0,0 +1,126 @@ +import { Prisma, PrismaClient } from "@sourcebot/db"; +import { createLogger } from "@sourcebot/logger"; +import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; +import { loadConfig } from "@sourcebot/shared"; +import chokidar, { FSWatcher } from 'chokidar'; +import { ConnectionManager } from "./connectionManager.js"; +import { SINGLE_TENANT_ORG_ID } from "./constants.js"; +import { syncSearchContexts } from "./ee/syncSearchContexts.js"; + +const logger = createLogger('config-manager'); + +export class ConfigManager { + private watcher: FSWatcher; + + constructor( + private db: PrismaClient, + private connectionManager: ConnectionManager, + configPath: string, + ) { + this.watcher = chokidar.watch(configPath, { + ignoreInitial: true, // Don't fire events for existing files + awaitWriteFinish: { + stabilityThreshold: 100, // File size stable for 100ms + pollInterval: 100 // Check every 100ms + }, + atomic: true // Handle atomic writes (temp file + rename) + }); + + this.watcher.on('change', async () => { + logger.info(`Config file ${configPath} changed. Syncing config.`); + try { + await this.syncConfig(configPath); + } catch (error) { + logger.error(`Failed to sync config: ${error}`); + } + }); + + this.syncConfig(configPath); + } + + private syncConfig = async (configPath: string) => { + const config = await loadConfig(configPath); + + await this.syncConnections(config.connections); + await syncSearchContexts({ + contexts: config.contexts, + orgId: SINGLE_TENANT_ORG_ID, + db: this.db, + }); + } + + private syncConnections = async (connections?: { [key: string]: ConnectionConfig }) => { + if (connections) { + for (const [key, newConnectionConfig] of Object.entries(connections)) { + const existingConnection = await this.db.connection.findUnique({ + where: { + name_orgId: { + name: key, + orgId: SINGLE_TENANT_ORG_ID, + } + } + }); + + + const existingConnectionConfig = existingConnection ? existingConnection.config as unknown as ConnectionConfig : undefined; + const connectionNeedsSyncing = + !existingConnection || + (JSON.stringify(existingConnectionConfig) !== JSON.stringify(newConnectionConfig)); + + // Either update the existing connection or create a new one. + const connection = existingConnection ? + await this.db.connection.update({ + where: { + id: existingConnection.id, + }, + data: { + config: newConnectionConfig as unknown as Prisma.InputJsonValue, + isDeclarative: true, + } + }) : + await this.db.connection.create({ + data: { + name: key, + config: newConnectionConfig as unknown as Prisma.InputJsonValue, + connectionType: newConnectionConfig.type, + isDeclarative: true, + org: { + connect: { + id: SINGLE_TENANT_ORG_ID, + } + } + } + }); + + if (connectionNeedsSyncing) { + const [jobId] = await this.connectionManager.createJobs([connection]); + logger.info(`Change detected for connection '${key}' (id: ${connection.id}). Created sync job ${jobId}.`); + } + } + } + + // Delete any connections that are no longer in the config. + const deletedConnections = await this.db.connection.findMany({ + where: { + isDeclarative: true, + name: { + notIn: Object.keys(connections ?? {}), + }, + orgId: SINGLE_TENANT_ORG_ID, + } + }); + + for (const connection of deletedConnections) { + logger.info(`Deleting connection with name '${connection.name}'. Connection ID: ${connection.id}`); + await this.db.connection.delete({ + where: { + id: connection.id, + } + }) + } + } + + public dispose = async () => { + await this.watcher.close(); + } +} \ No newline at end of file diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index e3be9941..ea700424 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -2,13 +2,15 @@ import * as Sentry from "@sentry/node"; import { Connection, ConnectionSyncJobStatus, PrismaClient } from "@sourcebot/db"; import { createLogger } from "@sourcebot/logger"; import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; -import { loadConfig, syncSearchContexts } from "@sourcebot/shared"; +import { loadConfig } from "@sourcebot/shared"; import { Job, Queue, ReservedJob, Worker } from "groupmq"; import { Redis } from 'ioredis'; import { env } from "./env.js"; import { compileAzureDevOpsConfig, compileBitbucketConfig, compileGenericGitHostConfig, compileGerritConfig, compileGiteaConfig, compileGithubConfig, compileGitlabConfig } from "./repoCompileUtils.js"; import { Settings } from "./types.js"; import { groupmqLifecycleExceptionWrapper } from "./utils.js"; +import { syncSearchContexts } from "./ee/syncSearchContexts.js"; +import { captureEvent } from "./posthog.js"; const LOG_TAG = 'connection-manager'; const logger = createLogger(LOG_TAG); @@ -136,6 +138,8 @@ export class ConnectionManager { jobId: job.id, }); } + + return jobs.map(job => job.id); } private async runJob(job: ReservedJob): Promise { @@ -297,10 +301,11 @@ export class ConnectionManager { logger.info(`Connection sync job ${job.id} for connection ${job.data.connectionName} (id: ${job.data.connectionId}) completed`); - // captureEvent('backend_connection_sync_job_completed', { - // connectionId: connectionId, - // repoCount: result.repoCount, - // }); + const result = job.returnvalue as JobResult; + captureEvent('backend_connection_sync_job_completed', { + connectionId: connectionId, + repoCount: result.repoCount, + }); }); private onJobFailed = async (job: Job) => @@ -332,10 +337,10 @@ export class ConnectionManager { logger.warn(`Failed job ${job.id} for connection ${connection.name} (id: ${connection.id}). Attempt ${attempt} / ${job.opts.attempts}. Retrying.`); } - // captureEvent('backend_connection_sync_job_failed', { - // connectionId: connectionId, - // error: err instanceof BackendException ? err.code : 'UNKNOWN', - // }); + captureEvent('backend_connection_sync_job_failed', { + connectionId: job.data.connectionId, + error: job.failedReason, + }); }); private onJobStalled = async (jobId: string) => @@ -354,6 +359,11 @@ export class ConnectionManager { }); logger.error(`Job ${jobId} stalled for connection ${connection.name} (id: ${connection.id})`); + + captureEvent('backend_connection_sync_job_failed', { + connectionId: connection.id, + error: 'Job stalled', + }); }); private async onWorkerError(error: Error) { diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index 3150187e..d6db3bec 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -1,6 +1,8 @@ import { env } from "./env.js"; import path from "path"; +export const SINGLE_TENANT_ORG_ID = 1; + export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES = [ 'github', ]; diff --git a/packages/shared/src/ee/syncSearchContexts.ts b/packages/backend/src/ee/syncSearchContexts.ts similarity index 98% rename from packages/shared/src/ee/syncSearchContexts.ts rename to packages/backend/src/ee/syncSearchContexts.ts index 8dd932ae..e53679ae 100644 --- a/packages/shared/src/ee/syncSearchContexts.ts +++ b/packages/backend/src/ee/syncSearchContexts.ts @@ -1,8 +1,7 @@ import micromatch from "micromatch"; import { createLogger } from "@sourcebot/logger"; import { PrismaClient } from "@sourcebot/db"; -import { getPlan, hasEntitlement } from "../entitlements.js"; -import { SOURCEBOT_SUPPORT_EMAIL } from "../constants.js"; +import { getPlan, hasEntitlement, SOURCEBOT_SUPPORT_EMAIL } from "@sourcebot/shared"; import { SearchContext } from "@sourcebot/schemas/v3/index.type"; const logger = createLogger('sync-search-contexts'); diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts index 5e6e1e1d..1ccf19d8 100644 --- a/packages/backend/src/env.ts +++ b/packages/backend/src/env.ts @@ -47,7 +47,7 @@ export const env = createEnv({ DEBUG_ENABLE_GROUPMQ_LOGGING: booleanSchema.default('false'), DATABASE_URL: z.string().url().default("postgresql://postgres:postgres@localhost:5432/postgres"), - CONFIG_PATH: z.string().optional(), + CONFIG_PATH: z.string(), CONNECTION_MANAGER_UPSERT_TIMEOUT_MS: numberSchema.default(300000), REPO_SYNC_RETRY_BASE_SLEEP_SECONDS: numberSchema.default(60), @@ -56,6 +56,8 @@ export const env = createEnv({ EXPERIMENT_EE_PERMISSION_SYNC_ENABLED: booleanSchema.default('false'), AUTH_EE_GITHUB_BASE_URL: z.string().optional(), + + FORCE_ENABLE_ANONYMOUS_ACCESS: booleanSchema.default('false'), }, runtimeEnv: process.env, emptyStringAsUndefined: true, diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index ca4253c0..464bb7b1 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -257,7 +257,7 @@ const getReposOwnedByUsers = async (users: string[], octokit: Octokit, signal: A const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSignal, url?: string) => { const results = await Promise.allSettled(orgs.map(async (org) => { try { - logger.info(`Fetching repository info for org ${org}...`); + logger.debug(`Fetching repository info for org ${org}...`); const octokitToUse = await getOctokitWithGithubApp(octokit, org, url, `org ${org}`); const { durationMs, data } = await measure(async () => { @@ -272,7 +272,7 @@ const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSi return fetchWithRetry(fetchFn, `org ${org}`, logger); }); - logger.info(`Found ${data.length} in org ${org} in ${durationMs}ms.`); + logger.debug(`Found ${data.length} in org ${org} in ${durationMs}ms.`); return { type: 'valid' as const, data @@ -306,7 +306,7 @@ const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSigna const results = await Promise.allSettled(repoList.map(async (repo) => { try { const [owner, repoName] = repo.split('/'); - logger.info(`Fetching repository info for ${repo}...`); + logger.debug(`Fetching repository info for ${repo}...`); const octokitToUse = await getOctokitWithGithubApp(octokit, owner, url, `repo ${repo}`); const { durationMs, data: result } = await measure(async () => { @@ -321,7 +321,7 @@ const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSigna return fetchWithRetry(fetchFn, repo, logger); }); - logger.info(`Found info for repository ${repo} in ${durationMs}ms`); + logger.debug(`Found info for repository ${repo} in ${durationMs}ms`); return { type: 'valid' as const, data: [result.data] diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 2e2ec569..1b3fbec3 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -6,14 +6,15 @@ import { getConfigSettings, hasEntitlement } from '@sourcebot/shared'; import { existsSync } from 'fs'; import { mkdir } from 'fs/promises'; import { Redis } from 'ioredis'; +import { ConfigManager } from "./configManager.js"; import { ConnectionManager } from './connectionManager.js'; import { INDEX_CACHE_DIR, REPOS_CACHE_DIR } from './constants.js'; +import { GithubAppManager } from "./ee/githubAppManager.js"; import { RepoPermissionSyncer } from './ee/repoPermissionSyncer.js'; import { UserPermissionSyncer } from "./ee/userPermissionSyncer.js"; -import { GithubAppManager } from "./ee/githubAppManager.js"; import { env } from "./env.js"; -import { RepoIndexManager } from "./repoIndexManager.js"; import { PromClient } from './promClient.js'; +import { RepoIndexManager } from "./repoIndexManager.js"; const logger = createLogger('backend-entrypoint'); @@ -53,6 +54,7 @@ const connectionManager = new ConnectionManager(prisma, settings, redis); const repoPermissionSyncer = new RepoPermissionSyncer(prisma, settings, redis); const userPermissionSyncer = new UserPermissionSyncer(prisma, settings, redis); const repoIndexManager = new RepoIndexManager(prisma, settings, redis, promClient); +const configManager = new ConfigManager(prisma, connectionManager, env.CONFIG_PATH); connectionManager.startScheduler(); repoIndexManager.startScheduler(); @@ -66,11 +68,13 @@ else if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement( userPermissionSyncer.startScheduler(); } +logger.info('Worker started.'); + const cleanup = async (signal: string) => { logger.info(`Received ${signal}, cleaning up...`); const shutdownTimeout = 30000; // 30 seconds - + try { await Promise.race([ Promise.all([ @@ -79,8 +83,9 @@ const cleanup = async (signal: string) => { repoPermissionSyncer.dispose(), userPermissionSyncer.dispose(), promClient.dispose(), + configManager.dispose(), ]), - new Promise((_, reject) => + new Promise((_, reject) => setTimeout(() => reject(new Error('Shutdown timeout')), shutdownTimeout) ) ]); diff --git a/packages/db/prisma/migrations/20251026194617_add_connection_job_table/migration.sql b/packages/db/prisma/migrations/20251026194617_add_connection_job_table/migration.sql new file mode 100644 index 00000000..60ba6801 --- /dev/null +++ b/packages/db/prisma/migrations/20251026194617_add_connection_job_table/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - You are about to drop the column `syncStatus` on the `Connection` table. All the data in the column will be lost. + - You are about to drop the column `syncStatusMetadata` on the `Connection` table. All the data in the column will be lost. + +*/ +-- CreateEnum +CREATE TYPE "ConnectionSyncJobStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED'); + +-- AlterTable +ALTER TABLE "Connection" DROP COLUMN "syncStatus", +DROP COLUMN "syncStatusMetadata"; + +-- CreateTable +CREATE TABLE "ConnectionSyncJob" ( + "id" TEXT NOT NULL, + "status" "ConnectionSyncJobStatus" NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + "warningMessages" TEXT[], + "errorMessage" TEXT, + "connectionId" INTEGER NOT NULL, + + CONSTRAINT "ConnectionSyncJob_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "ConnectionSyncJob" ADD CONSTRAINT "ConnectionSyncJob_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "Connection"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20251026194628_ensure_single_tenant_org/migration.sql b/packages/db/prisma/migrations/20251026194628_ensure_single_tenant_org/migration.sql new file mode 100644 index 00000000..8cb698f6 --- /dev/null +++ b/packages/db/prisma/migrations/20251026194628_ensure_single_tenant_org/migration.sql @@ -0,0 +1,9 @@ +-- Ensure single tenant organization exists +INSERT INTO "Org" (id, name, domain, "inviteLinkId", "createdAt", "updatedAt") +VALUES (1, 'default', '~', gen_random_uuid(), NOW(), NOW()) +ON CONFLICT (id) DO NOTHING; + +-- Backfill inviteLinkId for any existing orgs that don't have one +UPDATE "Org" +SET "inviteLinkId" = gen_random_uuid() +WHERE "inviteLinkId" IS NULL; \ No newline at end of file diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 5e9e5ca1..93adb717 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -134,11 +134,6 @@ model Connection { updatedAt DateTime @updatedAt repos RepoToConnection[] - /// @deprecated - syncStatus ConnectionSyncStatus @default(SYNC_NEEDED) - /// @deprecated - syncStatusMetadata Json? - // The type of connection (e.g., github, gitlab, etc.) connectionType String diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index bdaea067..f3303c14 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -24,7 +24,4 @@ export { isRemotePath, getConfigSettings, } from "./utils.js"; -export { - syncSearchContexts, -} from "./ee/syncSearchContexts.js"; export * from "./constants.js"; \ No newline at end of file diff --git a/packages/web/package.json b/packages/web/package.json index 709d0e47..7818853c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -113,7 +113,6 @@ "ai": "^5.0.45", "ajv": "^8.17.1", "bcryptjs": "^3.0.2", - "chokidar": "^4.0.3", "class-variance-authority": "^0.7.0", "client-only": "^0.0.1", "clsx": "^2.1.1", diff --git a/packages/web/src/app/api/(server)/stripe/route.ts b/packages/web/src/app/api/(server)/stripe/route.ts index 1150e0d1..84b67114 100644 --- a/packages/web/src/app/api/(server)/stripe/route.ts +++ b/packages/web/src/app/api/(server)/stripe/route.ts @@ -2,7 +2,7 @@ import { headers } from 'next/headers'; import { NextRequest } from 'next/server'; import Stripe from 'stripe'; import { prisma } from '@/prisma'; -import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db'; +import { StripeSubscriptionStatus } from '@sourcebot/db'; import { stripeClient } from '@/ee/features/billing/stripe'; import { env } from '@/env.mjs'; import { createLogger } from "@sourcebot/logger"; @@ -85,16 +85,6 @@ export async function POST(req: NextRequest) { }); logger.info(`Org ${org.id} subscription status updated to ACTIVE`); - // mark all of this org's connections for sync, since their repos may have been previously garbage collected - await prisma.connection.updateMany({ - where: { - orgId: org.id - }, - data: { - syncStatus: ConnectionSyncStatus.SYNC_NEEDED - } - }); - return new Response(JSON.stringify({ received: true }), { status: 200 }); diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts index 1063e58c..63eb6a47 100644 --- a/packages/web/src/initialize.ts +++ b/packages/web/src/initialize.ts @@ -1,129 +1,17 @@ -import { ConnectionSyncStatus, OrgRole, Prisma } from '@sourcebot/db'; -import { env } from './env.mjs'; -import { prisma } from "@/prisma"; -import { SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SINGLE_TENANT_ORG_NAME } from './lib/constants'; -import chokidar from 'chokidar'; -import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; -import { hasEntitlement, loadConfig, isRemotePath, syncSearchContexts } from '@sourcebot/shared'; -import { isServiceError, getOrgMetadata } from './lib/utils'; -import { ServiceErrorException } from './lib/serviceError'; -import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; -import { createLogger } from "@sourcebot/logger"; import { createGuestUser } from '@/lib/authUtils'; +import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; +import { prisma } from "@/prisma"; +import { OrgRole } from '@sourcebot/db'; +import { createLogger } from "@sourcebot/logger"; +import { hasEntitlement, loadConfig } from '@sourcebot/shared'; import { getOrgFromDomain } from './data/org'; +import { env } from './env.mjs'; +import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID, SOURCEBOT_GUEST_USER_ID } from './lib/constants'; +import { ServiceErrorException } from './lib/serviceError'; +import { getOrgMetadata, isServiceError } from './lib/utils'; const logger = createLogger('web-initialize'); -const syncConnections = async (connections?: { [key: string]: ConnectionConfig }) => { - if (connections) { - for (const [key, newConnectionConfig] of Object.entries(connections)) { - const currentConnection = await prisma.connection.findUnique({ - where: { - name_orgId: { - name: key, - orgId: SINGLE_TENANT_ORG_ID, - } - }, - include: { - repos: { - include: { - repo: true, - } - } - } - }); - - const currentConnectionConfig = currentConnection ? currentConnection.config as unknown as ConnectionConfig : undefined; - const syncNeededOnUpdate = - (currentConnectionConfig && JSON.stringify(currentConnectionConfig) !== JSON.stringify(newConnectionConfig)) || - (currentConnection?.syncStatus === ConnectionSyncStatus.FAILED); - - const connectionDb = await prisma.connection.upsert({ - where: { - name_orgId: { - name: key, - orgId: SINGLE_TENANT_ORG_ID, - } - }, - update: { - config: newConnectionConfig as unknown as Prisma.InputJsonValue, - syncStatus: syncNeededOnUpdate ? ConnectionSyncStatus.SYNC_NEEDED : undefined, - isDeclarative: true, - }, - create: { - name: key, - connectionType: newConnectionConfig.type, - config: newConnectionConfig as unknown as Prisma.InputJsonValue, - isDeclarative: true, - org: { - connect: { - id: SINGLE_TENANT_ORG_ID, - } - } - } - }); - - logger.info(`Upserted connection with name '${key}'. Connection ID: ${connectionDb.id}`); - } - } - - // Delete any connections that are no longer in the config. - const deletedConnections = await prisma.connection.findMany({ - where: { - isDeclarative: true, - name: { - notIn: Object.keys(connections ?? {}), - }, - orgId: SINGLE_TENANT_ORG_ID, - } - }); - - for (const connection of deletedConnections) { - logger.info(`Deleting connection with name '${connection.name}'. Connection ID: ${connection.id}`); - await prisma.connection.delete({ - where: { - id: connection.id, - } - }) - } -} - -const syncDeclarativeConfig = async (configPath: string) => { - const config = await loadConfig(configPath); - - const forceEnableAnonymousAccess = config.settings?.enablePublicAccess ?? env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true'; - if (forceEnableAnonymousAccess) { - const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); - if (!hasAnonymousAccessEntitlement) { - logger.warn(`FORCE_ENABLE_ANONYMOUS_ACCESS env var is set to true but anonymous access entitlement is not available. Setting will be ignored.`); - } else { - const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN); - if (org) { - const currentMetadata = getOrgMetadata(org); - const mergedMetadata = { - ...(currentMetadata ?? {}), - anonymousAccessEnabled: true, - }; - - await prisma.org.update({ - where: { id: org.id }, - data: { - metadata: mergedMetadata, - }, - }); - logger.info(`Anonymous access enabled via FORCE_ENABLE_ANONYMOUS_ACCESS environment variable`); - } - } - } - - await syncConnections(config.connections); - await syncSearchContexts({ - contexts: config.contexts, - orgId: SINGLE_TENANT_ORG_ID, - db: prisma, - }); -} - const pruneOldGuestUser = async () => { // The old guest user doesn't have the GUEST role const guestUser = await prisma.userToOrg.findUnique({ @@ -150,35 +38,6 @@ const pruneOldGuestUser = async () => { } const initSingleTenancy = async () => { - // Back fill the inviteId if the org has already been created to prevent needing to wipe the db - await prisma.$transaction(async (tx) => { - const org = await tx.org.findUnique({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - }); - - if (!org) { - await tx.org.create({ - data: { - id: SINGLE_TENANT_ORG_ID, - name: SINGLE_TENANT_ORG_NAME, - domain: SINGLE_TENANT_ORG_DOMAIN, - inviteLinkId: crypto.randomUUID(), - } - }); - } else if (!org.inviteLinkId) { - await tx.org.update({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - data: { - inviteLinkId: crypto.randomUUID(), - } - }); - } - }); - // This is needed because v4 introduces the GUEST org role as well as making authentication required. // To keep things simple, we'll just delete the old guest user if it exists in the DB await pruneOldGuestUser(); @@ -205,30 +64,32 @@ const initSingleTenancy = async () => { } } - // Load any connections defined declaratively in the config file. - const configPath = env.CONFIG_PATH; - if (configPath) { - await syncDeclarativeConfig(configPath); - - // watch for changes assuming it is a local file - if (!isRemotePath(configPath)) { - const watcher = chokidar.watch(configPath, { - ignoreInitial: true, // Don't fire events for existing files - awaitWriteFinish: { - stabilityThreshold: 100, // File size stable for 100ms - pollInterval: 100 // Check every 100ms - }, - atomic: true // Handle atomic writes (temp file + rename) - }); - - watcher.on('change', async () => { - logger.info(`Config file ${configPath} changed. Re-syncing...`); - try { - await syncDeclarativeConfig(configPath); - } catch (error) { - logger.error(`Failed to sync config: ${error}`); + // Sync anonymous access config from the config file + if (env.CONFIG_PATH) { + const config = await loadConfig(env.CONFIG_PATH); + const forceEnableAnonymousAccess = config.settings?.enablePublicAccess ?? env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true'; + + if (forceEnableAnonymousAccess) { + if (!hasAnonymousAccessEntitlement) { + logger.warn(`FORCE_ENABLE_ANONYMOUS_ACCESS env var is set to true but anonymous access entitlement is not available. Setting will be ignored.`); + } else { + const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN); + if (org) { + const currentMetadata = getOrgMetadata(org); + const mergedMetadata = { + ...(currentMetadata ?? {}), + anonymousAccessEnabled: true, + }; + + await prisma.org.update({ + where: { id: org.id }, + data: { + metadata: mergedMetadata, + }, + }); + logger.info(`Anonymous access enabled via FORCE_ENABLE_ANONYMOUS_ACCESS environment variable`); } - }); + } } } } diff --git a/yarn.lock b/yarn.lock index 34e6c1b2..6938b4ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7796,6 +7796,7 @@ __metadata: argparse: "npm:^2.0.1" azure-devops-node-api: "npm:^15.1.1" bullmq: "npm:^5.34.10" + chokidar: "npm:^4.0.3" cross-env: "npm:^7.0.3" cross-fetch: "npm:^4.0.0" dotenv: "npm:^16.4.5"