Bernt Christian Egeland 3 недель назад
Родитель
Сommit
d453eae755

+ 23 - 2
src/__tests__/lib/demo-guard-coverage.test.ts

@@ -50,6 +50,17 @@ const EGRESS_PATHS: Array<{ file: string; stoppedBy: RegExp }> = [
     file: 'src/features/notifications/Lib/pushToTechnician.ts',
     stoppedBy: /if \(isDemoMode\) return/,
   },
+  // The integrations catalog. Every connector call goes out through one HTTP
+  // client, every token exchange or refresh through the OAuth module, and
+  // every context a connector runs with (jobs, webhooks, tests, lookups,
+  // remote options) is built by the connection loader. All three refuse, so
+  // a connector's own bare fetch in a test probe is never reached either.
+  { file: 'src/features/integrations/Lib/http.ts', stoppedBy: /assertConnectorAllowed\(\)/ },
+  { file: 'src/features/integrations/Lib/oauth.ts', stoppedBy: /assertConnectorAllowed\(\)/ },
+  {
+    file: 'src/features/integrations/Lib/connections.ts',
+    stoppedBy: /assertConnectorAllowed\(\)/,
+  },
 ]
 
 describe('demo mode', () => {
@@ -81,7 +92,7 @@ describe('demo mode', () => {
     // finding one that nobody has classified is worth failing over. Payment
     // providers are exempt: their credentials cannot be set on the demo, so
     // there is no configured client for anything to call.
-    const searchRoots = ['src/lib', 'src/app/api', 'src/features']
+    const searchRoots = ['src/lib', 'src/app/api', 'src/features', 'src/integrations']
     // The directory names are anchored to a path separator on both sides:
     // unanchored, `hooks/` also matches `webhooks/` and quietly exempts the
     // one tree in here that talks to a URL somebody else chose.
@@ -101,13 +112,23 @@ describe('demo mode', () => {
     const known = new Set(EGRESS_PATHS.map((e) => e.file.split(path.posix.sep).join(path.sep)))
     // Adapters are reached only through their transport, which already refuses.
     const reachedViaTransport = /whatsapp[/\\]adapters[/\\]/
+    // A connector only ever runs with a context from the connection loader,
+    // which refuses; its bare fetches (credential probes, a token revoke) sit
+    // behind that. Anything under src/integrations that could run without a
+    // context would need a home of its own in this file.
+    const reachedViaConnection = /src[/\\]integrations[/\\]/
     // Guarded by demoGuard() in the action that calls them, or by isDemoMode
     // in the cron that does.
     const guardedByCaller =
       /aiSettingsActions\.ts$|validateLicense\.ts$|cron[/\\]check-licenses\.ts$/
 
     const unclassified = files.filter((file) => {
-      if (known.has(file) || reachedViaTransport.test(file) || guardedByCaller.test(file))
+      if (
+        known.has(file) ||
+        reachedViaTransport.test(file) ||
+        reachedViaConnection.test(file) ||
+        guardedByCaller.test(file)
+      )
         return false
       return /\bawait fetch\(|= fetch\(/.test(fs.readFileSync(file, 'utf-8'))
     })

+ 2 - 0
src/features/integrations/Lib/connections.ts

@@ -8,6 +8,7 @@
  */
 
 import { db } from '@/lib/db'
+import { assertConnectorAllowed } from '@/lib/demo'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { getConnector, getManifest } from '@/integrations/registry'
 import { createConnectorHttp } from './http'
@@ -118,6 +119,7 @@ export async function loadConnection(
   connectionId: string,
   options: { jobId?: string | null } = {}
 ): Promise<LoadedConnection> {
+  assertConnectorAllowed()
   const row = await db.integrationConnection.findUnique({ where: { id: connectionId } })
   if (!row) throw new Error('Integration connection not found')
   const manifest = getManifest(row.connectorId)

+ 2 - 0
src/features/integrations/Lib/http.ts

@@ -9,6 +9,7 @@
  */
 
 import { db } from '@/lib/db'
+import { assertConnectorAllowed } from '@/lib/demo'
 import { type OAuth2Spec, needsRefresh, refreshToken, resolveClient } from './oauth'
 import {
   type ConnectorHttp,
@@ -109,6 +110,7 @@ export function createConnectorHttp(input: {
   }
 
   const doFetch = async (url: string, init?: RequestInit): Promise<Response> => {
+    assertConnectorAllowed()
     let forceRefresh = false
     let last: Response | null = null
     for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {

+ 2 - 0
src/features/integrations/Lib/oauth.ts

@@ -9,6 +9,7 @@
  */
 
 import { createHash, randomBytes } from 'node:crypto'
+import { assertConnectorAllowed } from '@/lib/demo'
 import type { AuthSpec, ConnectorManifest, OAuthCredentials } from './types'
 
 export type OAuth2Spec = Extract<AuthSpec, { type: 'oauth2' }>
@@ -101,6 +102,7 @@ async function tokenRequest(
   client: OAuthClient,
   params: Record<string, string>
 ): Promise<TokenResponse> {
+  assertConnectorAllowed()
   const headers: Record<string, string> = {
     'Content-Type': 'application/x-www-form-urlencoded',
     Accept: 'application/json',

+ 7 - 0
src/lib/cron/integration-jobs.ts

@@ -1,4 +1,5 @@
 import { CronJob } from 'cron'
+import { isDemoMode } from '@/lib/demo'
 import {
   cleanupIntegrationHistory,
   recoverStuckJobs,
@@ -11,6 +12,12 @@ import {
  * left running, queue the timed syncs that are due, then run due jobs.
  */
 export function processIntegrationJobs() {
+  // Nothing can be connected on the demo, and a job that somehow exists would
+  // only fail against the connection loader's refusal every minute.
+  if (isDemoMode) {
+    console.warn('[cron] Integration job runner not started: demo mode')
+    return
+  }
   const job = new CronJob('* * * * *', async () => {
     try {
       const recovered = await recoverStuckJobs()

+ 15 - 0
src/lib/demo.ts

@@ -47,6 +47,21 @@ export function assertOutboundAllowed(channel: 'email' | 'sms' | 'whatsapp' | 't
   }
 }
 
+/**
+ * Hard stop for the integrations catalog: every connector call runs with a
+ * context built by `loadConnection`, goes out through the connector HTTP
+ * client, or exchanges a token through the OAuth module, and all three
+ * refuse here. The actions that connect a vendor already refuse, so no
+ * connection should exist on the demo; this is for the one that does.
+ */
+export function assertConnectorAllowed(): void {
+  if (isDemoMode) {
+    throw new Error(
+      'Integrations are disabled on the demo. Install Torqvoice on your own server to connect one.'
+    )
+  }
+}
+
 /**
  * Setting keys that store provider credentials / secrets. Demo visitors
  * shouldn't be able to paste real API keys into a shared demo DB.