diff --git a/apps/api/package.json b/apps/api/package.json index 1809a53c..9e5b5979 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "@ainyc/canonry-api-routes": "workspace:*", "@ainyc/canonry-config": "workspace:*", "@ainyc/canonry-db": "workspace:*", + "drizzle-orm": "^0.45.2", "fastify": "^5.8.5", "tsx": "^4.20.5" } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 32058ea2..7478d320 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,18 +1,41 @@ +import crypto from 'node:crypto' import Fastify from 'fastify' +import { eq } from 'drizzle-orm' import type { PlatformEnv } from '@ainyc/canonry-config' -import { createClient } from '@ainyc/canonry-db' -import { apiRoutes } from '@ainyc/canonry-api-routes' +import { createClient, migrate, apiKeys, appSettings } from '@ainyc/canonry-db' +import { + apiRoutes, + createSessionStore, + sessionRoutes, + type DashboardPasswordStore, +} from '@ainyc/canonry-api-routes' import { registerHealthRoutes } from './routes/health.js' +const SESSION_COOKIE_NAME = 'canonry_session' +const DASHBOARD_PASSWORD_KEY = 'dashboard_password_hash' + +// Hashes the opaque `cnry_…` API key (a 128-bit random token, not a +// user password) for the api_keys lookup. Fast SHA-256 is correct here — +// there is no wordlist to brute-force a high-entropy token, so a slow KDF +// would only add per-request latency. Dashboard passwords use salted scrypt +// (packages/api-routes session plugin). (CodeQL flags this as weak password +// hashing — false positive for opaque tokens.) +function hashApiKey(key: string): string { + return crypto.createHash('sha256').update(key).digest('hex') +} + export function buildApp(env: PlatformEnv) { const app = Fastify({ logger: true, }) - // Connect to database and register shared API routes + // Connect to database and register shared API routes. Run migrations + // up-front — apps/api is the entry point for cloud deployments, so the + // operator has no prior CLI step that would have applied them. const db = createClient(env.databaseUrl) + migrate(db) const providerSummary = (['gemini', 'openai', 'claude', 'perplexity'] as const).map(name => ({ name, @@ -21,10 +44,81 @@ export function buildApp(env: PlatformEnv) { quota: env.providers[name]?.quota, })) + // Seed the install's default API key into the api_keys table when set. + // The same pattern lives in `packages/canonry/src/server.ts`. Without this + // row, dashboard-password sessions have no apiKey to bind to. + if (env.apiKey) { + const keyHash = hashApiKey(env.apiKey) + const existing = db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).get() + if (!existing) { + const prefix = env.apiKey.slice(0, 12) + db.insert(apiKeys).values({ + id: `key_${crypto.randomBytes(8).toString('hex')}`, + name: 'default', + keyHash, + keyPrefix: prefix, + scopes: ['*'], + createdAt: new Date().toISOString(), + }).run() + } + } + + // Cookie-backed browser session. Cloud Run instances have no writable + // local config file, so the dashboard password hash lives in the + // `app_settings` DB row instead of `~/.canonry/config.yaml`. + const apiPrefix = env.basePath === '/' ? '/api/v1' : `${env.basePath.replace(/\/$/, '')}/api/v1` + const sessionCookiePath = env.basePath === '/' ? '/' : env.basePath.replace(/\/?$/, '/') + const sessionCookieSecure = Boolean(env.publicUrl?.startsWith('https://')) + const sessionStore = createSessionStore() + + const dashboardPassword: DashboardPasswordStore = { + get: () => { + const row = db.select().from(appSettings).where(eq(appSettings.key, DASHBOARD_PASSWORD_KEY)).get() + return row?.value + }, + set: (hash) => { + const now = new Date().toISOString() + db.insert(appSettings) + .values({ key: DASHBOARD_PASSWORD_KEY, value: hash, updatedAt: now }) + .onConflictDoUpdate({ + target: appSettings.key, + set: { value: hash, updatedAt: now }, + }) + .run() + }, + } + + // Register session routes BEFORE the main api-routes plugin so the + // cookie can be issued before any auth-gated route runs. The + // api-routes auth hook already skips /session* via shouldSkipAuth. + app.register(async (scope) => { + await sessionRoutes(scope, { + db, + store: sessionStore, + cookieName: SESSION_COOKIE_NAME, + cookiePath: sessionCookiePath, + cookieSecure: sessionCookieSecure, + ttlMs: sessionStore.ttlMs, + dashboardPassword, + getDefaultApiKey: () => { + if (!env.apiKey) return undefined + return db + .select() + .from(apiKeys) + .where(eq(apiKeys.keyHash, hashApiKey(env.apiKey))) + .get() + }, + }) + }, { prefix: apiPrefix }) + app.register(apiRoutes, { db, skipAuth: false, - routePrefix: env.basePath === '/' ? '/api/v1' : `${env.basePath.replace(/\/$/, '')}/api/v1`, + routePrefix: apiPrefix, + sessionCookieName: SESSION_COOKIE_NAME, + // Arrow-wrap so `this` stays bound when the auth plugin invokes it + // detached from the store (eslint @typescript-eslint/unbound-method). + resolveSessionApiKeyId: (sid) => sessionStore.resolveSessionApiKeyId(sid), openApiInfo: { title: 'Canonry API', version: '0.1.0', diff --git a/docs/architecture.md b/docs/architecture.md index b2c2cac6..ea41be23 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,10 +17,10 @@ flowchart LR subgraph Process["canonry serve"] direction LR - SPA["Static SPA\n/"] --> API["Fastify API\n/api/v1/*"] - API --> JobRunner["In-process\njob runner"] + SPA["Static SPA
/"] --> API["Fastify API
/api/v1/*"] + API --> JobRunner["In-process
job runner"] API --> SQLite["SQLite"] - JobRunner --> Registry["Provider\nRegistry"] + JobRunner --> Registry["Provider
Registry"] Registry --> Gemini["provider-gemini"] Registry --> OpenAI["provider-openai"] Registry --> Claude["provider-claude"] @@ -41,8 +41,8 @@ flowchart LR ### Key components - **`packages/canonry/`** — publishable npm package (`@ainyc/canonry`). Bundles CLI, Fastify server, job runner, and pre-built SPA. -- **`packages/api-routes/`** — shared Fastify route plugins. Used by both the local server and the cloud `apps/api/`. -- **`packages/db/`** — Drizzle ORM schema. SQLite locally, Postgres for cloud. Auto-migration on startup. +- **`packages/api-routes/`** — shared Fastify route plugins. The HTTP surface consumed by the local `cnry serve` process. +- **`packages/db/`** — Drizzle ORM schema, backed by SQLite. Auto-migration on startup. - **`packages/provider-*/`** — Provider adapters. Each implements `ProviderAdapter` from contracts. - **`packages/contracts/`** — shared DTOs, enums, config-schema (Zod), error codes. - **`apps/web/`** — Vite SPA source. Built and bundled into `packages/canonry/assets/`. @@ -61,8 +61,6 @@ flowchart LR ```mermaid flowchart TD subgraph Apps - api["apps/api"] - worker["apps/worker"] web["apps/web"] end @@ -91,10 +89,6 @@ flowchart TD wp["integration-wordpress"] end - api --> routes - api --> db - worker --> routes - worker --> db web -.-> contracts canonry --> routes @@ -167,17 +161,9 @@ The `ProviderRegistry` in `packages/canonry` collects all adapters at startup. W 3. Calls `normalizeResult()` to convert provider-specific responses to standard `NormalizedQueryResult` 4. Persists `query_snapshots` — one per query per provider per run -## Cloud Architecture +## Deployment Model -| Concern | Local | Cloud | -|---------|-------|-------| -| Database | SQLite | Managed Postgres | -| Process model | Single process | API + Worker + CDN | -| Job queue | In-process async | pg-boss | -| Auth | Auto-generated local key | Bootstrap endpoint + team keys | -| Web hosting | Fastify static | CDN | - -The same API routes, contracts, Drizzle schema, and dashboard code are used in both modes. The cloud deployment replaces the single-process server with separate services. +Canonry ships as a **self-hosted single-process install** — that is the only supported deployment. You run `cnry serve` on your own machine, a VPS, or a container; the SPA, API, job runner, and SQLite database all live in that one process. See [docs/deployment.md](deployment.md) for Docker, Railway, Render, systemd, and Tailscale recipes. ## Service Boundaries @@ -189,15 +175,13 @@ The same API routes, contracts, Drizzle schema, and dashboard code are used in b - **`packages/db/`** — schema, migrations, database access. - **`packages/contracts/`** — DTOs, enums, config validation, error codes. - **`packages/config/`** — typed environment parsing. -- **`apps/api/`** — cloud API entry point (imports `packages/api-routes/`). -- **`apps/worker/`** — cloud worker entry point. - **`apps/web/`** — SPA source code. ## Design Constraints - This repo remains independent from the audit package repo - Consume only published `@ainyc/aeo-audit` releases -- Same auth path for local and cloud (API key-based) +- API key-based auth - Raw observation snapshots only; transitions computed at query time ## Score Families diff --git a/docs/roadmap.md b/docs/roadmap.md index 3a988344..0338addc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,7 +15,7 @@ Canonry is a fully functional agent-first open-source AEO operating platform wit - **Audit logging**: All config mutations tracked with diffs - **Snapshot history**: Timeline with computed transitions, run diffs, per-provider breakdowns - **Project-scoped location-aware runs**: Named project locations, default location selection, explicit per-run overrides, all-location sweeps, and location-filtered history -- **Auth**: API key auth with scopes, same path local and cloud +- **Auth**: API key auth with scopes - **OpenAPI spec**: Auto-generated at `/api/v1/openapi.json` - **SQLite**: Local-first with Drizzle ORM and auto-migration diff --git a/package.json b/package.json index 66e46e13..8790b93f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "canonry", "private": true, - "version": "4.67.0", + "version": "4.68.0", "type": "module", "packageManager": "pnpm@10.28.2", "scripts": { diff --git a/packages/api-client-generated/src/generated/@tanstack/react-query.gen.ts b/packages/api-client-generated/src/generated/@tanstack/react-query.gen.ts index c13475a0..3fe74b66 100644 --- a/packages/api-client-generated/src/generated/@tanstack/react-query.gen.ts +++ b/packages/api-client-generated/src/generated/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen.js'; -import { deleteApiV1BacklinksCacheByRelease, deleteApiV1ProjectsByName, deleteApiV1ProjectsByNameAgentMemory, deleteApiV1ProjectsByNameAgentTranscript, deleteApiV1ProjectsByNameBingDisconnect, deleteApiV1ProjectsByNameCompetitors, deleteApiV1ProjectsByNameContentDismissalsByTargetRef, deleteApiV1ProjectsByNameGaDisconnect, deleteApiV1ProjectsByNameGbpConnection, deleteApiV1ProjectsByNameGoogleConnectionsByType, deleteApiV1ProjectsByNameKeywords, deleteApiV1ProjectsByNameLocationsByLabel, deleteApiV1ProjectsByNameNotificationsById, deleteApiV1ProjectsByNameQueries, deleteApiV1ProjectsByNameSchedule, deleteApiV1ProjectsByNameWordpressDisconnect, getApiV1BacklinksLatestRelease, getApiV1BacklinksReleases, getApiV1BacklinksStatus, getApiV1BacklinksSyncs, getApiV1BacklinksSyncsLatest, getApiV1CdpStatus, getApiV1Doctor, getApiV1GoogleCallback, getApiV1History, getApiV1NotificationsEvents, getApiV1OpenapiJson, getApiV1Projects, getApiV1ProjectsByName, getApiV1ProjectsByNameAgentMemory, getApiV1ProjectsByNameAgentProviders, getApiV1ProjectsByNameAgentTranscript, getApiV1ProjectsByNameAnalyticsGaps, getApiV1ProjectsByNameAnalyticsMetrics, getApiV1ProjectsByNameAnalyticsSources, getApiV1ProjectsByNameBacklinksDomains, getApiV1ProjectsByNameBacklinksHistory, getApiV1ProjectsByNameBacklinksSummary, getApiV1ProjectsByNameBingCoverage, getApiV1ProjectsByNameBingCoverageHistory, getApiV1ProjectsByNameBingInspections, getApiV1ProjectsByNameBingPerformance, getApiV1ProjectsByNameBingSites, getApiV1ProjectsByNameBingStatus, getApiV1ProjectsByNameCitationsVisibility, getApiV1ProjectsByNameCompetitors, getApiV1ProjectsByNameContentDismissals, getApiV1ProjectsByNameContentGaps, getApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysis, getApiV1ProjectsByNameContentSources, getApiV1ProjectsByNameContentTargets, getApiV1ProjectsByNameDeletePreview, getApiV1ProjectsByNameDiscoverSessions, getApiV1ProjectsByNameDiscoverSessionsById, getApiV1ProjectsByNameDiscoverSessionsByIdPromote, getApiV1ProjectsByNameDoctor, getApiV1ProjectsByNameExport, getApiV1ProjectsByNameGaAiReferralHistory, getApiV1ProjectsByNameGaAttributionTrend, getApiV1ProjectsByNameGaCoverage, getApiV1ProjectsByNameGaSessionHistory, getApiV1ProjectsByNameGaSocialReferralHistory, getApiV1ProjectsByNameGaSocialReferralTrend, getApiV1ProjectsByNameGaStatus, getApiV1ProjectsByNameGaTraffic, getApiV1ProjectsByNameGbpAccounts, getApiV1ProjectsByNameGbpKeywords, getApiV1ProjectsByNameGbpLocations, getApiV1ProjectsByNameGbpLodging, getApiV1ProjectsByNameGbpMetrics, getApiV1ProjectsByNameGbpPlaceActions, getApiV1ProjectsByNameGbpPlaces, getApiV1ProjectsByNameGbpSummary, getApiV1ProjectsByNameGoogleCallback, getApiV1ProjectsByNameGoogleConnections, getApiV1ProjectsByNameGoogleGscCoverage, getApiV1ProjectsByNameGoogleGscCoverageHistory, getApiV1ProjectsByNameGoogleGscDeindexed, getApiV1ProjectsByNameGoogleGscInspections, getApiV1ProjectsByNameGoogleGscPerformance, getApiV1ProjectsByNameGoogleGscPerformanceDaily, getApiV1ProjectsByNameGoogleGscSitemaps, getApiV1ProjectsByNameGoogleProperties, getApiV1ProjectsByNameHealthHistory, getApiV1ProjectsByNameHealthLatest, getApiV1ProjectsByNameHistory, getApiV1ProjectsByNameInsights, getApiV1ProjectsByNameInsightsById, getApiV1ProjectsByNameKeywords, getApiV1ProjectsByNameLocations, getApiV1ProjectsByNameNotifications, getApiV1ProjectsByNameOverview, getApiV1ProjectsByNameQueries, getApiV1ProjectsByNameReport, getApiV1ProjectsByNameReportHtml, getApiV1ProjectsByNameRuns, getApiV1ProjectsByNameRunsByRunIdBrowserDiff, getApiV1ProjectsByNameRunsLatest, getApiV1ProjectsByNameSchedule, getApiV1ProjectsByNameSearch, getApiV1ProjectsByNameSnapshots, getApiV1ProjectsByNameSnapshotsDiff, getApiV1ProjectsByNameTimeline, getApiV1ProjectsByNameTrafficEvents, getApiV1ProjectsByNameTrafficSources, getApiV1ProjectsByNameTrafficSourcesById, getApiV1ProjectsByNameTrafficStatus, getApiV1ProjectsByNameWordpressAudit, getApiV1ProjectsByNameWordpressDiff, getApiV1ProjectsByNameWordpressLlmsTxt, getApiV1ProjectsByNameWordpressPage, getApiV1ProjectsByNameWordpressPages, getApiV1ProjectsByNameWordpressSchema, getApiV1ProjectsByNameWordpressSchemaStatus, getApiV1ProjectsByNameWordpressStagingStatus, getApiV1ProjectsByNameWordpressStatus, getApiV1Runs, getApiV1RunsById, getApiV1ScreenshotsBySnapshotId, getApiV1Settings, getApiV1Telemetry, type Options, postApiV1Apply, postApiV1BacklinksInstall, postApiV1BacklinksSyncs, postApiV1CdpScreenshot, postApiV1ProjectsByNameBacklinksExtract, postApiV1ProjectsByNameBingConnect, postApiV1ProjectsByNameBingInspectSitemap, postApiV1ProjectsByNameBingInspectUrl, postApiV1ProjectsByNameBingRequestIndexing, postApiV1ProjectsByNameBingSetSite, postApiV1ProjectsByNameCompetitors, postApiV1ProjectsByNameContentDismissals, postApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyze, postApiV1ProjectsByNameDiscoverRun, postApiV1ProjectsByNameDiscoverSessionsByIdPromote, postApiV1ProjectsByNameGaConnect, postApiV1ProjectsByNameGaSync, postApiV1ProjectsByNameGbpLocationsDiscover, postApiV1ProjectsByNameGbpSync, postApiV1ProjectsByNameGoogleConnect, postApiV1ProjectsByNameGoogleGscDiscoverSitemaps, postApiV1ProjectsByNameGoogleGscInspect, postApiV1ProjectsByNameGoogleGscInspectSitemap, postApiV1ProjectsByNameGoogleGscSync, postApiV1ProjectsByNameGoogleIndexingRequest, postApiV1ProjectsByNameInsightsByIdDismiss, postApiV1ProjectsByNameKeywords, postApiV1ProjectsByNameKeywordsGenerate, postApiV1ProjectsByNameLocations, postApiV1ProjectsByNameNotifications, postApiV1ProjectsByNameNotificationsByIdTest, postApiV1ProjectsByNameQueries, postApiV1ProjectsByNameQueriesGenerate, postApiV1ProjectsByNameQueriesReplacePreview, postApiV1ProjectsByNameRuns, postApiV1ProjectsByNameTrafficConnectCloudRun, postApiV1ProjectsByNameTrafficConnectVercel, postApiV1ProjectsByNameTrafficConnectWordpress, postApiV1ProjectsByNameTrafficSourcesByIdBackfill, postApiV1ProjectsByNameTrafficSourcesByIdReset, postApiV1ProjectsByNameTrafficSourcesByIdSync, postApiV1ProjectsByNameWordpressConnect, postApiV1ProjectsByNameWordpressLlmsTxtManual, postApiV1ProjectsByNameWordpressOnboard, postApiV1ProjectsByNameWordpressPageMeta, postApiV1ProjectsByNameWordpressPages, postApiV1ProjectsByNameWordpressPagesMetaBulk, postApiV1ProjectsByNameWordpressSchemaDeploy, postApiV1ProjectsByNameWordpressSchemaManual, postApiV1ProjectsByNameWordpressStagingPush, postApiV1Runs, postApiV1RunsByIdCancel, postApiV1Snapshot, putApiV1ProjectsByName, putApiV1ProjectsByNameAgentMemory, putApiV1ProjectsByNameCompetitors, putApiV1ProjectsByNameGbpLocationsByLocationNameSelection, putApiV1ProjectsByNameGoogleConnectionsByTypeProperty, putApiV1ProjectsByNameGoogleConnectionsByTypeSitemap, putApiV1ProjectsByNameKeywords, putApiV1ProjectsByNameLocationsDefault, putApiV1ProjectsByNameQueries, putApiV1ProjectsByNameSchedule, putApiV1ProjectsByNameWordpressPage, putApiV1SettingsBing, putApiV1SettingsCdp, putApiV1SettingsGoogle, putApiV1SettingsProvidersByName, putApiV1Telemetry } from '../sdk.gen.js'; -import type { DeleteApiV1BacklinksCacheByReleaseData, DeleteApiV1BacklinksCacheByReleaseError, DeleteApiV1BacklinksCacheByReleaseResponse, DeleteApiV1ProjectsByNameAgentMemoryData, DeleteApiV1ProjectsByNameAgentMemoryError, DeleteApiV1ProjectsByNameAgentMemoryResponse, DeleteApiV1ProjectsByNameAgentTranscriptData, DeleteApiV1ProjectsByNameAgentTranscriptError, DeleteApiV1ProjectsByNameAgentTranscriptResponse, DeleteApiV1ProjectsByNameBingDisconnectData, DeleteApiV1ProjectsByNameBingDisconnectError, DeleteApiV1ProjectsByNameBingDisconnectResponse, DeleteApiV1ProjectsByNameCompetitorsData, DeleteApiV1ProjectsByNameCompetitorsError, DeleteApiV1ProjectsByNameCompetitorsResponse, DeleteApiV1ProjectsByNameContentDismissalsByTargetRefData, DeleteApiV1ProjectsByNameContentDismissalsByTargetRefError, DeleteApiV1ProjectsByNameContentDismissalsByTargetRefResponse, DeleteApiV1ProjectsByNameData, DeleteApiV1ProjectsByNameError, DeleteApiV1ProjectsByNameGaDisconnectData, DeleteApiV1ProjectsByNameGaDisconnectError, DeleteApiV1ProjectsByNameGaDisconnectResponse, DeleteApiV1ProjectsByNameGbpConnectionData, DeleteApiV1ProjectsByNameGbpConnectionError, DeleteApiV1ProjectsByNameGbpConnectionResponse, DeleteApiV1ProjectsByNameGoogleConnectionsByTypeData, DeleteApiV1ProjectsByNameGoogleConnectionsByTypeError, DeleteApiV1ProjectsByNameGoogleConnectionsByTypeResponse, DeleteApiV1ProjectsByNameKeywordsData, DeleteApiV1ProjectsByNameKeywordsError, DeleteApiV1ProjectsByNameKeywordsResponse, DeleteApiV1ProjectsByNameLocationsByLabelData, DeleteApiV1ProjectsByNameLocationsByLabelError, DeleteApiV1ProjectsByNameLocationsByLabelResponse, DeleteApiV1ProjectsByNameNotificationsByIdData, DeleteApiV1ProjectsByNameNotificationsByIdError, DeleteApiV1ProjectsByNameNotificationsByIdResponse, DeleteApiV1ProjectsByNameQueriesData, DeleteApiV1ProjectsByNameQueriesError, DeleteApiV1ProjectsByNameQueriesResponse, DeleteApiV1ProjectsByNameResponse, DeleteApiV1ProjectsByNameScheduleData, DeleteApiV1ProjectsByNameScheduleError, DeleteApiV1ProjectsByNameScheduleResponse, DeleteApiV1ProjectsByNameWordpressDisconnectData, DeleteApiV1ProjectsByNameWordpressDisconnectError, DeleteApiV1ProjectsByNameWordpressDisconnectResponse, GetApiV1BacklinksLatestReleaseData, GetApiV1BacklinksReleasesData, GetApiV1BacklinksStatusData, GetApiV1BacklinksSyncsData, GetApiV1BacklinksSyncsLatestData, GetApiV1CdpStatusData, GetApiV1DoctorData, GetApiV1GoogleCallbackData, GetApiV1HistoryData, GetApiV1NotificationsEventsData, GetApiV1OpenapiJsonData, GetApiV1ProjectsByNameAgentMemoryData, GetApiV1ProjectsByNameAgentProvidersData, GetApiV1ProjectsByNameAgentTranscriptData, GetApiV1ProjectsByNameAnalyticsGapsData, GetApiV1ProjectsByNameAnalyticsMetricsData, GetApiV1ProjectsByNameAnalyticsSourcesData, GetApiV1ProjectsByNameBacklinksDomainsData, GetApiV1ProjectsByNameBacklinksDomainsError, GetApiV1ProjectsByNameBacklinksDomainsResponse, GetApiV1ProjectsByNameBacklinksHistoryData, GetApiV1ProjectsByNameBacklinksSummaryData, GetApiV1ProjectsByNameBingCoverageData, GetApiV1ProjectsByNameBingCoverageHistoryData, GetApiV1ProjectsByNameBingInspectionsData, GetApiV1ProjectsByNameBingPerformanceData, GetApiV1ProjectsByNameBingSitesData, GetApiV1ProjectsByNameBingStatusData, GetApiV1ProjectsByNameCitationsVisibilityData, GetApiV1ProjectsByNameCompetitorsData, GetApiV1ProjectsByNameContentDismissalsData, GetApiV1ProjectsByNameContentGapsData, GetApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysisData, GetApiV1ProjectsByNameContentSourcesData, GetApiV1ProjectsByNameContentTargetsData, GetApiV1ProjectsByNameData, GetApiV1ProjectsByNameDeletePreviewData, GetApiV1ProjectsByNameDiscoverSessionsByIdData, GetApiV1ProjectsByNameDiscoverSessionsByIdPromoteData, GetApiV1ProjectsByNameDiscoverSessionsData, GetApiV1ProjectsByNameDoctorData, GetApiV1ProjectsByNameExportData, GetApiV1ProjectsByNameGaAiReferralHistoryData, GetApiV1ProjectsByNameGaAttributionTrendData, GetApiV1ProjectsByNameGaCoverageData, GetApiV1ProjectsByNameGaSessionHistoryData, GetApiV1ProjectsByNameGaSocialReferralHistoryData, GetApiV1ProjectsByNameGaSocialReferralTrendData, GetApiV1ProjectsByNameGaStatusData, GetApiV1ProjectsByNameGaTrafficData, GetApiV1ProjectsByNameGbpAccountsData, GetApiV1ProjectsByNameGbpKeywordsData, GetApiV1ProjectsByNameGbpLocationsData, GetApiV1ProjectsByNameGbpLodgingData, GetApiV1ProjectsByNameGbpMetricsData, GetApiV1ProjectsByNameGbpPlaceActionsData, GetApiV1ProjectsByNameGbpPlacesData, GetApiV1ProjectsByNameGbpSummaryData, GetApiV1ProjectsByNameGoogleCallbackData, GetApiV1ProjectsByNameGoogleConnectionsData, GetApiV1ProjectsByNameGoogleGscCoverageData, GetApiV1ProjectsByNameGoogleGscCoverageHistoryData, GetApiV1ProjectsByNameGoogleGscDeindexedData, GetApiV1ProjectsByNameGoogleGscInspectionsData, GetApiV1ProjectsByNameGoogleGscPerformanceDailyData, GetApiV1ProjectsByNameGoogleGscPerformanceData, GetApiV1ProjectsByNameGoogleGscPerformanceError, GetApiV1ProjectsByNameGoogleGscPerformanceResponse, GetApiV1ProjectsByNameGoogleGscSitemapsData, GetApiV1ProjectsByNameGooglePropertiesData, GetApiV1ProjectsByNameHealthHistoryData, GetApiV1ProjectsByNameHealthLatestData, GetApiV1ProjectsByNameHistoryData, GetApiV1ProjectsByNameInsightsByIdData, GetApiV1ProjectsByNameInsightsData, GetApiV1ProjectsByNameKeywordsData, GetApiV1ProjectsByNameLocationsData, GetApiV1ProjectsByNameNotificationsData, GetApiV1ProjectsByNameOverviewData, GetApiV1ProjectsByNameQueriesData, GetApiV1ProjectsByNameReportData, GetApiV1ProjectsByNameReportHtmlData, GetApiV1ProjectsByNameRunsByRunIdBrowserDiffData, GetApiV1ProjectsByNameRunsData, GetApiV1ProjectsByNameRunsLatestData, GetApiV1ProjectsByNameScheduleData, GetApiV1ProjectsByNameSearchData, GetApiV1ProjectsByNameSnapshotsData, GetApiV1ProjectsByNameSnapshotsDiffData, GetApiV1ProjectsByNameSnapshotsResponse, GetApiV1ProjectsByNameTimelineData, GetApiV1ProjectsByNameTrafficEventsData, GetApiV1ProjectsByNameTrafficSourcesByIdData, GetApiV1ProjectsByNameTrafficSourcesData, GetApiV1ProjectsByNameTrafficStatusData, GetApiV1ProjectsByNameWordpressAuditData, GetApiV1ProjectsByNameWordpressDiffData, GetApiV1ProjectsByNameWordpressLlmsTxtData, GetApiV1ProjectsByNameWordpressPageData, GetApiV1ProjectsByNameWordpressPagesData, GetApiV1ProjectsByNameWordpressSchemaData, GetApiV1ProjectsByNameWordpressSchemaStatusData, GetApiV1ProjectsByNameWordpressStagingStatusData, GetApiV1ProjectsByNameWordpressStatusData, GetApiV1ProjectsData, GetApiV1RunsByIdData, GetApiV1RunsData, GetApiV1ScreenshotsBySnapshotIdData, GetApiV1SettingsData, GetApiV1TelemetryData, PostApiV1ApplyData, PostApiV1ApplyError, PostApiV1ApplyResponse, PostApiV1BacklinksInstallData, PostApiV1BacklinksInstallError, PostApiV1BacklinksInstallResponse, PostApiV1BacklinksSyncsData, PostApiV1BacklinksSyncsError, PostApiV1BacklinksSyncsResponse, PostApiV1CdpScreenshotData, PostApiV1CdpScreenshotError, PostApiV1CdpScreenshotResponse, PostApiV1ProjectsByNameBacklinksExtractData, PostApiV1ProjectsByNameBacklinksExtractError, PostApiV1ProjectsByNameBacklinksExtractResponse, PostApiV1ProjectsByNameBingConnectData, PostApiV1ProjectsByNameBingConnectError, PostApiV1ProjectsByNameBingConnectResponse, PostApiV1ProjectsByNameBingInspectSitemapData, PostApiV1ProjectsByNameBingInspectSitemapError, PostApiV1ProjectsByNameBingInspectSitemapResponse, PostApiV1ProjectsByNameBingInspectUrlData, PostApiV1ProjectsByNameBingInspectUrlError, PostApiV1ProjectsByNameBingInspectUrlResponse, PostApiV1ProjectsByNameBingRequestIndexingData, PostApiV1ProjectsByNameBingRequestIndexingError, PostApiV1ProjectsByNameBingRequestIndexingResponse, PostApiV1ProjectsByNameBingSetSiteData, PostApiV1ProjectsByNameBingSetSiteError, PostApiV1ProjectsByNameBingSetSiteResponse, PostApiV1ProjectsByNameCompetitorsData, PostApiV1ProjectsByNameCompetitorsError, PostApiV1ProjectsByNameCompetitorsResponse, PostApiV1ProjectsByNameContentDismissalsData, PostApiV1ProjectsByNameContentDismissalsError, PostApiV1ProjectsByNameContentDismissalsResponse, PostApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyzeData, PostApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyzeError, PostApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyzeResponse, PostApiV1ProjectsByNameDiscoverRunData, PostApiV1ProjectsByNameDiscoverRunError, PostApiV1ProjectsByNameDiscoverRunResponse, PostApiV1ProjectsByNameDiscoverSessionsByIdPromoteData, PostApiV1ProjectsByNameDiscoverSessionsByIdPromoteError, PostApiV1ProjectsByNameDiscoverSessionsByIdPromoteResponse, PostApiV1ProjectsByNameGaConnectData, PostApiV1ProjectsByNameGaConnectError, PostApiV1ProjectsByNameGaConnectResponse, PostApiV1ProjectsByNameGaSyncData, PostApiV1ProjectsByNameGaSyncError, PostApiV1ProjectsByNameGaSyncResponse, PostApiV1ProjectsByNameGbpLocationsDiscoverData, PostApiV1ProjectsByNameGbpLocationsDiscoverError, PostApiV1ProjectsByNameGbpLocationsDiscoverResponse, PostApiV1ProjectsByNameGbpSyncData, PostApiV1ProjectsByNameGbpSyncError, PostApiV1ProjectsByNameGbpSyncResponse, PostApiV1ProjectsByNameGoogleConnectData, PostApiV1ProjectsByNameGoogleConnectError, PostApiV1ProjectsByNameGoogleConnectResponse, PostApiV1ProjectsByNameGoogleGscDiscoverSitemapsData, PostApiV1ProjectsByNameGoogleGscDiscoverSitemapsError, PostApiV1ProjectsByNameGoogleGscDiscoverSitemapsResponse, PostApiV1ProjectsByNameGoogleGscInspectData, PostApiV1ProjectsByNameGoogleGscInspectError, PostApiV1ProjectsByNameGoogleGscInspectResponse, PostApiV1ProjectsByNameGoogleGscInspectSitemapData, PostApiV1ProjectsByNameGoogleGscInspectSitemapError, PostApiV1ProjectsByNameGoogleGscInspectSitemapResponse, PostApiV1ProjectsByNameGoogleGscSyncData, PostApiV1ProjectsByNameGoogleGscSyncError, PostApiV1ProjectsByNameGoogleGscSyncResponse, PostApiV1ProjectsByNameGoogleIndexingRequestData, PostApiV1ProjectsByNameGoogleIndexingRequestError, PostApiV1ProjectsByNameGoogleIndexingRequestResponse, PostApiV1ProjectsByNameInsightsByIdDismissData, PostApiV1ProjectsByNameInsightsByIdDismissError, PostApiV1ProjectsByNameInsightsByIdDismissResponse, PostApiV1ProjectsByNameKeywordsData, PostApiV1ProjectsByNameKeywordsGenerateData, PostApiV1ProjectsByNameKeywordsGenerateError, PostApiV1ProjectsByNameKeywordsGenerateResponse, PostApiV1ProjectsByNameKeywordsResponse, PostApiV1ProjectsByNameLocationsData, PostApiV1ProjectsByNameLocationsError, PostApiV1ProjectsByNameLocationsResponse, PostApiV1ProjectsByNameNotificationsByIdTestData, PostApiV1ProjectsByNameNotificationsByIdTestError, PostApiV1ProjectsByNameNotificationsByIdTestResponse, PostApiV1ProjectsByNameNotificationsData, PostApiV1ProjectsByNameNotificationsResponse, PostApiV1ProjectsByNameQueriesData, PostApiV1ProjectsByNameQueriesGenerateData, PostApiV1ProjectsByNameQueriesGenerateError, PostApiV1ProjectsByNameQueriesGenerateResponse, PostApiV1ProjectsByNameQueriesReplacePreviewData, PostApiV1ProjectsByNameQueriesReplacePreviewError, PostApiV1ProjectsByNameQueriesReplacePreviewResponse, PostApiV1ProjectsByNameQueriesResponse, PostApiV1ProjectsByNameRunsData, PostApiV1ProjectsByNameRunsError, PostApiV1ProjectsByNameRunsResponse, PostApiV1ProjectsByNameTrafficConnectCloudRunData, PostApiV1ProjectsByNameTrafficConnectCloudRunError, PostApiV1ProjectsByNameTrafficConnectCloudRunResponse, PostApiV1ProjectsByNameTrafficConnectVercelData, PostApiV1ProjectsByNameTrafficConnectVercelError, PostApiV1ProjectsByNameTrafficConnectVercelResponse, PostApiV1ProjectsByNameTrafficConnectWordpressData, PostApiV1ProjectsByNameTrafficConnectWordpressError, PostApiV1ProjectsByNameTrafficConnectWordpressResponse, PostApiV1ProjectsByNameTrafficSourcesByIdBackfillData, PostApiV1ProjectsByNameTrafficSourcesByIdBackfillError, PostApiV1ProjectsByNameTrafficSourcesByIdBackfillResponse, PostApiV1ProjectsByNameTrafficSourcesByIdResetData, PostApiV1ProjectsByNameTrafficSourcesByIdResetError, PostApiV1ProjectsByNameTrafficSourcesByIdResetResponse, PostApiV1ProjectsByNameTrafficSourcesByIdSyncData, PostApiV1ProjectsByNameTrafficSourcesByIdSyncError, PostApiV1ProjectsByNameTrafficSourcesByIdSyncResponse, PostApiV1ProjectsByNameWordpressConnectData, PostApiV1ProjectsByNameWordpressConnectError, PostApiV1ProjectsByNameWordpressConnectResponse, PostApiV1ProjectsByNameWordpressLlmsTxtManualData, PostApiV1ProjectsByNameWordpressLlmsTxtManualError, PostApiV1ProjectsByNameWordpressLlmsTxtManualResponse, PostApiV1ProjectsByNameWordpressOnboardData, PostApiV1ProjectsByNameWordpressOnboardError, PostApiV1ProjectsByNameWordpressOnboardResponse, PostApiV1ProjectsByNameWordpressPageMetaData, PostApiV1ProjectsByNameWordpressPageMetaError, PostApiV1ProjectsByNameWordpressPageMetaResponse, PostApiV1ProjectsByNameWordpressPagesData, PostApiV1ProjectsByNameWordpressPagesError, PostApiV1ProjectsByNameWordpressPagesMetaBulkData, PostApiV1ProjectsByNameWordpressPagesMetaBulkError, PostApiV1ProjectsByNameWordpressPagesMetaBulkResponse, PostApiV1ProjectsByNameWordpressPagesResponse, PostApiV1ProjectsByNameWordpressSchemaDeployData, PostApiV1ProjectsByNameWordpressSchemaDeployError, PostApiV1ProjectsByNameWordpressSchemaDeployResponse, PostApiV1ProjectsByNameWordpressSchemaManualData, PostApiV1ProjectsByNameWordpressSchemaManualError, PostApiV1ProjectsByNameWordpressSchemaManualResponse, PostApiV1ProjectsByNameWordpressStagingPushData, PostApiV1ProjectsByNameWordpressStagingPushError, PostApiV1ProjectsByNameWordpressStagingPushResponse, PostApiV1RunsByIdCancelData, PostApiV1RunsByIdCancelError, PostApiV1RunsByIdCancelResponse, PostApiV1RunsData, PostApiV1RunsResponse, PostApiV1SnapshotData, PostApiV1SnapshotError, PostApiV1SnapshotResponse, PutApiV1ProjectsByNameAgentMemoryData, PutApiV1ProjectsByNameAgentMemoryError, PutApiV1ProjectsByNameAgentMemoryResponse, PutApiV1ProjectsByNameCompetitorsData, PutApiV1ProjectsByNameCompetitorsResponse, PutApiV1ProjectsByNameData, PutApiV1ProjectsByNameGbpLocationsByLocationNameSelectionData, PutApiV1ProjectsByNameGbpLocationsByLocationNameSelectionError, PutApiV1ProjectsByNameGbpLocationsByLocationNameSelectionResponse, PutApiV1ProjectsByNameGoogleConnectionsByTypePropertyData, PutApiV1ProjectsByNameGoogleConnectionsByTypePropertyError, PutApiV1ProjectsByNameGoogleConnectionsByTypePropertyResponse, PutApiV1ProjectsByNameGoogleConnectionsByTypeSitemapData, PutApiV1ProjectsByNameGoogleConnectionsByTypeSitemapError, PutApiV1ProjectsByNameGoogleConnectionsByTypeSitemapResponse, PutApiV1ProjectsByNameKeywordsData, PutApiV1ProjectsByNameKeywordsResponse, PutApiV1ProjectsByNameLocationsDefaultData, PutApiV1ProjectsByNameLocationsDefaultError, PutApiV1ProjectsByNameLocationsDefaultResponse, PutApiV1ProjectsByNameQueriesData, PutApiV1ProjectsByNameQueriesResponse, PutApiV1ProjectsByNameResponse, PutApiV1ProjectsByNameScheduleData, PutApiV1ProjectsByNameScheduleError, PutApiV1ProjectsByNameScheduleResponse, PutApiV1ProjectsByNameWordpressPageData, PutApiV1ProjectsByNameWordpressPageError, PutApiV1ProjectsByNameWordpressPageResponse, PutApiV1SettingsBingData, PutApiV1SettingsBingError, PutApiV1SettingsBingResponse, PutApiV1SettingsCdpData, PutApiV1SettingsCdpError, PutApiV1SettingsCdpResponse, PutApiV1SettingsGoogleData, PutApiV1SettingsGoogleError, PutApiV1SettingsGoogleResponse, PutApiV1SettingsProvidersByNameData, PutApiV1SettingsProvidersByNameError, PutApiV1SettingsProvidersByNameResponse, PutApiV1TelemetryData, PutApiV1TelemetryError, PutApiV1TelemetryResponse } from '../types.gen.js'; +import { deleteApiV1BacklinksCacheByRelease, deleteApiV1ProjectsByName, deleteApiV1ProjectsByNameAgentMemory, deleteApiV1ProjectsByNameAgentTranscript, deleteApiV1ProjectsByNameBingDisconnect, deleteApiV1ProjectsByNameCompetitors, deleteApiV1ProjectsByNameContentDismissalsByTargetRef, deleteApiV1ProjectsByNameGaDisconnect, deleteApiV1ProjectsByNameGbpConnection, deleteApiV1ProjectsByNameGoogleConnectionsByType, deleteApiV1ProjectsByNameKeywords, deleteApiV1ProjectsByNameLocationsByLabel, deleteApiV1ProjectsByNameNotificationsById, deleteApiV1ProjectsByNameQueries, deleteApiV1ProjectsByNameSchedule, deleteApiV1ProjectsByNameWordpressDisconnect, getApiV1BacklinksLatestRelease, getApiV1BacklinksReleases, getApiV1BacklinksStatus, getApiV1BacklinksSyncs, getApiV1BacklinksSyncsLatest, getApiV1CdpStatus, getApiV1Doctor, getApiV1GoogleCallback, getApiV1GuestReportById, getApiV1History, getApiV1NotificationsEvents, getApiV1OpenapiJson, getApiV1Projects, getApiV1ProjectsByName, getApiV1ProjectsByNameAgentMemory, getApiV1ProjectsByNameAgentProviders, getApiV1ProjectsByNameAgentTranscript, getApiV1ProjectsByNameAnalyticsGaps, getApiV1ProjectsByNameAnalyticsMetrics, getApiV1ProjectsByNameAnalyticsSources, getApiV1ProjectsByNameBacklinksDomains, getApiV1ProjectsByNameBacklinksHistory, getApiV1ProjectsByNameBacklinksSummary, getApiV1ProjectsByNameBingCoverage, getApiV1ProjectsByNameBingCoverageHistory, getApiV1ProjectsByNameBingInspections, getApiV1ProjectsByNameBingPerformance, getApiV1ProjectsByNameBingSites, getApiV1ProjectsByNameBingStatus, getApiV1ProjectsByNameCitationsVisibility, getApiV1ProjectsByNameCompetitors, getApiV1ProjectsByNameContentDismissals, getApiV1ProjectsByNameContentGaps, getApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysis, getApiV1ProjectsByNameContentSources, getApiV1ProjectsByNameContentTargets, getApiV1ProjectsByNameDeletePreview, getApiV1ProjectsByNameDiscoverSessions, getApiV1ProjectsByNameDiscoverSessionsById, getApiV1ProjectsByNameDiscoverSessionsByIdPromote, getApiV1ProjectsByNameDoctor, getApiV1ProjectsByNameExport, getApiV1ProjectsByNameGaAiReferralHistory, getApiV1ProjectsByNameGaAttributionTrend, getApiV1ProjectsByNameGaCoverage, getApiV1ProjectsByNameGaSessionHistory, getApiV1ProjectsByNameGaSocialReferralHistory, getApiV1ProjectsByNameGaSocialReferralTrend, getApiV1ProjectsByNameGaStatus, getApiV1ProjectsByNameGaTraffic, getApiV1ProjectsByNameGbpAccounts, getApiV1ProjectsByNameGbpKeywords, getApiV1ProjectsByNameGbpLocations, getApiV1ProjectsByNameGbpLodging, getApiV1ProjectsByNameGbpMetrics, getApiV1ProjectsByNameGbpPlaceActions, getApiV1ProjectsByNameGbpPlaces, getApiV1ProjectsByNameGbpSummary, getApiV1ProjectsByNameGoogleCallback, getApiV1ProjectsByNameGoogleConnections, getApiV1ProjectsByNameGoogleGscCoverage, getApiV1ProjectsByNameGoogleGscCoverageHistory, getApiV1ProjectsByNameGoogleGscDeindexed, getApiV1ProjectsByNameGoogleGscInspections, getApiV1ProjectsByNameGoogleGscPerformance, getApiV1ProjectsByNameGoogleGscPerformanceDaily, getApiV1ProjectsByNameGoogleGscSitemaps, getApiV1ProjectsByNameGoogleProperties, getApiV1ProjectsByNameHealthHistory, getApiV1ProjectsByNameHealthLatest, getApiV1ProjectsByNameHistory, getApiV1ProjectsByNameInsights, getApiV1ProjectsByNameInsightsById, getApiV1ProjectsByNameKeywords, getApiV1ProjectsByNameLocations, getApiV1ProjectsByNameNotifications, getApiV1ProjectsByNameOverview, getApiV1ProjectsByNameQueries, getApiV1ProjectsByNameReport, getApiV1ProjectsByNameReportHtml, getApiV1ProjectsByNameRuns, getApiV1ProjectsByNameRunsByRunIdBrowserDiff, getApiV1ProjectsByNameRunsLatest, getApiV1ProjectsByNameSchedule, getApiV1ProjectsByNameSearch, getApiV1ProjectsByNameSnapshots, getApiV1ProjectsByNameSnapshotsDiff, getApiV1ProjectsByNameTimeline, getApiV1ProjectsByNameTrafficEvents, getApiV1ProjectsByNameTrafficSources, getApiV1ProjectsByNameTrafficSourcesById, getApiV1ProjectsByNameTrafficStatus, getApiV1ProjectsByNameWordpressAudit, getApiV1ProjectsByNameWordpressDiff, getApiV1ProjectsByNameWordpressLlmsTxt, getApiV1ProjectsByNameWordpressPage, getApiV1ProjectsByNameWordpressPages, getApiV1ProjectsByNameWordpressSchema, getApiV1ProjectsByNameWordpressSchemaStatus, getApiV1ProjectsByNameWordpressStagingStatus, getApiV1ProjectsByNameWordpressStatus, getApiV1Runs, getApiV1RunsById, getApiV1ScreenshotsBySnapshotId, getApiV1Settings, getApiV1Telemetry, type Options, postApiV1Apply, postApiV1BacklinksInstall, postApiV1BacklinksSyncs, postApiV1CdpScreenshot, postApiV1GuestReport, postApiV1GuestReportByIdClaim, postApiV1ProjectsByNameBacklinksExtract, postApiV1ProjectsByNameBingConnect, postApiV1ProjectsByNameBingInspectSitemap, postApiV1ProjectsByNameBingInspectUrl, postApiV1ProjectsByNameBingRequestIndexing, postApiV1ProjectsByNameBingSetSite, postApiV1ProjectsByNameCompetitors, postApiV1ProjectsByNameContentDismissals, postApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyze, postApiV1ProjectsByNameDiscoverRun, postApiV1ProjectsByNameDiscoverSessionsByIdPromote, postApiV1ProjectsByNameGaConnect, postApiV1ProjectsByNameGaSync, postApiV1ProjectsByNameGbpLocationsDiscover, postApiV1ProjectsByNameGbpSync, postApiV1ProjectsByNameGoogleConnect, postApiV1ProjectsByNameGoogleGscDiscoverSitemaps, postApiV1ProjectsByNameGoogleGscInspect, postApiV1ProjectsByNameGoogleGscInspectSitemap, postApiV1ProjectsByNameGoogleGscSync, postApiV1ProjectsByNameGoogleIndexingRequest, postApiV1ProjectsByNameInsightsByIdDismiss, postApiV1ProjectsByNameKeywords, postApiV1ProjectsByNameKeywordsGenerate, postApiV1ProjectsByNameLocations, postApiV1ProjectsByNameNotifications, postApiV1ProjectsByNameNotificationsByIdTest, postApiV1ProjectsByNameQueries, postApiV1ProjectsByNameQueriesGenerate, postApiV1ProjectsByNameQueriesReplacePreview, postApiV1ProjectsByNameRuns, postApiV1ProjectsByNameTrafficConnectCloudRun, postApiV1ProjectsByNameTrafficConnectVercel, postApiV1ProjectsByNameTrafficConnectWordpress, postApiV1ProjectsByNameTrafficSourcesByIdBackfill, postApiV1ProjectsByNameTrafficSourcesByIdReset, postApiV1ProjectsByNameTrafficSourcesByIdSync, postApiV1ProjectsByNameWordpressConnect, postApiV1ProjectsByNameWordpressLlmsTxtManual, postApiV1ProjectsByNameWordpressOnboard, postApiV1ProjectsByNameWordpressPageMeta, postApiV1ProjectsByNameWordpressPages, postApiV1ProjectsByNameWordpressPagesMetaBulk, postApiV1ProjectsByNameWordpressSchemaDeploy, postApiV1ProjectsByNameWordpressSchemaManual, postApiV1ProjectsByNameWordpressStagingPush, postApiV1Runs, postApiV1RunsByIdCancel, postApiV1Snapshot, putApiV1ProjectsByName, putApiV1ProjectsByNameAgentMemory, putApiV1ProjectsByNameCompetitors, putApiV1ProjectsByNameGbpLocationsByLocationNameSelection, putApiV1ProjectsByNameGoogleConnectionsByTypeProperty, putApiV1ProjectsByNameGoogleConnectionsByTypeSitemap, putApiV1ProjectsByNameKeywords, putApiV1ProjectsByNameLocationsDefault, putApiV1ProjectsByNameQueries, putApiV1ProjectsByNameSchedule, putApiV1ProjectsByNameWordpressPage, putApiV1SettingsBing, putApiV1SettingsCdp, putApiV1SettingsGoogle, putApiV1SettingsProvidersByName, putApiV1Telemetry } from '../sdk.gen.js'; +import type { DeleteApiV1BacklinksCacheByReleaseData, DeleteApiV1BacklinksCacheByReleaseError, DeleteApiV1BacklinksCacheByReleaseResponse, DeleteApiV1ProjectsByNameAgentMemoryData, DeleteApiV1ProjectsByNameAgentMemoryError, DeleteApiV1ProjectsByNameAgentMemoryResponse, DeleteApiV1ProjectsByNameAgentTranscriptData, DeleteApiV1ProjectsByNameAgentTranscriptError, DeleteApiV1ProjectsByNameAgentTranscriptResponse, DeleteApiV1ProjectsByNameBingDisconnectData, DeleteApiV1ProjectsByNameBingDisconnectError, DeleteApiV1ProjectsByNameBingDisconnectResponse, DeleteApiV1ProjectsByNameCompetitorsData, DeleteApiV1ProjectsByNameCompetitorsError, DeleteApiV1ProjectsByNameCompetitorsResponse, DeleteApiV1ProjectsByNameContentDismissalsByTargetRefData, DeleteApiV1ProjectsByNameContentDismissalsByTargetRefError, DeleteApiV1ProjectsByNameContentDismissalsByTargetRefResponse, DeleteApiV1ProjectsByNameData, DeleteApiV1ProjectsByNameError, DeleteApiV1ProjectsByNameGaDisconnectData, DeleteApiV1ProjectsByNameGaDisconnectError, DeleteApiV1ProjectsByNameGaDisconnectResponse, DeleteApiV1ProjectsByNameGbpConnectionData, DeleteApiV1ProjectsByNameGbpConnectionError, DeleteApiV1ProjectsByNameGbpConnectionResponse, DeleteApiV1ProjectsByNameGoogleConnectionsByTypeData, DeleteApiV1ProjectsByNameGoogleConnectionsByTypeError, DeleteApiV1ProjectsByNameGoogleConnectionsByTypeResponse, DeleteApiV1ProjectsByNameKeywordsData, DeleteApiV1ProjectsByNameKeywordsError, DeleteApiV1ProjectsByNameKeywordsResponse, DeleteApiV1ProjectsByNameLocationsByLabelData, DeleteApiV1ProjectsByNameLocationsByLabelError, DeleteApiV1ProjectsByNameLocationsByLabelResponse, DeleteApiV1ProjectsByNameNotificationsByIdData, DeleteApiV1ProjectsByNameNotificationsByIdError, DeleteApiV1ProjectsByNameNotificationsByIdResponse, DeleteApiV1ProjectsByNameQueriesData, DeleteApiV1ProjectsByNameQueriesError, DeleteApiV1ProjectsByNameQueriesResponse, DeleteApiV1ProjectsByNameResponse, DeleteApiV1ProjectsByNameScheduleData, DeleteApiV1ProjectsByNameScheduleError, DeleteApiV1ProjectsByNameScheduleResponse, DeleteApiV1ProjectsByNameWordpressDisconnectData, DeleteApiV1ProjectsByNameWordpressDisconnectError, DeleteApiV1ProjectsByNameWordpressDisconnectResponse, GetApiV1BacklinksLatestReleaseData, GetApiV1BacklinksReleasesData, GetApiV1BacklinksStatusData, GetApiV1BacklinksSyncsData, GetApiV1BacklinksSyncsLatestData, GetApiV1CdpStatusData, GetApiV1DoctorData, GetApiV1GoogleCallbackData, GetApiV1GuestReportByIdData, GetApiV1HistoryData, GetApiV1NotificationsEventsData, GetApiV1OpenapiJsonData, GetApiV1ProjectsByNameAgentMemoryData, GetApiV1ProjectsByNameAgentProvidersData, GetApiV1ProjectsByNameAgentTranscriptData, GetApiV1ProjectsByNameAnalyticsGapsData, GetApiV1ProjectsByNameAnalyticsMetricsData, GetApiV1ProjectsByNameAnalyticsSourcesData, GetApiV1ProjectsByNameBacklinksDomainsData, GetApiV1ProjectsByNameBacklinksDomainsError, GetApiV1ProjectsByNameBacklinksDomainsResponse, GetApiV1ProjectsByNameBacklinksHistoryData, GetApiV1ProjectsByNameBacklinksSummaryData, GetApiV1ProjectsByNameBingCoverageData, GetApiV1ProjectsByNameBingCoverageHistoryData, GetApiV1ProjectsByNameBingInspectionsData, GetApiV1ProjectsByNameBingPerformanceData, GetApiV1ProjectsByNameBingSitesData, GetApiV1ProjectsByNameBingStatusData, GetApiV1ProjectsByNameCitationsVisibilityData, GetApiV1ProjectsByNameCompetitorsData, GetApiV1ProjectsByNameContentDismissalsData, GetApiV1ProjectsByNameContentGapsData, GetApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysisData, GetApiV1ProjectsByNameContentSourcesData, GetApiV1ProjectsByNameContentTargetsData, GetApiV1ProjectsByNameData, GetApiV1ProjectsByNameDeletePreviewData, GetApiV1ProjectsByNameDiscoverSessionsByIdData, GetApiV1ProjectsByNameDiscoverSessionsByIdPromoteData, GetApiV1ProjectsByNameDiscoverSessionsData, GetApiV1ProjectsByNameDoctorData, GetApiV1ProjectsByNameExportData, GetApiV1ProjectsByNameGaAiReferralHistoryData, GetApiV1ProjectsByNameGaAttributionTrendData, GetApiV1ProjectsByNameGaCoverageData, GetApiV1ProjectsByNameGaSessionHistoryData, GetApiV1ProjectsByNameGaSocialReferralHistoryData, GetApiV1ProjectsByNameGaSocialReferralTrendData, GetApiV1ProjectsByNameGaStatusData, GetApiV1ProjectsByNameGaTrafficData, GetApiV1ProjectsByNameGbpAccountsData, GetApiV1ProjectsByNameGbpKeywordsData, GetApiV1ProjectsByNameGbpLocationsData, GetApiV1ProjectsByNameGbpLodgingData, GetApiV1ProjectsByNameGbpMetricsData, GetApiV1ProjectsByNameGbpPlaceActionsData, GetApiV1ProjectsByNameGbpPlacesData, GetApiV1ProjectsByNameGbpSummaryData, GetApiV1ProjectsByNameGoogleCallbackData, GetApiV1ProjectsByNameGoogleConnectionsData, GetApiV1ProjectsByNameGoogleGscCoverageData, GetApiV1ProjectsByNameGoogleGscCoverageHistoryData, GetApiV1ProjectsByNameGoogleGscDeindexedData, GetApiV1ProjectsByNameGoogleGscInspectionsData, GetApiV1ProjectsByNameGoogleGscPerformanceDailyData, GetApiV1ProjectsByNameGoogleGscPerformanceData, GetApiV1ProjectsByNameGoogleGscPerformanceError, GetApiV1ProjectsByNameGoogleGscPerformanceResponse, GetApiV1ProjectsByNameGoogleGscSitemapsData, GetApiV1ProjectsByNameGooglePropertiesData, GetApiV1ProjectsByNameHealthHistoryData, GetApiV1ProjectsByNameHealthLatestData, GetApiV1ProjectsByNameHistoryData, GetApiV1ProjectsByNameInsightsByIdData, GetApiV1ProjectsByNameInsightsData, GetApiV1ProjectsByNameKeywordsData, GetApiV1ProjectsByNameLocationsData, GetApiV1ProjectsByNameNotificationsData, GetApiV1ProjectsByNameOverviewData, GetApiV1ProjectsByNameQueriesData, GetApiV1ProjectsByNameReportData, GetApiV1ProjectsByNameReportHtmlData, GetApiV1ProjectsByNameRunsByRunIdBrowserDiffData, GetApiV1ProjectsByNameRunsData, GetApiV1ProjectsByNameRunsLatestData, GetApiV1ProjectsByNameScheduleData, GetApiV1ProjectsByNameSearchData, GetApiV1ProjectsByNameSnapshotsData, GetApiV1ProjectsByNameSnapshotsDiffData, GetApiV1ProjectsByNameSnapshotsResponse, GetApiV1ProjectsByNameTimelineData, GetApiV1ProjectsByNameTrafficEventsData, GetApiV1ProjectsByNameTrafficSourcesByIdData, GetApiV1ProjectsByNameTrafficSourcesData, GetApiV1ProjectsByNameTrafficStatusData, GetApiV1ProjectsByNameWordpressAuditData, GetApiV1ProjectsByNameWordpressDiffData, GetApiV1ProjectsByNameWordpressLlmsTxtData, GetApiV1ProjectsByNameWordpressPageData, GetApiV1ProjectsByNameWordpressPagesData, GetApiV1ProjectsByNameWordpressSchemaData, GetApiV1ProjectsByNameWordpressSchemaStatusData, GetApiV1ProjectsByNameWordpressStagingStatusData, GetApiV1ProjectsByNameWordpressStatusData, GetApiV1ProjectsData, GetApiV1RunsByIdData, GetApiV1RunsData, GetApiV1ScreenshotsBySnapshotIdData, GetApiV1SettingsData, GetApiV1TelemetryData, PostApiV1ApplyData, PostApiV1ApplyError, PostApiV1ApplyResponse, PostApiV1BacklinksInstallData, PostApiV1BacklinksInstallError, PostApiV1BacklinksInstallResponse, PostApiV1BacklinksSyncsData, PostApiV1BacklinksSyncsError, PostApiV1BacklinksSyncsResponse, PostApiV1CdpScreenshotData, PostApiV1CdpScreenshotError, PostApiV1CdpScreenshotResponse, PostApiV1GuestReportByIdClaimData, PostApiV1GuestReportByIdClaimError, PostApiV1GuestReportByIdClaimResponse, PostApiV1GuestReportData, PostApiV1GuestReportError, PostApiV1GuestReportResponse, PostApiV1ProjectsByNameBacklinksExtractData, PostApiV1ProjectsByNameBacklinksExtractError, PostApiV1ProjectsByNameBacklinksExtractResponse, PostApiV1ProjectsByNameBingConnectData, PostApiV1ProjectsByNameBingConnectError, PostApiV1ProjectsByNameBingConnectResponse, PostApiV1ProjectsByNameBingInspectSitemapData, PostApiV1ProjectsByNameBingInspectSitemapError, PostApiV1ProjectsByNameBingInspectSitemapResponse, PostApiV1ProjectsByNameBingInspectUrlData, PostApiV1ProjectsByNameBingInspectUrlError, PostApiV1ProjectsByNameBingInspectUrlResponse, PostApiV1ProjectsByNameBingRequestIndexingData, PostApiV1ProjectsByNameBingRequestIndexingError, PostApiV1ProjectsByNameBingRequestIndexingResponse, PostApiV1ProjectsByNameBingSetSiteData, PostApiV1ProjectsByNameBingSetSiteError, PostApiV1ProjectsByNameBingSetSiteResponse, PostApiV1ProjectsByNameCompetitorsData, PostApiV1ProjectsByNameCompetitorsError, PostApiV1ProjectsByNameCompetitorsResponse, PostApiV1ProjectsByNameContentDismissalsData, PostApiV1ProjectsByNameContentDismissalsError, PostApiV1ProjectsByNameContentDismissalsResponse, PostApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyzeData, PostApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyzeError, PostApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyzeResponse, PostApiV1ProjectsByNameDiscoverRunData, PostApiV1ProjectsByNameDiscoverRunError, PostApiV1ProjectsByNameDiscoverRunResponse, PostApiV1ProjectsByNameDiscoverSessionsByIdPromoteData, PostApiV1ProjectsByNameDiscoverSessionsByIdPromoteError, PostApiV1ProjectsByNameDiscoverSessionsByIdPromoteResponse, PostApiV1ProjectsByNameGaConnectData, PostApiV1ProjectsByNameGaConnectError, PostApiV1ProjectsByNameGaConnectResponse, PostApiV1ProjectsByNameGaSyncData, PostApiV1ProjectsByNameGaSyncError, PostApiV1ProjectsByNameGaSyncResponse, PostApiV1ProjectsByNameGbpLocationsDiscoverData, PostApiV1ProjectsByNameGbpLocationsDiscoverError, PostApiV1ProjectsByNameGbpLocationsDiscoverResponse, PostApiV1ProjectsByNameGbpSyncData, PostApiV1ProjectsByNameGbpSyncError, PostApiV1ProjectsByNameGbpSyncResponse, PostApiV1ProjectsByNameGoogleConnectData, PostApiV1ProjectsByNameGoogleConnectError, PostApiV1ProjectsByNameGoogleConnectResponse, PostApiV1ProjectsByNameGoogleGscDiscoverSitemapsData, PostApiV1ProjectsByNameGoogleGscDiscoverSitemapsError, PostApiV1ProjectsByNameGoogleGscDiscoverSitemapsResponse, PostApiV1ProjectsByNameGoogleGscInspectData, PostApiV1ProjectsByNameGoogleGscInspectError, PostApiV1ProjectsByNameGoogleGscInspectResponse, PostApiV1ProjectsByNameGoogleGscInspectSitemapData, PostApiV1ProjectsByNameGoogleGscInspectSitemapError, PostApiV1ProjectsByNameGoogleGscInspectSitemapResponse, PostApiV1ProjectsByNameGoogleGscSyncData, PostApiV1ProjectsByNameGoogleGscSyncError, PostApiV1ProjectsByNameGoogleGscSyncResponse, PostApiV1ProjectsByNameGoogleIndexingRequestData, PostApiV1ProjectsByNameGoogleIndexingRequestError, PostApiV1ProjectsByNameGoogleIndexingRequestResponse, PostApiV1ProjectsByNameInsightsByIdDismissData, PostApiV1ProjectsByNameInsightsByIdDismissError, PostApiV1ProjectsByNameInsightsByIdDismissResponse, PostApiV1ProjectsByNameKeywordsData, PostApiV1ProjectsByNameKeywordsGenerateData, PostApiV1ProjectsByNameKeywordsGenerateError, PostApiV1ProjectsByNameKeywordsGenerateResponse, PostApiV1ProjectsByNameKeywordsResponse, PostApiV1ProjectsByNameLocationsData, PostApiV1ProjectsByNameLocationsError, PostApiV1ProjectsByNameLocationsResponse, PostApiV1ProjectsByNameNotificationsByIdTestData, PostApiV1ProjectsByNameNotificationsByIdTestError, PostApiV1ProjectsByNameNotificationsByIdTestResponse, PostApiV1ProjectsByNameNotificationsData, PostApiV1ProjectsByNameNotificationsResponse, PostApiV1ProjectsByNameQueriesData, PostApiV1ProjectsByNameQueriesGenerateData, PostApiV1ProjectsByNameQueriesGenerateError, PostApiV1ProjectsByNameQueriesGenerateResponse, PostApiV1ProjectsByNameQueriesReplacePreviewData, PostApiV1ProjectsByNameQueriesReplacePreviewError, PostApiV1ProjectsByNameQueriesReplacePreviewResponse, PostApiV1ProjectsByNameQueriesResponse, PostApiV1ProjectsByNameRunsData, PostApiV1ProjectsByNameRunsError, PostApiV1ProjectsByNameRunsResponse, PostApiV1ProjectsByNameTrafficConnectCloudRunData, PostApiV1ProjectsByNameTrafficConnectCloudRunError, PostApiV1ProjectsByNameTrafficConnectCloudRunResponse, PostApiV1ProjectsByNameTrafficConnectVercelData, PostApiV1ProjectsByNameTrafficConnectVercelError, PostApiV1ProjectsByNameTrafficConnectVercelResponse, PostApiV1ProjectsByNameTrafficConnectWordpressData, PostApiV1ProjectsByNameTrafficConnectWordpressError, PostApiV1ProjectsByNameTrafficConnectWordpressResponse, PostApiV1ProjectsByNameTrafficSourcesByIdBackfillData, PostApiV1ProjectsByNameTrafficSourcesByIdBackfillError, PostApiV1ProjectsByNameTrafficSourcesByIdBackfillResponse, PostApiV1ProjectsByNameTrafficSourcesByIdResetData, PostApiV1ProjectsByNameTrafficSourcesByIdResetError, PostApiV1ProjectsByNameTrafficSourcesByIdResetResponse, PostApiV1ProjectsByNameTrafficSourcesByIdSyncData, PostApiV1ProjectsByNameTrafficSourcesByIdSyncError, PostApiV1ProjectsByNameTrafficSourcesByIdSyncResponse, PostApiV1ProjectsByNameWordpressConnectData, PostApiV1ProjectsByNameWordpressConnectError, PostApiV1ProjectsByNameWordpressConnectResponse, PostApiV1ProjectsByNameWordpressLlmsTxtManualData, PostApiV1ProjectsByNameWordpressLlmsTxtManualError, PostApiV1ProjectsByNameWordpressLlmsTxtManualResponse, PostApiV1ProjectsByNameWordpressOnboardData, PostApiV1ProjectsByNameWordpressOnboardError, PostApiV1ProjectsByNameWordpressOnboardResponse, PostApiV1ProjectsByNameWordpressPageMetaData, PostApiV1ProjectsByNameWordpressPageMetaError, PostApiV1ProjectsByNameWordpressPageMetaResponse, PostApiV1ProjectsByNameWordpressPagesData, PostApiV1ProjectsByNameWordpressPagesError, PostApiV1ProjectsByNameWordpressPagesMetaBulkData, PostApiV1ProjectsByNameWordpressPagesMetaBulkError, PostApiV1ProjectsByNameWordpressPagesMetaBulkResponse, PostApiV1ProjectsByNameWordpressPagesResponse, PostApiV1ProjectsByNameWordpressSchemaDeployData, PostApiV1ProjectsByNameWordpressSchemaDeployError, PostApiV1ProjectsByNameWordpressSchemaDeployResponse, PostApiV1ProjectsByNameWordpressSchemaManualData, PostApiV1ProjectsByNameWordpressSchemaManualError, PostApiV1ProjectsByNameWordpressSchemaManualResponse, PostApiV1ProjectsByNameWordpressStagingPushData, PostApiV1ProjectsByNameWordpressStagingPushError, PostApiV1ProjectsByNameWordpressStagingPushResponse, PostApiV1RunsByIdCancelData, PostApiV1RunsByIdCancelError, PostApiV1RunsByIdCancelResponse, PostApiV1RunsData, PostApiV1RunsResponse, PostApiV1SnapshotData, PostApiV1SnapshotError, PostApiV1SnapshotResponse, PutApiV1ProjectsByNameAgentMemoryData, PutApiV1ProjectsByNameAgentMemoryError, PutApiV1ProjectsByNameAgentMemoryResponse, PutApiV1ProjectsByNameCompetitorsData, PutApiV1ProjectsByNameCompetitorsResponse, PutApiV1ProjectsByNameData, PutApiV1ProjectsByNameGbpLocationsByLocationNameSelectionData, PutApiV1ProjectsByNameGbpLocationsByLocationNameSelectionError, PutApiV1ProjectsByNameGbpLocationsByLocationNameSelectionResponse, PutApiV1ProjectsByNameGoogleConnectionsByTypePropertyData, PutApiV1ProjectsByNameGoogleConnectionsByTypePropertyError, PutApiV1ProjectsByNameGoogleConnectionsByTypePropertyResponse, PutApiV1ProjectsByNameGoogleConnectionsByTypeSitemapData, PutApiV1ProjectsByNameGoogleConnectionsByTypeSitemapError, PutApiV1ProjectsByNameGoogleConnectionsByTypeSitemapResponse, PutApiV1ProjectsByNameKeywordsData, PutApiV1ProjectsByNameKeywordsResponse, PutApiV1ProjectsByNameLocationsDefaultData, PutApiV1ProjectsByNameLocationsDefaultError, PutApiV1ProjectsByNameLocationsDefaultResponse, PutApiV1ProjectsByNameQueriesData, PutApiV1ProjectsByNameQueriesResponse, PutApiV1ProjectsByNameResponse, PutApiV1ProjectsByNameScheduleData, PutApiV1ProjectsByNameScheduleError, PutApiV1ProjectsByNameScheduleResponse, PutApiV1ProjectsByNameWordpressPageData, PutApiV1ProjectsByNameWordpressPageError, PutApiV1ProjectsByNameWordpressPageResponse, PutApiV1SettingsBingData, PutApiV1SettingsBingError, PutApiV1SettingsBingResponse, PutApiV1SettingsCdpData, PutApiV1SettingsCdpError, PutApiV1SettingsCdpResponse, PutApiV1SettingsGoogleData, PutApiV1SettingsGoogleError, PutApiV1SettingsGoogleResponse, PutApiV1SettingsProvidersByNameData, PutApiV1SettingsProvidersByNameError, PutApiV1SettingsProvidersByNameResponse, PutApiV1TelemetryData, PutApiV1TelemetryError, PutApiV1TelemetryResponse } from '../types.gen.js'; export type QueryKey = [ Pick & { @@ -63,6 +63,66 @@ export const getApiV1OpenapiJsonOptions = (options?: Options>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await postApiV1GuestReport({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const getApiV1GuestReportByIdQueryKey = (options: Options) => createQueryKey('getApiV1GuestReportById', options); + +/** + * Fetch a guest report + * + * Returns the current state of the report including audit/sweep scores, top findings, proposed plan, and the full progressEvents replay buffer. Anonymous — no auth required. + */ +export const getApiV1GuestReportByIdOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getApiV1GuestReportById({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getApiV1GuestReportByIdQueryKey(options) + }); +}; + +/** + * Claim a guest report into the authenticated workspace + * + * Promotes the transient guest project into the operator workspace, marking the report claimed. Idempotent — calling twice returns `alreadyClaimed: true` with the same projectId. Requires a valid session cookie or bearer token. + */ +export const postApiV1GuestReportByIdClaimMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await postApiV1GuestReportByIdClaim({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + /** * Delete a project */ diff --git a/packages/api-client-generated/src/generated/sdk.gen.ts b/packages/api-client-generated/src/generated/sdk.gen.ts index 12b4bcdf..fe416332 100644 --- a/packages/api-client-generated/src/generated/sdk.gen.ts +++ b/packages/api-client-generated/src/generated/sdk.gen.ts @@ -2,7 +2,7 @@ import { client } from './client.gen.js'; import type { Client, Options as Options2, TDataShape } from './client/index.js'; -import type { DeleteApiV1BacklinksCacheByReleaseData, DeleteApiV1BacklinksCacheByReleaseErrors, DeleteApiV1BacklinksCacheByReleaseResponses, DeleteApiV1ProjectsByNameAgentMemoryData, DeleteApiV1ProjectsByNameAgentMemoryErrors, DeleteApiV1ProjectsByNameAgentMemoryResponses, DeleteApiV1ProjectsByNameAgentTranscriptData, DeleteApiV1ProjectsByNameAgentTranscriptErrors, DeleteApiV1ProjectsByNameAgentTranscriptResponses, DeleteApiV1ProjectsByNameBingDisconnectData, DeleteApiV1ProjectsByNameBingDisconnectErrors, DeleteApiV1ProjectsByNameBingDisconnectResponses, DeleteApiV1ProjectsByNameCompetitorsData, DeleteApiV1ProjectsByNameCompetitorsErrors, DeleteApiV1ProjectsByNameCompetitorsResponses, DeleteApiV1ProjectsByNameContentDismissalsByTargetRefData, DeleteApiV1ProjectsByNameContentDismissalsByTargetRefErrors, DeleteApiV1ProjectsByNameContentDismissalsByTargetRefResponses, DeleteApiV1ProjectsByNameData, DeleteApiV1ProjectsByNameErrors, DeleteApiV1ProjectsByNameGaDisconnectData, DeleteApiV1ProjectsByNameGaDisconnectErrors, DeleteApiV1ProjectsByNameGaDisconnectResponses, DeleteApiV1ProjectsByNameGbpConnectionData, DeleteApiV1ProjectsByNameGbpConnectionErrors, DeleteApiV1ProjectsByNameGbpConnectionResponses, DeleteApiV1ProjectsByNameGoogleConnectionsByTypeData, DeleteApiV1ProjectsByNameGoogleConnectionsByTypeErrors, DeleteApiV1ProjectsByNameGoogleConnectionsByTypeResponses, DeleteApiV1ProjectsByNameKeywordsData, DeleteApiV1ProjectsByNameKeywordsErrors, DeleteApiV1ProjectsByNameKeywordsResponses, DeleteApiV1ProjectsByNameLocationsByLabelData, DeleteApiV1ProjectsByNameLocationsByLabelErrors, DeleteApiV1ProjectsByNameLocationsByLabelResponses, DeleteApiV1ProjectsByNameNotificationsByIdData, DeleteApiV1ProjectsByNameNotificationsByIdErrors, DeleteApiV1ProjectsByNameNotificationsByIdResponses, DeleteApiV1ProjectsByNameQueriesData, DeleteApiV1ProjectsByNameQueriesErrors, DeleteApiV1ProjectsByNameQueriesResponses, DeleteApiV1ProjectsByNameResponses, DeleteApiV1ProjectsByNameScheduleData, DeleteApiV1ProjectsByNameScheduleErrors, DeleteApiV1ProjectsByNameScheduleResponses, DeleteApiV1ProjectsByNameWordpressDisconnectData, DeleteApiV1ProjectsByNameWordpressDisconnectErrors, DeleteApiV1ProjectsByNameWordpressDisconnectResponses, GetApiV1BacklinksLatestReleaseData, GetApiV1BacklinksLatestReleaseErrors, GetApiV1BacklinksLatestReleaseResponses, GetApiV1BacklinksReleasesData, GetApiV1BacklinksReleasesResponses, GetApiV1BacklinksStatusData, GetApiV1BacklinksStatusErrors, GetApiV1BacklinksStatusResponses, GetApiV1BacklinksSyncsData, GetApiV1BacklinksSyncsLatestData, GetApiV1BacklinksSyncsLatestResponses, GetApiV1BacklinksSyncsResponses, GetApiV1CdpStatusData, GetApiV1CdpStatusErrors, GetApiV1CdpStatusResponses, GetApiV1DoctorData, GetApiV1DoctorResponses, GetApiV1GoogleCallbackData, GetApiV1GoogleCallbackErrors, GetApiV1GoogleCallbackResponses, GetApiV1HistoryData, GetApiV1HistoryResponses, GetApiV1NotificationsEventsData, GetApiV1NotificationsEventsResponses, GetApiV1OpenapiJsonData, GetApiV1OpenapiJsonResponses, GetApiV1ProjectsByNameAgentMemoryData, GetApiV1ProjectsByNameAgentMemoryErrors, GetApiV1ProjectsByNameAgentMemoryResponses, GetApiV1ProjectsByNameAgentProvidersData, GetApiV1ProjectsByNameAgentProvidersErrors, GetApiV1ProjectsByNameAgentProvidersResponses, GetApiV1ProjectsByNameAgentTranscriptData, GetApiV1ProjectsByNameAgentTranscriptErrors, GetApiV1ProjectsByNameAgentTranscriptResponses, GetApiV1ProjectsByNameAnalyticsGapsData, GetApiV1ProjectsByNameAnalyticsGapsErrors, GetApiV1ProjectsByNameAnalyticsGapsResponses, GetApiV1ProjectsByNameAnalyticsMetricsData, GetApiV1ProjectsByNameAnalyticsMetricsErrors, GetApiV1ProjectsByNameAnalyticsMetricsResponses, GetApiV1ProjectsByNameAnalyticsSourcesData, GetApiV1ProjectsByNameAnalyticsSourcesErrors, GetApiV1ProjectsByNameAnalyticsSourcesResponses, GetApiV1ProjectsByNameBacklinksDomainsData, GetApiV1ProjectsByNameBacklinksDomainsErrors, GetApiV1ProjectsByNameBacklinksDomainsResponses, GetApiV1ProjectsByNameBacklinksHistoryData, GetApiV1ProjectsByNameBacklinksHistoryErrors, GetApiV1ProjectsByNameBacklinksHistoryResponses, GetApiV1ProjectsByNameBacklinksSummaryData, GetApiV1ProjectsByNameBacklinksSummaryErrors, GetApiV1ProjectsByNameBacklinksSummaryResponses, GetApiV1ProjectsByNameBingCoverageData, GetApiV1ProjectsByNameBingCoverageErrors, GetApiV1ProjectsByNameBingCoverageHistoryData, GetApiV1ProjectsByNameBingCoverageHistoryErrors, GetApiV1ProjectsByNameBingCoverageHistoryResponses, GetApiV1ProjectsByNameBingCoverageResponses, GetApiV1ProjectsByNameBingInspectionsData, GetApiV1ProjectsByNameBingInspectionsErrors, GetApiV1ProjectsByNameBingInspectionsResponses, GetApiV1ProjectsByNameBingPerformanceData, GetApiV1ProjectsByNameBingPerformanceErrors, GetApiV1ProjectsByNameBingPerformanceResponses, GetApiV1ProjectsByNameBingSitesData, GetApiV1ProjectsByNameBingSitesErrors, GetApiV1ProjectsByNameBingSitesResponses, GetApiV1ProjectsByNameBingStatusData, GetApiV1ProjectsByNameBingStatusErrors, GetApiV1ProjectsByNameBingStatusResponses, GetApiV1ProjectsByNameCitationsVisibilityData, GetApiV1ProjectsByNameCitationsVisibilityErrors, GetApiV1ProjectsByNameCitationsVisibilityResponses, GetApiV1ProjectsByNameCompetitorsData, GetApiV1ProjectsByNameCompetitorsResponses, GetApiV1ProjectsByNameContentDismissalsData, GetApiV1ProjectsByNameContentDismissalsErrors, GetApiV1ProjectsByNameContentDismissalsResponses, GetApiV1ProjectsByNameContentGapsData, GetApiV1ProjectsByNameContentGapsErrors, GetApiV1ProjectsByNameContentGapsResponses, GetApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysisData, GetApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysisErrors, GetApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysisResponses, GetApiV1ProjectsByNameContentSourcesData, GetApiV1ProjectsByNameContentSourcesErrors, GetApiV1ProjectsByNameContentSourcesResponses, GetApiV1ProjectsByNameContentTargetsData, GetApiV1ProjectsByNameContentTargetsErrors, GetApiV1ProjectsByNameContentTargetsResponses, GetApiV1ProjectsByNameData, GetApiV1ProjectsByNameDeletePreviewData, GetApiV1ProjectsByNameDeletePreviewErrors, GetApiV1ProjectsByNameDeletePreviewResponses, GetApiV1ProjectsByNameDiscoverSessionsByIdData, GetApiV1ProjectsByNameDiscoverSessionsByIdErrors, GetApiV1ProjectsByNameDiscoverSessionsByIdPromoteData, GetApiV1ProjectsByNameDiscoverSessionsByIdPromoteErrors, GetApiV1ProjectsByNameDiscoverSessionsByIdPromoteResponses, GetApiV1ProjectsByNameDiscoverSessionsByIdResponses, GetApiV1ProjectsByNameDiscoverSessionsData, GetApiV1ProjectsByNameDiscoverSessionsErrors, GetApiV1ProjectsByNameDiscoverSessionsResponses, GetApiV1ProjectsByNameDoctorData, GetApiV1ProjectsByNameDoctorErrors, GetApiV1ProjectsByNameDoctorResponses, GetApiV1ProjectsByNameErrors, GetApiV1ProjectsByNameExportData, GetApiV1ProjectsByNameExportErrors, GetApiV1ProjectsByNameExportResponses, GetApiV1ProjectsByNameGaAiReferralHistoryData, GetApiV1ProjectsByNameGaAiReferralHistoryErrors, GetApiV1ProjectsByNameGaAiReferralHistoryResponses, GetApiV1ProjectsByNameGaAttributionTrendData, GetApiV1ProjectsByNameGaAttributionTrendErrors, GetApiV1ProjectsByNameGaAttributionTrendResponses, GetApiV1ProjectsByNameGaCoverageData, GetApiV1ProjectsByNameGaCoverageErrors, GetApiV1ProjectsByNameGaCoverageResponses, GetApiV1ProjectsByNameGaSessionHistoryData, GetApiV1ProjectsByNameGaSessionHistoryErrors, GetApiV1ProjectsByNameGaSessionHistoryResponses, GetApiV1ProjectsByNameGaSocialReferralHistoryData, GetApiV1ProjectsByNameGaSocialReferralHistoryErrors, GetApiV1ProjectsByNameGaSocialReferralHistoryResponses, GetApiV1ProjectsByNameGaSocialReferralTrendData, GetApiV1ProjectsByNameGaSocialReferralTrendErrors, GetApiV1ProjectsByNameGaSocialReferralTrendResponses, GetApiV1ProjectsByNameGaStatusData, GetApiV1ProjectsByNameGaStatusErrors, GetApiV1ProjectsByNameGaStatusResponses, GetApiV1ProjectsByNameGaTrafficData, GetApiV1ProjectsByNameGaTrafficErrors, GetApiV1ProjectsByNameGaTrafficResponses, GetApiV1ProjectsByNameGbpAccountsData, GetApiV1ProjectsByNameGbpAccountsErrors, GetApiV1ProjectsByNameGbpAccountsResponses, GetApiV1ProjectsByNameGbpKeywordsData, GetApiV1ProjectsByNameGbpKeywordsErrors, GetApiV1ProjectsByNameGbpKeywordsResponses, GetApiV1ProjectsByNameGbpLocationsData, GetApiV1ProjectsByNameGbpLocationsErrors, GetApiV1ProjectsByNameGbpLocationsResponses, GetApiV1ProjectsByNameGbpLodgingData, GetApiV1ProjectsByNameGbpLodgingErrors, GetApiV1ProjectsByNameGbpLodgingResponses, GetApiV1ProjectsByNameGbpMetricsData, GetApiV1ProjectsByNameGbpMetricsErrors, GetApiV1ProjectsByNameGbpMetricsResponses, GetApiV1ProjectsByNameGbpPlaceActionsData, GetApiV1ProjectsByNameGbpPlaceActionsErrors, GetApiV1ProjectsByNameGbpPlaceActionsResponses, GetApiV1ProjectsByNameGbpPlacesData, GetApiV1ProjectsByNameGbpPlacesErrors, GetApiV1ProjectsByNameGbpPlacesResponses, GetApiV1ProjectsByNameGbpSummaryData, GetApiV1ProjectsByNameGbpSummaryErrors, GetApiV1ProjectsByNameGbpSummaryResponses, GetApiV1ProjectsByNameGoogleCallbackData, GetApiV1ProjectsByNameGoogleCallbackErrors, GetApiV1ProjectsByNameGoogleCallbackResponses, GetApiV1ProjectsByNameGoogleConnectionsData, GetApiV1ProjectsByNameGoogleConnectionsErrors, GetApiV1ProjectsByNameGoogleConnectionsResponses, GetApiV1ProjectsByNameGoogleGscCoverageData, GetApiV1ProjectsByNameGoogleGscCoverageErrors, GetApiV1ProjectsByNameGoogleGscCoverageHistoryData, GetApiV1ProjectsByNameGoogleGscCoverageHistoryErrors, GetApiV1ProjectsByNameGoogleGscCoverageHistoryResponses, GetApiV1ProjectsByNameGoogleGscCoverageResponses, GetApiV1ProjectsByNameGoogleGscDeindexedData, GetApiV1ProjectsByNameGoogleGscDeindexedErrors, GetApiV1ProjectsByNameGoogleGscDeindexedResponses, GetApiV1ProjectsByNameGoogleGscInspectionsData, GetApiV1ProjectsByNameGoogleGscInspectionsErrors, GetApiV1ProjectsByNameGoogleGscInspectionsResponses, GetApiV1ProjectsByNameGoogleGscPerformanceDailyData, GetApiV1ProjectsByNameGoogleGscPerformanceDailyErrors, GetApiV1ProjectsByNameGoogleGscPerformanceDailyResponses, GetApiV1ProjectsByNameGoogleGscPerformanceData, GetApiV1ProjectsByNameGoogleGscPerformanceErrors, GetApiV1ProjectsByNameGoogleGscPerformanceResponses, GetApiV1ProjectsByNameGoogleGscSitemapsData, GetApiV1ProjectsByNameGoogleGscSitemapsErrors, GetApiV1ProjectsByNameGoogleGscSitemapsResponses, GetApiV1ProjectsByNameGooglePropertiesData, GetApiV1ProjectsByNameGooglePropertiesErrors, GetApiV1ProjectsByNameGooglePropertiesResponses, GetApiV1ProjectsByNameHealthHistoryData, GetApiV1ProjectsByNameHealthHistoryErrors, GetApiV1ProjectsByNameHealthHistoryResponses, GetApiV1ProjectsByNameHealthLatestData, GetApiV1ProjectsByNameHealthLatestErrors, GetApiV1ProjectsByNameHealthLatestResponses, GetApiV1ProjectsByNameHistoryData, GetApiV1ProjectsByNameHistoryResponses, GetApiV1ProjectsByNameInsightsByIdData, GetApiV1ProjectsByNameInsightsByIdErrors, GetApiV1ProjectsByNameInsightsByIdResponses, GetApiV1ProjectsByNameInsightsData, GetApiV1ProjectsByNameInsightsErrors, GetApiV1ProjectsByNameInsightsResponses, GetApiV1ProjectsByNameKeywordsData, GetApiV1ProjectsByNameKeywordsResponses, GetApiV1ProjectsByNameLocationsData, GetApiV1ProjectsByNameLocationsErrors, GetApiV1ProjectsByNameLocationsResponses, GetApiV1ProjectsByNameNotificationsData, GetApiV1ProjectsByNameNotificationsResponses, GetApiV1ProjectsByNameOverviewData, GetApiV1ProjectsByNameOverviewErrors, GetApiV1ProjectsByNameOverviewResponses, GetApiV1ProjectsByNameQueriesData, GetApiV1ProjectsByNameQueriesResponses, GetApiV1ProjectsByNameReportData, GetApiV1ProjectsByNameReportErrors, GetApiV1ProjectsByNameReportHtmlData, GetApiV1ProjectsByNameReportHtmlErrors, GetApiV1ProjectsByNameReportHtmlResponses, GetApiV1ProjectsByNameReportResponses, GetApiV1ProjectsByNameResponses, GetApiV1ProjectsByNameRunsByRunIdBrowserDiffData, GetApiV1ProjectsByNameRunsByRunIdBrowserDiffErrors, GetApiV1ProjectsByNameRunsByRunIdBrowserDiffResponses, GetApiV1ProjectsByNameRunsData, GetApiV1ProjectsByNameRunsLatestData, GetApiV1ProjectsByNameRunsLatestResponses, GetApiV1ProjectsByNameRunsResponses, GetApiV1ProjectsByNameScheduleData, GetApiV1ProjectsByNameScheduleErrors, GetApiV1ProjectsByNameScheduleResponses, GetApiV1ProjectsByNameSearchData, GetApiV1ProjectsByNameSearchErrors, GetApiV1ProjectsByNameSearchResponses, GetApiV1ProjectsByNameSnapshotsData, GetApiV1ProjectsByNameSnapshotsDiffData, GetApiV1ProjectsByNameSnapshotsDiffErrors, GetApiV1ProjectsByNameSnapshotsDiffResponses, GetApiV1ProjectsByNameSnapshotsResponses, GetApiV1ProjectsByNameTimelineData, GetApiV1ProjectsByNameTimelineResponses, GetApiV1ProjectsByNameTrafficEventsData, GetApiV1ProjectsByNameTrafficEventsErrors, GetApiV1ProjectsByNameTrafficEventsResponses, GetApiV1ProjectsByNameTrafficSourcesByIdData, GetApiV1ProjectsByNameTrafficSourcesByIdErrors, GetApiV1ProjectsByNameTrafficSourcesByIdResponses, GetApiV1ProjectsByNameTrafficSourcesData, GetApiV1ProjectsByNameTrafficSourcesErrors, GetApiV1ProjectsByNameTrafficSourcesResponses, GetApiV1ProjectsByNameTrafficStatusData, GetApiV1ProjectsByNameTrafficStatusErrors, GetApiV1ProjectsByNameTrafficStatusResponses, GetApiV1ProjectsByNameWordpressAuditData, GetApiV1ProjectsByNameWordpressAuditErrors, GetApiV1ProjectsByNameWordpressAuditResponses, GetApiV1ProjectsByNameWordpressDiffData, GetApiV1ProjectsByNameWordpressDiffErrors, GetApiV1ProjectsByNameWordpressDiffResponses, GetApiV1ProjectsByNameWordpressLlmsTxtData, GetApiV1ProjectsByNameWordpressLlmsTxtErrors, GetApiV1ProjectsByNameWordpressLlmsTxtResponses, GetApiV1ProjectsByNameWordpressPageData, GetApiV1ProjectsByNameWordpressPageErrors, GetApiV1ProjectsByNameWordpressPageResponses, GetApiV1ProjectsByNameWordpressPagesData, GetApiV1ProjectsByNameWordpressPagesErrors, GetApiV1ProjectsByNameWordpressPagesResponses, GetApiV1ProjectsByNameWordpressSchemaData, GetApiV1ProjectsByNameWordpressSchemaErrors, GetApiV1ProjectsByNameWordpressSchemaResponses, GetApiV1ProjectsByNameWordpressSchemaStatusData, GetApiV1ProjectsByNameWordpressSchemaStatusErrors, GetApiV1ProjectsByNameWordpressSchemaStatusResponses, GetApiV1ProjectsByNameWordpressStagingStatusData, GetApiV1ProjectsByNameWordpressStagingStatusErrors, GetApiV1ProjectsByNameWordpressStagingStatusResponses, GetApiV1ProjectsByNameWordpressStatusData, GetApiV1ProjectsByNameWordpressStatusErrors, GetApiV1ProjectsByNameWordpressStatusResponses, GetApiV1ProjectsData, GetApiV1ProjectsResponses, GetApiV1RunsByIdData, GetApiV1RunsByIdErrors, GetApiV1RunsByIdResponses, GetApiV1RunsData, GetApiV1RunsResponses, GetApiV1ScreenshotsBySnapshotIdData, GetApiV1ScreenshotsBySnapshotIdErrors, GetApiV1ScreenshotsBySnapshotIdResponses, GetApiV1SettingsData, GetApiV1SettingsResponses, GetApiV1TelemetryData, GetApiV1TelemetryErrors, GetApiV1TelemetryResponses, PostApiV1ApplyData, PostApiV1ApplyErrors, PostApiV1ApplyResponses, PostApiV1BacklinksInstallData, PostApiV1BacklinksInstallErrors, PostApiV1BacklinksInstallResponses, PostApiV1BacklinksSyncsData, PostApiV1BacklinksSyncsErrors, PostApiV1BacklinksSyncsResponses, PostApiV1CdpScreenshotData, PostApiV1CdpScreenshotErrors, PostApiV1CdpScreenshotResponses, PostApiV1ProjectsByNameBacklinksExtractData, PostApiV1ProjectsByNameBacklinksExtractErrors, PostApiV1ProjectsByNameBacklinksExtractResponses, PostApiV1ProjectsByNameBingConnectData, PostApiV1ProjectsByNameBingConnectErrors, PostApiV1ProjectsByNameBingConnectResponses, PostApiV1ProjectsByNameBingInspectSitemapData, PostApiV1ProjectsByNameBingInspectSitemapErrors, PostApiV1ProjectsByNameBingInspectSitemapResponses, PostApiV1ProjectsByNameBingInspectUrlData, PostApiV1ProjectsByNameBingInspectUrlErrors, PostApiV1ProjectsByNameBingInspectUrlResponses, PostApiV1ProjectsByNameBingRequestIndexingData, PostApiV1ProjectsByNameBingRequestIndexingErrors, PostApiV1ProjectsByNameBingRequestIndexingResponses, PostApiV1ProjectsByNameBingSetSiteData, PostApiV1ProjectsByNameBingSetSiteErrors, PostApiV1ProjectsByNameBingSetSiteResponses, PostApiV1ProjectsByNameCompetitorsData, PostApiV1ProjectsByNameCompetitorsErrors, PostApiV1ProjectsByNameCompetitorsResponses, PostApiV1ProjectsByNameContentDismissalsData, PostApiV1ProjectsByNameContentDismissalsErrors, PostApiV1ProjectsByNameContentDismissalsResponses, PostApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyzeData, PostApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyzeErrors, PostApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyzeResponses, PostApiV1ProjectsByNameDiscoverRunData, PostApiV1ProjectsByNameDiscoverRunErrors, PostApiV1ProjectsByNameDiscoverRunResponses, PostApiV1ProjectsByNameDiscoverSessionsByIdPromoteData, PostApiV1ProjectsByNameDiscoverSessionsByIdPromoteErrors, PostApiV1ProjectsByNameDiscoverSessionsByIdPromoteResponses, PostApiV1ProjectsByNameGaConnectData, PostApiV1ProjectsByNameGaConnectErrors, PostApiV1ProjectsByNameGaConnectResponses, PostApiV1ProjectsByNameGaSyncData, PostApiV1ProjectsByNameGaSyncErrors, PostApiV1ProjectsByNameGaSyncResponses, PostApiV1ProjectsByNameGbpLocationsDiscoverData, PostApiV1ProjectsByNameGbpLocationsDiscoverErrors, PostApiV1ProjectsByNameGbpLocationsDiscoverResponses, PostApiV1ProjectsByNameGbpSyncData, PostApiV1ProjectsByNameGbpSyncErrors, PostApiV1ProjectsByNameGbpSyncResponses, PostApiV1ProjectsByNameGoogleConnectData, PostApiV1ProjectsByNameGoogleConnectErrors, PostApiV1ProjectsByNameGoogleConnectResponses, PostApiV1ProjectsByNameGoogleGscDiscoverSitemapsData, PostApiV1ProjectsByNameGoogleGscDiscoverSitemapsErrors, PostApiV1ProjectsByNameGoogleGscDiscoverSitemapsResponses, PostApiV1ProjectsByNameGoogleGscInspectData, PostApiV1ProjectsByNameGoogleGscInspectErrors, PostApiV1ProjectsByNameGoogleGscInspectResponses, PostApiV1ProjectsByNameGoogleGscInspectSitemapData, PostApiV1ProjectsByNameGoogleGscInspectSitemapErrors, PostApiV1ProjectsByNameGoogleGscInspectSitemapResponses, PostApiV1ProjectsByNameGoogleGscSyncData, PostApiV1ProjectsByNameGoogleGscSyncErrors, PostApiV1ProjectsByNameGoogleGscSyncResponses, PostApiV1ProjectsByNameGoogleIndexingRequestData, PostApiV1ProjectsByNameGoogleIndexingRequestErrors, PostApiV1ProjectsByNameGoogleIndexingRequestResponses, PostApiV1ProjectsByNameInsightsByIdDismissData, PostApiV1ProjectsByNameInsightsByIdDismissErrors, PostApiV1ProjectsByNameInsightsByIdDismissResponses, PostApiV1ProjectsByNameKeywordsData, PostApiV1ProjectsByNameKeywordsGenerateData, PostApiV1ProjectsByNameKeywordsGenerateErrors, PostApiV1ProjectsByNameKeywordsGenerateResponses, PostApiV1ProjectsByNameKeywordsResponses, PostApiV1ProjectsByNameLocationsData, PostApiV1ProjectsByNameLocationsErrors, PostApiV1ProjectsByNameLocationsResponses, PostApiV1ProjectsByNameNotificationsByIdTestData, PostApiV1ProjectsByNameNotificationsByIdTestErrors, PostApiV1ProjectsByNameNotificationsByIdTestResponses, PostApiV1ProjectsByNameNotificationsData, PostApiV1ProjectsByNameNotificationsResponses, PostApiV1ProjectsByNameQueriesData, PostApiV1ProjectsByNameQueriesGenerateData, PostApiV1ProjectsByNameQueriesGenerateErrors, PostApiV1ProjectsByNameQueriesGenerateResponses, PostApiV1ProjectsByNameQueriesReplacePreviewData, PostApiV1ProjectsByNameQueriesReplacePreviewErrors, PostApiV1ProjectsByNameQueriesReplacePreviewResponses, PostApiV1ProjectsByNameQueriesResponses, PostApiV1ProjectsByNameRunsData, PostApiV1ProjectsByNameRunsErrors, PostApiV1ProjectsByNameRunsResponses, PostApiV1ProjectsByNameTrafficConnectCloudRunData, PostApiV1ProjectsByNameTrafficConnectCloudRunErrors, PostApiV1ProjectsByNameTrafficConnectCloudRunResponses, PostApiV1ProjectsByNameTrafficConnectVercelData, PostApiV1ProjectsByNameTrafficConnectVercelErrors, PostApiV1ProjectsByNameTrafficConnectVercelResponses, PostApiV1ProjectsByNameTrafficConnectWordpressData, PostApiV1ProjectsByNameTrafficConnectWordpressErrors, PostApiV1ProjectsByNameTrafficConnectWordpressResponses, PostApiV1ProjectsByNameTrafficSourcesByIdBackfillData, PostApiV1ProjectsByNameTrafficSourcesByIdBackfillErrors, PostApiV1ProjectsByNameTrafficSourcesByIdBackfillResponses, PostApiV1ProjectsByNameTrafficSourcesByIdResetData, PostApiV1ProjectsByNameTrafficSourcesByIdResetErrors, PostApiV1ProjectsByNameTrafficSourcesByIdResetResponses, PostApiV1ProjectsByNameTrafficSourcesByIdSyncData, PostApiV1ProjectsByNameTrafficSourcesByIdSyncErrors, PostApiV1ProjectsByNameTrafficSourcesByIdSyncResponses, PostApiV1ProjectsByNameWordpressConnectData, PostApiV1ProjectsByNameWordpressConnectErrors, PostApiV1ProjectsByNameWordpressConnectResponses, PostApiV1ProjectsByNameWordpressLlmsTxtManualData, PostApiV1ProjectsByNameWordpressLlmsTxtManualErrors, PostApiV1ProjectsByNameWordpressLlmsTxtManualResponses, PostApiV1ProjectsByNameWordpressOnboardData, PostApiV1ProjectsByNameWordpressOnboardErrors, PostApiV1ProjectsByNameWordpressOnboardResponses, PostApiV1ProjectsByNameWordpressPageMetaData, PostApiV1ProjectsByNameWordpressPageMetaErrors, PostApiV1ProjectsByNameWordpressPageMetaResponses, PostApiV1ProjectsByNameWordpressPagesData, PostApiV1ProjectsByNameWordpressPagesErrors, PostApiV1ProjectsByNameWordpressPagesMetaBulkData, PostApiV1ProjectsByNameWordpressPagesMetaBulkErrors, PostApiV1ProjectsByNameWordpressPagesMetaBulkResponses, PostApiV1ProjectsByNameWordpressPagesResponses, PostApiV1ProjectsByNameWordpressSchemaDeployData, PostApiV1ProjectsByNameWordpressSchemaDeployErrors, PostApiV1ProjectsByNameWordpressSchemaDeployResponses, PostApiV1ProjectsByNameWordpressSchemaManualData, PostApiV1ProjectsByNameWordpressSchemaManualErrors, PostApiV1ProjectsByNameWordpressSchemaManualResponses, PostApiV1ProjectsByNameWordpressStagingPushData, PostApiV1ProjectsByNameWordpressStagingPushErrors, PostApiV1ProjectsByNameWordpressStagingPushResponses, PostApiV1RunsByIdCancelData, PostApiV1RunsByIdCancelErrors, PostApiV1RunsByIdCancelResponses, PostApiV1RunsData, PostApiV1RunsResponses, PostApiV1SnapshotData, PostApiV1SnapshotErrors, PostApiV1SnapshotResponses, PutApiV1ProjectsByNameAgentMemoryData, PutApiV1ProjectsByNameAgentMemoryErrors, PutApiV1ProjectsByNameAgentMemoryResponses, PutApiV1ProjectsByNameCompetitorsData, PutApiV1ProjectsByNameCompetitorsResponses, PutApiV1ProjectsByNameData, PutApiV1ProjectsByNameGbpLocationsByLocationNameSelectionData, PutApiV1ProjectsByNameGbpLocationsByLocationNameSelectionErrors, PutApiV1ProjectsByNameGbpLocationsByLocationNameSelectionResponses, PutApiV1ProjectsByNameGoogleConnectionsByTypePropertyData, PutApiV1ProjectsByNameGoogleConnectionsByTypePropertyErrors, PutApiV1ProjectsByNameGoogleConnectionsByTypePropertyResponses, PutApiV1ProjectsByNameGoogleConnectionsByTypeSitemapData, PutApiV1ProjectsByNameGoogleConnectionsByTypeSitemapErrors, PutApiV1ProjectsByNameGoogleConnectionsByTypeSitemapResponses, PutApiV1ProjectsByNameKeywordsData, PutApiV1ProjectsByNameKeywordsResponses, PutApiV1ProjectsByNameLocationsDefaultData, PutApiV1ProjectsByNameLocationsDefaultErrors, PutApiV1ProjectsByNameLocationsDefaultResponses, PutApiV1ProjectsByNameQueriesData, PutApiV1ProjectsByNameQueriesResponses, PutApiV1ProjectsByNameResponses, PutApiV1ProjectsByNameScheduleData, PutApiV1ProjectsByNameScheduleErrors, PutApiV1ProjectsByNameScheduleResponses, PutApiV1ProjectsByNameWordpressPageData, PutApiV1ProjectsByNameWordpressPageErrors, PutApiV1ProjectsByNameWordpressPageResponses, PutApiV1SettingsBingData, PutApiV1SettingsBingErrors, PutApiV1SettingsBingResponses, PutApiV1SettingsCdpData, PutApiV1SettingsCdpErrors, PutApiV1SettingsCdpResponses, PutApiV1SettingsGoogleData, PutApiV1SettingsGoogleErrors, PutApiV1SettingsGoogleResponses, PutApiV1SettingsProvidersByNameData, PutApiV1SettingsProvidersByNameErrors, PutApiV1SettingsProvidersByNameResponses, PutApiV1TelemetryData, PutApiV1TelemetryErrors, PutApiV1TelemetryResponses } from './types.gen.js'; +import type { DeleteApiV1BacklinksCacheByReleaseData, DeleteApiV1BacklinksCacheByReleaseErrors, DeleteApiV1BacklinksCacheByReleaseResponses, DeleteApiV1ProjectsByNameAgentMemoryData, DeleteApiV1ProjectsByNameAgentMemoryErrors, DeleteApiV1ProjectsByNameAgentMemoryResponses, DeleteApiV1ProjectsByNameAgentTranscriptData, DeleteApiV1ProjectsByNameAgentTranscriptErrors, DeleteApiV1ProjectsByNameAgentTranscriptResponses, DeleteApiV1ProjectsByNameBingDisconnectData, DeleteApiV1ProjectsByNameBingDisconnectErrors, DeleteApiV1ProjectsByNameBingDisconnectResponses, DeleteApiV1ProjectsByNameCompetitorsData, DeleteApiV1ProjectsByNameCompetitorsErrors, DeleteApiV1ProjectsByNameCompetitorsResponses, DeleteApiV1ProjectsByNameContentDismissalsByTargetRefData, DeleteApiV1ProjectsByNameContentDismissalsByTargetRefErrors, DeleteApiV1ProjectsByNameContentDismissalsByTargetRefResponses, DeleteApiV1ProjectsByNameData, DeleteApiV1ProjectsByNameErrors, DeleteApiV1ProjectsByNameGaDisconnectData, DeleteApiV1ProjectsByNameGaDisconnectErrors, DeleteApiV1ProjectsByNameGaDisconnectResponses, DeleteApiV1ProjectsByNameGbpConnectionData, DeleteApiV1ProjectsByNameGbpConnectionErrors, DeleteApiV1ProjectsByNameGbpConnectionResponses, DeleteApiV1ProjectsByNameGoogleConnectionsByTypeData, DeleteApiV1ProjectsByNameGoogleConnectionsByTypeErrors, DeleteApiV1ProjectsByNameGoogleConnectionsByTypeResponses, DeleteApiV1ProjectsByNameKeywordsData, DeleteApiV1ProjectsByNameKeywordsErrors, DeleteApiV1ProjectsByNameKeywordsResponses, DeleteApiV1ProjectsByNameLocationsByLabelData, DeleteApiV1ProjectsByNameLocationsByLabelErrors, DeleteApiV1ProjectsByNameLocationsByLabelResponses, DeleteApiV1ProjectsByNameNotificationsByIdData, DeleteApiV1ProjectsByNameNotificationsByIdErrors, DeleteApiV1ProjectsByNameNotificationsByIdResponses, DeleteApiV1ProjectsByNameQueriesData, DeleteApiV1ProjectsByNameQueriesErrors, DeleteApiV1ProjectsByNameQueriesResponses, DeleteApiV1ProjectsByNameResponses, DeleteApiV1ProjectsByNameScheduleData, DeleteApiV1ProjectsByNameScheduleErrors, DeleteApiV1ProjectsByNameScheduleResponses, DeleteApiV1ProjectsByNameWordpressDisconnectData, DeleteApiV1ProjectsByNameWordpressDisconnectErrors, DeleteApiV1ProjectsByNameWordpressDisconnectResponses, GetApiV1BacklinksLatestReleaseData, GetApiV1BacklinksLatestReleaseErrors, GetApiV1BacklinksLatestReleaseResponses, GetApiV1BacklinksReleasesData, GetApiV1BacklinksReleasesResponses, GetApiV1BacklinksStatusData, GetApiV1BacklinksStatusErrors, GetApiV1BacklinksStatusResponses, GetApiV1BacklinksSyncsData, GetApiV1BacklinksSyncsLatestData, GetApiV1BacklinksSyncsLatestResponses, GetApiV1BacklinksSyncsResponses, GetApiV1CdpStatusData, GetApiV1CdpStatusErrors, GetApiV1CdpStatusResponses, GetApiV1DoctorData, GetApiV1DoctorResponses, GetApiV1GoogleCallbackData, GetApiV1GoogleCallbackErrors, GetApiV1GoogleCallbackResponses, GetApiV1GuestReportByIdData, GetApiV1GuestReportByIdErrors, GetApiV1GuestReportByIdResponses, GetApiV1HistoryData, GetApiV1HistoryResponses, GetApiV1NotificationsEventsData, GetApiV1NotificationsEventsResponses, GetApiV1OpenapiJsonData, GetApiV1OpenapiJsonResponses, GetApiV1ProjectsByNameAgentMemoryData, GetApiV1ProjectsByNameAgentMemoryErrors, GetApiV1ProjectsByNameAgentMemoryResponses, GetApiV1ProjectsByNameAgentProvidersData, GetApiV1ProjectsByNameAgentProvidersErrors, GetApiV1ProjectsByNameAgentProvidersResponses, GetApiV1ProjectsByNameAgentTranscriptData, GetApiV1ProjectsByNameAgentTranscriptErrors, GetApiV1ProjectsByNameAgentTranscriptResponses, GetApiV1ProjectsByNameAnalyticsGapsData, GetApiV1ProjectsByNameAnalyticsGapsErrors, GetApiV1ProjectsByNameAnalyticsGapsResponses, GetApiV1ProjectsByNameAnalyticsMetricsData, GetApiV1ProjectsByNameAnalyticsMetricsErrors, GetApiV1ProjectsByNameAnalyticsMetricsResponses, GetApiV1ProjectsByNameAnalyticsSourcesData, GetApiV1ProjectsByNameAnalyticsSourcesErrors, GetApiV1ProjectsByNameAnalyticsSourcesResponses, GetApiV1ProjectsByNameBacklinksDomainsData, GetApiV1ProjectsByNameBacklinksDomainsErrors, GetApiV1ProjectsByNameBacklinksDomainsResponses, GetApiV1ProjectsByNameBacklinksHistoryData, GetApiV1ProjectsByNameBacklinksHistoryErrors, GetApiV1ProjectsByNameBacklinksHistoryResponses, GetApiV1ProjectsByNameBacklinksSummaryData, GetApiV1ProjectsByNameBacklinksSummaryErrors, GetApiV1ProjectsByNameBacklinksSummaryResponses, GetApiV1ProjectsByNameBingCoverageData, GetApiV1ProjectsByNameBingCoverageErrors, GetApiV1ProjectsByNameBingCoverageHistoryData, GetApiV1ProjectsByNameBingCoverageHistoryErrors, GetApiV1ProjectsByNameBingCoverageHistoryResponses, GetApiV1ProjectsByNameBingCoverageResponses, GetApiV1ProjectsByNameBingInspectionsData, GetApiV1ProjectsByNameBingInspectionsErrors, GetApiV1ProjectsByNameBingInspectionsResponses, GetApiV1ProjectsByNameBingPerformanceData, GetApiV1ProjectsByNameBingPerformanceErrors, GetApiV1ProjectsByNameBingPerformanceResponses, GetApiV1ProjectsByNameBingSitesData, GetApiV1ProjectsByNameBingSitesErrors, GetApiV1ProjectsByNameBingSitesResponses, GetApiV1ProjectsByNameBingStatusData, GetApiV1ProjectsByNameBingStatusErrors, GetApiV1ProjectsByNameBingStatusResponses, GetApiV1ProjectsByNameCitationsVisibilityData, GetApiV1ProjectsByNameCitationsVisibilityErrors, GetApiV1ProjectsByNameCitationsVisibilityResponses, GetApiV1ProjectsByNameCompetitorsData, GetApiV1ProjectsByNameCompetitorsResponses, GetApiV1ProjectsByNameContentDismissalsData, GetApiV1ProjectsByNameContentDismissalsErrors, GetApiV1ProjectsByNameContentDismissalsResponses, GetApiV1ProjectsByNameContentGapsData, GetApiV1ProjectsByNameContentGapsErrors, GetApiV1ProjectsByNameContentGapsResponses, GetApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysisData, GetApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysisErrors, GetApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysisResponses, GetApiV1ProjectsByNameContentSourcesData, GetApiV1ProjectsByNameContentSourcesErrors, GetApiV1ProjectsByNameContentSourcesResponses, GetApiV1ProjectsByNameContentTargetsData, GetApiV1ProjectsByNameContentTargetsErrors, GetApiV1ProjectsByNameContentTargetsResponses, GetApiV1ProjectsByNameData, GetApiV1ProjectsByNameDeletePreviewData, GetApiV1ProjectsByNameDeletePreviewErrors, GetApiV1ProjectsByNameDeletePreviewResponses, GetApiV1ProjectsByNameDiscoverSessionsByIdData, GetApiV1ProjectsByNameDiscoverSessionsByIdErrors, GetApiV1ProjectsByNameDiscoverSessionsByIdPromoteData, GetApiV1ProjectsByNameDiscoverSessionsByIdPromoteErrors, GetApiV1ProjectsByNameDiscoverSessionsByIdPromoteResponses, GetApiV1ProjectsByNameDiscoverSessionsByIdResponses, GetApiV1ProjectsByNameDiscoverSessionsData, GetApiV1ProjectsByNameDiscoverSessionsErrors, GetApiV1ProjectsByNameDiscoverSessionsResponses, GetApiV1ProjectsByNameDoctorData, GetApiV1ProjectsByNameDoctorErrors, GetApiV1ProjectsByNameDoctorResponses, GetApiV1ProjectsByNameErrors, GetApiV1ProjectsByNameExportData, GetApiV1ProjectsByNameExportErrors, GetApiV1ProjectsByNameExportResponses, GetApiV1ProjectsByNameGaAiReferralHistoryData, GetApiV1ProjectsByNameGaAiReferralHistoryErrors, GetApiV1ProjectsByNameGaAiReferralHistoryResponses, GetApiV1ProjectsByNameGaAttributionTrendData, GetApiV1ProjectsByNameGaAttributionTrendErrors, GetApiV1ProjectsByNameGaAttributionTrendResponses, GetApiV1ProjectsByNameGaCoverageData, GetApiV1ProjectsByNameGaCoverageErrors, GetApiV1ProjectsByNameGaCoverageResponses, GetApiV1ProjectsByNameGaSessionHistoryData, GetApiV1ProjectsByNameGaSessionHistoryErrors, GetApiV1ProjectsByNameGaSessionHistoryResponses, GetApiV1ProjectsByNameGaSocialReferralHistoryData, GetApiV1ProjectsByNameGaSocialReferralHistoryErrors, GetApiV1ProjectsByNameGaSocialReferralHistoryResponses, GetApiV1ProjectsByNameGaSocialReferralTrendData, GetApiV1ProjectsByNameGaSocialReferralTrendErrors, GetApiV1ProjectsByNameGaSocialReferralTrendResponses, GetApiV1ProjectsByNameGaStatusData, GetApiV1ProjectsByNameGaStatusErrors, GetApiV1ProjectsByNameGaStatusResponses, GetApiV1ProjectsByNameGaTrafficData, GetApiV1ProjectsByNameGaTrafficErrors, GetApiV1ProjectsByNameGaTrafficResponses, GetApiV1ProjectsByNameGbpAccountsData, GetApiV1ProjectsByNameGbpAccountsErrors, GetApiV1ProjectsByNameGbpAccountsResponses, GetApiV1ProjectsByNameGbpKeywordsData, GetApiV1ProjectsByNameGbpKeywordsErrors, GetApiV1ProjectsByNameGbpKeywordsResponses, GetApiV1ProjectsByNameGbpLocationsData, GetApiV1ProjectsByNameGbpLocationsErrors, GetApiV1ProjectsByNameGbpLocationsResponses, GetApiV1ProjectsByNameGbpLodgingData, GetApiV1ProjectsByNameGbpLodgingErrors, GetApiV1ProjectsByNameGbpLodgingResponses, GetApiV1ProjectsByNameGbpMetricsData, GetApiV1ProjectsByNameGbpMetricsErrors, GetApiV1ProjectsByNameGbpMetricsResponses, GetApiV1ProjectsByNameGbpPlaceActionsData, GetApiV1ProjectsByNameGbpPlaceActionsErrors, GetApiV1ProjectsByNameGbpPlaceActionsResponses, GetApiV1ProjectsByNameGbpPlacesData, GetApiV1ProjectsByNameGbpPlacesErrors, GetApiV1ProjectsByNameGbpPlacesResponses, GetApiV1ProjectsByNameGbpSummaryData, GetApiV1ProjectsByNameGbpSummaryErrors, GetApiV1ProjectsByNameGbpSummaryResponses, GetApiV1ProjectsByNameGoogleCallbackData, GetApiV1ProjectsByNameGoogleCallbackErrors, GetApiV1ProjectsByNameGoogleCallbackResponses, GetApiV1ProjectsByNameGoogleConnectionsData, GetApiV1ProjectsByNameGoogleConnectionsErrors, GetApiV1ProjectsByNameGoogleConnectionsResponses, GetApiV1ProjectsByNameGoogleGscCoverageData, GetApiV1ProjectsByNameGoogleGscCoverageErrors, GetApiV1ProjectsByNameGoogleGscCoverageHistoryData, GetApiV1ProjectsByNameGoogleGscCoverageHistoryErrors, GetApiV1ProjectsByNameGoogleGscCoverageHistoryResponses, GetApiV1ProjectsByNameGoogleGscCoverageResponses, GetApiV1ProjectsByNameGoogleGscDeindexedData, GetApiV1ProjectsByNameGoogleGscDeindexedErrors, GetApiV1ProjectsByNameGoogleGscDeindexedResponses, GetApiV1ProjectsByNameGoogleGscInspectionsData, GetApiV1ProjectsByNameGoogleGscInspectionsErrors, GetApiV1ProjectsByNameGoogleGscInspectionsResponses, GetApiV1ProjectsByNameGoogleGscPerformanceDailyData, GetApiV1ProjectsByNameGoogleGscPerformanceDailyErrors, GetApiV1ProjectsByNameGoogleGscPerformanceDailyResponses, GetApiV1ProjectsByNameGoogleGscPerformanceData, GetApiV1ProjectsByNameGoogleGscPerformanceErrors, GetApiV1ProjectsByNameGoogleGscPerformanceResponses, GetApiV1ProjectsByNameGoogleGscSitemapsData, GetApiV1ProjectsByNameGoogleGscSitemapsErrors, GetApiV1ProjectsByNameGoogleGscSitemapsResponses, GetApiV1ProjectsByNameGooglePropertiesData, GetApiV1ProjectsByNameGooglePropertiesErrors, GetApiV1ProjectsByNameGooglePropertiesResponses, GetApiV1ProjectsByNameHealthHistoryData, GetApiV1ProjectsByNameHealthHistoryErrors, GetApiV1ProjectsByNameHealthHistoryResponses, GetApiV1ProjectsByNameHealthLatestData, GetApiV1ProjectsByNameHealthLatestErrors, GetApiV1ProjectsByNameHealthLatestResponses, GetApiV1ProjectsByNameHistoryData, GetApiV1ProjectsByNameHistoryResponses, GetApiV1ProjectsByNameInsightsByIdData, GetApiV1ProjectsByNameInsightsByIdErrors, GetApiV1ProjectsByNameInsightsByIdResponses, GetApiV1ProjectsByNameInsightsData, GetApiV1ProjectsByNameInsightsErrors, GetApiV1ProjectsByNameInsightsResponses, GetApiV1ProjectsByNameKeywordsData, GetApiV1ProjectsByNameKeywordsResponses, GetApiV1ProjectsByNameLocationsData, GetApiV1ProjectsByNameLocationsErrors, GetApiV1ProjectsByNameLocationsResponses, GetApiV1ProjectsByNameNotificationsData, GetApiV1ProjectsByNameNotificationsResponses, GetApiV1ProjectsByNameOverviewData, GetApiV1ProjectsByNameOverviewErrors, GetApiV1ProjectsByNameOverviewResponses, GetApiV1ProjectsByNameQueriesData, GetApiV1ProjectsByNameQueriesResponses, GetApiV1ProjectsByNameReportData, GetApiV1ProjectsByNameReportErrors, GetApiV1ProjectsByNameReportHtmlData, GetApiV1ProjectsByNameReportHtmlErrors, GetApiV1ProjectsByNameReportHtmlResponses, GetApiV1ProjectsByNameReportResponses, GetApiV1ProjectsByNameResponses, GetApiV1ProjectsByNameRunsByRunIdBrowserDiffData, GetApiV1ProjectsByNameRunsByRunIdBrowserDiffErrors, GetApiV1ProjectsByNameRunsByRunIdBrowserDiffResponses, GetApiV1ProjectsByNameRunsData, GetApiV1ProjectsByNameRunsLatestData, GetApiV1ProjectsByNameRunsLatestResponses, GetApiV1ProjectsByNameRunsResponses, GetApiV1ProjectsByNameScheduleData, GetApiV1ProjectsByNameScheduleErrors, GetApiV1ProjectsByNameScheduleResponses, GetApiV1ProjectsByNameSearchData, GetApiV1ProjectsByNameSearchErrors, GetApiV1ProjectsByNameSearchResponses, GetApiV1ProjectsByNameSnapshotsData, GetApiV1ProjectsByNameSnapshotsDiffData, GetApiV1ProjectsByNameSnapshotsDiffErrors, GetApiV1ProjectsByNameSnapshotsDiffResponses, GetApiV1ProjectsByNameSnapshotsResponses, GetApiV1ProjectsByNameTimelineData, GetApiV1ProjectsByNameTimelineResponses, GetApiV1ProjectsByNameTrafficEventsData, GetApiV1ProjectsByNameTrafficEventsErrors, GetApiV1ProjectsByNameTrafficEventsResponses, GetApiV1ProjectsByNameTrafficSourcesByIdData, GetApiV1ProjectsByNameTrafficSourcesByIdErrors, GetApiV1ProjectsByNameTrafficSourcesByIdResponses, GetApiV1ProjectsByNameTrafficSourcesData, GetApiV1ProjectsByNameTrafficSourcesErrors, GetApiV1ProjectsByNameTrafficSourcesResponses, GetApiV1ProjectsByNameTrafficStatusData, GetApiV1ProjectsByNameTrafficStatusErrors, GetApiV1ProjectsByNameTrafficStatusResponses, GetApiV1ProjectsByNameWordpressAuditData, GetApiV1ProjectsByNameWordpressAuditErrors, GetApiV1ProjectsByNameWordpressAuditResponses, GetApiV1ProjectsByNameWordpressDiffData, GetApiV1ProjectsByNameWordpressDiffErrors, GetApiV1ProjectsByNameWordpressDiffResponses, GetApiV1ProjectsByNameWordpressLlmsTxtData, GetApiV1ProjectsByNameWordpressLlmsTxtErrors, GetApiV1ProjectsByNameWordpressLlmsTxtResponses, GetApiV1ProjectsByNameWordpressPageData, GetApiV1ProjectsByNameWordpressPageErrors, GetApiV1ProjectsByNameWordpressPageResponses, GetApiV1ProjectsByNameWordpressPagesData, GetApiV1ProjectsByNameWordpressPagesErrors, GetApiV1ProjectsByNameWordpressPagesResponses, GetApiV1ProjectsByNameWordpressSchemaData, GetApiV1ProjectsByNameWordpressSchemaErrors, GetApiV1ProjectsByNameWordpressSchemaResponses, GetApiV1ProjectsByNameWordpressSchemaStatusData, GetApiV1ProjectsByNameWordpressSchemaStatusErrors, GetApiV1ProjectsByNameWordpressSchemaStatusResponses, GetApiV1ProjectsByNameWordpressStagingStatusData, GetApiV1ProjectsByNameWordpressStagingStatusErrors, GetApiV1ProjectsByNameWordpressStagingStatusResponses, GetApiV1ProjectsByNameWordpressStatusData, GetApiV1ProjectsByNameWordpressStatusErrors, GetApiV1ProjectsByNameWordpressStatusResponses, GetApiV1ProjectsData, GetApiV1ProjectsResponses, GetApiV1RunsByIdData, GetApiV1RunsByIdErrors, GetApiV1RunsByIdResponses, GetApiV1RunsData, GetApiV1RunsResponses, GetApiV1ScreenshotsBySnapshotIdData, GetApiV1ScreenshotsBySnapshotIdErrors, GetApiV1ScreenshotsBySnapshotIdResponses, GetApiV1SettingsData, GetApiV1SettingsResponses, GetApiV1TelemetryData, GetApiV1TelemetryErrors, GetApiV1TelemetryResponses, PostApiV1ApplyData, PostApiV1ApplyErrors, PostApiV1ApplyResponses, PostApiV1BacklinksInstallData, PostApiV1BacklinksInstallErrors, PostApiV1BacklinksInstallResponses, PostApiV1BacklinksSyncsData, PostApiV1BacklinksSyncsErrors, PostApiV1BacklinksSyncsResponses, PostApiV1CdpScreenshotData, PostApiV1CdpScreenshotErrors, PostApiV1CdpScreenshotResponses, PostApiV1GuestReportByIdClaimData, PostApiV1GuestReportByIdClaimErrors, PostApiV1GuestReportByIdClaimResponses, PostApiV1GuestReportData, PostApiV1GuestReportErrors, PostApiV1GuestReportResponses, PostApiV1ProjectsByNameBacklinksExtractData, PostApiV1ProjectsByNameBacklinksExtractErrors, PostApiV1ProjectsByNameBacklinksExtractResponses, PostApiV1ProjectsByNameBingConnectData, PostApiV1ProjectsByNameBingConnectErrors, PostApiV1ProjectsByNameBingConnectResponses, PostApiV1ProjectsByNameBingInspectSitemapData, PostApiV1ProjectsByNameBingInspectSitemapErrors, PostApiV1ProjectsByNameBingInspectSitemapResponses, PostApiV1ProjectsByNameBingInspectUrlData, PostApiV1ProjectsByNameBingInspectUrlErrors, PostApiV1ProjectsByNameBingInspectUrlResponses, PostApiV1ProjectsByNameBingRequestIndexingData, PostApiV1ProjectsByNameBingRequestIndexingErrors, PostApiV1ProjectsByNameBingRequestIndexingResponses, PostApiV1ProjectsByNameBingSetSiteData, PostApiV1ProjectsByNameBingSetSiteErrors, PostApiV1ProjectsByNameBingSetSiteResponses, PostApiV1ProjectsByNameCompetitorsData, PostApiV1ProjectsByNameCompetitorsErrors, PostApiV1ProjectsByNameCompetitorsResponses, PostApiV1ProjectsByNameContentDismissalsData, PostApiV1ProjectsByNameContentDismissalsErrors, PostApiV1ProjectsByNameContentDismissalsResponses, PostApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyzeData, PostApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyzeErrors, PostApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyzeResponses, PostApiV1ProjectsByNameDiscoverRunData, PostApiV1ProjectsByNameDiscoverRunErrors, PostApiV1ProjectsByNameDiscoverRunResponses, PostApiV1ProjectsByNameDiscoverSessionsByIdPromoteData, PostApiV1ProjectsByNameDiscoverSessionsByIdPromoteErrors, PostApiV1ProjectsByNameDiscoverSessionsByIdPromoteResponses, PostApiV1ProjectsByNameGaConnectData, PostApiV1ProjectsByNameGaConnectErrors, PostApiV1ProjectsByNameGaConnectResponses, PostApiV1ProjectsByNameGaSyncData, PostApiV1ProjectsByNameGaSyncErrors, PostApiV1ProjectsByNameGaSyncResponses, PostApiV1ProjectsByNameGbpLocationsDiscoverData, PostApiV1ProjectsByNameGbpLocationsDiscoverErrors, PostApiV1ProjectsByNameGbpLocationsDiscoverResponses, PostApiV1ProjectsByNameGbpSyncData, PostApiV1ProjectsByNameGbpSyncErrors, PostApiV1ProjectsByNameGbpSyncResponses, PostApiV1ProjectsByNameGoogleConnectData, PostApiV1ProjectsByNameGoogleConnectErrors, PostApiV1ProjectsByNameGoogleConnectResponses, PostApiV1ProjectsByNameGoogleGscDiscoverSitemapsData, PostApiV1ProjectsByNameGoogleGscDiscoverSitemapsErrors, PostApiV1ProjectsByNameGoogleGscDiscoverSitemapsResponses, PostApiV1ProjectsByNameGoogleGscInspectData, PostApiV1ProjectsByNameGoogleGscInspectErrors, PostApiV1ProjectsByNameGoogleGscInspectResponses, PostApiV1ProjectsByNameGoogleGscInspectSitemapData, PostApiV1ProjectsByNameGoogleGscInspectSitemapErrors, PostApiV1ProjectsByNameGoogleGscInspectSitemapResponses, PostApiV1ProjectsByNameGoogleGscSyncData, PostApiV1ProjectsByNameGoogleGscSyncErrors, PostApiV1ProjectsByNameGoogleGscSyncResponses, PostApiV1ProjectsByNameGoogleIndexingRequestData, PostApiV1ProjectsByNameGoogleIndexingRequestErrors, PostApiV1ProjectsByNameGoogleIndexingRequestResponses, PostApiV1ProjectsByNameInsightsByIdDismissData, PostApiV1ProjectsByNameInsightsByIdDismissErrors, PostApiV1ProjectsByNameInsightsByIdDismissResponses, PostApiV1ProjectsByNameKeywordsData, PostApiV1ProjectsByNameKeywordsGenerateData, PostApiV1ProjectsByNameKeywordsGenerateErrors, PostApiV1ProjectsByNameKeywordsGenerateResponses, PostApiV1ProjectsByNameKeywordsResponses, PostApiV1ProjectsByNameLocationsData, PostApiV1ProjectsByNameLocationsErrors, PostApiV1ProjectsByNameLocationsResponses, PostApiV1ProjectsByNameNotificationsByIdTestData, PostApiV1ProjectsByNameNotificationsByIdTestErrors, PostApiV1ProjectsByNameNotificationsByIdTestResponses, PostApiV1ProjectsByNameNotificationsData, PostApiV1ProjectsByNameNotificationsResponses, PostApiV1ProjectsByNameQueriesData, PostApiV1ProjectsByNameQueriesGenerateData, PostApiV1ProjectsByNameQueriesGenerateErrors, PostApiV1ProjectsByNameQueriesGenerateResponses, PostApiV1ProjectsByNameQueriesReplacePreviewData, PostApiV1ProjectsByNameQueriesReplacePreviewErrors, PostApiV1ProjectsByNameQueriesReplacePreviewResponses, PostApiV1ProjectsByNameQueriesResponses, PostApiV1ProjectsByNameRunsData, PostApiV1ProjectsByNameRunsErrors, PostApiV1ProjectsByNameRunsResponses, PostApiV1ProjectsByNameTrafficConnectCloudRunData, PostApiV1ProjectsByNameTrafficConnectCloudRunErrors, PostApiV1ProjectsByNameTrafficConnectCloudRunResponses, PostApiV1ProjectsByNameTrafficConnectVercelData, PostApiV1ProjectsByNameTrafficConnectVercelErrors, PostApiV1ProjectsByNameTrafficConnectVercelResponses, PostApiV1ProjectsByNameTrafficConnectWordpressData, PostApiV1ProjectsByNameTrafficConnectWordpressErrors, PostApiV1ProjectsByNameTrafficConnectWordpressResponses, PostApiV1ProjectsByNameTrafficSourcesByIdBackfillData, PostApiV1ProjectsByNameTrafficSourcesByIdBackfillErrors, PostApiV1ProjectsByNameTrafficSourcesByIdBackfillResponses, PostApiV1ProjectsByNameTrafficSourcesByIdResetData, PostApiV1ProjectsByNameTrafficSourcesByIdResetErrors, PostApiV1ProjectsByNameTrafficSourcesByIdResetResponses, PostApiV1ProjectsByNameTrafficSourcesByIdSyncData, PostApiV1ProjectsByNameTrafficSourcesByIdSyncErrors, PostApiV1ProjectsByNameTrafficSourcesByIdSyncResponses, PostApiV1ProjectsByNameWordpressConnectData, PostApiV1ProjectsByNameWordpressConnectErrors, PostApiV1ProjectsByNameWordpressConnectResponses, PostApiV1ProjectsByNameWordpressLlmsTxtManualData, PostApiV1ProjectsByNameWordpressLlmsTxtManualErrors, PostApiV1ProjectsByNameWordpressLlmsTxtManualResponses, PostApiV1ProjectsByNameWordpressOnboardData, PostApiV1ProjectsByNameWordpressOnboardErrors, PostApiV1ProjectsByNameWordpressOnboardResponses, PostApiV1ProjectsByNameWordpressPageMetaData, PostApiV1ProjectsByNameWordpressPageMetaErrors, PostApiV1ProjectsByNameWordpressPageMetaResponses, PostApiV1ProjectsByNameWordpressPagesData, PostApiV1ProjectsByNameWordpressPagesErrors, PostApiV1ProjectsByNameWordpressPagesMetaBulkData, PostApiV1ProjectsByNameWordpressPagesMetaBulkErrors, PostApiV1ProjectsByNameWordpressPagesMetaBulkResponses, PostApiV1ProjectsByNameWordpressPagesResponses, PostApiV1ProjectsByNameWordpressSchemaDeployData, PostApiV1ProjectsByNameWordpressSchemaDeployErrors, PostApiV1ProjectsByNameWordpressSchemaDeployResponses, PostApiV1ProjectsByNameWordpressSchemaManualData, PostApiV1ProjectsByNameWordpressSchemaManualErrors, PostApiV1ProjectsByNameWordpressSchemaManualResponses, PostApiV1ProjectsByNameWordpressStagingPushData, PostApiV1ProjectsByNameWordpressStagingPushErrors, PostApiV1ProjectsByNameWordpressStagingPushResponses, PostApiV1RunsByIdCancelData, PostApiV1RunsByIdCancelErrors, PostApiV1RunsByIdCancelResponses, PostApiV1RunsData, PostApiV1RunsResponses, PostApiV1SnapshotData, PostApiV1SnapshotErrors, PostApiV1SnapshotResponses, PutApiV1ProjectsByNameAgentMemoryData, PutApiV1ProjectsByNameAgentMemoryErrors, PutApiV1ProjectsByNameAgentMemoryResponses, PutApiV1ProjectsByNameCompetitorsData, PutApiV1ProjectsByNameCompetitorsResponses, PutApiV1ProjectsByNameData, PutApiV1ProjectsByNameGbpLocationsByLocationNameSelectionData, PutApiV1ProjectsByNameGbpLocationsByLocationNameSelectionErrors, PutApiV1ProjectsByNameGbpLocationsByLocationNameSelectionResponses, PutApiV1ProjectsByNameGoogleConnectionsByTypePropertyData, PutApiV1ProjectsByNameGoogleConnectionsByTypePropertyErrors, PutApiV1ProjectsByNameGoogleConnectionsByTypePropertyResponses, PutApiV1ProjectsByNameGoogleConnectionsByTypeSitemapData, PutApiV1ProjectsByNameGoogleConnectionsByTypeSitemapErrors, PutApiV1ProjectsByNameGoogleConnectionsByTypeSitemapResponses, PutApiV1ProjectsByNameKeywordsData, PutApiV1ProjectsByNameKeywordsResponses, PutApiV1ProjectsByNameLocationsDefaultData, PutApiV1ProjectsByNameLocationsDefaultErrors, PutApiV1ProjectsByNameLocationsDefaultResponses, PutApiV1ProjectsByNameQueriesData, PutApiV1ProjectsByNameQueriesResponses, PutApiV1ProjectsByNameResponses, PutApiV1ProjectsByNameScheduleData, PutApiV1ProjectsByNameScheduleErrors, PutApiV1ProjectsByNameScheduleResponses, PutApiV1ProjectsByNameWordpressPageData, PutApiV1ProjectsByNameWordpressPageErrors, PutApiV1ProjectsByNameWordpressPageResponses, PutApiV1SettingsBingData, PutApiV1SettingsBingErrors, PutApiV1SettingsBingResponses, PutApiV1SettingsCdpData, PutApiV1SettingsCdpErrors, PutApiV1SettingsCdpResponses, PutApiV1SettingsGoogleData, PutApiV1SettingsGoogleErrors, PutApiV1SettingsGoogleResponses, PutApiV1SettingsProvidersByNameData, PutApiV1SettingsProvidersByNameErrors, PutApiV1SettingsProvidersByNameResponses, PutApiV1TelemetryData, PutApiV1TelemetryErrors, PutApiV1TelemetryResponses } from './types.gen.js'; export type Options = Options2 & { /** @@ -30,6 +30,52 @@ export const getApiV1OpenapiJson = (option }); }; +/** + * Create an anonymous guest report for a domain + * + * Kicks off the audit + AI sweep on a transient guest project for the given domain. Returns immediately with the report id so the SPA can subscribe to the SSE stream; the actual work runs in the background. Anonymous — no auth required. Rows expire after 7 days unless claimed. + */ +export const postApiV1GuestReport = (options: Options) => { + return (options.client ?? client).post({ + url: '/api/v1/guest/report', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +/** + * Fetch a guest report + * + * Returns the current state of the report including audit/sweep scores, top findings, proposed plan, and the full progressEvents replay buffer. Anonymous — no auth required. + */ +export const getApiV1GuestReportById = (options: Options) => { + return (options.client ?? client).get({ + url: '/api/v1/guest/report/{id}', + ...options + }); +}; + +/** + * Claim a guest report into the authenticated workspace + * + * Promotes the transient guest project into the operator workspace, marking the report claimed. Idempotent — calling twice returns `alreadyClaimed: true` with the same projectId. Requires a valid session cookie or bearer token. + */ +export const postApiV1GuestReportByIdClaim = (options: Options) => { + return (options.client ?? client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/guest/report/{id}/claim', + ...options + }); +}; + /** * Delete a project */ diff --git a/packages/api-client-generated/src/generated/types.gen.ts b/packages/api-client-generated/src/generated/types.gen.ts index efd8b1c8..57755f43 100644 --- a/packages/api-client-generated/src/generated/types.gen.ts +++ b/packages/api-client-generated/src/generated/types.gen.ts @@ -740,6 +740,58 @@ export type GoogleConnectionDto = { updatedAt: string; }; +export type GuestReportClaimResponseDto = { + claimed?: true; + alreadyClaimed?: true; + projectName: string | null; + projectId: string; +}; + +export type GuestReportCreateResponseDto = { + id: string; + domain: string; + status: 'pending' | 'auditing' | 'sweeping' | 'completed' | 'failed'; + expiresAt: string; +}; + +export type GuestReportDto = { + id: string; + domain: string; + projectId: string; + status: 'pending' | 'auditing' | 'sweeping' | 'completed' | 'failed'; + auditScore: number | null; + auditPagesCrawled: number; + auditFindingsCount: number; + auditTopFindings: Array<{ + severity: string; + title: string; + url: string; + pointsLost: number; + }>; + overallScore: number | null; + aiCitedCount: number | null; + aiQueryCount: number | null; + aiMentionedCount: number | null; + topCompetitor: string | null; + topCompetitorCitedCount: number | null; + proposedPlan: Array<{ + label: string; + pointsImpact: number; + rationale: string; + }>; + progressEvents: Array<{ + at: string; + type: 'sitemap-pulled' | 'page-audited' | 'audit-complete' | 'sweep-started' | 'provider-checked' | 'overall-complete' | 'failed'; + payload: { + [key: string]: unknown; + }; + }>; + errorMessage: string | null; + createdAt: string; + expiresAt: string; + claimedAt: string | null; +}; + export type GscCoverageSnapshotDto = { date: string; indexed: number; @@ -972,12 +1024,12 @@ export type LocationContext = { export type NotificationDto = { id: string; - projectId: string; + projectId: string | null; channel: 'webhook'; url: string; urlDisplay: string; urlHost: string; - events: Array<'citation.lost' | 'citation.gained' | 'run.completed' | 'run.failed' | 'insight.critical' | 'insight.high'>; + events: Array<'citation.lost' | 'citation.gained' | 'run.completed' | 'run.failed' | 'insight.critical' | 'insight.high' | 'baseline.completed' | 'digest.generated' | 'action.created' | 'action.completed' | 'connection.created' | 'connection.revoked'>; enabled: boolean; source?: string; webhookSecret?: string; @@ -2120,6 +2172,100 @@ export type GetApiV1OpenapiJsonResponses = { export type GetApiV1OpenapiJsonResponse = GetApiV1OpenapiJsonResponses[keyof GetApiV1OpenapiJsonResponses]; +export type PostApiV1GuestReportData = { + body: { + /** + * Bare domain or full URL — normalized server-side. + */ + domain: string; + }; + path?: never; + query?: never; + url: '/api/v1/guest/report'; +}; + +export type PostApiV1GuestReportErrors = { + /** + * Invalid or missing domain. + */ + 400: ErrorEnvelope; +}; + +export type PostApiV1GuestReportError = PostApiV1GuestReportErrors[keyof PostApiV1GuestReportErrors]; + +export type PostApiV1GuestReportResponses = { + /** + * Guest report created. + */ + 201: GuestReportCreateResponseDto; +}; + +export type PostApiV1GuestReportResponse = PostApiV1GuestReportResponses[keyof PostApiV1GuestReportResponses]; + +export type GetApiV1GuestReportByIdData = { + body?: never; + path: { + /** + * Guest report id. + */ + id: string; + }; + query?: never; + url: '/api/v1/guest/report/{id}'; +}; + +export type GetApiV1GuestReportByIdErrors = { + /** + * Guest report not found. + */ + 404: ErrorEnvelope; +}; + +export type GetApiV1GuestReportByIdError = GetApiV1GuestReportByIdErrors[keyof GetApiV1GuestReportByIdErrors]; + +export type GetApiV1GuestReportByIdResponses = { + /** + * Guest report returned. + */ + 200: GuestReportDto; +}; + +export type GetApiV1GuestReportByIdResponse = GetApiV1GuestReportByIdResponses[keyof GetApiV1GuestReportByIdResponses]; + +export type PostApiV1GuestReportByIdClaimData = { + body?: never; + path: { + /** + * Guest report id. + */ + id: string; + }; + query?: never; + url: '/api/v1/guest/report/{id}/claim'; +}; + +export type PostApiV1GuestReportByIdClaimErrors = { + /** + * Authentication required. + */ + 401: ErrorEnvelope; + /** + * Guest report not found. + */ + 404: ErrorEnvelope; +}; + +export type PostApiV1GuestReportByIdClaimError = PostApiV1GuestReportByIdClaimErrors[keyof PostApiV1GuestReportByIdClaimErrors]; + +export type PostApiV1GuestReportByIdClaimResponses = { + /** + * Report claimed (or already claimed). + */ + 200: GuestReportClaimResponseDto; +}; + +export type PostApiV1GuestReportByIdClaimResponse = PostApiV1GuestReportByIdClaimResponses[keyof PostApiV1GuestReportByIdClaimResponses]; + export type DeleteApiV1ProjectsByNameData = { body?: never; path: { diff --git a/packages/api-routes/package.json b/packages/api-routes/package.json index 95b98bd3..73dd13aa 100644 --- a/packages/api-routes/package.json +++ b/packages/api-routes/package.json @@ -19,6 +19,7 @@ "dependencies": { "@ainyc/canonry-contracts": "workspace:*", "@ainyc/canonry-db": "workspace:*", + "@fastify/rate-limit": "^10.2.2", "@ainyc/canonry-integration-bing": "workspace:*", "@ainyc/canonry-integration-cloud-run": "workspace:*", "@ainyc/canonry-integration-commoncrawl": "workspace:*", diff --git a/packages/api-routes/src/auth.ts b/packages/api-routes/src/auth.ts index 4c4c1c3b..5fe7473b 100644 --- a/packages/api-routes/src/auth.ts +++ b/packages/api-routes/src/auth.ts @@ -57,6 +57,12 @@ function shouldSkipAuth(url: string): boolean { if (url.endsWith('/openapi.json')) return true if (url.includes('/google/callback')) return true if (url.endsWith('/session') || url.endsWith('/session/setup')) return true + // Aero owner-view onboarding: the free first report runs before the + // visitor signs up. POST /guest/report, GET /guest/report/:id, and the + // SSE stream are anonymous. The /claim endpoint requires auth and is + // intentionally NOT in the skip list — the matcher uses non-capturing + // groups so /claim falls through to the auth check. + if (url.match(/\/guest\/report(?:\/[^/]+(?:\/stream)?)?$/)) return true return false } diff --git a/packages/api-routes/src/bing.ts b/packages/api-routes/src/bing.ts index 03038816..b5dae93c 100644 --- a/packages/api-routes/src/bing.ts +++ b/packages/api-routes/src/bing.ts @@ -4,6 +4,7 @@ import type { FastifyInstance } from 'fastify' import { bingUrlInspections, bingCoverageSnapshots, runs } from '@ainyc/canonry-db' import { validationError, notFound, RunKinds, RunStatuses, RunTriggers } from '@ainyc/canonry-contracts' import { resolveProject, writeAuditLog } from './helpers.js' +import { emitConnectionEvent } from './cloud/emit-connection-event.js' import { getSites, getUrlInfo, @@ -179,6 +180,18 @@ export async function bingRoutes(app: FastifyInstance, opts: BingRoutesOptions) entityId: project.canonicalDomain, }) + // Track 3 (Canonry Hosted): emit `connection.created` so the cloud + // control plane can surface the new connection. + await emitConnectionEvent(app.db, { + event: 'connection.created', + project: { id: project.id, name: project.name, canonicalDomain: project.canonicalDomain }, + payload: { + connectionType: 'bing', + propertyRef: existing?.siteUrl ?? null, + scopes: [], + }, + }) + return { connected: true, domain: project.canonicalDomain, @@ -218,6 +231,19 @@ export async function bingRoutes(app: FastifyInstance, opts: BingRoutesOptions) entityId: project.canonicalDomain, }) + // Track 3 (Canonry Hosted): emit `connection.revoked` so the cloud + // control plane can mark the surface revoked. + await emitConnectionEvent(app.db, { + event: 'connection.revoked', + project: { id: project.id, name: project.name, canonicalDomain: project.canonicalDomain }, + payload: { + connectionType: 'bing', + propertyRef: existing.siteUrl ?? null, + scopes: [], + reason: 'user-disconnected', + }, + }) + return reply.status(204).send() }) diff --git a/packages/api-routes/src/cloud/bing-key.ts b/packages/api-routes/src/cloud/bing-key.ts new file mode 100644 index 00000000..1c33e87d --- /dev/null +++ b/packages/api-routes/src/cloud/bing-key.ts @@ -0,0 +1,75 @@ +import type { FastifyInstance } from 'fastify' +import { validationError } from '@ainyc/canonry-contracts' +import type { BingConnectionStore } from '../bing.js' +import { requireCloudBootstrap, resolveProject, writeAuditLog } from '../helpers.js' +import { cloudImportBingKeyRequestSchema } from './schema.js' +import { emitConnectionEvent } from './emit-connection-event.js' + +export interface CloudBingKeyRoutesOptions { + /** + * Existing Bing connection store — same singleton that `bingRoutes` + * consumes. Required when this route is registered; the registrar in + * `index.ts` skips registration when the store isn't wired. + */ + bingConnectionStore: BingConnectionStore +} + +export async function cloudBingKeyRoutes(app: FastifyInstance, opts: CloudBingKeyRoutesOptions) { + // POST /cloud/bing/import-key — bypasses the legacy `/bing/connect` + // verify-then-store flow. Bing Webmaster Tools is API-key based; the + // control plane is trusted to ship a valid key. + app.post('/cloud/bing/import-key', async (request, reply) => { + requireCloudBootstrap(request) + + const parsed = cloudImportBingKeyRequestSchema.safeParse(request.body) + if (!parsed.success) { + throw validationError('Invalid Bing key import request', { + issues: parsed.error.issues.map(i => ({ path: i.path.join('.'), message: i.message })), + }) + } + + const project = resolveProject(app.db, parsed.data.project_slug) + const now = new Date().toISOString() + const existing = opts.bingConnectionStore.getConnection(project.canonicalDomain) + + // Same admin-bypass rationale as `cloud/google/import-tokens`: the + // cloud bridge owns this tenant's managed-credential surface and is + // allowed to overwrite an existing connection regardless of owner. + opts.bingConnectionStore.upsertConnection({ + domain: project.canonicalDomain, + apiKey: parsed.data.api_key, + siteUrl: parsed.data.site_url, + createdByProjectId: existing?.createdByProjectId ?? project.id, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }) + + writeAuditLog(app.db, { + projectId: project.id, + actor: 'cloud', + action: 'cloud.bing.imported', + entityType: 'bing_connection', + entityId: project.canonicalDomain, + diff: { + domain: project.canonicalDomain, + siteUrl: parsed.data.site_url, + }, + }) + + await emitConnectionEvent(app.db, { + event: 'connection.created', + project, + payload: { + connectionType: 'bing', + propertyRef: parsed.data.site_url, + scopes: [], + }, + }) + + return reply.status(200).send({ + imported: true, + domain: project.canonicalDomain, + site_url: parsed.data.site_url, + }) + }) +} diff --git a/packages/api-routes/src/cloud/bootstrap.ts b/packages/api-routes/src/cloud/bootstrap.ts new file mode 100644 index 00000000..be75bd7e --- /dev/null +++ b/packages/api-routes/src/cloud/bootstrap.ts @@ -0,0 +1,178 @@ +import crypto from 'node:crypto' +import { eq } from 'drizzle-orm' +import type { FastifyInstance } from 'fastify' +import { cloudMetadata, notifications, parseJsonColumn } from '@ainyc/canonry-db' +import { type NotificationEvent, validationError } from '@ainyc/canonry-contracts' +import { requireCloudBootstrap, writeAuditLog } from '../helpers.js' +import { resolveWebhookTarget } from '../webhooks.js' +import { cloudBootstrapRequestSchema } from './schema.js' + +/** + * The full set of event types the control plane subscribes to via the + * bootstrap-created notification row. Six legacy events + six cloud events + * — the control plane wants all of them so it can mirror state changes, + * trigger downstream automation, and dispatch digest emails without a + * second registration step. + */ +const CONTROL_PLANE_SUBSCRIBED_EVENTS: NotificationEvent[] = [ + // Legacy six (existing `WebhookPayload` envelope). + 'citation.lost', + 'citation.gained', + 'run.completed', + 'run.failed', + 'insight.critical', + 'insight.high', + // Cloud six (new `CloudWebhookPayload` envelope; Track 3). + 'baseline.completed', + 'digest.generated', + 'action.created', + 'action.completed', + 'connection.created', + 'connection.revoked', +] + +/** + * Options injected by the host so the bootstrap response can report the + * runtime's actual version + so the route doesn't need to import package.json + * directly (api-routes is bundled separately and `import.meta.url` parsing + * gets messy across the cjs/esm dual-build). + */ +export interface CloudBootstrapRoutesOptions { + /** Tenant runtime version reported in the bootstrap response. */ + canonryVersion?: string + /** Allow webhook URLs that resolve to loopback addresses. */ + allowLoopbackWebhooks?: boolean + /** Allow webhook URLs that resolve to private RFC 1918 / Docker-bridge ranges. */ + allowPrivateNetworkWebhooks?: boolean +} + +export async function cloudBootstrapRoutes(app: FastifyInstance, opts: CloudBootstrapRoutesOptions = {}) { + const allowLoopback = opts.allowLoopbackWebhooks === true + const allowPrivateNetworks = opts.allowPrivateNetworkWebhooks === true + const canonryVersion = opts.canonryVersion ?? '0.0.0' + + // POST /cloud/bootstrap — register the control plane against this tenant. + app.post('/cloud/bootstrap', async (request, reply) => { + requireCloudBootstrap(request) + + const parsed = cloudBootstrapRequestSchema.safeParse(request.body) + if (!parsed.success) { + throw validationError('Invalid cloud bootstrap request', { + issues: parsed.error.issues.map(i => ({ path: i.path.join('.'), message: i.message })), + }) + } + + // Validate the control-plane callback URL through the SSRF guard before + // entering the transaction. Bootstrap is operator-driven and the URL + // comes from a trusted control plane, but the same guard that protects + // user-supplied webhooks still applies — preserves the invariant that + // every URL written into `notifications` has been resolved at least once. + const callbackUrl = parsed.data.control_plane_callback_url + const urlCheck = await resolveWebhookTarget(callbackUrl, { allowLoopback, allowPrivateNetworks }) + if (!urlCheck.ok) { + throw validationError(`control_plane_callback_url is not reachable: ${urlCheck.message}`) + } + + const now = new Date().toISOString() + + app.db.transaction((tx) => { + // Upsert the singleton cloud_metadata row. The migration's CHECK + // constraint pins id='singleton' — re-running bootstrap with the + // same tenant_id is idempotent and refreshes the row. A second + // bootstrap with a *different* tenant_id would also collapse into + // the same row, which is correct: one tenant id per DB per + // deployment-posture. + const existing = tx.select().from(cloudMetadata).where(eq(cloudMetadata.id, 'singleton')).get() + if (existing) { + tx.update(cloudMetadata) + .set({ + tenantId: parsed.data.tenant_id, + accountId: parsed.data.account_id, + plan: parsed.data.plan, + controlPlaneCallbackUrl: callbackUrl, + webhookSecret: parsed.data.webhook_secret, + managedGoogleClientId: parsed.data.managed_oauth.google_client_id, + managedGoogleRedirectUrl: parsed.data.managed_oauth.google_callback_url, + updatedAt: now, + }) + .where(eq(cloudMetadata.id, 'singleton')) + .run() + } else { + tx.insert(cloudMetadata).values({ + id: 'singleton', + tenantId: parsed.data.tenant_id, + accountId: parsed.data.account_id, + plan: parsed.data.plan, + controlPlaneCallbackUrl: callbackUrl, + webhookSecret: parsed.data.webhook_secret, + managedGoogleClientId: parsed.data.managed_oauth.google_client_id, + managedGoogleRedirectUrl: parsed.data.managed_oauth.google_callback_url, + bootstrappedAt: now, + updatedAt: now, + }).run() + } + + // Register (or refresh) the control plane as a tenant-scoped + // notification subscriber so the existing event-dispatch path + // delivers our 12 events without a second integration. We key the + // lookup off the URL — if a prior bootstrap registered the same + // callback, refresh the events list and webhook secret in place + // rather than creating a duplicate. + // + // `projectId: null` indicates a tenant-scoped webhook (the migration + // that flipped this column nullable shipped in the same PR as the + // bootstrap endpoint — see migration v69). + const allRows = tx.select().from(notifications).all() + const existingSubscriber = allRows.find((row) => { + const config = parseJsonColumn<{ url?: string }>( + typeof row.config === 'string' ? row.config : JSON.stringify(row.config), + {}, + ) + return config.url === callbackUrl + }) + + if (existingSubscriber) { + tx.update(notifications) + .set({ + config: { url: callbackUrl, events: CONTROL_PLANE_SUBSCRIBED_EVENTS }, + webhookSecret: parsed.data.webhook_secret, + enabled: true, + updatedAt: now, + }) + .where(eq(notifications.id, existingSubscriber.id)) + .run() + } else { + tx.insert(notifications).values({ + id: crypto.randomUUID(), + projectId: null, + channel: 'webhook', + config: { url: callbackUrl, events: CONTROL_PLANE_SUBSCRIBED_EVENTS }, + webhookSecret: parsed.data.webhook_secret, + enabled: true, + createdAt: now, + updatedAt: now, + }).run() + } + + writeAuditLog(tx, { + projectId: null, + actor: 'cloud', + action: 'cloud.bootstrap', + entityType: 'cloud_metadata', + entityId: parsed.data.tenant_id, + diff: { + tenantId: parsed.data.tenant_id, + accountId: parsed.data.account_id, + plan: parsed.data.plan, + callbackUrl, + }, + }) + }) + + return reply.status(200).send({ + canonry_version: canonryVersion, + bootstrap_completed_at: now, + webhook_attached: true, + }) + }) +} diff --git a/packages/api-routes/src/cloud/emit-connection-event.ts b/packages/api-routes/src/cloud/emit-connection-event.ts new file mode 100644 index 00000000..f05b3feb --- /dev/null +++ b/packages/api-routes/src/cloud/emit-connection-event.ts @@ -0,0 +1,163 @@ +import crypto from 'node:crypto' +import { eq } from 'drizzle-orm' +import type { DatabaseClient } from '@ainyc/canonry-db' +import { notifications, parseJsonColumn } from '@ainyc/canonry-db' +import type { + CloudNotificationEvent, + CloudWebhookPayload, + NotificationEvent, +} from '@ainyc/canonry-contracts' +import { deliverWebhook, resolveWebhookTarget } from '../webhooks.js' + +/** + * Honor the same operator opt-in flag the host sets for the SSRF guard at + * boot (`CANONRY_ALLOW_PRIVATE_WEBHOOKS=1` in `canonry/src/server.ts`). + * + * The bootstrap route validates the control-plane callback URL with this + * flag threaded in via `ApiRoutesOptions.allowPrivateNetworkWebhooks`; the + * cloud event dispatcher re-validates the URL on every emit (DNS / firewall + * may have moved) and must apply the same operator policy or every emit to + * a Docker-bridge / VPN-resolved target silently fails. + * + * Read at call time rather than module load so a test that toggles the env + * between cases sees the change. + */ +function privateWebhooksAllowed(): boolean { + const v = process.env.CANONRY_ALLOW_PRIVATE_WEBHOOKS?.trim().toLowerCase() + return v === '1' || v === 'true' || v === 'yes' || v === 'on' +} + +/** + * Snapshot of the project's identity fields the cloud envelope embeds. + * Sourced directly from the `projects` row so callers don't have to + * reshape it. + */ +export interface CloudEventProject { + id: string + name: string + canonicalDomain: string +} + +export interface EmitCloudEventOptions { + event: CloudNotificationEvent + project: CloudEventProject + payload: Record + /** Override `occurred_at`; defaults to `new Date().toISOString()`. */ + occurredAt?: string + /** Override `event_id`; defaults to a fresh UUID. */ + eventId?: string +} + +export interface EmitConnectionEventOptions { + event: Extract + project: CloudEventProject + payload: { + connectionType: string + propertyRef: string | null + scopes: string[] + /** Free-form reason field — populated for `connection.revoked`. */ + reason?: string + } + /** Override `occurred_at`; defaults to `new Date().toISOString()`. */ + occurredAt?: string + /** Override `event_id`; defaults to a fresh UUID. */ + eventId?: string +} + +/** + * Generic cloud-event dispatcher — used for `baseline.completed` and any + * future tenant-emitted cloud envelope. Connection events use the + * narrower `emitConnectionEvent` wrapper that constrains the payload + * shape. + * + * Best-effort fire-and-forget: failures are swallowed (logged via + * console.error) so callers' write paths aren't blocked by a slow or + * down subscriber. + */ +export async function emitCloudEvent( + db: DatabaseClient, + options: EmitCloudEventOptions, +): Promise { + const subscribers = matchingSubscribers(db, options.event) + if (subscribers.length === 0) return + + const occurredAt = options.occurredAt ?? new Date().toISOString() + const eventId = options.eventId ?? crypto.randomUUID() + + const cloudPayload: CloudWebhookPayload = { + source: 'canonry-cloud', + event: options.event, + event_id: eventId, + project: { + name: options.project.name, + canonicalDomain: options.project.canonicalDomain, + }, + payload: options.payload, + occurred_at: occurredAt, + } + + const allowPrivateNetworks = privateWebhooksAllowed() + for (const subscriber of subscribers) { + const url = subscriber.url + try { + const target = await resolveWebhookTarget(url, { allowLoopback: true, allowPrivateNetworks }) + if (!target.ok) { + // SSRF / unreachable target — log to stderr and continue. The + // bootstrap registration already validated reachability; this + // path only fails if DNS / firewall changed afterward. + console.error(`[cloud-event] resolve failed for ${url}: ${target.message}`) + continue + } + await deliverWebhook(target.target, cloudPayload, subscriber.webhookSecret) + } catch (err) { + console.error(`[cloud-event] deliver failed for ${url}:`, err) + } + } +} + +/** + * Narrower wrapper for `connection.created` / `connection.revoked` — + * constrains the payload to the documented shape (spec §12 table). + */ +export async function emitConnectionEvent( + db: DatabaseClient, + options: EmitConnectionEventOptions, +): Promise { + return emitCloudEvent(db, { + event: options.event, + project: options.project, + payload: { ...options.payload }, + occurredAt: options.occurredAt, + eventId: options.eventId, + }) +} + +interface MatchingSubscriber { + url: string + webhookSecret: string | null +} + +/** + * Pull every enabled notification row that subscribes to the given event. + * Includes both project-scoped legacy subscribers (`projectId IS NOT NULL`) + * and the tenant-scoped bootstrap subscriber (`projectId IS NULL`) so + * connection events reach all interested parties — the control plane in + * cloud mode and any operator-installed external agents in OSS. + */ +function matchingSubscribers( + db: DatabaseClient, + event: NotificationEvent, +): MatchingSubscriber[] { + const rows = db.select().from(notifications).where(eq(notifications.enabled, true)).all() + const out: MatchingSubscriber[] = [] + for (const row of rows) { + const config = parseJsonColumn<{ url?: string; events?: string[] }>( + typeof row.config === 'string' ? row.config : JSON.stringify(row.config), + {}, + ) + if (!config.url) continue + if (!Array.isArray(config.events) || !config.events.includes(event)) continue + out.push({ url: config.url, webhookSecret: row.webhookSecret ?? null }) + } + return out +} diff --git a/packages/api-routes/src/cloud/google-tokens.ts b/packages/api-routes/src/cloud/google-tokens.ts new file mode 100644 index 00000000..558135fa --- /dev/null +++ b/packages/api-routes/src/cloud/google-tokens.ts @@ -0,0 +1,98 @@ +import type { FastifyInstance } from 'fastify' +import { validationError } from '@ainyc/canonry-contracts' +import type { GoogleConnectionStore } from '../google.js' +import { requireCloudBootstrap, resolveProject, writeAuditLog } from '../helpers.js' +import { cloudImportGoogleTokensRequestSchema } from './schema.js' +import { emitConnectionEvent } from './emit-connection-event.js' + +export interface CloudGoogleTokensRoutesOptions { + /** + * Existing Google connection store — same singleton that + * `googleRoutes` consumes. Reused so the cloud import path lands the + * same row shape that the legacy OAuth callback would have written. + * Required when this route is registered; the registrar in `index.ts` + * skips registration when the store isn't wired (mirrors the legacy + * `googleRoutes` posture). + */ + googleConnectionStore: GoogleConnectionStore +} + +export async function cloudGoogleTokensRoutes(app: FastifyInstance, opts: CloudGoogleTokensRoutesOptions) { + // POST /cloud/google/import-tokens — bypasses the normal OAuth dance and + // accepts pre-exchanged tokens from a trusted control plane. + app.post('/cloud/google/import-tokens', async (request, reply) => { + requireCloudBootstrap(request) + + const parsed = cloudImportGoogleTokensRequestSchema.safeParse(request.body) + if (!parsed.success) { + throw validationError('Invalid Google token import request', { + issues: parsed.error.issues.map(i => ({ path: i.path.join('.'), message: i.message })), + }) + } + + const project = resolveProject(app.db, parsed.data.project_slug) + const now = new Date().toISOString() + const expiresAt = parsed.data.expiry + + const existing = opts.googleConnectionStore.getConnection( + project.canonicalDomain, + parsed.data.connection_type, + ) + + // The cloud bridge runs with admin scope and is the source of truth for + // managed-OAuth tokens for this tenant — bypass the cross-project + // takeover guard that protects the legacy connect route. There's still + // one tenant per Canonry runtime per deployment-posture, so the worst + // case is replacing one of this tenant's own project connections — + // which is the intended behavior (re-importing tokens for a different + // tracked project on the same canonical domain). + opts.googleConnectionStore.upsertConnection({ + domain: project.canonicalDomain, + connectionType: parsed.data.connection_type, + propertyId: parsed.data.property_ref, + accessToken: parsed.data.access_token, + refreshToken: parsed.data.refresh_token, + tokenExpiresAt: expiresAt, + scopes: parsed.data.scopes, + createdByProjectId: existing?.createdByProjectId ?? project.id, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }) + + writeAuditLog(app.db, { + projectId: project.id, + actor: 'cloud', + action: 'cloud.google.imported', + entityType: 'google_connection', + entityId: parsed.data.connection_type, + diff: { + domain: project.canonicalDomain, + connectionType: parsed.data.connection_type, + propertyRef: parsed.data.property_ref, + accountEmail: parsed.data.account_email, + scopes: parsed.data.scopes, + }, + }) + + // Emit the cloud `connection.created` event so any subscriber (notably + // the control plane registered at bootstrap) can mark the connection + // surface as live. Fired AFTER the DB write so failures don't leave a + // partial state. + await emitConnectionEvent(app.db, { + event: 'connection.created', + project, + payload: { + connectionType: parsed.data.connection_type, + propertyRef: parsed.data.property_ref, + scopes: parsed.data.scopes, + }, + }) + + return reply.status(200).send({ + imported: true, + domain: project.canonicalDomain, + connection_type: parsed.data.connection_type, + property_ref: parsed.data.property_ref, + }) + }) +} diff --git a/packages/api-routes/src/cloud/index.ts b/packages/api-routes/src/cloud/index.ts new file mode 100644 index 00000000..44ac7f2a --- /dev/null +++ b/packages/api-routes/src/cloud/index.ts @@ -0,0 +1,45 @@ +import type { FastifyInstance } from 'fastify' +import { cloudBootstrapRoutes } from './bootstrap.js' +import type { CloudBootstrapRoutesOptions } from './bootstrap.js' +import { cloudGoogleTokensRoutes } from './google-tokens.js' +import type { CloudGoogleTokensRoutesOptions } from './google-tokens.js' +import { cloudBingKeyRoutes } from './bing-key.js' +import type { CloudBingKeyRoutesOptions } from './bing-key.js' + +export interface CloudRoutesOptions + extends CloudBootstrapRoutesOptions { + /** Inject the Google connection store — same singleton `googleRoutes` uses. */ + googleConnectionStore?: CloudGoogleTokensRoutesOptions['googleConnectionStore'] + /** Inject the Bing connection store — same singleton `bingRoutes` uses. */ + bingConnectionStore?: CloudBingKeyRoutesOptions['bingConnectionStore'] +} + +/** + * Track 3 (Canonry Hosted) cloud-bridge routes. Always registered, but the + * inner routes gate themselves on the `CANONRY_ENABLE_CLOUD_BOOTSTRAP=1` + * env flag + `X-Admin-Scope: 1` header via `requireCloudBootstrap`. OSS + * deployments leave the flag unset and these routes return 404. + * + * The Google and Bing import routes are only registered when their + * respective connection stores are wired — same posture as `googleRoutes` + * / `bingRoutes`. Without the store, calls would have nowhere to write. + */ +export async function cloudRoutes(app: FastifyInstance, opts: CloudRoutesOptions = {}) { + await app.register(cloudBootstrapRoutes, { + canonryVersion: opts.canonryVersion, + allowLoopbackWebhooks: opts.allowLoopbackWebhooks, + allowPrivateNetworkWebhooks: opts.allowPrivateNetworkWebhooks, + }) + + if (opts.googleConnectionStore) { + await app.register(cloudGoogleTokensRoutes, { + googleConnectionStore: opts.googleConnectionStore, + }) + } + + if (opts.bingConnectionStore) { + await app.register(cloudBingKeyRoutes, { + bingConnectionStore: opts.bingConnectionStore, + }) + } +} diff --git a/packages/api-routes/src/cloud/schema.ts b/packages/api-routes/src/cloud/schema.ts new file mode 100644 index 00000000..ffa23c31 --- /dev/null +++ b/packages/api-routes/src/cloud/schema.ts @@ -0,0 +1,85 @@ +import { z } from 'zod' + +/** + * Track 3 (Canonry Hosted) request / response shapes for the three cloud + * bridge endpoints. Defined here rather than in `@ainyc/canonry-contracts` + * because they're a tenant-side ingress contract for a sibling control-plane + * — not a public SDK shape that needs to ride the OpenAPI catalog. + * + * The cloud-bridge endpoints aren't currently registered in OpenAPI: they're + * gated by `CANONRY_ENABLE_CLOUD_BOOTSTRAP=1` and `X-Admin-Scope: 1`, and + * publishing them would imply they're part of the supported public SDK + * surface. Keep them out of `openapi.ts` until / unless that changes. + */ + +/** Locale block — `{ country, language }` ISO-style identifiers. */ +const localeSchema = z.object({ + country: z.string().min(2).max(8), + language: z.string().min(2).max(16), +}) + +/** + * Managed Google OAuth client metadata pushed by the control plane during + * bootstrap. The tenant runtime does NOT exchange tokens with this client — + * `POST /cloud/google/import-tokens` does that after the control plane has + * brokered the OAuth dance. We just persist the client id and the + * tenant-facing redirect URL so `canonry doctor` can surface what the + * tenant *thinks* it's wired against if a connection later goes sideways. + */ +const managedOAuthSchema = z.object({ + google_client_id: z.string().min(1), + google_client_secret: z.string().min(1), + google_callback_url: z.string().url(), +}) + +export const cloudBootstrapRequestSchema = z.object({ + tenant_id: z.string().min(1), + account_id: z.string().min(1), + plan: z.string().min(1), + control_plane_callback_url: z.string().url(), + webhook_secret: z.string().min(1), + default_locale: localeSchema, + managed_oauth: managedOAuthSchema, +}) +export type CloudBootstrapRequest = z.infer + +export const cloudBootstrapResponseSchema = z.object({ + canonry_version: z.string(), + bootstrap_completed_at: z.string(), + webhook_attached: z.boolean(), +}) +export type CloudBootstrapResponse = z.infer + +export const cloudImportGoogleTokensRequestSchema = z.object({ + project_slug: z.string().min(1), + connection_type: z.enum(['gsc', 'ga4']), + property_ref: z.string().min(1), + access_token: z.string().min(1), + refresh_token: z.string().min(1), + expiry: z.string().min(1), + scopes: z.array(z.string()).default([]), + account_email: z.string().min(1), +}) +export type CloudImportGoogleTokensRequest = z.infer + +export const cloudImportGoogleTokensResponseSchema = z.object({ + imported: z.literal(true), + domain: z.string(), + connection_type: z.enum(['gsc', 'ga4']), + property_ref: z.string().nullable(), +}) +export type CloudImportGoogleTokensResponse = z.infer + +export const cloudImportBingKeyRequestSchema = z.object({ + project_slug: z.string().min(1), + api_key: z.string().min(1), + site_url: z.string().min(1), +}) +export type CloudImportBingKeyRequest = z.infer + +export const cloudImportBingKeyResponseSchema = z.object({ + imported: z.literal(true), + domain: z.string(), + site_url: z.string().nullable(), +}) +export type CloudImportBingKeyResponse = z.infer diff --git a/packages/api-routes/src/google.ts b/packages/api-routes/src/google.ts index 5871cae3..4069247b 100644 --- a/packages/api-routes/src/google.ts +++ b/packages/api-routes/src/google.ts @@ -13,6 +13,7 @@ import { import { extractPlaceAmenities, type PlaceDetails } from '@ainyc/canonry-integration-google-places' import { buildGbpSummary } from './gbp-summary.js' import { resolveProject, writeAuditLog } from './helpers.js' +import { emitConnectionEvent } from './cloud/emit-connection-event.js' import { getAuthUrl, exchangeCode, @@ -416,6 +417,20 @@ export async function googleRoutes(app: FastifyInstance, opts: GoogleRoutesOptio diff: { domain, type, propertyId }, }) + // Track 3 (Canonry Hosted): emit a cloud `connection.created` event so + // the bootstrap-registered control-plane subscriber can update its + // `oauth_connections.state` cache. Fired after the DB write, swallows + // its own errors so a flaky subscriber doesn't block the OAuth UX. + await emitConnectionEvent(app.db, { + event: 'connection.created', + project: { id: project.id, name: project.name, canonicalDomain: project.canonicalDomain }, + payload: { + connectionType: type as 'gsc' | 'ga4', + propertyRef: propertyId ?? existing?.propertyId ?? null, + scopes: tokens.scope?.split(' ') ?? [], + }, + }) + return reply.type('text/html').send( `

Connected successfully!

@@ -474,6 +489,19 @@ export async function googleRoutes(app: FastifyInstance, opts: GoogleRoutesOptio entityId: type, }) + // Track 3 (Canonry Hosted): emit `connection.revoked` so the + // bootstrap-registered subscriber can mark the surface revoked. + await emitConnectionEvent(app.db, { + event: 'connection.revoked', + project: { id: project.id, name: project.name, canonicalDomain: project.canonicalDomain }, + payload: { + connectionType: type, + propertyRef: existing.propertyId ?? null, + scopes: existing.scopes ?? [], + reason: 'user-disconnected', + }, + }) + return reply.status(204).send() }) diff --git a/packages/api-routes/src/guest-report.ts b/packages/api-routes/src/guest-report.ts new file mode 100644 index 00000000..09dca172 --- /dev/null +++ b/packages/api-routes/src/guest-report.ts @@ -0,0 +1,707 @@ +/** + * Guest-report routes — the anonymous free-first-report flow that powers + * the /aero onboarding experience. + * + * POST /api/v1/guest/report — create a new guest report (no auth) + * GET /api/v1/guest/report/:id — read report state (no auth) + * GET /api/v1/guest/report/:id/stream — SSE stream of live progress (no auth) + * POST /api/v1/guest/report/:id/claim — claim into the user's workspace (authed) + * + * The visitor drops a domain on the front page, we create a transient + * `projects` row + a `guest_reports` row, and an in-process simulator + * emits the audit + AI-visibility events that the SPA renders as Aero's + * live work. The DB column `progress_events` doubles as an SSE replay + * buffer so a flaky reconnect doesn't lose state. + * + * The simulator is intentionally self-contained (no worker dispatch) so + * the /aero flow can be exercised end-to-end without spinning up the + * full audit + sweep pipeline. Real audit hooks land in a follow-up that + * replaces `runDemoSimulation` with a worker callback. + */ +import crypto from 'node:crypto' +import { EventEmitter } from 'node:events' +import { and, eq, lt, isNull } from 'drizzle-orm' +import type { FastifyInstance } from 'fastify' +import rateLimit from '@fastify/rate-limit' +import { guestReports, projects, users } from '@ainyc/canonry-db' +import { validationError, notFound, authRequired } from '@ainyc/canonry-contracts' + +const REPORT_TTL_DAYS = 7 + +/** + * Status state machine for guest reports. + * + * pending → auditing → audit-complete → sweeping → completed + * ↘ failed + */ +const STATUSES = { + pending: 'pending', + auditing: 'auditing', + auditDone: 'audit-complete', + sweeping: 'sweeping', + completed: 'completed', + failed: 'failed', +} as const + +export interface GuestReportProgressEvent { + at: string + type: + | 'sitemap-pulled' + | 'page-audited' + | 'audit-complete' + | 'sweep-started' + | 'provider-checked' + | 'overall-complete' + | 'failed' + payload: Record +} + +/** + * In-process pub/sub for SSE subscribers. Key: guest report id; value: + * EventEmitter that emits `'event'` with each new progress event. Cleared + * when no listeners remain to avoid leaking. + */ +const liveBus = new Map() + +function getBus(id: string): EventEmitter { + let bus = liveBus.get(id) + if (!bus) { + bus = new EventEmitter() + bus.setMaxListeners(0) // arbitrary subscribers + liveBus.set(id, bus) + } + return bus +} + +function disposeBusIfEmpty(id: string): void { + const bus = liveBus.get(id) + if (bus && bus.listenerCount('event') === 0) { + liveBus.delete(id) + } +} + +function shortId(): string { + // 12-char URL-safe id, plenty for non-PII guest report identifiers. + return crypto.randomBytes(9).toString('base64url') +} + +function guestReportsEnabled(): boolean { + const value = process.env.CANONRY_ENABLE_GUEST_REPORTS?.trim().toLowerCase() + return value === '1' || value === 'true' || value === 'yes' || value === 'on' +} + +function requireGuestReportsEnabled(path: string): void { + if (!guestReportsEnabled()) { + throw notFound('endpoint', path) + } +} + +/** + * Normalize a user-entered domain to bare host. Accepts "https://www.acme.com/path", + * "acme.com", "Acme.com" and returns "acme.com". Throws on garbage. + */ +export function normalizeDomain(raw: string): string { + const trimmed = raw.trim().toLowerCase() + if (!trimmed) throw validationError('domain is required') + // Use linear, non-backtracking string ops instead of regex on the + // user-controlled value (CodeQL: polynomial-regex ReDoS on a string of + // many '/'). Strip the scheme, then the path/query/fragment, then a + // leading `www.`. + let host = trimmed + const schemeIdx = host.indexOf('://') + if (schemeIdx !== -1) host = host.slice(schemeIdx + 3) + host = host.split('/', 1)[0]! // drop everything from the first slash on — O(n), no backtracking + if (host.startsWith('www.')) host = host.slice(4) + // Single linear character-class strip (no quantifier ambiguity → ReDoS-safe). + const stripped = host.replace(/[^a-z0-9.-]/g, '') + if (!stripped.includes('.') || stripped.length < 4) { + throw validationError('Enter a valid domain — e.g. acme.com') + } + return stripped +} + +export interface GuestReportRoutesOptions { + /** Optional override: drive the audit/sweep using real workers instead of + * the bundled simulator. Default (undefined) uses the simulator so the + * /aero flow is exercisable without the worker pipeline. + * + * When set, the function should kick off async work that calls + * `appendProgressEvent` as findings arrive and finalize the row via the + * database directly. The simulator is the reference implementation. */ + driver?: (input: { + db: FastifyInstance['db'] + guestReportId: string + domain: string + projectId: string + onProgress: (event: GuestReportProgressEvent) => void + onAuditComplete: (data: { + auditScore: number + pagesCrawled: number + findingsCount: number + topFindings: Array<{ severity: 'critical' | 'high' | 'medium' | 'low'; title: string; url: string; pointsLost: number }> + }) => void + onComplete: (data: { + overallScore: number + citedCount: number + mentionedCount: number + queryCount: number + topCompetitor: string | null + topCompetitorCitedCount: number | null + proposedPlan: Array<{ label: string; pointsImpact: number; rationale: string }> + }) => void + onFailed: (errorMessage: string) => void + }) => void +} + +/** + * Default demo simulator. Emits a tight, narratively-strong sequence of + * events that map to Aero's voice on the front-end. Tuned to feel like + * an analyst reading the site and asking questions out loud. + * + * Total duration: ~22 seconds. Phase split: + * 0-2s sitemap pull + * 2-10s audit (page-by-page) + * 10-11s audit reveal + * 11-12s sweep handoff + * 12-22s AI engines (one event per provider per query) + * 22s overall reveal + * + * Real workers will replace this; the timing here is what looks right in + * the front-end. Don't shorten without re-validating the visual pacing. + */ +function runDemoSimulation(input: { + guestReportId: string + domain: string + onProgress: (event: GuestReportProgressEvent) => void + onAuditComplete: (data: Parameters>[0]['onAuditComplete'] extends (d: infer D) => void ? D : never) => void + onComplete: (data: Parameters>[0]['onComplete'] extends (d: infer D) => void ? D : never) => void +}): void { + const { domain } = input + // Stable pseudo-random based on the domain so the same input gives the same + // demo (helps with testing + screenshots). + const seed = crypto.createHash('sha256').update(domain).digest() + const randInt = (offset: number, min: number, max: number): number => { + const byte = seed[offset % seed.length] ?? 0 + return min + (byte % (max - min + 1)) + } + + const pageCount = randInt(0, 12, 47) + const auditScore = randInt(1, 32, 58) + const overallScore = Math.max(20, auditScore - randInt(2, 8, 18)) + const queryCount = randInt(3, 12, 18) + const citedCount = randInt(4, 1, Math.min(6, queryCount - 4)) + const mentionedCount = Math.max(citedCount, randInt(5, citedCount, Math.min(citedCount + 3, queryCount))) + const topCompetitorCitedCount = randInt(6, queryCount - 4, queryCount - 1) + + const queries = [ + `${domain.split('.')[0]} reviews`, + 'best in town', + 'open weekends', + 'pricing', + 'how it works', + 'contact information', + 'service area', + ].slice(0, queryCount) + const competitors = ['competitor-a.com', 'competitor-b.com', 'rival-pro.com'] + const topCompetitor = competitors[seed[7]! % competitors.length]! + + let cancelled = false + const cancel = () => { cancelled = true } + + const fire = (event: GuestReportProgressEvent) => { + if (cancelled) return + input.onProgress(event) + } + + const at = (ms: number, fn: () => void) => { + setTimeout(() => { + if (!cancelled) fn() + }, ms) + } + + // Phase 1: sitemap pull + at(700, () => fire({ + at: new Date().toISOString(), + type: 'sitemap-pulled', + payload: { pageCount, sitemapUrl: `https://${domain}/sitemap.xml` }, + })) + + // Phase 2: page-by-page audit — emit 5 representative pages + const samplePages = [ + { path: '/', score: randInt(20, 60, 85) }, + { path: '/about', score: randInt(21, 50, 78) }, + { path: '/services', score: randInt(22, 35, 65) }, + { path: '/faq', score: randInt(23, 65, 92) }, + { path: '/contact', score: randInt(24, 55, 80) }, + ] + samplePages.forEach((p, i) => { + at(2500 + i * 1300, () => fire({ + at: new Date().toISOString(), + type: 'page-audited', + payload: { url: `https://${domain}${p.path}`, score: p.score, pageIndex: i + 1, total: samplePages.length }, + })) + }) + + // Phase 3: audit complete + reveal + at(9500, () => { + const topFindings: Array<{ severity: 'critical' | 'high' | 'medium' | 'low'; title: string; url: string; pointsLost: number }> = [ + { severity: 'high', title: 'Missing FAQ schema on most pages', url: `https://${domain}`, pointsLost: 18 }, + { severity: 'high', title: 'Thin content on key service pages', url: `https://${domain}/services`, pointsLost: 12 }, + { severity: 'medium', title: 'No author or Person schema', url: `https://${domain}/about`, pointsLost: 8 }, + { severity: 'medium', title: 'Outdated dateModified fields', url: `https://${domain}/about`, pointsLost: 5 }, + ] + fire({ + at: new Date().toISOString(), + type: 'audit-complete', + payload: { auditScore, pagesCrawled: pageCount, findingsCount: 12, topFindings }, + }) + input.onAuditComplete({ + auditScore, + pagesCrawled: pageCount, + findingsCount: 12, + topFindings, + }) + }) + + // Phase 4: sweep handoff + at(11000, () => fire({ + at: new Date().toISOString(), + type: 'sweep-started', + payload: { providerCount: 3, queryCount, providers: ['ChatGPT', 'Claude', 'Gemini'] }, + })) + + // Phase 5: AI engines, one event per provider per query (sampled to ~6 visible events) + const sampleQueries = queries.slice(0, Math.min(4, queries.length)) + const providers: Array<'ChatGPT' | 'Claude' | 'Gemini'> = ['ChatGPT', 'Claude', 'Gemini'] + let sweepIdx = 0 + sampleQueries.forEach((q, qi) => { + providers.forEach((prov, pi) => { + const cited = (qi + pi) % 4 === 0 + const competitorCited = !cited + at(12500 + sweepIdx * 700, () => fire({ + at: new Date().toISOString(), + type: 'provider-checked', + payload: { + provider: prov, + query: q, + citedYou: cited, + competitorCited: competitorCited ? topCompetitor : null, + }, + })) + sweepIdx += 1 + }) + }) + + // Phase 6: overall complete + reveal + const totalSweepMs = 12500 + sweepIdx * 700 + 1200 + at(totalSweepMs, () => { + const proposedPlan: Array<{ label: string; pointsImpact: number; rationale: string }> = [ + { label: '5 focused FAQ pages targeting your top under-cited queries', pointsImpact: 8, rationale: 'AI engines weight Q&A structure heavily.' }, + { label: 'Author + Person schema across your team pages', pointsImpact: 5, rationale: 'AI prefers cited sources with named experts.' }, + { label: 'Expand 12 thin service pages with depth matching competitors', pointsImpact: 6, rationale: 'Your service pages average half the depth of the top citer.' }, + { label: 'Cross-link your existing FAQ + service pages', pointsImpact: 3, rationale: 'Helps AI understand the topical authority of your site.' }, + ] + fire({ + at: new Date().toISOString(), + type: 'overall-complete', + payload: { + overallScore, + citedCount, + mentionedCount, + queryCount, + topCompetitor, + topCompetitorCitedCount, + proposedPlan, + }, + }) + input.onComplete({ + overallScore, + citedCount, + mentionedCount, + queryCount, + topCompetitor, + topCompetitorCitedCount, + proposedPlan, + }) + }) + + // Cleanup hook — caller can ignore, this is here for hot-path testing. + void cancel +} + +/** + * Append an event to the report's progress buffer (durable for SSE replay) + * and broadcast to any live subscribers. + * + * Failures to write the DB column are non-fatal — we still emit the live + * event so connected clients see it; the SSE buffer just won't have it on + * reconnect. + */ +function appendProgress( + db: FastifyInstance['db'], + guestReportId: string, + event: GuestReportProgressEvent, +): void { + try { + const row = db.select().from(guestReports).where(eq(guestReports.id, guestReportId)).get() + if (row) { + const next = [...row.progressEvents, event] + db.update(guestReports).set({ progressEvents: next }).where(eq(guestReports.id, guestReportId)).run() + } + } catch { + // swallow — live event still fires below + } + getBus(guestReportId).emit('event', event) +} + +function isExpiredUnclaimed(row: typeof guestReports.$inferSelect, now = new Date().toISOString()): boolean { + return !row.claimedAt && row.expiresAt < now +} + +function deleteExpiredUnclaimedReport( + db: FastifyInstance['db'], + row: typeof guestReports.$inferSelect, +): void { + db.transaction((tx) => { + tx.delete(guestReports).where(eq(guestReports.id, row.id)).run() + tx.delete(projects).where(eq(projects.id, row.projectId)).run() + }) +} + +function getActiveGuestReportOrThrow( + db: FastifyInstance['db'], + id: string, +): typeof guestReports.$inferSelect { + const row = db.select().from(guestReports).where(eq(guestReports.id, id)).get() + if (!row) throw notFound('Guest report', id) + if (isExpiredUnclaimed(row)) { + deleteExpiredUnclaimedReport(db, row) + throw notFound('Guest report', id) + } + return row +} + +/** Map a guest report row to the SDK shape the SPA consumes. */ +function serializeGuestReport(row: typeof guestReports.$inferSelect): { + id: string + domain: string + projectId: string + status: string + auditScore: number | null + auditPagesCrawled: number + auditFindingsCount: number + auditTopFindings: Array<{ severity: string; title: string; url: string; pointsLost: number }> + overallScore: number | null + aiCitedCount: number | null + aiQueryCount: number | null + aiMentionedCount: number | null + topCompetitor: string | null + topCompetitorCitedCount: number | null + proposedPlan: Array<{ label: string; pointsImpact: number; rationale: string }> + progressEvents: GuestReportProgressEvent[] + errorMessage: string | null + createdAt: string + expiresAt: string + claimedAt: string | null +} { + return { + id: row.id, + domain: row.domain, + projectId: row.projectId, + status: row.status, + auditScore: row.auditScore, + auditPagesCrawled: row.auditPagesCrawled, + auditFindingsCount: row.auditFindingsCount, + auditTopFindings: row.auditTopFindings, + overallScore: row.overallScore, + aiCitedCount: row.aiCitedCount, + aiQueryCount: row.aiQueryCount, + aiMentionedCount: row.aiMentionedCount, + topCompetitor: row.topCompetitor, + topCompetitorCitedCount: row.topCompetitorCitedCount, + proposedPlan: row.proposedPlan, + progressEvents: row.progressEvents as GuestReportProgressEvent[], + errorMessage: row.errorMessage, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + claimedAt: row.claimedAt, + } +} + +export async function guestReportRoutes(app: FastifyInstance, opts: GuestReportRoutesOptions = {}) { + // Abuse guard for the anonymous /guest/report surface. `@fastify/rate-limit` + // is scoped to this encapsulated plugin (only guest routes live here), so it + // never touches the rest of the API. The 60/min default is generous for the + // SPA's status polling + SSE; `create` (spins up a project + audit) and + // `claim` (performs authorization) tighten via per-route config. + await app.register(rateLimit, { global: true, max: 60, timeWindow: '1 minute' }) + + /** Sweep expired unclaimed rows on startup. Cheap — bounded to a few hundred + * per deployment in practice. We also clear the transient guest project so + * the projects list stays clean. */ + if (guestReportsEnabled()) try { + const nowIso = new Date().toISOString() + const stale = app.db + .select() + .from(guestReports) + .where(and(lt(guestReports.expiresAt, nowIso), isNull(guestReports.claimedAt))) + .all() + const unclaimedProjectIds = stale.map((r) => r.projectId) + if (stale.length > 0) { + app.db + .delete(guestReports) + .where(and(lt(guestReports.expiresAt, nowIso), isNull(guestReports.claimedAt))) + .run() + } + for (const pid of unclaimedProjectIds) { + try { + app.db.delete(projects).where(eq(projects.id, pid)).run() + } catch { + // project may have been claimed + renamed; ignore + } + } + } catch (err) { + app.log.warn({ err }, 'guest-report: startup cleanup failed') + } + + // POST /api/v1/guest/report — start a new guest report. + // Anonymous — no auth required. + app.post<{ Body: { domain?: string } | undefined }>('/guest/report', { + config: { rateLimit: { max: 15, timeWindow: '1 minute' } }, + }, async (request, reply) => { + requireGuestReportsEnabled(request.url.split('?')[0]!) + const domain = normalizeDomain(request.body?.domain ?? '') + const id = shortId() + const projectId = crypto.randomUUID() + const projectName = `guest-${id}` + const now = new Date().toISOString() + const expiresAt = new Date(Date.now() + REPORT_TTL_DAYS * 86_400_000).toISOString() + + // One transaction: create the transient project + the guest report row. + // The project is named `guest-` and tagged with `configSource='guest'` + // so it's visually distinguishable in DB inspections and can be reaped by + // the startup cleanup if the row expires without a claim. + app.db.transaction((tx) => { + tx.insert(projects).values({ + id: projectId, + name: projectName, + displayName: domain, + canonicalDomain: domain, + country: 'US', + language: 'en', + configSource: 'guest', + configRevision: 1, + createdAt: now, + updatedAt: now, + }).run() + tx.insert(guestReports).values({ + id, + domain, + projectId, + status: STATUSES.auditing, + createdAt: now, + expiresAt, + }).run() + }) + + // Kick off the audit/sweep simulator (or the real driver if injected). + // setImmediate so the POST response returns before any progress event + // fires; the client connects to /stream and replays from the DB buffer + // for anything that landed before the SSE subscription started. + setImmediate(() => { + try { + const onProgress = (event: GuestReportProgressEvent) => appendProgress(app.db, id, event) + const onAuditComplete = (data: { + auditScore: number + pagesCrawled: number + findingsCount: number + topFindings: Array<{ severity: 'critical' | 'high' | 'medium' | 'low'; title: string; url: string; pointsLost: number }> + }) => { + app.db.update(guestReports).set({ + status: STATUSES.sweeping, + auditScore: data.auditScore, + auditPagesCrawled: data.pagesCrawled, + auditFindingsCount: data.findingsCount, + auditTopFindings: data.topFindings, + }).where(eq(guestReports.id, id)).run() + } + const onComplete = (data: { + overallScore: number + citedCount: number + mentionedCount: number + queryCount: number + topCompetitor: string | null + topCompetitorCitedCount: number | null + proposedPlan: Array<{ label: string; pointsImpact: number; rationale: string }> + }) => { + app.db.update(guestReports).set({ + status: STATUSES.completed, + overallScore: data.overallScore, + aiCitedCount: data.citedCount, + aiMentionedCount: data.mentionedCount, + aiQueryCount: data.queryCount, + topCompetitor: data.topCompetitor, + topCompetitorCitedCount: data.topCompetitorCitedCount, + proposedPlan: data.proposedPlan, + }).where(eq(guestReports.id, id)).run() + } + const onFailed = (message: string) => { + app.db.update(guestReports).set({ + status: STATUSES.failed, + errorMessage: message, + }).where(eq(guestReports.id, id)).run() + appendProgress(app.db, id, { + at: new Date().toISOString(), + type: 'failed', + payload: { message }, + }) + } + + if (opts.driver) { + opts.driver({ + db: app.db, + guestReportId: id, + domain, + projectId, + onProgress, + onAuditComplete, + onComplete, + onFailed, + }) + } else { + runDemoSimulation({ + guestReportId: id, + domain, + onProgress, + onAuditComplete, + onComplete, + }) + } + } catch (err) { + app.log.error({ err, guestReportId: id }, 'guest-report: driver crashed') + try { + app.db.update(guestReports).set({ + status: STATUSES.failed, + errorMessage: err instanceof Error ? err.message : String(err), + }).where(eq(guestReports.id, id)).run() + } catch { + // last-ditch — DB unreachable + } + } + }) + + return reply.status(201).send({ id, domain, status: STATUSES.auditing, expiresAt }) + }) + + // GET /api/v1/guest/report/:id — read full state (polling fallback). + app.get<{ Params: { id: string } }>('/guest/report/:id', async (request) => { + requireGuestReportsEnabled(request.url.split('?')[0]!) + const row = getActiveGuestReportOrThrow(app.db, request.params.id) + return serializeGuestReport(row) + }) + + // GET /api/v1/guest/report/:id/stream — SSE live progress + replay. + // Replays the durable progress_events buffer first so clients that + // reconnect (or arrive late) don't miss any events that already landed. + app.get<{ Params: { id: string } }>('/guest/report/:id/stream', async (request, reply) => { + requireGuestReportsEnabled(request.url.split('?')[0]!) + const row = getActiveGuestReportOrThrow(app.db, request.params.id) + + reply.raw.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + 'x-accel-buffering': 'no', + }) + reply.raw.write('retry: 5000\n\n') + + const write = (event: { type: string; data: unknown }) => { + try { + reply.raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`) + } catch { + // socket closed + } + } + + // Replay durable events first. + for (const e of row.progressEvents as GuestReportProgressEvent[]) { + write({ type: 'progress', data: e }) + } + // Also send a `state` snapshot so the client has the latest computed + // fields (audit score, etc.) without re-querying. + write({ type: 'state', data: serializeGuestReport(row) }) + + // If the report is already finished, close the stream after replay. + if (row.status === STATUSES.completed || row.status === STATUSES.failed) { + reply.raw.write('event: done\ndata: {}\n\n') + reply.raw.end() + return + } + + // Subscribe to the live bus. + const bus = getBus(request.params.id) + const onEvent = (event: GuestReportProgressEvent) => { + write({ type: 'progress', data: event }) + if (event.type === 'overall-complete' || event.type === 'failed') { + // Send a fresh state snapshot + signal completion. + const updated = app.db.select().from(guestReports).where(eq(guestReports.id, request.params.id)).get() + if (updated) write({ type: 'state', data: serializeGuestReport(updated) }) + write({ type: 'done', data: {} }) + } + } + bus.on('event', onEvent) + + const close = () => { + bus.off('event', onEvent) + disposeBusIfEmpty(request.params.id) + try { + reply.raw.end() + } catch { + // already closed + } + } + request.raw.on('close', close) + request.raw.on('end', close) + }) + + // POST /api/v1/guest/report/:id/claim — claim into the user's workspace. + // Requires auth (cookie or API key). After claim, the transient guest + // project becomes a regular project — name stays as `guest-` for now + // (the SPA can offer rename later) but configSource flips to 'dashboard'. + app.post<{ Params: { id: string } }>('/guest/report/:id/claim', { + config: { rateLimit: { max: 20, timeWindow: '1 minute' } }, + }, async (request, reply) => { + requireGuestReportsEnabled(request.url.split('?')[0]!) + if (!request.apiKey) throw authRequired() + const row = getActiveGuestReportOrThrow(app.db, request.params.id) + if (row.claimedAt) { + // Already claimed — return the project info so the SPA can navigate. + const project = app.db.select().from(projects).where(eq(projects.id, row.projectId)).get() + return reply.send({ + alreadyClaimed: true, + projectName: project?.name ?? null, + projectId: row.projectId, + }) + } + const claimedAt = new Date().toISOString() + // Look up the user this API key belongs to (created at signup time). + const userRow = app.db.select().from(users).where(eq(users.apiKeyId, request.apiKey.id)).get() + const userId = userRow?.id ?? null + app.db.transaction((tx) => { + tx.update(guestReports).set({ + claimedAt, + claimedByUserId: userId, + }).where(eq(guestReports.id, request.params.id)).run() + tx.update(projects).set({ + configSource: 'dashboard', + updatedAt: claimedAt, + }).where(eq(projects.id, row.projectId)).run() + }) + const project = app.db.select().from(projects).where(eq(projects.id, row.projectId)).get() + return reply.send({ + claimed: true, + projectName: project?.name ?? null, + projectId: row.projectId, + }) + }) +} diff --git a/packages/api-routes/src/helpers.ts b/packages/api-routes/src/helpers.ts index 731991e8..0fdbaaa5 100644 --- a/packages/api-routes/src/helpers.ts +++ b/packages/api-routes/src/helpers.ts @@ -1,11 +1,13 @@ import crypto from 'node:crypto' import { eq, ne, sql, type SQL } from 'drizzle-orm' import type { DatabaseClient } from '@ainyc/canonry-db' +import type { FastifyRequest } from 'fastify' import { projects, runs, auditLog, usageCounters, parseJsonColumn } from '@ainyc/canonry-db' import { extractAnswerMentions, effectiveBrandNames, effectiveDomains, + forbidden, mentionStateFromAnswerMentioned, notFound, RunTriggers, @@ -39,6 +41,63 @@ export function resolveProject(db: DatabaseClient, name: string) { return project } +/** + * Track 3 (Canonry Hosted) — gate for cloud-bridge routes. + * + * The cloud bridge endpoints (`/cloud/bootstrap`, `/cloud/google/import-tokens`, + * `/cloud/bing/import-key`) are designed to be invoked only from a sibling + * control-plane container that the operator has deliberately provisioned. + * OSS deployments should not expose them at all — calling code paths returns + * a 404 indistinguishable from "no such route" when the env flag is unset. + * + * Two-layer gate: + * 1. `CANONRY_ENABLE_CLOUD_BOOTSTRAP=1` (env, set by the host-operator's + * Compose template) — Track 1 owns this flag definitively. This helper + * currently re-reads `process.env` rather than threading config plumbing + * because Track 1 hasn't shipped yet and we don't want to race them on + * `index.ts` options. Consolidate when Track 1 lands. + * 2. `X-Admin-Scope: 1` header — gates per-request. The control plane sets + * this header on every cloud-bridge call. Anyone else who somehow gets + * hold of a valid `cnry_…` key (which already has full instance access + * per the deployment-posture rules) will still be rejected without the + * header. + * + * The two checks together let a sibling control-plane container drive + * tenant config without the operator having to construct a privileged + * admin scope on the API key. Future hardening (real `admin` scope on the + * key + RBAC) lives on the multi-tenancy off-ramp. + * + * TODO(Track 1): once `CANONRY_ENABLE_CLOUD_BOOTSTRAP` is consolidated into + * a single config surface (probably a `cloudBootstrapEnabled` field on + * `ApiRoutesOptions`), drop the direct env-var read and accept a boolean. + */ +export function requireCloudBootstrap(request: FastifyRequest): void { + if (!cloudBootstrapEnabled()) { + // Return 404 (not 403) so unauthenticated probes can't fingerprint + // whether a deployment is hosted vs. OSS. `notFound('endpoint', ...)` + // matches the global error handler's existing 404 envelope. + throw notFound('endpoint', request.url.split('?')[0] ?? '/') + } + // Header values can be string | string[] under HTTP; only accept the + // exact scalar '1' so cookies / accidental duplicates never satisfy. + const adminScope = request.headers['x-admin-scope'] + if (adminScope !== '1') { + throw forbidden('This endpoint requires the X-Admin-Scope header.') + } +} + +/** + * Accept the same truthy set as `packages/config/src/index.ts`'s + * `parseBooleanFlag` (`1`, `true`, `yes`, `on`, case-insensitive). Keeping + * the two helpers in sync avoids a config drift where an operator sees + * `readCloudModeFlags()` report cloud-enabled while the routes still 404 + * because they only honored the literal `'1'`. + */ +function cloudBootstrapEnabled(): boolean { + const v = process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP?.trim().toLowerCase() + return v === '1' || v === 'true' || v === 'yes' || v === 'on' +} + export interface AuditEntry { projectId?: string | null actor: string diff --git a/packages/api-routes/src/index.ts b/packages/api-routes/src/index.ts index 22f5539a..159b3da4 100644 --- a/packages/api-routes/src/index.ts +++ b/packages/api-routes/src/index.ts @@ -19,6 +19,7 @@ import { reportRoutes } from './report.js' import { citationRoutes } from './citations.js' import { compositeRoutes } from './composites.js' import { contentRoutes } from './content.js' +import { guestReportRoutes } from './guest-report.js' import { openApiRoutes } from './openapi.js' import type { OpenApiInfo } from './openapi.js' import { settingsRoutes } from './settings.js' @@ -55,6 +56,8 @@ import { import { doctorRoutes } from './doctor.js' import { discoveryRoutes } from './discovery/index.js' import type { DiscoveryRoutesOptions } from './discovery/index.js' +import { cloudRoutes } from './cloud/index.js' +import type { CloudRoutesOptions } from './cloud/index.js' import { CheckStatuses, TrafficSourceTypes } from '@ainyc/canonry-contracts' import type { BundledSkillSnapshot } from '@ainyc/canonry-contracts' import type { CheckOutput, TrafficSourceProbe, TrafficSourceValidator } from './doctor/types.js' @@ -187,6 +190,13 @@ export interface ApiRoutesOptions { * dev workflows that point webhooks at localhost. */ allowLoopbackWebhooks?: boolean + /** + * Allow webhook URLs that resolve to private RFC 1918 ranges (10/8, 172.16/12, + * 192.168/16) plus CGNAT / link-local / benchmark blocks. Required for the + * Hosted v1 single-host deployment where the control-plane callback URL is a + * Docker-internal hostname like `canonry-control-plane:8080`. + */ + allowPrivateNetworkWebhooks?: boolean /** * On-disk paths the daemon depends on at runtime. When wired, a pre-request * hook fails non-doctor / non-health requests with HTTP 503 @@ -202,6 +212,14 @@ export interface ApiRoutesOptions { * and the check `skipped`. */ bundledSkills?: BundledSkillSnapshot[] + /** + * Track 3 (Canonry Hosted) — runtime version reported by + * `POST /api/v1/cloud/bootstrap`. The host passes the value from its own + * package.json so the bootstrap response is honest about what the control + * plane just provisioned. Defaults to `'0.0.0'` when unset; OSS + * deployments don't surface the route at all (env-gated). + */ + canonryVersion?: string } export async function apiRoutes(app: FastifyInstance, opts: ApiRoutesOptions) { @@ -322,6 +340,7 @@ export async function apiRoutes(app: FastifyInstance, opts: ApiRoutesOptions) { await api.register(citationRoutes) await api.register(compositeRoutes) await api.register(contentRoutes, { explainContentRecommendation: opts.explainContentRecommendation }) + await api.register(guestReportRoutes) await api.register(settingsRoutes, { providerSummary: opts.providerSummary, providerAdapters: opts.providerAdapters, @@ -402,6 +421,16 @@ export async function apiRoutes(app: FastifyInstance, opts: ApiRoutesOptions) { await api.register(discoveryRoutes, { onDiscoveryRunRequested: opts.onDiscoveryRunRequested, } satisfies DiscoveryRoutesOptions) + // Track 3 (Canonry Hosted) cloud-bridge routes — always mounted, but + // each route returns 404 when `CANONRY_ENABLE_CLOUD_BOOTSTRAP` is unset + // (i.e. on every OSS deployment). + await api.register(cloudRoutes, { + canonryVersion: opts.canonryVersion, + allowLoopbackWebhooks: opts.allowLoopbackWebhooks, + allowPrivateNetworkWebhooks: opts.allowPrivateNetworkWebhooks, + googleConnectionStore: opts.googleConnectionStore, + bingConnectionStore: opts.bingConnectionStore, + } satisfies CloudRoutesOptions) await api.register(doctorRoutes, { googleConnectionStore: opts.googleConnectionStore, bingConnectionStore: opts.bingConnectionStore, @@ -443,6 +472,8 @@ export type { OnDiscoveryRunRequested, } from './discovery/index.js' export { deliverWebhook, resolveWebhookTarget } from './webhooks.js' +export { emitCloudEvent, emitConnectionEvent } from './cloud/emit-connection-event.js' +export type { CloudEventProject, EmitCloudEventOptions, EmitConnectionEventOptions } from './cloud/emit-connection-event.js' export { redactNotificationDiff, redactNotificationUrl } from './notification-redaction.js' export type { SafeWebhookTarget } from './webhooks.js' export type { RunRoutesOptions } from './runs.js' @@ -477,6 +508,18 @@ export type { } from './content.js' export { buildOpenApiDocument } from './openapi.js' export type { OpenApiInfo } from './openapi.js' +export { + sessionRoutes, + createSessionStore, + hashDashboardPassword, + verifyDashboardPassword, +} from './session.js' +export type { + SessionStore, + SessionRoutesOptions, + DashboardPasswordStore, + CreateSessionStoreOptions, +} from './session.js' /** * Build the per-source-type validator map consumed by the generic diff --git a/packages/api-routes/src/openapi-schemas.ts b/packages/api-routes/src/openapi-schemas.ts index a168b4b8..0daa2c0c 100644 --- a/packages/api-routes/src/openapi-schemas.ts +++ b/packages/api-routes/src/openapi-schemas.ts @@ -73,6 +73,9 @@ import { gbpPlaceDetailsListResponseSchema, gbpSummaryDtoSchema, googleConnectionDtoSchema, + guestReportClaimResponseSchema, + guestReportCreateResponseSchema, + guestReportDtoSchema, gscCoverageSnapshotDtoSchema, gscCoverageSummaryDtoSchema, gscDeindexedRowSchema, @@ -171,6 +174,9 @@ const SCHEMA_TABLE = { GbpSummaryDto: gbpSummaryDtoSchema, GbpSyncResponse: gbpSyncResponseSchema, GoogleConnectionDto: googleConnectionDtoSchema, + GuestReportClaimResponseDto: guestReportClaimResponseSchema, + GuestReportCreateResponseDto: guestReportCreateResponseSchema, + GuestReportDto: guestReportDtoSchema, GscCoverageSnapshotDto: gscCoverageSnapshotDtoSchema, GscCoverageSummaryDto: gscCoverageSummaryDtoSchema, GscDeindexedRowDto: gscDeindexedRowSchema, diff --git a/packages/api-routes/src/openapi.ts b/packages/api-routes/src/openapi.ts index c48c776a..7c404384 100644 --- a/packages/api-routes/src/openapi.ts +++ b/packages/api-routes/src/openapi.ts @@ -247,6 +247,82 @@ const routeCatalog: OpenApiOperation[] = [ 200: rawJsonResponse('OpenAPI document.', looseObjectSchema), }, }, + // ─── Aero owner-view onboarding (anonymous guest report) ─────────────── + // The /aero free-first-report flow: a visitor drops a domain, we run the + // audit + AI sweep on a transient guest project, then optionally promote + // the report into their workspace when they sign up. The three guest + // endpoints below are anonymous (excluded from auth via shouldSkipAuth); + // the claim endpoint requires auth and lives behind the standard bearer + // + session cookie surface. + { + method: 'post', + path: '/api/v1/guest/report', + summary: 'Create an anonymous guest report for a domain', + description: + 'Kicks off the audit + AI sweep on a transient guest project for the given domain. Returns immediately with the report id so the SPA can subscribe to the SSE stream; the actual work runs in the background. Anonymous — no auth required. Rows expire after 7 days unless claimed.', + tags: ['guest-report'], + auth: false, + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['domain'], + properties: { + domain: { type: 'string', description: 'Bare domain or full URL — normalized server-side.' }, + }, + }, + }, + }, + }, + responses: { + 201: jsonResponse('Guest report created.', 'GuestReportCreateResponseDto'), + 400: errorResponse('Invalid or missing domain.'), + }, + }, + { + method: 'get', + path: '/api/v1/guest/report/{id}', + summary: 'Fetch a guest report', + description: 'Returns the current state of the report including audit/sweep scores, top findings, proposed plan, and the full progressEvents replay buffer. Anonymous — no auth required.', + tags: ['guest-report'], + auth: false, + parameters: [{ name: 'id', in: 'path', required: true, description: 'Guest report id.', schema: stringSchema }], + responses: { + 200: jsonResponse('Guest report returned.', 'GuestReportDto'), + 404: errorResponse('Guest report not found.'), + }, + }, + { + method: 'get', + path: '/api/v1/guest/report/{id}/stream', + summary: 'Subscribe to guest report progress (SSE)', + description: + 'Server-Sent Events stream of `state` and progress event frames for the guest report. Each frame is `data: \\n\\n`. The server replays existing progressEvents on connect (so a reload mid-audit catches up) and then forwards live events as they fire. Closes when the report reaches `completed` or `failed`. Anonymous — no auth required.', + tags: ['guest-report'], + auth: false, + parameters: [{ name: 'id', in: 'path', required: true, description: 'Guest report id.', schema: stringSchema }], + responses: { + // Returns text/event-stream — codegen consumers should treat as a stream. + 200: { description: 'SSE stream of GuestReport frames.', content: { 'text/event-stream': { schema: { type: 'string' } } } }, + 404: errorResponse('Guest report not found.'), + }, + }, + { + method: 'post', + path: '/api/v1/guest/report/{id}/claim', + summary: 'Claim a guest report into the authenticated workspace', + description: + 'Promotes the transient guest project into the operator workspace, marking the report claimed. Idempotent — calling twice returns `alreadyClaimed: true` with the same projectId. Requires a valid session cookie or bearer token.', + tags: ['guest-report'], + parameters: [{ name: 'id', in: 'path', required: true, description: 'Guest report id.', schema: stringSchema }], + responses: { + 200: jsonResponse('Report claimed (or already claimed).', 'GuestReportClaimResponseDto'), + 401: errorResponse('Authentication required.'), + 404: errorResponse('Guest report not found.'), + }, + }, { method: 'put', path: '/api/v1/projects/{name}', diff --git a/packages/api-routes/src/session.ts b/packages/api-routes/src/session.ts new file mode 100644 index 00000000..9c694f9a --- /dev/null +++ b/packages/api-routes/src/session.ts @@ -0,0 +1,389 @@ +/** + * Cookie-backed browser session plugin. + * + * Extracted from `packages/canonry/src/server.ts` so both the local + * `canonry serve` daemon and the cloud `apps/api` Fastify server can + * share the same routes and in-memory store. The plugin owns: + * + * - `GET /session` — session status + whether setup is required + * - `POST /session/setup` — first-time password setup (single-tenant) + * - `POST /session` — login with password or `cnry_…` bearer + * - `DELETE /session` — logout (clear cookie + session record) + * + * The dashboard password storage differs between deployments — local + * canonry serve keeps it in `~/.canonry/config.yaml`; apps/api keeps it + * in the `app_settings` DB row. The plugin takes a `DashboardPasswordStore` + * adapter so the storage strategy is the caller's concern. + * + * Sessions are deliberately in-process — that matches the existing + * single-tenant deployment posture (one Cloud Run service per team, + * one local daemon per operator). A restart logs everyone out, which + * is exactly the right behavior for a single-tenant CLI tool. If we + * ever move to multi-instance, sessions move into the DB. + */ + +import crypto from 'node:crypto' +import { eq } from 'drizzle-orm' +import type { FastifyInstance, FastifyReply } from 'fastify' + +import rateLimit from '@fastify/rate-limit' +import { apiKeys, type DatabaseClient } from '@ainyc/canonry-db' +import { authInvalid, validationError } from '@ainyc/canonry-contracts' + +// ─── Session store ─────────────────────────────────────────────────────────── + +interface SessionRecord { + apiKeyId: string + expiresAt: number +} + +export interface SessionStore { + /** Look up the apiKey bound to a session id, or null if expired/missing. */ + resolveSessionApiKeyId(sessionId: string): string | null + /** Mint a fresh session id bound to the given apiKey, returning it. */ + createSession(apiKeyId: string): string + /** Drop a session record. No-op if missing. */ + clearSession(sessionId: string | undefined): void +} + +export interface CreateSessionStoreOptions { + /** TTL for new sessions. Defaults to 12 hours. */ + ttlMs?: number +} + +const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000 + +export function createSessionStore(opts: CreateSessionStoreOptions = {}): SessionStore & { ttlMs: number } { + const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS + const sessions = new Map() + + const pruneExpired = () => { + const now = Date.now() + for (const [id, record] of sessions.entries()) { + if (record.expiresAt <= now) sessions.delete(id) + } + } + + return { + ttlMs, + createSession(apiKeyId: string) { + pruneExpired() + const id = crypto.randomBytes(32).toString('hex') + sessions.set(id, { apiKeyId, expiresAt: Date.now() + ttlMs }) + return id + }, + resolveSessionApiKeyId(sessionId: string) { + pruneExpired() + const record = sessions.get(sessionId) + if (!record) return null + if (record.expiresAt <= Date.now()) { + sessions.delete(sessionId) + return null + } + return record.apiKeyId + }, + clearSession(sessionId: string | undefined) { + if (sessionId) sessions.delete(sessionId) + }, + } +} + +// ─── Password hashing ──────────────────────────────────────────────────────── +// +// Dashboard passwords are user-chosen and may be reused from elsewhere, so a +// leaked config file must not be trivially cracked against a wordlist. We +// use salted scrypt (N=32768, ~80ms on a modern laptop) with a version field +// in the stored format so we can rotate to a stronger KDF later without +// breaking existing installs. +// +// Stored format: `scrypt$1$$`. +// +// Legacy unsalted SHA-256 hex hashes (from before this rewrite) are still +// accepted at login time; when one matches, the caller rewrites the config +// with a fresh scrypt hash so the next login no longer needs the legacy +// fallback path. + +const DASHBOARD_SCRYPT_KEYLEN = 64 +const DASHBOARD_SCRYPT_COST = 1 << 15 +// Node's default scrypt `maxmem` is 32 MiB which is exactly at the boundary +// for our chosen N (128 * 32768 * 8 ≈ 32 MiB). Bump to 64 MiB to leave +// headroom and keep the derivation comfortably within the limit. +const DASHBOARD_SCRYPT_MAXMEM = 64 * 1024 * 1024 + +// `cnry_…` API keys are 128-bit cryptographically-random tokens (see +// `canonry init`), NOT user-chosen passwords. Fast SHA-256 is the correct +// choice for hashing high-entropy tokens — a slow KDF (scrypt/argon2) would +// add latency to every authenticated request for zero security gain, since +// there is no wordlist to brute-force against a 128-bit random value. User +// *passwords* use salted scrypt instead — see `hashDashboardPassword`. +// (CodeQL "insufficient computational effort" flags this as password +// hashing; it is a false positive for opaque tokens.) +function hashApiKey(key: string): string { + return crypto.createHash('sha256').update(key).digest('hex') +} + +export function hashDashboardPassword(password: string): string { + const salt = crypto.randomBytes(16) + const derived = crypto.scryptSync(password, salt, DASHBOARD_SCRYPT_KEYLEN, { + N: DASHBOARD_SCRYPT_COST, + maxmem: DASHBOARD_SCRYPT_MAXMEM, + }) + return `scrypt$1$${salt.toString('base64')}$${derived.toString('base64')}` +} + +interface DashboardPasswordVerifyResult { + ok: boolean + /** True when the stored hash used the legacy SHA-256 format and the caller should rewrite. */ + needsRehash: boolean +} + +export function verifyDashboardPassword(password: string, storedHash: string): DashboardPasswordVerifyResult { + // New format: scrypt with salt. + if (storedHash.startsWith('scrypt$1$')) { + const parts = storedHash.split('$') + if (parts.length !== 4) return { ok: false, needsRehash: false } + const saltB64 = parts[2] + const hashB64 = parts[3] + if (!saltB64 || !hashB64) return { ok: false, needsRehash: false } + let salt: Buffer + let expected: Buffer + try { + salt = Buffer.from(saltB64, 'base64') + expected = Buffer.from(hashB64, 'base64') + } catch { + return { ok: false, needsRehash: false } + } + const derived = crypto.scryptSync(password, salt, expected.length, { + N: DASHBOARD_SCRYPT_COST, + maxmem: DASHBOARD_SCRYPT_MAXMEM, + }) + if (derived.length !== expected.length) return { ok: false, needsRehash: false } + return { ok: crypto.timingSafeEqual(derived, expected), needsRehash: false } + } + + // Legacy SHA-256 hex format — accept once for migration, then rehash. + if (/^[a-f0-9]{64}$/i.test(storedHash)) { + const candidate = Buffer.from(hashApiKey(password), 'hex') + const expected = Buffer.from(storedHash, 'hex') + if (candidate.length !== expected.length) return { ok: false, needsRehash: false } + const ok = crypto.timingSafeEqual(candidate, expected) + return { ok, needsRehash: ok } + } + + return { ok: false, needsRehash: false } +} + +// ─── Cookie helpers ────────────────────────────────────────────────────────── + +function parseCookies(header: string | undefined): Record { + if (!header) return {} + + return header + .split(';') + .map((part) => part.trim()) + .filter(Boolean) + .reduce>((cookies, part) => { + const eqIdx = part.indexOf('=') + if (eqIdx <= 0) return cookies + const name = part.slice(0, eqIdx).trim() + const value = part.slice(eqIdx + 1).trim() + if (!name) return cookies + try { + cookies[name] = decodeURIComponent(value) + } catch { + cookies[name] = value + } + return cookies + }, {}) +} + +interface SerializeCookieOpts { + name: string + value: string | null + path: string + secure: boolean + ttlMs: number +} + +function serializeSessionCookie(opts: SerializeCookieOpts): string { + const parts = [ + `${opts.name}=${opts.value ? encodeURIComponent(opts.value) : ''}`, + `Path=${opts.path}`, + 'HttpOnly', + 'SameSite=Lax', + ] + parts.push(opts.value ? `Max-Age=${Math.floor(opts.ttlMs / 1000)}` : 'Max-Age=0') + if (opts.secure) parts.push('Secure') + return parts.join('; ') +} + +// ─── Plugin ────────────────────────────────────────────────────────────────── + +/** + * Pluggable storage for the single dashboard password hash. Local canonry + * serve wires this to `~/.canonry/config.yaml`; apps/api wires it to the + * `app_settings` DB row. The plugin doesn't care which. + */ +export interface DashboardPasswordStore { + /** Current scrypt-format (or legacy SHA-256) hash, or undefined if unconfigured. */ + get(): string | undefined + /** Persist a freshly-computed hash. May be async for DB-backed stores. */ + set(hash: string): void | Promise +} + +export interface SessionRoutesOptions { + db: DatabaseClient + store: SessionStore + /** Cookie name (must match what the api-routes auth plugin reads). */ + cookieName: string + /** Cookie Path attribute. Use `basePath` so cookies scope to the install. */ + cookiePath: string + /** Set `Secure` on the cookie. Should be true for HTTPS deployments. */ + cookieSecure: boolean + /** Same TTL the store was created with. Used for the cookie's Max-Age. */ + ttlMs: number + /** Where the dashboard password hash lives. */ + dashboardPassword: DashboardPasswordStore + /** + * Lookup the install's default API key — the one bound to password + * sessions. Local canonry maps this to `config.apiKey`; apps/api can + * either pin a single key or use the first non-revoked apiKey row. + */ + getDefaultApiKey: () => { id: string; revokedAt?: string | null } | undefined +} + +/** + * Mount /session, /session/setup, DELETE /session under the registering + * Fastify scope. Caller is responsible for the API prefix and for excluding + * these paths from the auth hook (the api-routes auth plugin already + * does that via `shouldSkipAuth`). + */ +export async function sessionRoutes(app: FastifyInstance, opts: SessionRoutesOptions) { + // Brute-force guard. `@fastify/rate-limit` is scoped to THIS encapsulated + // plugin instance, which contains only the (sensitive) /session routes — + // so it never throttles the main dashboard/API surface. In-memory store + // matches the single-tenant deployment posture. The 30/min default covers + // status + logout; login + setup tighten to 10/min via per-route config. + await app.register(rateLimit, { global: true, max: 30, timeWindow: '1 minute' }) + + const createPasswordSession = (reply: FastifyReply): boolean => { + const key = opts.getDefaultApiKey() + if (!key || key.revokedAt) return false + + const sessionId = opts.store.createSession(key.id) + reply.header('set-cookie', serializeSessionCookie({ + name: opts.cookieName, + value: sessionId, + path: opts.cookiePath, + secure: opts.cookieSecure, + ttlMs: opts.ttlMs, + })) + return true + } + + app.get('/session', async (request, reply) => { + const sessionId = parseCookies(request.headers.cookie)[opts.cookieName] + return reply.send({ + authenticated: Boolean(sessionId && opts.store.resolveSessionApiKeyId(sessionId)), + setupRequired: !opts.dashboardPassword.get(), + }) + }) + + // First-time password setup. Only works when no password is configured yet. + app.post<{ + Body: { password?: string } + }>('/session/setup', { + config: { rateLimit: { max: 10, timeWindow: '1 minute' } }, + }, async (request, reply) => { + if (opts.dashboardPassword.get()) { + throw validationError('Dashboard password is already configured') + } + + const password = request.body?.password?.trim() + if (!password || password.length < 8) { + throw validationError('Password must be at least 8 characters') + } + + await opts.dashboardPassword.set(hashDashboardPassword(password)) + + if (!createPasswordSession(reply)) { + throw authInvalid() + } + return reply.send({ authenticated: true }) + }) + + // Login with dashboard password or `cnry_…` bearer. + app.post<{ + Body: { password?: string; apiKey?: string } + }>('/session', { + config: { rateLimit: { max: 10, timeWindow: '1 minute' } }, + }, async (request, reply) => { + const password = request.body?.password?.trim() + const apiKey = request.body?.apiKey?.trim() + + if (password) { + const stored = opts.dashboardPassword.get() + if (!stored) { + throw validationError('No dashboard password configured — use /session/setup first') + } + const verification = verifyDashboardPassword(password, stored) + if (!verification.ok) { + return reply.status(401).send({ error: { code: 'AUTH_INVALID', message: 'Incorrect password' } }) + } + // Transparent migration: a successful login against the legacy + // unsalted SHA-256 hash rewrites the store with a fresh scrypt hash + // so the next login no longer needs the legacy fallback path. + if (verification.needsRehash) { + await opts.dashboardPassword.set(hashDashboardPassword(password)) + } + if (!createPasswordSession(reply)) { + return reply.status(401).send({ error: { code: 'AUTH_INVALID', message: 'Server API key not found — re-run canonry init' } }) + } + return reply.send({ authenticated: true }) + } + + if (apiKey) { + const key = opts.db + .select() + .from(apiKeys) + .where(eq(apiKeys.keyHash, hashApiKey(apiKey))) + .get() + + if (!key || key.revokedAt) { + throw authInvalid() + } + + opts.db + .update(apiKeys) + .set({ lastUsedAt: new Date().toISOString() }) + .where(eq(apiKeys.id, key.id)) + .run() + + const sessionId = opts.store.createSession(key.id) + reply.header('set-cookie', serializeSessionCookie({ + name: opts.cookieName, + value: sessionId, + path: opts.cookiePath, + secure: opts.cookieSecure, + ttlMs: opts.ttlMs, + })) + return reply.send({ authenticated: true }) + } + + throw validationError('Either password or apiKey is required') + }) + + // Logout uses the plugin's 30/min default (no per-route override needed). + app.delete('/session', async (request, reply) => { + const sessionId = parseCookies(request.headers.cookie)[opts.cookieName] + opts.store.clearSession(sessionId) + reply.header('set-cookie', serializeSessionCookie({ + name: opts.cookieName, + value: null, + path: opts.cookiePath, + secure: opts.cookieSecure, + ttlMs: opts.ttlMs, + })) + return reply.status(204).send() + }) +} diff --git a/packages/api-routes/src/settings.ts b/packages/api-routes/src/settings.ts index 4533e718..df3dfa9f 100644 --- a/packages/api-routes/src/settings.ts +++ b/packages/api-routes/src/settings.ts @@ -4,9 +4,36 @@ import { validationError, notImplemented, internalError, + forbidden, } from '@ainyc/canonry-contracts' import { requireScope } from './auth.js' +/** + * Cloud-mode flag (Track 1 — Canonry Hosted). When + * `CANONRY_MANAGED_SETTINGS=1` is set on the tenant container, every + * `/settings/*` write returns HTTP 403 — the cloud control plane owns the + * provider keys, OAuth client credentials, and Bing API key. Read endpoints + * remain available so the dashboard can show the managed values (with + * write controls hidden in the UI; that's a separate ticket). + * + * Defaults to false (OSS posture). Evaluated per-request inside + * `requireWritableSettings()` so a test runner that mutates `process.env` + * between cases can override it cleanly without a module reset. + */ +function managedSettingsEnabled(): boolean { + const raw = process.env.CANONRY_MANAGED_SETTINGS?.trim().toLowerCase() + return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on' +} + +function requireWritableSettings(): void { + if (managedSettingsEnabled()) { + // The dashboard UI hides these forms when cloud mode is active, but + // direct API callers can still hit the routes — fail closed so a + // tenant API key can't rewrite the operator-managed pool keys. + throw forbidden('Settings are managed by the control plane in cloud mode; writes are not permitted.') + } +} + /** * Scope required to mutate any global setting — provider API keys, * Google OAuth client credentials, Bing API key, CDP endpoint. @@ -72,6 +99,7 @@ export async function settingsRoutes(app: FastifyInstance, opts: SettingsRoutesO Params: { name: string } Body: { apiKey?: string; baseUrl?: string; model?: string; quota?: Partial } }>('/settings/providers/:name', async (request) => { + requireWritableSettings() requireScope(request, SETTINGS_WRITE_SCOPE) const { apiKey, baseUrl, model, quota } = request.body ?? {} const name = request.params.name @@ -142,6 +170,7 @@ export async function settingsRoutes(app: FastifyInstance, opts: SettingsRoutesO app.put<{ Body: { clientId?: string; clientSecret?: string } }>('/settings/google', async (request) => { + requireWritableSettings() requireScope(request, SETTINGS_WRITE_SCOPE) const { clientId, clientSecret } = request.body ?? {} @@ -164,6 +193,7 @@ export async function settingsRoutes(app: FastifyInstance, opts: SettingsRoutesO app.put<{ Body: { apiKey?: string } }>('/settings/bing', async (request) => { + requireWritableSettings() requireScope(request, SETTINGS_WRITE_SCOPE) const { apiKey } = request.body ?? {} diff --git a/packages/api-routes/src/webhooks.ts b/packages/api-routes/src/webhooks.ts index 92422cdd..372c88f9 100644 --- a/packages/api-routes/src/webhooks.ts +++ b/packages/api-routes/src/webhooks.ts @@ -25,6 +25,16 @@ export interface ResolveWebhookTargetOptions { * dev workflows that point webhooks at localhost. */ allowLoopback?: boolean + /** + * Allow private RFC 1918 ranges (10/8, 172.16/12, 192.168/16) plus CGNAT + * (100.64/10), link-local (169.254/16), and benchmark (198.18/15). Defaults + * to false. Hosted Canonry deployments (spec §1.1) need this set when the + * control plane is reachable via a Docker bridge or VPN hostname — the + * tenant runtime POSTs to e.g. `http://canonry-control-plane:8080` which + * resolves into the Docker 172.16/12 range. Operators trust this address + * because they wrote it into the tenant manifest themselves. + */ + allowPrivateNetworks?: boolean } export async function resolveWebhookTarget( @@ -184,8 +194,12 @@ function isBlockedIpv4(address: string, options: ResolveWebhookTargetOptions): b if (first === 127 && !options.allowLoopback) { return true } + if (first === 0) return true + if (options.allowPrivateNetworks) { + // Operator opted in (e.g. Docker bridge networks, VPN endpoints). + return false + } return ( - first === 0 || first === 10 || (first === 100 && second >= 64 && second <= 127) || (first === 169 && second === 254) || @@ -210,6 +224,12 @@ function isBlockedIpv6(address: string, options: ResolveWebhookTargetOptions): b return true } + if (options.allowPrivateNetworks) { + // Operator opted in — same posture as the IPv4 branch. Lets IPv6-routed + // Docker bridges / VPN endpoints through. + return false + } + return ( (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) || (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) diff --git a/packages/api-routes/test/cloud-bing-key.test.ts b/packages/api-routes/test/cloud-bing-key.test.ts new file mode 100644 index 00000000..b2c90a8c --- /dev/null +++ b/packages/api-routes/test/cloud-bing-key.test.ts @@ -0,0 +1,169 @@ +import { describe, it, beforeEach, afterEach, expect } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import Fastify, { type FastifyInstance } from 'fastify' +import { createClient, migrate, projects } from '@ainyc/canonry-db' +import { apiRoutes } from '../src/index.js' +import type { BingConnectionRecord, BingConnectionStore } from '../src/bing.js' + +function createMemoryStore(): BingConnectionStore & { connections: Map } { + const connections = new Map() + return { + connections, + getConnection: (domain) => connections.get(domain.toLowerCase()), + upsertConnection: (record) => { + connections.set(record.domain.toLowerCase(), record) + return record + }, + updateConnection: (domain, patch) => { + const existing = connections.get(domain.toLowerCase()) + if (!existing) return undefined + const next = { ...existing, ...patch } + connections.set(domain.toLowerCase(), next) + return next + }, + deleteConnection: (domain) => connections.delete(domain.toLowerCase()), + } +} + +function buildApp() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloud-bing-test-')) + const dbPath = path.join(tmpDir, 'test.db') + const db = createClient(dbPath) + migrate(db) + + const now = new Date().toISOString() + db.insert(projects).values({ + id: 'proj-1', + name: 'acme', + displayName: 'Acme', + canonicalDomain: 'acme.com', + country: 'US', + language: 'en', + createdAt: now, + updatedAt: now, + }).run() + + const store = createMemoryStore() + const app = Fastify() + app.register(apiRoutes, { + db, + skipAuth: true, + bingConnectionStore: store, + allowLoopbackWebhooks: true, + }) + return { app, db, store, tmpDir, dbPath } +} + +const VALID_REQUEST = { + project_slug: 'acme', + api_key: 'bing-api-key-deadbeef', + site_url: 'https://acme.com/', +} + +let app: FastifyInstance +// `_db` is assigned per-test so the in-memory DB is rebuilt; the body assertions +// route through the `store` decorator, not direct DB queries. +let _db: ReturnType +let store: ReturnType +let tmpDir: string + +describe('POST /api/v1/cloud/bing/import-key', () => { + beforeEach(async () => { + process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP = '1' + const ctx = buildApp() + app = ctx.app + _db = ctx.db + store = ctx.store + tmpDir = ctx.tmpDir + await app.ready() + }) + + afterEach(async () => { + delete process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('upserts a Bing connection on happy path', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bing/import-key', + headers: { 'X-Admin-Scope': '1' }, + payload: VALID_REQUEST, + }) + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.payload) + expect(body).toEqual({ + imported: true, + domain: 'acme.com', + site_url: 'https://acme.com/', + }) + + const stored = store.getConnection('acme.com') + expect(stored).toBeDefined() + expect(stored?.apiKey).toBe(VALID_REQUEST.api_key) + expect(stored?.siteUrl).toBe(VALID_REQUEST.site_url) + }) + + it('rejects request without X-Admin-Scope header with 403', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bing/import-key', + payload: VALID_REQUEST, + }) + expect(res.statusCode).toBe(403) + }) + + it('rejects unknown project with 404', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bing/import-key', + headers: { 'X-Admin-Scope': '1' }, + payload: { ...VALID_REQUEST, project_slug: 'no-such-project' }, + }) + expect(res.statusCode).toBe(404) + const body = JSON.parse(res.payload) + expect(body.error.code).toBe('NOT_FOUND') + }) + + it('rejects malformed body with 400 VALIDATION_ERROR', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bing/import-key', + headers: { 'X-Admin-Scope': '1' }, + payload: { project_slug: 'acme' }, // missing api_key + site_url + }) + expect(res.statusCode).toBe(400) + const body = JSON.parse(res.payload) + expect(body.error.code).toBe('VALIDATION_ERROR') + }) +}) + +describe('POST /api/v1/cloud/bing/import-key with flag unset', () => { + beforeEach(async () => { + delete process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP + const ctx = buildApp() + app = ctx.app + _db = ctx.db + store = ctx.store + tmpDir = ctx.tmpDir + await app.ready() + }) + + afterEach(async () => { + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('returns 404 when flag is unset, even with admin scope', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bing/import-key', + headers: { 'X-Admin-Scope': '1' }, + payload: VALID_REQUEST, + }) + expect(res.statusCode).toBe(404) + }) +}) diff --git a/packages/api-routes/test/cloud-bootstrap.test.ts b/packages/api-routes/test/cloud-bootstrap.test.ts new file mode 100644 index 00000000..dde40d86 --- /dev/null +++ b/packages/api-routes/test/cloud-bootstrap.test.ts @@ -0,0 +1,253 @@ +import { describe, it, beforeEach, afterEach, expect } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import Fastify, { type FastifyInstance } from 'fastify' +import { createClient, migrate, cloudMetadata, notifications, parseJsonColumn } from '@ainyc/canonry-db' +import { eq } from 'drizzle-orm' +import { apiRoutes } from '../src/index.js' + +/** + * Helper to spin up a fresh API + DB for each test. The cloud bootstrap + * endpoints behave very differently depending on `CANONRY_ENABLE_CLOUD_BOOTSTRAP`, + * so each test runs against its own DB and env state. + */ +function buildApp() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloud-bootstrap-test-')) + const dbPath = path.join(tmpDir, 'test.db') + const db = createClient(dbPath) + migrate(db) + const app = Fastify() + app.register(apiRoutes, { db, skipAuth: true, canonryVersion: '1.2.3', allowLoopbackWebhooks: true }) + return { app, db, tmpDir, dbPath } +} + +const VALID_REQUEST = { + tenant_id: 'tenant-abc', + account_id: 'acct-123', + plan: 'starter', + // Loopback URL — the test enables `allowLoopbackWebhooks` so the SSRF + // guard doesn't reject it. Real deployments use a Docker-network URL. + control_plane_callback_url: 'http://127.0.0.1:18081/cloud/events', + webhook_secret: 'whsec_deadbeef_deadbeef_deadbeef_dead', + default_locale: { country: 'US', language: 'en' }, + managed_oauth: { + google_client_id: 'gci-123.apps.googleusercontent.com', + google_client_secret: 'gcs-secret-456', + google_callback_url: 'https://app.canonry.ai/oauth/callback/google', + }, +} + +let app: FastifyInstance +let db: ReturnType +let tmpDir: string + +describe('POST /api/v1/cloud/bootstrap', () => { + beforeEach(async () => { + process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP = '1' + const ctx = buildApp() + app = ctx.app + db = ctx.db + tmpDir = ctx.tmpDir + await app.ready() + }) + + afterEach(async () => { + delete process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('writes cloud_metadata + notification subscriber on first call', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bootstrap', + headers: { 'X-Admin-Scope': '1' }, + payload: VALID_REQUEST, + }) + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.payload) + expect(body).toMatchObject({ + canonry_version: '1.2.3', + webhook_attached: true, + }) + expect(typeof body.bootstrap_completed_at).toBe('string') + + const row = db.select().from(cloudMetadata).where(eq(cloudMetadata.id, 'singleton')).get() + expect(row).toBeDefined() + expect(row?.tenantId).toBe('tenant-abc') + expect(row?.accountId).toBe('acct-123') + expect(row?.plan).toBe('starter') + expect(row?.controlPlaneCallbackUrl).toBe(VALID_REQUEST.control_plane_callback_url) + expect(row?.managedGoogleClientId).toBe(VALID_REQUEST.managed_oauth.google_client_id) + expect(row?.managedGoogleRedirectUrl).toBe(VALID_REQUEST.managed_oauth.google_callback_url) + + const subs = db.select().from(notifications).all() + expect(subs).toHaveLength(1) + const config = parseJsonColumn<{ url: string; events: string[]; source?: string }>( + typeof subs[0]!.config === 'string' ? subs[0]!.config : JSON.stringify(subs[0]!.config), + {}, + ) + expect(subs[0]!.projectId).toBeNull() + expect(config.url).toBe(VALID_REQUEST.control_plane_callback_url) + // Subscribed to all 12 events (six legacy + six cloud). + expect(config.events).toHaveLength(12) + expect(config.events).toContain('baseline.completed') + expect(config.events).toContain('connection.created') + expect(config.events).toContain('run.completed') + }) + + it('is idempotent — re-running with the same tenant_id refreshes the row', async () => { + const first = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bootstrap', + headers: { 'X-Admin-Scope': '1' }, + payload: VALID_REQUEST, + }) + expect(first.statusCode).toBe(200) + + const second = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bootstrap', + headers: { 'X-Admin-Scope': '1' }, + payload: { ...VALID_REQUEST, plan: 'growth' }, + }) + expect(second.statusCode).toBe(200) + + // Still one row, with updated plan. + const allMetadata = db.select().from(cloudMetadata).all() + expect(allMetadata).toHaveLength(1) + expect(allMetadata[0]!.plan).toBe('growth') + + // Still one notification row — the second bootstrap refreshed it, not duplicated. + const subs = db.select().from(notifications).all() + expect(subs).toHaveLength(1) + }) + + it('rejects request without X-Admin-Scope header with 403', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bootstrap', + payload: VALID_REQUEST, + }) + expect(res.statusCode).toBe(403) + const body = JSON.parse(res.payload) + expect(body.error.code).toBe('FORBIDDEN') + }) + + it('rejects request with wrong X-Admin-Scope value with 403', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bootstrap', + headers: { 'X-Admin-Scope': 'true' }, + payload: VALID_REQUEST, + }) + expect(res.statusCode).toBe(403) + }) + + it('rejects malformed body with 400 VALIDATION_ERROR', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bootstrap', + headers: { 'X-Admin-Scope': '1' }, + payload: { tenant_id: 'tenant-x' }, // missing required fields + }) + expect(res.statusCode).toBe(400) + const body = JSON.parse(res.payload) + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('rejects invalid control_plane_callback_url with 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bootstrap', + headers: { 'X-Admin-Scope': '1' }, + payload: { ...VALID_REQUEST, control_plane_callback_url: 'not-a-url' }, + }) + expect(res.statusCode).toBe(400) + }) +}) + +describe('CANONRY_ENABLE_CLOUD_BOOTSTRAP value parsing', () => { + // Mirror packages/config/src/index.ts:parseBooleanFlag — the route gate + // must accept the same truthy set so an operator who sets + // CANONRY_ENABLE_CLOUD_BOOTSTRAP=true (rather than =1) doesn't see + // config flag the deployment as cloud-enabled while the route still 404s. + afterEach(async () => { + delete process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP + if (app) await app.close() + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + for (const value of ['1', 'true', 'TRUE', 'yes', 'on']) { + it(`accepts CANONRY_ENABLE_CLOUD_BOOTSTRAP=${value}`, async () => { + process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP = value + const ctx = buildApp() + app = ctx.app + db = ctx.db + tmpDir = ctx.tmpDir + await app.ready() + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bootstrap', + headers: { 'X-Admin-Scope': '1' }, + payload: VALID_REQUEST, + }) + // 200 = route was reached + the bootstrap completed. 404 here would + // signal the env value parse fell through. + expect(res.statusCode, `expected ${value} to enable the route`).toBe(200) + }) + } + + for (const value of ['0', 'false', '', 'no']) { + it(`rejects CANONRY_ENABLE_CLOUD_BOOTSTRAP=${JSON.stringify(value)}`, async () => { + if (value === '') { + delete process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP + } else { + process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP = value + } + const ctx = buildApp() + app = ctx.app + db = ctx.db + tmpDir = ctx.tmpDir + await app.ready() + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bootstrap', + headers: { 'X-Admin-Scope': '1' }, + payload: VALID_REQUEST, + }) + expect(res.statusCode).toBe(404) + }) + } +}) + +describe('cloud-bridge endpoints with CANONRY_ENABLE_CLOUD_BOOTSTRAP unset', () => { + beforeEach(async () => { + delete process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP + const ctx = buildApp() + app = ctx.app + db = ctx.db + tmpDir = ctx.tmpDir + await app.ready() + }) + + afterEach(async () => { + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('returns 404 from /cloud/bootstrap when the flag is unset', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/bootstrap', + headers: { 'X-Admin-Scope': '1' }, + payload: VALID_REQUEST, + }) + expect(res.statusCode).toBe(404) + const body = JSON.parse(res.payload) + expect(body.error.code).toBe('NOT_FOUND') + }) +}) diff --git a/packages/api-routes/test/cloud-emit-event.test.ts b/packages/api-routes/test/cloud-emit-event.test.ts new file mode 100644 index 00000000..a4c45417 --- /dev/null +++ b/packages/api-routes/test/cloud-emit-event.test.ts @@ -0,0 +1,202 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createClient, migrate, notifications } from '@ainyc/canonry-db' +import { cloudWebhookPayloadSchema } from '@ainyc/canonry-contracts' + +/** + * Cloud OUTBOUND bridge — `emitCloudEvent` / `emitConnectionEvent`. + * + * This is the OSS → canonry-cloud seam: when a tenant runtime fires a + * connection / baseline event, it POSTs a signed `CloudWebhookPayload` to + * every enabled subscriber. The two invariants that let OSS and the control + * plane be developed independently: + * + * 1. GATING — with no matching subscriber row, the dispatcher is inert. + * A standalone OSS install (no cloud bootstrap → no `projectId IS NULL` + * subscriber) emits nothing. The cloud bridge can't fire by accident. + * 2. CONTRACT — when a subscriber DOES match, the delivered body is exactly + * the `CloudWebhookPayload` envelope (source/event/event_id/project/ + * payload/occurred_at) and nothing more (notably: no `project.id` leak). + * + * `resolveWebhookTarget` + `deliverWebhook` are mocked so no real network / + * DNS happens — we assert on what the dispatcher *would* deliver. + */ + +const mocks = vi.hoisted(() => ({ + resolveWebhookTarget: vi.fn(), + deliverWebhook: vi.fn(), +})) + +vi.mock('../src/webhooks.js', () => ({ + resolveWebhookTarget: mocks.resolveWebhookTarget, + deliverWebhook: mocks.deliverWebhook, +})) + +const { emitCloudEvent, emitConnectionEvent } = await import('../src/cloud/emit-connection-event.js') + +type Db = ReturnType + +const PROJECT = { id: 'proj-1', name: 'acme', canonicalDomain: 'https://acme.com' } +const FIXED_EVENT_ID = '00000000-0000-4000-8000-000000000001' +const FIXED_OCCURRED_AT = '2026-01-15T12:00:00.000Z' + +function seedSubscriber( + db: Db, + opts: { + url: string + events: string[] + enabled?: boolean + projectId?: string | null + webhookSecret?: string | null + }, +) { + const now = new Date().toISOString() + db.insert(notifications) + .values({ + id: crypto.randomUUID(), + projectId: opts.projectId ?? null, + channel: 'webhook', + config: { url: opts.url, events: opts.events }, + webhookSecret: opts.webhookSecret ?? 'whsec_test', + enabled: opts.enabled ?? true, + createdAt: now, + updatedAt: now, + }) + .run() +} + +describe('cloud outbound event dispatcher', () => { + let db: Db + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'canonry-cloud-emit-')) + db = createClient(path.join(tmpDir, 'test.db')) + migrate(db) + mocks.resolveWebhookTarget.mockReset() + mocks.deliverWebhook.mockReset() + // Default: target resolves cleanly, delivery succeeds. + mocks.resolveWebhookTarget.mockResolvedValue({ ok: true, target: { url: new URL('https://cp.example.com/cloud/events') } }) + mocks.deliverWebhook.mockResolvedValue({ status: 200, error: null }) + }) + + afterEach(() => { + db.$client.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + // ── GATING ────────────────────────────────────────────────────────────── + + it('is inert when no subscribers exist (standalone OSS)', async () => { + await emitCloudEvent(db, { event: 'baseline.completed', project: PROJECT, payload: {} }) + expect(mocks.resolveWebhookTarget).not.toHaveBeenCalled() + expect(mocks.deliverWebhook).not.toHaveBeenCalled() + }) + + it('does not deliver to a subscriber not subscribed to the event', async () => { + seedSubscriber(db, { url: 'https://cp.example.com/cloud/events', events: ['digest.generated'] }) + await emitCloudEvent(db, { event: 'baseline.completed', project: PROJECT, payload: {} }) + expect(mocks.deliverWebhook).not.toHaveBeenCalled() + }) + + it('does not deliver to a disabled subscriber', async () => { + seedSubscriber(db, { url: 'https://cp.example.com/cloud/events', events: ['baseline.completed'], enabled: false }) + await emitCloudEvent(db, { event: 'baseline.completed', project: PROJECT, payload: {} }) + expect(mocks.deliverWebhook).not.toHaveBeenCalled() + }) + + // ── CONTRACT ──────────────────────────────────────────────────────────── + + it('delivers exactly the CloudWebhookPayload envelope to a matching subscriber', async () => { + seedSubscriber(db, { + url: 'https://cp.example.com/cloud/events', + events: ['baseline.completed'], + webhookSecret: 'whsec_abc', + }) + + await emitCloudEvent(db, { + event: 'baseline.completed', + project: PROJECT, + payload: { runId: 'run-9', cited: 3 }, + eventId: FIXED_EVENT_ID, + occurredAt: FIXED_OCCURRED_AT, + }) + + expect(mocks.deliverWebhook).toHaveBeenCalledTimes(1) + const [, payload, secret] = mocks.deliverWebhook.mock.calls[0]! + + // The body must satisfy the published contract schema verbatim. + expect(() => cloudWebhookPayloadSchema.parse(payload)).not.toThrow() + expect(payload).toEqual({ + source: 'canonry-cloud', + event: 'baseline.completed', + event_id: FIXED_EVENT_ID, + project: { name: 'acme', canonicalDomain: 'https://acme.com' }, + payload: { runId: 'run-9', cited: 3 }, + occurred_at: FIXED_OCCURRED_AT, + }) + // The internal project id must NOT leak into the envelope. + expect((payload as { project: Record }).project).not.toHaveProperty('id') + // The subscriber's signing secret is threaded through to delivery. + expect(secret).toBe('whsec_abc') + }) + + it('emitConnectionEvent constrains the payload to the connection shape', async () => { + seedSubscriber(db, { url: 'https://cp.example.com/cloud/events', events: ['connection.created'] }) + + await emitConnectionEvent(db, { + event: 'connection.created', + project: PROJECT, + payload: { + connectionType: 'gsc', + propertyRef: 'sc-domain:acme.com', + scopes: ['webmasters.readonly'], + }, + eventId: FIXED_EVENT_ID, + occurredAt: FIXED_OCCURRED_AT, + }) + + expect(mocks.deliverWebhook).toHaveBeenCalledTimes(1) + const [, payload] = mocks.deliverWebhook.mock.calls[0]! + expect(() => cloudWebhookPayloadSchema.parse(payload)).not.toThrow() + expect(payload).toMatchObject({ + source: 'canonry-cloud', + event: 'connection.created', + payload: { connectionType: 'gsc', propertyRef: 'sc-domain:acme.com', scopes: ['webmasters.readonly'] }, + }) + }) + + it('fans out to every matching subscriber', async () => { + seedSubscriber(db, { url: 'https://a.example.com/hook', events: ['baseline.completed'] }) + seedSubscriber(db, { url: 'https://b.example.com/hook', events: ['baseline.completed'] }) + await emitCloudEvent(db, { event: 'baseline.completed', project: PROJECT, payload: {} }) + expect(mocks.deliverWebhook).toHaveBeenCalledTimes(2) + }) + + // ── BEST-EFFORT (failures must not block the caller's write path) ───────── + + it('skips a subscriber whose target fails to resolve (SSRF / DNS) without throwing', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + seedSubscriber(db, { url: 'https://blocked.internal/hook', events: ['baseline.completed'] }) + mocks.resolveWebhookTarget.mockResolvedValueOnce({ ok: false, message: 'private address' }) + + await expect( + emitCloudEvent(db, { event: 'baseline.completed', project: PROJECT, payload: {} }), + ).resolves.toBeUndefined() + expect(mocks.deliverWebhook).not.toHaveBeenCalled() + }) + + it('swallows a delivery error so the caller is never blocked', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + seedSubscriber(db, { url: 'https://cp.example.com/cloud/events', events: ['baseline.completed'] }) + mocks.deliverWebhook.mockRejectedValueOnce(new Error('socket hang up')) + + await expect( + emitCloudEvent(db, { event: 'baseline.completed', project: PROJECT, payload: {} }), + ).resolves.toBeUndefined() + }) +}) diff --git a/packages/api-routes/test/cloud-google-tokens.test.ts b/packages/api-routes/test/cloud-google-tokens.test.ts new file mode 100644 index 00000000..368eeecc --- /dev/null +++ b/packages/api-routes/test/cloud-google-tokens.test.ts @@ -0,0 +1,197 @@ +import { describe, it, beforeEach, afterEach, expect } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import Fastify, { type FastifyInstance } from 'fastify' +import { createClient, migrate, projects } from '@ainyc/canonry-db' +import { apiRoutes } from '../src/index.js' +import type { GoogleConnectionRecord, GoogleConnectionStore } from '../src/google.js' + +/** + * In-memory `GoogleConnectionStore` matching the interface canonry's + * real config-backed store implements. Suitable for tests that exercise + * the cloud import path without writing to disk. + */ +function createMemoryStore(): GoogleConnectionStore & { connections: Map } { + const connections = new Map() + const key = (domain: string, type: 'gsc' | 'ga4') => `${domain.toLowerCase()}:${type}` + return { + connections, + listConnections: (domain: string) => + [...connections.values()].filter((c) => c.domain.toLowerCase() === domain.toLowerCase()), + getConnection: (domain: string, type: 'gsc' | 'ga4') => connections.get(key(domain, type)), + upsertConnection: (record: GoogleConnectionRecord) => { + connections.set(key(record.domain, record.connectionType), record) + return record + }, + updateConnection: (domain, type, patch) => { + const existing = connections.get(key(domain, type)) + if (!existing) return undefined + const next = { ...existing, ...patch } + connections.set(key(domain, type), next) + return next + }, + deleteConnection: (domain, type) => connections.delete(key(domain, type)), + } +} + +function buildApp() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloud-google-test-')) + const dbPath = path.join(tmpDir, 'test.db') + const db = createClient(dbPath) + migrate(db) + + const now = new Date().toISOString() + db.insert(projects).values({ + id: 'proj-1', + name: 'acme', + displayName: 'Acme', + canonicalDomain: 'acme.com', + country: 'US', + language: 'en', + createdAt: now, + updatedAt: now, + }).run() + + const store = createMemoryStore() + const app = Fastify() + app.register(apiRoutes, { + db, + skipAuth: true, + googleConnectionStore: store, + googleStateSecret: 'test-state-secret-deadbeef', + allowLoopbackWebhooks: true, + }) + return { app, db, store, tmpDir, dbPath } +} + +const VALID_REQUEST = { + project_slug: 'acme', + connection_type: 'gsc' as const, + property_ref: 'sc-domain:acme.com', + access_token: 'ya29.access-token', + refresh_token: '1//refresh-token', + expiry: new Date(Date.now() + 3600 * 1000).toISOString(), + scopes: ['https://www.googleapis.com/auth/webmasters.readonly'], + account_email: 'owner@acme.com', +} + +let app: FastifyInstance +// `_db` is assigned per-test for symmetry with cloud-bing-key.test.ts; assertions +// route through the `store` decorator, not direct DB queries. +let _db: ReturnType +let store: ReturnType +let tmpDir: string + +describe('POST /api/v1/cloud/google/import-tokens', () => { + beforeEach(async () => { + process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP = '1' + const ctx = buildApp() + app = ctx.app + _db = ctx.db + store = ctx.store + tmpDir = ctx.tmpDir + await app.ready() + }) + + afterEach(async () => { + delete process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('upserts a Google connection on happy path', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/google/import-tokens', + headers: { 'X-Admin-Scope': '1' }, + payload: VALID_REQUEST, + }) + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.payload) + expect(body).toEqual({ + imported: true, + domain: 'acme.com', + connection_type: 'gsc', + property_ref: 'sc-domain:acme.com', + }) + + const stored = store.getConnection('acme.com', 'gsc') + expect(stored).toBeDefined() + expect(stored?.accessToken).toBe(VALID_REQUEST.access_token) + expect(stored?.refreshToken).toBe(VALID_REQUEST.refresh_token) + expect(stored?.tokenExpiresAt).toBe(VALID_REQUEST.expiry) + expect(stored?.propertyId).toBe('sc-domain:acme.com') + expect(stored?.scopes).toEqual(VALID_REQUEST.scopes) + }) + + it('rejects request without X-Admin-Scope header with 403', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/google/import-tokens', + payload: VALID_REQUEST, + }) + expect(res.statusCode).toBe(403) + }) + + it('rejects unknown project with 404', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/google/import-tokens', + headers: { 'X-Admin-Scope': '1' }, + payload: { ...VALID_REQUEST, project_slug: 'no-such-project' }, + }) + expect(res.statusCode).toBe(404) + const body = JSON.parse(res.payload) + expect(body.error.code).toBe('NOT_FOUND') + }) + + it('rejects malformed body with 400 VALIDATION_ERROR', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/google/import-tokens', + headers: { 'X-Admin-Scope': '1' }, + payload: { project_slug: 'acme', connection_type: 'gsc' }, // missing required tokens + }) + expect(res.statusCode).toBe(400) + const body = JSON.parse(res.payload) + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('rejects invalid connection_type with 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/google/import-tokens', + headers: { 'X-Admin-Scope': '1' }, + payload: { ...VALID_REQUEST, connection_type: 'bing' }, + }) + expect(res.statusCode).toBe(400) + }) +}) + +describe('POST /api/v1/cloud/google/import-tokens with flag unset', () => { + beforeEach(async () => { + delete process.env.CANONRY_ENABLE_CLOUD_BOOTSTRAP + const ctx = buildApp() + app = ctx.app + _db = ctx.db + store = ctx.store + tmpDir = ctx.tmpDir + await app.ready() + }) + + afterEach(async () => { + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('returns 404 when flag is unset, even with admin scope', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/cloud/google/import-tokens', + headers: { 'X-Admin-Scope': '1' }, + payload: VALID_REQUEST, + }) + expect(res.statusCode).toBe(404) + }) +}) diff --git a/packages/api-routes/test/db-dto-coverage.test.ts b/packages/api-routes/test/db-dto-coverage.test.ts index bb46a81e..0e71a9fa 100644 --- a/packages/api-routes/test/db-dto-coverage.test.ts +++ b/packages/api-routes/test/db-dto-coverage.test.ts @@ -454,6 +454,26 @@ const COVERAGE: Record = { kind: 'internal-only', reason: 'Internal migration bookkeeping; never exposed.', }, + cloudMetadata: { + kind: 'internal-only', + reason: 'Tenant bootstrap singleton (Track 3 Canonry Hosted); read by cloud-bridge / doctor only, never on the public DTO surface.', + }, + providerTokenUsage: { + kind: 'internal-only', + reason: 'Per-(run, provider, model) token-cost telemetry (Track 1 Canonry Hosted); consumed by the cloud control plane for billing, not exposed on a public DTO yet.', + }, + users: { + kind: 'internal-only', + reason: 'Identity record bound to an api_keys row; the auth/session surface returns booleans + projectName, not the user row itself.', + }, + guestReports: { + kind: 'internal-only', + reason: 'Anonymous /aero guest-report state; the guest-report endpoints expose a hand-shaped GuestReportDto rather than the raw row.', + }, + appSettings: { + kind: 'internal-only', + reason: 'Generic instance-wide key/value store (e.g. dashboard password hash for apps/api); deliberately never exposed via a DTO.', + }, } interface DiscoveredTable { diff --git a/packages/api-routes/test/guest-report-normalize.test.ts b/packages/api-routes/test/guest-report-normalize.test.ts new file mode 100644 index 00000000..bc65d72e --- /dev/null +++ b/packages/api-routes/test/guest-report-normalize.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { normalizeDomain } from '../src/guest-report.js' + +/** + * `normalizeDomain` parses a user-entered domain on the anonymous + * /guest/report endpoint. It was rewritten to use linear string ops instead + * of a backtracking regex (CodeQL: polynomial-regex ReDoS). These tests pin + * both the normalization behavior and the ReDoS-safety. + */ +describe('normalizeDomain', () => { + it('strips scheme, path, and leading www', () => { + expect(normalizeDomain('https://www.acme.com/path?q=1#frag')).toBe('acme.com') + expect(normalizeDomain('http://acme.com')).toBe('acme.com') + expect(normalizeDomain('www.acme.com')).toBe('acme.com') + expect(normalizeDomain('acme.com')).toBe('acme.com') + expect(normalizeDomain('acme.com/foo/bar')).toBe('acme.com') + }) + + it('lowercases and trims', () => { + expect(normalizeDomain(' Acme.COM ')).toBe('acme.com') + }) + + it('keeps subdomains and hyphens, drops stray characters', () => { + expect(normalizeDomain('https://shop.acme-corp.co.uk/')).toBe('shop.acme-corp.co.uk') + }) + + it('rejects empty / too-short / dotless input', () => { + expect(() => normalizeDomain('')).toThrow() + expect(() => normalizeDomain(' ')).toThrow() + expect(() => normalizeDomain('ab')).toThrow() + expect(() => normalizeDomain('localhost')).toThrow() // no dot + }) + + it('handles a pathological all-slashes input quickly (no ReDoS)', () => { + // A backtracking regex on this could hang; the linear rewrite returns + // in well under a frame. We assert both fast completion and the throw. + const evil = '/'.repeat(200_000) + const start = Date.now() + expect(() => normalizeDomain(evil)).toThrow() // everything before the first '/' is empty → invalid + expect(Date.now() - start).toBeLessThan(100) + }) + + it('handles a long repeated-dot input quickly', () => { + const evil = `${'a.'.repeat(100_000)}com` + const start = Date.now() + // Valid-ish (contains dots) — just assert it returns fast without hanging. + const out = normalizeDomain(evil) + expect(Date.now() - start).toBeLessThan(100) + expect(out.endsWith('com')).toBe(true) + }) +}) diff --git a/packages/api-routes/test/guest-report-routes.test.ts b/packages/api-routes/test/guest-report-routes.test.ts new file mode 100644 index 00000000..e72975a5 --- /dev/null +++ b/packages/api-routes/test/guest-report-routes.test.ts @@ -0,0 +1,166 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import Fastify, { type FastifyInstance } from 'fastify' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { apiKeys, createClient, guestReports, migrate, projects } from '@ainyc/canonry-db' +import { apiRoutes } from '../src/index.js' + +const RAW_KEY = 'cnry_guest_route_test' + +function hashKey(key: string): string { + return crypto.createHash('sha256').update(key).digest('hex') +} + +function insertProject(db: ReturnType, id = crypto.randomUUID()) { + db.insert(projects).values({ + id, + name: `guest-${id.slice(0, 8)}`, + displayName: 'Expired Guest', + canonicalDomain: 'expired.example', + country: 'US', + language: 'en', + configSource: 'guest', + configRevision: 1, + createdAt: '2026-05-01T00:00:00.000Z', + updatedAt: '2026-05-01T00:00:00.000Z', + }).run() + return id +} + +function insertGuestReport( + db: ReturnType, + projectId: string, + opts: { id?: string; expiresAt?: string; claimedAt?: string | null } = {}, +) { + const id = opts.id ?? `gr_${crypto.randomBytes(6).toString('hex')}` + db.insert(guestReports).values({ + id, + domain: 'expired.example', + projectId, + status: 'completed', + createdAt: '2026-05-01T00:00:00.000Z', + expiresAt: opts.expiresAt ?? '2026-05-01T00:00:00.000Z', + claimedAt: opts.claimedAt ?? null, + }).run() + return id +} + +function buildApp() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'canonry-guest-report-')) + const db = createClient(path.join(tmpDir, 'test.db')) + migrate(db) + db.insert(apiKeys).values({ + id: 'key_guest_route_test', + name: 'default', + keyHash: hashKey(RAW_KEY), + keyPrefix: RAW_KEY.slice(0, 12), + scopes: ['*'], + createdAt: '2026-05-01T00:00:00.000Z', + }).run() + + const app = Fastify() + app.register(apiRoutes, { db, skipAuth: false }) + return { app, db, tmpDir } +} + +describe('guest report routes', () => { + let app: FastifyInstance + let db: ReturnType + let tmpDir: string + + beforeEach(async () => { + delete process.env.CANONRY_ENABLE_GUEST_REPORTS + const ctx = buildApp() + app = ctx.app + db = ctx.db + tmpDir = ctx.tmpDir + await app.ready() + }) + + afterEach(async () => { + delete process.env.CANONRY_ENABLE_GUEST_REPORTS + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('keeps the anonymous create route disabled unless explicitly enabled', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/guest/report', + payload: { domain: 'example.com' }, + }) + + expect(res.statusCode).toBe(404) + expect(db.select().from(projects).all()).toHaveLength(0) + expect(db.select().from(guestReports).all()).toHaveLength(0) + }) + + it('does not return expired unclaimed reports', async () => { + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + process.env.CANONRY_ENABLE_GUEST_REPORTS = '1' + const ctx = buildApp() + app = ctx.app + db = ctx.db + tmpDir = ctx.tmpDir + await app.ready() + + const projectId = insertProject(db) + const reportId = insertGuestReport(db, projectId, { expiresAt: '2026-05-01T00:00:00.000Z' }) + + const res = await app.inject({ method: 'GET', url: `/api/v1/guest/report/${reportId}` }) + + expect(res.statusCode).toBe(404) + expect(db.select().from(guestReports).where(eq(guestReports.id, reportId)).get()).toBeUndefined() + expect(db.select().from(projects).where(eq(projects.id, projectId)).get()).toBeUndefined() + }) + + it('does not allow claiming an expired unclaimed report', async () => { + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + process.env.CANONRY_ENABLE_GUEST_REPORTS = '1' + const ctx = buildApp() + app = ctx.app + db = ctx.db + tmpDir = ctx.tmpDir + await app.ready() + + const projectId = insertProject(db) + const reportId = insertGuestReport(db, projectId, { expiresAt: '2026-05-01T00:00:00.000Z' }) + + const res = await app.inject({ + method: 'POST', + url: `/api/v1/guest/report/${reportId}/claim`, + headers: { authorization: `Bearer ${RAW_KEY}` }, + }) + + expect(res.statusCode).toBe(404) + expect(db.select().from(guestReports).where(eq(guestReports.id, reportId)).get()).toBeUndefined() + expect(db.select().from(projects).where(eq(projects.id, projectId)).get()).toBeUndefined() + }) + + it('still returns claimed reports after their original expiry', async () => { + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + process.env.CANONRY_ENABLE_GUEST_REPORTS = '1' + const ctx = buildApp() + app = ctx.app + db = ctx.db + tmpDir = ctx.tmpDir + await app.ready() + + const projectId = insertProject(db) + const reportId = insertGuestReport(db, projectId, { + expiresAt: '2026-05-01T00:00:00.000Z', + claimedAt: '2026-05-01T00:05:00.000Z', + }) + + const res = await app.inject({ method: 'GET', url: `/api/v1/guest/report/${reportId}` }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toMatchObject({ id: reportId, projectId, claimedAt: '2026-05-01T00:05:00.000Z' }) + }) +}) diff --git a/packages/api-routes/test/openapi-contract.test.ts b/packages/api-routes/test/openapi-contract.test.ts index 89cab236..48cef713 100644 --- a/packages/api-routes/test/openapi-contract.test.ts +++ b/packages/api-routes/test/openapi-contract.test.ts @@ -102,7 +102,13 @@ describe('openapi contract', () => { const body = res.json() as { paths: Record> } const localIds = canonryLocalRouteIds() const specMinusLocal = normalizeSpecRoutes(body.paths).filter((entry) => !localIds.has(entry)) - expect(specMinusLocal).toEqual(normalizeObservedRoutes(ctx.observedRoutes)) + // `/cloud/*` are admin-scope routes (Track 3 — Canonry Hosted bridge). They are + // registered by api-routes but intentionally excluded from the public OpenAPI spec + // because they are admin-scope only and not part of the public surface. + const observedMinusCloud = normalizeObservedRoutes(ctx.observedRoutes).filter( + (id) => !id.includes(' /api/v1/cloud/'), + ) + expect(specMinusLocal).toEqual(observedMinusCloud) }) it('marks public unauthenticated routes with empty security requirements', async () => { diff --git a/packages/api-routes/test/session.test.ts b/packages/api-routes/test/session.test.ts new file mode 100644 index 00000000..eee30fc1 --- /dev/null +++ b/packages/api-routes/test/session.test.ts @@ -0,0 +1,344 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import Fastify from 'fastify' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createClient, migrate, apiKeys } from '@ainyc/canonry-db' +import { + createSessionStore, + sessionRoutes, + hashDashboardPassword, + verifyDashboardPassword, + type DashboardPasswordStore, +} from '../src/index.js' + +/** + * Tests for the extracted session plugin. Covers: + * + * 1. Password hashing roundtrip (scrypt + legacy SHA-256 migration). + * 2. In-memory session store TTL / lifecycle. + * 3. The Fastify routes — status, setup, login (password + bearer), logout. + * + * The plugin previously lived inline in `packages/canonry/src/server.ts` + * with no test coverage; extracting it is also the moment to lock the + * invariants down so a future refactor can't silently break the dashboard + * login or the Aero owner-view claim flow. + */ + +function makeDashboardStore(initial?: string): DashboardPasswordStore & { current: () => string | undefined } { + let value = initial + return { + get: () => value, + set: (hash) => { + value = hash + }, + current: () => value, + } +} + +function makeApp() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'canonry-session-')) + const db = createClient(path.join(tmpDir, 'test.db')) + migrate(db) + + // Seed a default API key. Password sessions bind to this key. + const keyId = `key_${crypto.randomBytes(8).toString('hex')}` + const keyHash = crypto.createHash('sha256').update('cnry_test').digest('hex') + db.insert(apiKeys).values({ + id: keyId, + name: 'default', + keyHash, + keyPrefix: 'cnry_test', + scopes: ['*'], + createdAt: new Date().toISOString(), + }).run() + + const store = createSessionStore() + const dashboardPassword = makeDashboardStore() + + const app = Fastify() + app.register(async (scope) => { + await sessionRoutes(scope, { + db, + store, + cookieName: 'test_session', + cookiePath: '/', + cookieSecure: false, + ttlMs: store.ttlMs, + dashboardPassword, + getDefaultApiKey: () => ({ id: keyId }), + }) + }, { prefix: '/api/v1' }) + + return { app, db, store, dashboardPassword, tmpDir, keyId, rawKey: 'cnry_test' } +} + +describe('hashDashboardPassword / verifyDashboardPassword', () => { + it('roundtrips a password via scrypt', () => { + const hash = hashDashboardPassword('hunter2-correct-horse') + expect(hash.startsWith('scrypt$1$')).toBe(true) + expect(verifyDashboardPassword('hunter2-correct-horse', hash)).toEqual({ ok: true, needsRehash: false }) + }) + + it('rejects the wrong password', () => { + const hash = hashDashboardPassword('rightpw1') + expect(verifyDashboardPassword('wrongpw1', hash)).toEqual({ ok: false, needsRehash: false }) + }) + + it('verifies legacy SHA-256 hex hashes once, with needsRehash=true', () => { + const legacyHash = crypto.createHash('sha256').update('legacy-pw-1234').digest('hex') + expect(verifyDashboardPassword('legacy-pw-1234', legacyHash)).toEqual({ ok: true, needsRehash: true }) + expect(verifyDashboardPassword('wrong-pw', legacyHash)).toEqual({ ok: false, needsRehash: false }) + }) + + it('rejects malformed scrypt entries without throwing', () => { + expect(verifyDashboardPassword('anything', 'scrypt$1$broken')).toEqual({ ok: false, needsRehash: false }) + expect(verifyDashboardPassword('anything', 'totally-not-a-hash')).toEqual({ ok: false, needsRehash: false }) + }) +}) + +describe('createSessionStore', () => { + it('issues fresh ids and resolves them to the bound apiKey', () => { + const store = createSessionStore() + const sid = store.createSession('key_abc') + expect(sid).toHaveLength(64) // 32-byte hex + expect(store.resolveSessionApiKeyId(sid)).toBe('key_abc') + }) + + it('returns null for unknown sessions', () => { + const store = createSessionStore() + expect(store.resolveSessionApiKeyId('does-not-exist')).toBe(null) + }) + + it('drops sessions past TTL', async () => { + const store = createSessionStore({ ttlMs: 10 }) + const sid = store.createSession('key_x') + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(store.resolveSessionApiKeyId(sid)).toBe(null) + }) + + it('clearSession removes the record', () => { + const store = createSessionStore() + const sid = store.createSession('key_y') + store.clearSession(sid) + expect(store.resolveSessionApiKeyId(sid)).toBe(null) + }) + + it('clearSession with undefined is a no-op', () => { + const store = createSessionStore() + const sid = store.createSession('key_z') + store.clearSession(undefined) + expect(store.resolveSessionApiKeyId(sid)).toBe('key_z') + }) +}) + +describe('session routes', () => { + let ctx: ReturnType + + beforeEach(() => { + ctx = makeApp() + }) + + afterEach(async () => { + await ctx.app.close() + fs.rmSync(ctx.tmpDir, { recursive: true, force: true }) + }) + + it('GET /session reports setupRequired=true before a password is set', async () => { + const res = await ctx.app.inject({ method: 'GET', url: '/api/v1/session' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ authenticated: false, setupRequired: true }) + }) + + it('POST /session/setup rejects passwords under 8 chars', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session/setup', + payload: { password: 'short' }, + }) + expect(res.statusCode).toBe(400) + expect(ctx.dashboardPassword.current()).toBeUndefined() + }) + + it('POST /session/setup stores a scrypt hash, sets a cookie, and authenticates', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session/setup', + payload: { password: 'a-real-password-1' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ authenticated: true }) + + const cookie = res.headers['set-cookie'] + expect(cookie).toMatch(/test_session=/) + expect(cookie).toMatch(/HttpOnly/) + expect(ctx.dashboardPassword.current()).toMatch(/^scrypt\$1\$/) + }) + + it('POST /session/setup refuses when a password already exists', async () => { + await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session/setup', + payload: { password: 'first-password-1' }, + }) + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session/setup', + payload: { password: 'second-password-2' }, + }) + expect(res.statusCode).toBe(400) + }) + + it('POST /session with a correct password issues a session cookie', async () => { + await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session/setup', + payload: { password: 'login-test-pwd-1' }, + }) + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session', + payload: { password: 'login-test-pwd-1' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ authenticated: true }) + expect(res.headers['set-cookie']).toMatch(/test_session=/) + }) + + it('POST /session returns 401 for a wrong password', async () => { + await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session/setup', + payload: { password: 'correct-password-1' }, + }) + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session', + payload: { password: 'wrong-password-9' }, + }) + expect(res.statusCode).toBe(401) + expect(res.json().error.code).toBe('AUTH_INVALID') + }) + + it('POST /session accepts a valid cnry_ bearer token', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session', + payload: { apiKey: ctx.rawKey }, + }) + expect(res.statusCode).toBe(200) + expect(res.headers['set-cookie']).toMatch(/test_session=/) + }) + + it('POST /session rejects an unknown apiKey', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session', + payload: { apiKey: 'cnry_unknown_key' }, + }) + expect(res.statusCode).toBe(401) + }) + + it('POST /session requires either password or apiKey', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session', + payload: {}, + }) + expect(res.statusCode).toBe(400) + }) + + it('GET /session after login reports authenticated=true', async () => { + await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session/setup', + payload: { password: 'auth-check-pwd-1' }, + }) + const login = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session', + payload: { password: 'auth-check-pwd-1' }, + }) + const cookie = login.headers['set-cookie']! + const sessionCookie = (Array.isArray(cookie) ? cookie[0] : cookie).split(';')[0] + + const status = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/session', + headers: { cookie: sessionCookie }, + }) + expect(status.json()).toEqual({ authenticated: true, setupRequired: false }) + }) + + it('DELETE /session clears the cookie', async () => { + const res = await ctx.app.inject({ method: 'DELETE', url: '/api/v1/session' }) + expect(res.statusCode).toBe(204) + const cookie = res.headers['set-cookie'] + expect(cookie).toMatch(/Max-Age=0/) + }) + + it('transparently rehashes a legacy SHA-256 password on first successful login', async () => { + const legacyHash = crypto.createHash('sha256').update('legacy-login-pw-1').digest('hex') + ctx.dashboardPassword.set(legacyHash) + + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session', + payload: { password: 'legacy-login-pw-1' }, + }) + expect(res.statusCode).toBe(200) + // The stored hash should now be in scrypt format. + expect(ctx.dashboardPassword.current()).toMatch(/^scrypt\$1\$/) + }) + + it('rate-limits repeated password-login attempts with 429 (brute-force guard)', async () => { + await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session/setup', + payload: { password: 'brute-force-target-1' }, + }) + + // The login limiter allows 10/min; the 11th wrong-password attempt from + // the same IP must be rejected with 429 (QUOTA_EXCEEDED), not another 401. + let saw429 = false + let attempts = 0 + for (let i = 0; i < 15; i++) { + attempts++ + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session', + payload: { password: 'wrong-guess-xxxx' }, + }) + if (res.statusCode === 429) { + // 429 = QUOTA_EXCEEDED (AppError.statusCode). The canonry + // `{error:{code}}` envelope is applied by the global error handler + // in the full apiRoutes plugin; this isolated harness uses Fastify's + // default serializer, so we assert on the status code here. + saw429 = true + break + } + expect(res.statusCode).toBe(401) + } + expect(saw429).toBe(true) + // The guard should trip right after the 10-request window, not let all 15 through. + expect(attempts).toBeLessThanOrEqual(11) + }) + + it('does not rate-limit a normal login burst under the threshold', async () => { + await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session/setup', + payload: { password: 'normal-usage-pwd-1' }, + }) + // 5 legitimate logins in a row stay well under the 10/min cap. + for (let i = 0; i < 5; i++) { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/session', + payload: { password: 'normal-usage-pwd-1' }, + }) + expect(res.statusCode).toBe(200) + } + }) +}) diff --git a/packages/api-routes/test/settings-managed.test.ts b/packages/api-routes/test/settings-managed.test.ts new file mode 100644 index 00000000..11923a1a --- /dev/null +++ b/packages/api-routes/test/settings-managed.test.ts @@ -0,0 +1,196 @@ +/** + * `/settings/*` write protection under `CANONRY_MANAGED_SETTINGS=1` + * (Track 1 — Canonry Hosted). + * + * The cloud control plane owns provider API keys, OAuth client credentials, + * and the Bing API key. In cloud mode the tenant container refuses writes to + * those routes so a leaked tenant API key can't silently swap the operator's + * pool keys for an attacker's. Read endpoints stay available so the UI can + * display the managed values (the hide-in-cloud UI logic is a separate + * ticket). + */ +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import Fastify from 'fastify' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' +import { apiKeys, createClient, migrate } from '@ainyc/canonry-db' +import { apiRoutes } from '../src/index.js' +import type { ApiRoutesOptions } from '../src/index.js' + +function buildApp(opts: Partial> = {}) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'canonry-managed-settings-')) + const db = createClient(path.join(tmpDir, 'test.db')) + migrate(db) + const app = Fastify() + app.register(apiRoutes, { db, skipAuth: false, ...opts }) + return { app, db, tmpDir } +} + +function insertApiKey(db: ReturnType): string { + const raw = `cnry_${crypto.randomBytes(16).toString('hex')}` + db.insert(apiKeys).values({ + id: crypto.randomUUID(), + name: 'admin', + keyHash: crypto.createHash('sha256').update(raw).digest('hex'), + keyPrefix: raw.slice(0, 9), + scopes: ['*'], + createdAt: new Date().toISOString(), + }).run() + return raw +} + +describe('CANONRY_MANAGED_SETTINGS=1 blocks /settings/* writes', () => { + const originalEnv = process.env.CANONRY_MANAGED_SETTINGS + + beforeEach(() => { + process.env.CANONRY_MANAGED_SETTINGS = '1' + }) + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.CANONRY_MANAGED_SETTINGS + } else { + process.env.CANONRY_MANAGED_SETTINGS = originalEnv + } + }) + + test('PUT /settings/google returns 403 with FORBIDDEN', async () => { + const { app, db, tmpDir } = buildApp({ + googleSettingsSummary: { configured: false }, + onGoogleSettingsUpdate: () => ({ configured: true }), + }) + const key = insertApiKey(db) + await app.ready() + try { + const res = await app.inject({ + method: 'PUT', + url: '/api/v1/settings/google', + headers: { authorization: `Bearer ${key}` }, + payload: { clientId: 'g', clientSecret: 's' }, + }) + expect(res.statusCode).toBe(403) + expect(JSON.parse(res.body).error.code).toBe('FORBIDDEN') + } finally { + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('PUT /settings/bing returns 403 with FORBIDDEN', async () => { + const { app, db, tmpDir } = buildApp({ + bingSettingsSummary: { configured: false }, + onBingSettingsUpdate: () => ({ configured: true }), + }) + const key = insertApiKey(db) + await app.ready() + try { + const res = await app.inject({ + method: 'PUT', + url: '/api/v1/settings/bing', + headers: { authorization: `Bearer ${key}` }, + payload: { apiKey: 'whatever' }, + }) + expect(res.statusCode).toBe(403) + expect(JSON.parse(res.body).error.code).toBe('FORBIDDEN') + } finally { + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('PUT /settings/providers/:name returns 403 with FORBIDDEN', async () => { + const { app, db, tmpDir } = buildApp({ + providerAdapters: [ + { + name: 'openai', displayName: 'OpenAI', mode: 'api', + modelValidationPattern: /./, modelValidationHint: 'any', + }, + ], + onProviderUpdate: () => ({ name: 'openai', configured: true }), + }) + const key = insertApiKey(db) + await app.ready() + try { + const res = await app.inject({ + method: 'PUT', + url: '/api/v1/settings/providers/openai', + headers: { authorization: `Bearer ${key}` }, + payload: { apiKey: 'sk-test' }, + }) + expect(res.statusCode).toBe(403) + expect(JSON.parse(res.body).error.code).toBe('FORBIDDEN') + } finally { + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('GET /settings still works (reads are not blocked)', async () => { + // The dashboard needs to read the configured provider list to render + // the cloud-mode settings tab — the cloud UI shows the managed values + // but disables the controls. + const { app, db, tmpDir } = buildApp({ + providerSummary: [{ name: 'openai', configured: true }], + googleSettingsSummary: { configured: true }, + bingSettingsSummary: { configured: false }, + }) + const key = insertApiKey(db) + await app.ready() + try { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/settings', + headers: { authorization: `Bearer ${key}` }, + }) + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.body) + expect(body.providers).toEqual([{ name: 'openai', configured: true }]) + expect(body.google).toEqual({ configured: true }) + expect(body.bing).toEqual({ configured: false }) + } finally { + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) +}) + +describe('CANONRY_MANAGED_SETTINGS unset (OSS default) allows writes', () => { + // Regression guard: removing the env flag must NOT trip the 403 — OSS + // operators still need to configure provider keys via the UI. + const originalEnv = process.env.CANONRY_MANAGED_SETTINGS + + beforeEach(() => { + delete process.env.CANONRY_MANAGED_SETTINGS + }) + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.CANONRY_MANAGED_SETTINGS + } else { + process.env.CANONRY_MANAGED_SETTINGS = originalEnv + } + }) + + test('PUT /settings/google succeeds with admin scope', async () => { + const { app, db, tmpDir } = buildApp({ + googleSettingsSummary: { configured: false }, + onGoogleSettingsUpdate: () => ({ configured: true }), + }) + const key = insertApiKey(db) + await app.ready() + try { + const res = await app.inject({ + method: 'PUT', + url: '/api/v1/settings/google', + headers: { authorization: `Bearer ${key}` }, + payload: { clientId: 'g', clientSecret: 's' }, + }) + expect(res.statusCode).toBe(200) + } finally { + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/api-routes/test/webhooks.test.ts b/packages/api-routes/test/webhooks.test.ts index 85ca1a46..736f3003 100644 --- a/packages/api-routes/test/webhooks.test.ts +++ b/packages/api-routes/test/webhooks.test.ts @@ -47,3 +47,40 @@ test('resolveWebhookTarget accepts public literal addresses', async () => { expect(result.target.address).toBe('8.8.8.8') } }) + +test('resolveWebhookTarget accepts IPv4 private ranges when allowPrivateNetworks is true', async () => { + for (const [url, address] of [ + ['http://10.0.0.5/hook', '10.0.0.5'], + ['http://172.16.0.10/hook', '172.16.0.10'], + ['http://192.168.1.10/hook', '192.168.1.10'], + ] as const) { + const result = await resolveWebhookTarget(url, { allowPrivateNetworks: true }) + expect(result.ok, `expected ${url} to be allowed`).toBe(true) + if (result.ok) { + expect(result.target.address).toBe(address) + } + } +}) + +test('resolveWebhookTarget accepts IPv6 ULA + link-local when allowPrivateNetworks is true', async () => { + // Symmetric with the IPv4 opt-in — operators routing the control plane + // over an IPv6 Docker bridge / VPN need the same escape hatch. + for (const [url, address] of [ + ['http://[fc00::1]/hook', 'fc00::1'], + ['http://[fd12:3456:789a::1]/hook', 'fd12:3456:789a::1'], + ['http://[fe80::1]/hook', 'fe80::1'], + ] as const) { + const result = await resolveWebhookTarget(url, { allowPrivateNetworks: true }) + expect(result.ok, `expected ${url} to be allowed`).toBe(true) + if (result.ok) { + expect(result.target.address).toBe(address) + } + } +}) + +test('resolveWebhookTarget still rejects IPv6 :: even when allowPrivateNetworks is true', async () => { + // The unspecified address is never legitimate; the opt-in must not + // unlock it. + const result = await resolveWebhookTarget('http://[::]/hook', { allowPrivateNetworks: true }) + expect(result.ok).toBe(false) +}) diff --git a/packages/canonry/package.json b/packages/canonry/package.json index 6e6e463d..24ec887b 100644 --- a/packages/canonry/package.json +++ b/packages/canonry/package.json @@ -1,6 +1,6 @@ { "name": "@ainyc/canonry", - "version": "4.67.0", + "version": "4.68.0", "type": "module", "description": "Agent-first open-source AEO operating platform - track how answer engines cite your domain", "license": "FSL-1.1-ALv2", diff --git a/packages/canonry/src/commands/notify.ts b/packages/canonry/src/commands/notify.ts index e57209e2..05374441 100644 --- a/packages/canonry/src/commands/notify.ts +++ b/packages/canonry/src/commands/notify.ts @@ -96,6 +96,12 @@ const EVENT_DESCRIPTIONS: Record = { 'run.failed': 'An AEO sweep failed', 'insight.critical': 'A critical-severity insight was generated', 'insight.high': 'A high-severity insight was generated', + 'baseline.completed': 'The first answer-visibility sweep finished and the baseline report is ready', + 'digest.generated': 'A daily morning digest was produced for the project', + 'action.created': 'Aero proposed a new content action (e.g. a new FAQ page)', + 'action.completed': 'A proposed action was published (PR merged) or terminated', + 'connection.created': 'A new integration (GSC / GA4 / Bing / GitHub) was connected', + 'connection.revoked': 'An integration was disconnected', } export function listEvents(format?: string): void { diff --git a/packages/canonry/src/logger.ts b/packages/canonry/src/logger.ts index 4f7c2f73..8291ab62 100644 --- a/packages/canonry/src/logger.ts +++ b/packages/canonry/src/logger.ts @@ -42,6 +42,19 @@ const IS_TTY = process.stdout.isTTY === true +/** + * Cloud-mode telemetry tag (Track 1 — Canonry Hosted). When + * `CANONRY_RUNTIME_MODE=cloud` is set on the tenant container, every log + * entry is tagged with `runtime_mode=cloud` so the cloud operator's log + * aggregator can filter cloud-runtime emissions from incidental noise. + * OSS deployments leave this unset and pay no overhead. + * + * Read at module-load (process env doesn't change at runtime in either + * deployment shape) so we don't pay a `process.env` lookup per log line. + */ +const RUNTIME_MODE_TAG = + process.env.CANONRY_RUNTIME_MODE?.trim().toLowerCase() === 'cloud' ? 'cloud' : null + type LogLevel = 'info' | 'warn' | 'error' interface LogEntry { @@ -50,6 +63,7 @@ interface LogEntry { module: string action: string msg?: string + runtime_mode?: string [key: string]: unknown } @@ -90,6 +104,10 @@ export function createLogger(module: string): Logger { level, module, action, + // Cloud tag is emitted first when set so it's the leftmost field after + // the standard prefix — log aggregators that scan by prefix don't have + // to look deep into the line to know this is a cloud-runtime event. + ...(RUNTIME_MODE_TAG ? { runtime_mode: RUNTIME_MODE_TAG } : {}), ...ctx, } emit(entry) diff --git a/packages/canonry/src/mcp/openapi-classification.ts b/packages/canonry/src/mcp/openapi-classification.ts index 767e8d2d..f8d0fc98 100644 --- a/packages/canonry/src/mcp/openapi-classification.ts +++ b/packages/canonry/src/mcp/openapi-classification.ts @@ -200,4 +200,12 @@ export const MCP_OPENAPI_OPERATION_CLASSIFICATIONS = { 'GET /api/v1/projects/{name}/discover/sessions/{id}': 'included', 'GET /api/v1/projects/{name}/discover/sessions/{id}/promote': 'included', 'POST /api/v1/projects/{name}/discover/sessions/{id}/promote': 'included', + // /aero owner-view onboarding endpoints — deferred from MCP for now. + // The flow is browser-driven (anonymous visitor with SSE), not something + // an MCP-using agent would orchestrate; expose as MCP tools only when an + // agent use case appears. + 'POST /api/v1/guest/report': 'deferred', + 'GET /api/v1/guest/report/{id}': 'deferred', + 'GET /api/v1/guest/report/{id}/stream': 'excluded-protocol', + 'POST /api/v1/guest/report/{id}/claim': 'deferred', } as const satisfies Record diff --git a/packages/canonry/src/notifier.ts b/packages/canonry/src/notifier.ts index 0af658a6..7e0b49a0 100644 --- a/packages/canonry/src/notifier.ts +++ b/packages/canonry/src/notifier.ts @@ -1,4 +1,4 @@ -import { eq, desc, and, inArray, or } from 'drizzle-orm' +import { eq, desc, and, inArray, or, isNull } from 'drizzle-orm' import { deliverWebhook, redactNotificationUrl, resolveWebhookTarget } from '@ainyc/canonry-api-routes' import type { DatabaseClient } from '@ainyc/canonry-db' import { auditLog, groupRunsByCreatedAt, notifications, projects, queries, querySnapshots, runs } from '@ainyc/canonry-db' @@ -22,13 +22,9 @@ export class Notifier { async onRunCompleted(runId: string, projectId: string): Promise { log.info('run.completed', { runId, projectId }) - // Get project notifications - const notifs = this.db - .select() - .from(notifications) - .where(eq(notifications.projectId, projectId)) - .all() - .filter(n => n.enabled) + // Get project-scoped notifications plus tenant-scoped subscribers + // (projectId NULL) registered by cloud bootstrap. + const notifs = this.listEnabledNotifications(projectId) if (notifs.length === 0) { log.info('notifications.none-enabled', { projectId }) @@ -112,12 +108,7 @@ export class Notifier { if (highInsights.length > 0) insightEvents.push('insight.high') if (insightEvents.length === 0) return - const notifs = this.db - .select() - .from(notifications) - .where(eq(notifications.projectId, projectId)) - .all() - .filter(n => n.enabled) + const notifs = this.listEnabledNotifications(projectId) if (notifs.length === 0) return @@ -156,6 +147,15 @@ export class Notifier { } } + private listEnabledNotifications(projectId: string): Array { + return this.db + .select() + .from(notifications) + .where(or(eq(notifications.projectId, projectId), isNull(notifications.projectId))) + .all() + .filter(n => n.enabled) + } + private computeTransitions(runId: string, projectId: string): Array<{ query: string; from: string; to: string; provider: string; location: string | null }> { diff --git a/packages/canonry/src/quota/client.ts b/packages/canonry/src/quota/client.ts new file mode 100644 index 00000000..0ceba0b9 --- /dev/null +++ b/packages/canonry/src/quota/client.ts @@ -0,0 +1,356 @@ +/** + * Lease-aware quota client (Track 1 — Canonry Hosted, spec §14). + * + * Talks to the canonry-cloud control plane via three HTTP endpoints: + * + * POST {controlPlaneUrl}/quota/check + * POST {controlPlaneUrl}/quota/lease + * POST {controlPlaneUrl}/quota/lease/{leaseId}/close + * + * Auth is the same `cnry_…` API key the tenant container already uses for + * cross-container calls — the spec assumes the control plane recognises + * the tenant's bearer. No persistence, no logging beyond return values: + * the caller decides what to do with quota-exceeded vs. transport errors. + * + * Failure semantics: + * • RPC (`check`) — fail closed. A transport error throws + * `QuotaUnavailableError('rpc-unreachable')` so the caller aborts the + * operation rather than running it without an authoritative debit. + * • Lease (`lease`) — fail open with a 10%-of-last-grant emergency + * reserve. The client caches the last successful grant per + * (scope, metricKey) so subsequent lease requests under outage can + * fall through to the cached reserve. Once the reserve is exhausted + * the client throws `QuotaUnavailableError('lease-reserve-exhausted')`. + * + * The fetch implementation is injectable so tests can run without a live + * control plane. Default uses `globalThis.fetch`. + */ +import { + QuotaExceededError, + QuotaUnavailableError, + type QuotaCheckRequest, + type QuotaCheckResult, + type QuotaLeaseCloseRequest, + type QuotaLeaseCloseResult, + type QuotaLeaseGrant, + type QuotaLeaseRequest, +} from './types.js' + +export interface QuotaClientOptions { + /** + * Base URL of the canonry-cloud control plane (no trailing slash). + * Typically read from `CANONRY_CONTROL_PLANE_URL` at process start. + * Falls back to throwing on the first call when absent. + */ + controlPlaneUrl: string | undefined + /** + * Bearer credential the control plane expects. Reuses the tenant's + * existing `cnry_…` API key by default. Per the trust-boundary rules + * in AGENTS.md, every key on an instance can talk to the cloud surface; + * there's no per-call scope tightening. + */ + apiKey: string | undefined + /** + * Override fetch for tests. Same signature as `globalThis.fetch`. + * Defaults to the runtime's `fetch` implementation. + */ + fetch?: typeof globalThis.fetch + /** Request timeout in milliseconds. Defaults to 5_000. */ + timeoutMs?: number +} + +interface LeaseHistoryEntry { + lastGrantedAmount: number + remainingReserve: number +} + +/** + * Lease-aware quota client. Single-host instances (one canonry container) + * keep one client per process — there's no shared cache to worry about, so + * the constructor is cheap. + */ +export class QuotaClient { + private readonly controlPlaneUrl: string | undefined + private readonly apiKey: string | undefined + private readonly fetchFn: typeof globalThis.fetch + private readonly timeoutMs: number + + /** + * Per-(scope, metricKey) cache of the last successful lease grant. Used + * to compute the 10% emergency reserve when the control plane is + * unreachable. Keyed by `${scope}::${metricKey}` so multiple sites in + * the same scope don't share a reserve. + */ + private readonly leaseHistory = new Map() + + constructor(opts: QuotaClientOptions) { + this.controlPlaneUrl = opts.controlPlaneUrl + this.apiKey = opts.apiKey + this.fetchFn = opts.fetch ?? globalThis.fetch + this.timeoutMs = opts.timeoutMs ?? 5_000 + } + + /** + * Synchronous per-call RPC check. Fails closed: if the control plane is + * unreachable, throws `QuotaUnavailableError('rpc-unreachable')` so the + * caller aborts the operation. + * + * The control plane returns HTTP 200 for both allowed and quota-exceeded + * outcomes — `allowed=false` does NOT throw on its own. Use + * `checkOrThrow()` if you want a quota-exceeded result to throw. + * + * Per spec §14 the legacy HTTP 429 mapping (with body + * `{ resets_at }`) is also accepted for forwards-compat with older + * control-plane builds. + */ + async check(request: QuotaCheckRequest): Promise { + if (!this.controlPlaneUrl || !this.apiKey) { + throw new QuotaUnavailableError( + request.scope, + 'rpc-unreachable', + 'control plane URL or API key not configured', + ) + } + const url = `${this.controlPlaneUrl}/quota/check` + let response: Response + try { + response = await this.postJson(url, { + tenant_id: request.tenantId, + project_slug: request.projectSlug, + scope: request.scope, + metric_key: request.metricKey, + amount: request.amount, + }) + } catch (err) { + throw new QuotaUnavailableError( + request.scope, + 'rpc-unreachable', + err instanceof Error ? err.message : String(err), + ) + } + + if (response.status === 429) { + const body = await response.json().catch(() => ({})) as { resets_at?: string; remaining?: number } + return { + allowed: false, + remaining: typeof body.remaining === 'number' ? body.remaining : 0, + resetsAt: typeof body.resets_at === 'string' ? body.resets_at : undefined, + } + } + if (!response.ok) { + throw new QuotaUnavailableError( + request.scope, + 'rpc-unreachable', + `control plane responded ${response.status}`, + ) + } + + const body = await response.json().catch(() => ({})) as { + allowed?: boolean + remaining?: number + resets_at?: string + } + return { + allowed: body.allowed === true, + remaining: typeof body.remaining === 'number' ? body.remaining : 0, + resetsAt: typeof body.resets_at === 'string' ? body.resets_at : undefined, + } + } + + /** + * Convenience wrapper around `check` that throws `QuotaExceededError` + * when the call is denied. Most call sites want this — they only ever + * proceed when allowed. + */ + async checkOrThrow(request: QuotaCheckRequest): Promise { + const result = await this.check(request) + if (!result.allowed) { + throw new QuotaExceededError(request.scope, request.metricKey, result.resetsAt) + } + return result + } + + /** + * Acquire a lease for a high-volume scope. Fails open with a degraded + * reserve: when the control plane is unreachable AND we have a prior + * successful grant cached for this (scope, metricKey), we return a + * synthetic grant of up to 10% of the last grant. The synthetic + * `leaseId` is prefixed `degraded-` so `close()` recognises it and + * skips the network round-trip. + * + * Once the reserve is exhausted (or no prior grant exists at all), + * throws `QuotaUnavailableError('lease-reserve-exhausted')`. + */ + async acquireLease(request: QuotaLeaseRequest): Promise { + const cacheKey = `${request.scope}::${request.metricKey}` + + if (!this.controlPlaneUrl || !this.apiKey) { + // Unconfigured — fall through to degraded path. + return this.takeDegradedReserve(request, cacheKey, 'control plane URL or API key not configured') + } + + let response: Response + try { + response = await this.postJson(`${this.controlPlaneUrl}/quota/lease`, { + tenant_id: request.tenantId, + project_slug: request.projectSlug, + scope: request.scope, + metric_key: request.metricKey, + requested_amount: request.requestedAmount, + max_duration_seconds: request.maxDurationSeconds, + idempotency_key: request.idempotencyKey, + }) + } catch (err) { + return this.takeDegradedReserve( + request, + cacheKey, + err instanceof Error ? err.message : String(err), + ) + } + + if (response.status === 429) { + const body = await response.json().catch(() => ({})) as { resets_at?: string } + throw new QuotaExceededError(request.scope, request.metricKey, body.resets_at) + } + if (!response.ok) { + return this.takeDegradedReserve( + request, + cacheKey, + `control plane responded ${response.status}`, + ) + } + + const body = await response.json().catch(() => ({})) as { + lease_id?: string + granted_amount?: number + expires_at?: string + } + if (!body.lease_id || typeof body.granted_amount !== 'number' || !body.expires_at) { + throw new QuotaUnavailableError( + request.scope, + 'rpc-unreachable', + 'malformed lease grant from control plane', + ) + } + + const grant: QuotaLeaseGrant = { + leaseId: body.lease_id, + grantedAmount: body.granted_amount, + expiresAt: body.expires_at, + } + + // Refresh the degraded-mode reserve based on this fresh grant — next + // outage starts from a known-good baseline. We don't add to the + // existing reserve; we replace it (the most recent grant is the best + // estimate of typical demand). + this.leaseHistory.set(cacheKey, { + lastGrantedAmount: grant.grantedAmount, + remainingReserve: Math.floor(grant.grantedAmount * 0.1), + }) + + return grant + } + + /** + * Close out a lease, refunding the unused balance. Idempotent on the + * server side — retries with the same `leaseId` collapse to one refund. + * + * Synthetic `degraded-` leases (issued from the local reserve during a + * control-plane outage) skip the network round-trip and resolve + * immediately with `refunded=0` since there's nothing on the server + * side to refund to. + */ + async closeLease(leaseId: string, request: QuotaLeaseCloseRequest): Promise { + if (leaseId.startsWith('degraded-')) { + return { refunded: 0 } + } + if (!this.controlPlaneUrl || !this.apiKey) { + // Unconfigured but real leaseId — refund attempt is futile. + return { refunded: 0 } + } + let response: Response + try { + response = await this.postJson( + `${this.controlPlaneUrl}/quota/lease/${encodeURIComponent(leaseId)}/close`, + { used_amount: request.usedAmount }, + ) + } catch { + // Best-effort: a failed close doesn't poison the operation that + // consumed the lease. The control plane's expiry reaper will + // eventually mark the lease as expired with no refund. + return { refunded: 0 } + } + if (!response.ok) { + return { refunded: 0 } + } + const body = await response.json().catch(() => ({})) as { refunded?: number } + return { refunded: typeof body.refunded === 'number' ? body.refunded : 0 } + } + + // ── internal ────────────────────────────────────────────────────── + + private takeDegradedReserve( + request: QuotaLeaseRequest, + cacheKey: string, + reason: string, + ): QuotaLeaseGrant { + const history = this.leaseHistory.get(cacheKey) + if (!history || history.remainingReserve <= 0) { + throw new QuotaUnavailableError( + request.scope, + 'lease-reserve-exhausted', + reason, + ) + } + + // Hand out the smaller of the caller's requested amount and the + // remaining reserve. The reserve can be drained across multiple + // degraded-mode calls until it's gone. + const grantedAmount = Math.min(request.requestedAmount, history.remainingReserve) + history.remainingReserve -= grantedAmount + + return { + leaseId: `degraded-${cacheKey}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + grantedAmount, + // Synthetic expiry — caller should treat degraded leases as + // short-lived. We use the caller's requested duration capped to + // 60 seconds since the control plane will catch up shortly. + expiresAt: new Date(Date.now() + Math.min(request.maxDurationSeconds, 60) * 1000).toISOString(), + } + } + + /** + * Internal POST helper with timeout + auth header injection. Throws on + * any transport error so callers can map it to the appropriate + * QuotaUnavailableError variant. + */ + private async postJson(url: string, body: unknown): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), this.timeoutMs) + try { + return await this.fetchFn(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }) + } finally { + clearTimeout(timer) + } + } +} + +/** + * Convenience constructor that reads `CANONRY_CONTROL_PLANE_URL` and + * `CANONRY_API_KEY` from `process.env`. Same posture as the rest of the + * runtime — config flows through env vars in cloud deployments. + */ +export function createQuotaClientFromEnv(env: NodeJS.ProcessEnv = process.env): QuotaClient { + return new QuotaClient({ + controlPlaneUrl: env.CANONRY_CONTROL_PLANE_URL?.trim().replace(/\/+$/, '') || undefined, + apiKey: env.CANONRY_API_KEY?.trim() || undefined, + }) +} diff --git a/packages/canonry/src/quota/index.ts b/packages/canonry/src/quota/index.ts new file mode 100644 index 00000000..3026a24e --- /dev/null +++ b/packages/canonry/src/quota/index.ts @@ -0,0 +1,24 @@ +/** + * Lease-aware quota client (Track 1 — Canonry Hosted). + * + * Public surface used by the LLM proxy + GSC sync paths to coordinate with + * the cloud control plane on tenant budgets. See `client.ts` for the spec. + */ +export { + QuotaClient, + createQuotaClientFromEnv, + type QuotaClientOptions, +} from './client.js' +export { + QuotaExceededError, + QuotaUnavailableError, + type QuotaCheckRequest, + type QuotaCheckResult, + type QuotaLeaseCloseRequest, + type QuotaLeaseCloseResult, + type QuotaLeaseGrant, + type QuotaLeaseRequest, + type QuotaLeaseScope, + type QuotaRpcScope, + type QuotaScope, +} from './types.js' diff --git a/packages/canonry/src/quota/types.ts b/packages/canonry/src/quota/types.ts new file mode 100644 index 00000000..75c6159a --- /dev/null +++ b/packages/canonry/src/quota/types.ts @@ -0,0 +1,122 @@ +/** + * Quota client types (Track 1 — Canonry Hosted, spec §14). + * + * Two surfaces: + * • RPC mode — synchronous per-call check for coarse scopes (sweeps, + * discovery, action executions). Latency is acceptable + * because each scope sees at most a few calls per minute. + * • Lease mode — pre-issued allowance for high-volume scopes (provider + * tokens, GSC API calls). Caller reserves an amount, uses + * some fraction, then refunds the rest on close. + * + * Failure semantics per spec §14: + * - RPC scopes fail closed when the control plane is unreachable — the + * caller throws so the operation aborts. + * - Lease scopes fail open with a degraded-mode cap: 10% of the last + * successful grant becomes an emergency reserve, after which the + * caller throws. The reserve protects the operator from runaway spend + * during a control-plane outage without halting all work immediately. + */ + +/** Coarse RPC scopes — one network round-trip per check. */ +export type QuotaRpcScope = + | 'sweeps_per_tenant_per_month' + | 'discovery_per_tenant_per_month' + | 'action_executions_per_tenant_per_month' + +/** Lease-based scopes — pre-issued allowance, closed-out on completion. */ +export type QuotaLeaseScope = + | 'provider_tokens_per_tenant_per_month' + | 'gsc_per_site_per_day' + | 'gsc_per_managed_client_aggregate_per_day' + +export type QuotaScope = QuotaRpcScope | QuotaLeaseScope + +export interface QuotaCheckRequest { + /** Tenant scope identifier. Sourced from `cloud_metadata.tenant_id`. */ + tenantId: string + /** Optional project slug — present for per-project scopes; omit for tenant-wide. */ + projectSlug?: string + scope: QuotaRpcScope + /** Free-form metric key — typically a site URL, domain, or `*` for wildcard. */ + metricKey: string + /** Amount to debit on success. Must be a positive integer. */ + amount: number +} + +export interface QuotaCheckResult { + allowed: boolean + /** Remaining budget after this debit (0 when allowed=false). */ + remaining: number + /** ISO-8601 timestamp when the period rolls over and budget resets. */ + resetsAt?: string +} + +export interface QuotaLeaseRequest { + tenantId: string + projectSlug?: string + scope: QuotaLeaseScope + metricKey: string + /** Amount the caller would like to reserve. Server may grant less. */ + requestedAmount: number + /** Max lease duration. Server may impose a shorter cap. */ + maxDurationSeconds: number + /** + * Idempotency token — when the caller retries a lease request after a + * network error, pass the same token so the control plane returns the + * existing lease instead of issuing a second one. + */ + idempotencyKey?: string +} + +export interface QuotaLeaseGrant { + leaseId: string + /** Actually granted amount (may be < requestedAmount). */ + grantedAmount: number + /** ISO-8601 expiry. After this timestamp the lease is reaped server-side. */ + expiresAt: string +} + +export interface QuotaLeaseCloseRequest { + /** Tokens actually used. Must be in [0, grantedAmount]. */ + usedAmount: number +} + +export interface QuotaLeaseCloseResult { + /** Amount returned to the per-tenant budget (grantedAmount - usedAmount). */ + refunded: number +} + +/** + * Lifted from the spec §14 envelope so callers can `instanceof` on it + * and route quota-exceeded vs. transport errors differently. + */ +export class QuotaExceededError extends Error { + readonly scope: QuotaScope + readonly metricKey: string + readonly resetsAt: string | undefined + constructor(scope: QuotaScope, metricKey: string, resetsAt?: string) { + super(`Quota exceeded for ${scope} (${metricKey})${resetsAt ? `; resets at ${resetsAt}` : ''}`) + this.name = 'QuotaExceededError' + this.scope = scope + this.metricKey = metricKey + this.resetsAt = resetsAt + } +} + +/** + * Raised when the control plane is unreachable AND the failure-mode + * for this scope is fail-closed (RPC mode), or when a lease scope has + * exhausted its emergency reserve and the next call would exceed the + * 10%-of-last-grant cap. + */ +export class QuotaUnavailableError extends Error { + readonly scope: QuotaScope + readonly reason: 'rpc-unreachable' | 'lease-reserve-exhausted' + constructor(scope: QuotaScope, reason: QuotaUnavailableError['reason'], detail?: string) { + super(`Quota service unavailable for ${scope}: ${reason}${detail ? ` (${detail})` : ''}`) + this.name = 'QuotaUnavailableError' + this.scope = scope + this.reason = reason + } +} diff --git a/packages/canonry/src/run-coordinator.ts b/packages/canonry/src/run-coordinator.ts index af7492e3..f2e9f5db 100644 --- a/packages/canonry/src/run-coordinator.ts +++ b/packages/canonry/src/run-coordinator.ts @@ -1,19 +1,54 @@ -import { eq } from 'drizzle-orm' +import crypto from 'node:crypto' +import { and, eq, lt, ne, or } from 'drizzle-orm' import type { DatabaseClient } from '@ainyc/canonry-db' -import { discoverySessions, runs } from '@ainyc/canonry-db' +import { discoverySessions, projects, providerTokenUsage, querySnapshots, runs } from '@ainyc/canonry-db' import { RunKinds, + RunStatuses, RunTriggers, type DiscoveryCompetitorMapEntry, type RunKind, } from '@ainyc/canonry-contracts' +import { emitCloudEvent } from '@ainyc/canonry-api-routes' import type { Notifier } from './notifier.js' import type { IntelligenceService } from './intelligence-service.js' import type { AnalysisResult, Insight } from '@ainyc/canonry-intelligence' import { createLogger } from './logger.js' +import { extractTokenUsage } from './token-usage.js' const log = createLogger('RunCoordinator') +/** + * Fixed UUID namespace for `baseline.completed` / future stable-id cloud + * events. The actual value doesn't matter — it just needs to stay + * constant so the same `(eventType, projectId)` always derives the same + * v5 UUID. Arbitrary v4 UUID picked once and frozen. + */ +const CLOUD_EVENT_NAMESPACE = Buffer.from( + '5ba7b811-9dad-11d1-80b4-00c04fd430c8'.replace(/-/g, ''), + 'hex', +) + +/** + * Derive a deterministic RFC 4122 v5 UUID from `(eventType, projectId)`. + * Same inputs always yield the same UUID, so the control plane's + * `event_idempotency` table collapses accidental duplicates regardless of + * how many times the tenant emits. + * + * Exported for unit testing; production callers go through the + * `stableEventId('baseline.completed', projectId)` site inside this module. + */ +export function stableEventId(eventType: string, projectId: string): string { + const hash = crypto.createHash('sha1') + hash.update(CLOUD_EVENT_NAMESPACE) + hash.update(`${eventType}:${projectId}`) + const bytes = hash.digest().subarray(0, 16) + bytes[6] = (bytes[6] & 0x0f) | 0x50 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = bytes.toString('hex') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` +} + /** * Notifies the built-in Aero agent that a run just completed. * @@ -130,14 +165,51 @@ export class RunCoordinator { } } - // 2. Notifications — may short-circuit if no webhooks configured, catches its own errors + // 2. Cloud baseline event — fire `baseline.completed` exactly once per + // project, on the first answer-visibility run that lands in a + // terminal-success state. Subsequent runs don't re-emit. Subscribers + // are typically the bootstrap-registered control plane; OSS + // deployments emit but no one listens. Failures are swallowed — + // falling through to notifier/Aero is more important than reaching + // every cloud subscriber. + if ( + kind === RunKinds['answer-visibility'] && + runRow && + (runRow.status === RunStatuses.completed || runRow.status === RunStatuses.partial) + ) { + try { + await this.maybeEmitBaselineCompleted(runRow, projectId) + } catch (err) { + log.error('cloud.baseline-completed.failed', { + runId, + error: err instanceof Error ? err.message : String(err), + }) + } + } + + // 3. Token-cost telemetry (Track 1 — Canonry Hosted). Persist a + // per-(provider, model) usage row for every query snapshot in this run + // that carries a usage block. Best-effort: persistence failures log + // but never block the rest of the post-run pipeline. OSS deployments + // silently accumulate the rows — the cloud control plane is the + // primary consumer (billing + cost dashboards). Skipped for browser + // providers and snapshots written before the extractor shipped. + if (kind === RunKinds['answer-visibility']) { + try { + this.persistTokenUsage(runId, projectId) + } catch (err) { + log.error('token-usage.failed', { runId, error: err instanceof Error ? err.message : String(err) }) + } + } + + // 4. Notifications — may short-circuit if no webhooks configured, catches its own errors try { await this.notifier.onRunCompleted(runId, projectId) } catch (err) { log.error('notifier.failed', { runId, error: err instanceof Error ? err.message : String(err) }) } - // 3. Aero — enqueue + drain so the built-in agent wakes up unprompted. + // 5. Aero — enqueue + drain so the built-in agent wakes up unprompted. if (this.onAeroEvent) { try { const ctx: AeroEventContext = kind === RunKinds['aeo-discover-probe'] @@ -156,6 +228,145 @@ export class RunCoordinator { } } + /** + * Track 3 (Canonry Hosted) — emit `baseline.completed` if this is the + * first successful answer-visibility run for the project. "First" is + * defined as: no other `completed` or `partial` answer-visibility run + * exists for this project that is strictly older by `createdAt`. Probe + * runs are excluded so an operator smoke-test doesn't masquerade as the + * baseline. + * + * Concurrent-completion races (two answer-visibility runs finishing at + * once, or an older-but-longer-running run completing AFTER a newer one) + * can still produce a second emit because the lookup is non-atomic. To + * keep the control plane's `event_idempotency` table doing the right + * thing, we derive a STABLE `event_id` from `(event, projectId)` — every + * baseline emission for the same project carries the same UUID, so the + * control plane collapses accidental duplicates without depending on + * tenant-side serialization. + */ + private async maybeEmitBaselineCompleted( + runRow: typeof runs.$inferSelect, + projectId: string, + ): Promise { + // Look for any earlier completed/partial answer-visibility run for + // this project that isn't a probe. If one exists, this isn't the + // baseline. + const earlier = this.db + .select({ id: runs.id }) + .from(runs) + .where( + and( + eq(runs.projectId, projectId), + eq(runs.kind, RunKinds['answer-visibility']), + ne(runs.trigger, RunTriggers.probe), + or(eq(runs.status, RunStatuses.completed), eq(runs.status, RunStatuses.partial)), + ne(runs.id, runRow.id), + lt(runs.createdAt, runRow.createdAt), + ), + ) + .limit(1) + .get() + if (earlier) return + + const project = this.db.select().from(projects).where(eq(projects.id, projectId)).get() + if (!project) { + log.warn('cloud.baseline-completed.project-missing', { runId: runRow.id, projectId }) + return + } + + await emitCloudEvent(this.db, { + event: 'baseline.completed', + project: { id: project.id, name: project.name, canonicalDomain: project.canonicalDomain }, + payload: { + runId: runRow.id, + reportSummary: { + status: runRow.status, + finishedAt: runRow.finishedAt, + kind: runRow.kind, + }, + }, + eventId: stableEventId('baseline.completed', projectId), + }) + log.info('cloud.baseline-completed.emitted', { runId: runRow.id, projectId }) + } + + /** + * Track 1 (Canonry Hosted) — persist per-(provider, model) token-cost + * telemetry for a completed run. + * + * Reads every `query_snapshots` row for this run, asks the provider-aware + * extractor for a `{ inputTokens, outputTokens, cachedInputTokens }` + * triple, and rolls them up by `(provider, model)` so one row covers all + * the queries that fanned out to the same model. Snapshots without a + * recognized usage block (browser providers, older rows from before + * instrumentation shipped) are skipped — we don't write zero-counter + * rows that would dilute downstream cost dashboards. + * + * Writes happen synchronously inside a single transaction so a partial + * failure rolls back. The caller wraps this in a try/catch so a + * persistence error logs but never blocks the rest of the run-completion + * pipeline. + */ + private persistTokenUsage(runId: string, projectId: string): void { + const snapshots = this.db + .select({ + provider: querySnapshots.provider, + model: querySnapshots.model, + rawResponse: querySnapshots.rawResponse, + }) + .from(querySnapshots) + .where(eq(querySnapshots.runId, runId)) + .all() + + if (snapshots.length === 0) return + + type Bucket = { provider: string; model: string | null; input: number; output: number; cached: number } + const buckets = new Map() + + for (const snap of snapshots) { + if (!snap.rawResponse) continue + const usage = extractTokenUsage(snap.provider, snap.rawResponse) + if (!usage) continue + const key = `${snap.provider}::${snap.model ?? ''}` + const existing = buckets.get(key) + if (existing) { + existing.input += usage.inputTokens + existing.output += usage.outputTokens + existing.cached += usage.cachedInputTokens + } else { + buckets.set(key, { + provider: snap.provider, + model: snap.model, + input: usage.inputTokens, + output: usage.outputTokens, + cached: usage.cachedInputTokens, + }) + } + } + + if (buckets.size === 0) return + + const now = new Date().toISOString() + this.db.transaction((tx) => { + for (const bucket of buckets.values()) { + tx.insert(providerTokenUsage).values({ + id: crypto.randomUUID(), + runId, + projectId, + provider: bucket.provider, + model: bucket.model, + inputTokens: bucket.input, + outputTokens: bucket.output, + cachedInputTokens: bucket.cached, + occurredAt: now, + }).run() + } + }) + + log.info('token-usage.persisted', { runId, projectId, bucketCount: buckets.size }) + } + /** * Pull the discovery session that owns this run and project a payload Aero * can act on: bucket counts, top competitors, the seed provider, and the diff --git a/packages/canonry/src/scheduler.ts b/packages/canonry/src/scheduler.ts index 254c657d..8f00795b 100644 --- a/packages/canonry/src/scheduler.ts +++ b/packages/canonry/src/scheduler.ts @@ -10,6 +10,20 @@ import { createLogger } from './logger.js' const log = createLogger('Scheduler') +/** + * Cloud-mode flag (Track 1 — Canonry Hosted). When `CANONRY_SCHEDULER=external` + * is set on the tenant container, the in-process node-cron scheduler stops + * firing — the cloud control plane dispatches runs instead. Schedule API + * endpoints still accept writes so the dashboard can mirror the cloud + * scheduler's state, but `start()`, `upsert()`, and the cron tick handler + * all become no-ops to avoid double-firing alongside the control plane. + * + * Read at module load so the flag is stable for the lifetime of the + * `canonry serve` process. OSS deployments leave it unset. + */ +const SCHEDULER_EXTERNAL = + process.env.CANONRY_SCHEDULER?.trim().toLowerCase() === 'external' + export interface SchedulerCallbacks { /** Fired when an answer-visibility schedule triggers. Existing canonry callsites wire this to the JobRunner. */ onRunCreated: (runId: string, projectId: string, providers?: ProviderName[], location?: LocationContext | null) => void @@ -56,6 +70,13 @@ export class Scheduler { /** Load all enabled schedules from DB and register cron jobs. */ start(): void { + if (SCHEDULER_EXTERNAL) { + // Cloud mode — the control plane dispatches runs via the run-dispatch + // queue (spec §13). Skip the cron registration; schedule API endpoints + // still accept writes so the dashboard mirrors the cloud state. + log.info('scheduler.external', { msg: 'CANONRY_SCHEDULER=external — in-process scheduler disabled' }) + return + } const allSchedules = this.db .select() .from(schedules) @@ -91,8 +112,14 @@ export class Scheduler { * Add or update a cron registration at runtime (called when schedule API * is used). Keyed by `(projectId, kind)` so a project's traffic-sync and * answer-visibility schedules can coexist independently. + * + * Cloud-mode short-circuit: when `CANONRY_SCHEDULER=external` the cron + * tasks map stays empty (and `start()` never populated it), so the + * stop/register dance is unnecessary work. Endpoints still call upsert + * after the DB row lands; we simply don't act on it. */ upsert(projectId: string, kind: SchedulableRunKind): void { + if (SCHEDULER_EXTERNAL) return const key = taskKey(projectId, kind) const existing = this.tasks.get(key) if (existing) { diff --git a/packages/canonry/src/server.ts b/packages/canonry/src/server.ts index ad78a06a..048691af 100644 --- a/packages/canonry/src/server.ts +++ b/packages/canonry/src/server.ts @@ -9,7 +9,7 @@ const _require = createRequire(import.meta.url) const { version: PKG_VERSION } = _require('../package.json') as { version: string } import Fastify from 'fastify' import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' -import { apiRoutes } from '@ainyc/canonry-api-routes' +import { apiRoutes, createSessionStore, sessionRoutes } from '@ainyc/canonry-api-routes' import { apiKeys, auditLog, projects, runs, extractLegacyCredentials, dropLegacyCredentialColumns, type DatabaseClient, type LegacyCredentialRows } from '@ainyc/canonry-db' import os from 'node:os' import { geminiAdapter } from '@ainyc/canonry-provider-gemini' @@ -18,7 +18,7 @@ import { claudeAdapter } from '@ainyc/canonry-provider-claude' import { localAdapter } from '@ainyc/canonry-provider-local' import { cdpChatgptAdapter } from '@ainyc/canonry-provider-cdp' import { perplexityAdapter } from '@ainyc/canonry-provider-perplexity' -import { authInvalid, validationError, RunKinds, RunStatuses, RunTriggers, type ProviderAdapter } from '@ainyc/canonry-contracts' +import { RunKinds, RunStatuses, RunTriggers, type ProviderAdapter } from '@ainyc/canonry-contracts' import type { CanonryConfig, ProviderConfigEntry } from './config.js' import { saveConfigPatch, loadConfig, getConfigPath } from './config.js' import { getPlacesConfig } from './places-config.js' @@ -104,12 +104,6 @@ const DEFAULT_QUOTA = { } const SESSION_COOKIE_NAME = 'canonry_session' -const SESSION_TTL_MS = 12 * 60 * 60 * 1000 - -interface SessionRecord { - apiKeyId: string - expiresAt: number -} /** All known API adapters — add new providers here */ const API_ADAPTERS: ProviderAdapter[] = [ @@ -141,125 +135,6 @@ function hashApiKey(key: string): string { return crypto.createHash('sha256').update(key).digest('hex') } -// Dashboard password storage uses scrypt (salted, slow KDF) — not plain -// SHA-256. The bearer-token path above still hashes with SHA-256 because -// those are 128-bit random `cnry_…` tokens (no brute-force exposure on a -// 64-hex hash). Dashboard passwords are user-chosen and may be reused from -// elsewhere, so a leaked `config.yaml` must not be trivially cracked -// against a wordlist. -// -// Stored format: `scrypt$1$$`. The version field -// (`1`) lets future code rotate to a stronger KDF without breaking existing -// installs. Legacy 64-hex SHA-256 hashes are still accepted at login time -// (see `verifyDashboardPassword`); when one matches, the caller writes the -// fresh scrypt-format hash back into the config so the next login no longer -// needs the legacy fallback. -const DASHBOARD_SCRYPT_KEYLEN = 64 -const DASHBOARD_SCRYPT_COST = 1 << 15 // N=32768 — ~80ms on a modern laptop -// Node's default scrypt `maxmem` is 32 MiB which is exactly at the boundary -// for our chosen N (128 * 32768 * 8 ≈ 32 MiB). Bump to 64 MiB to leave -// headroom and keep the derivation comfortably within the limit. -const DASHBOARD_SCRYPT_MAXMEM = 64 * 1024 * 1024 - -function hashDashboardPassword(password: string): string { - const salt = crypto.randomBytes(16) - const derived = crypto.scryptSync(password, salt, DASHBOARD_SCRYPT_KEYLEN, { - N: DASHBOARD_SCRYPT_COST, - maxmem: DASHBOARD_SCRYPT_MAXMEM, - }) - return `scrypt$1$${salt.toString('base64')}$${derived.toString('base64')}` -} - -interface DashboardPasswordVerifyResult { - ok: boolean - needsRehash: boolean -} - -function verifyDashboardPassword(password: string, storedHash: string): DashboardPasswordVerifyResult { - // New format: scrypt with salt. - if (storedHash.startsWith('scrypt$1$')) { - const parts = storedHash.split('$') - if (parts.length !== 4) return { ok: false, needsRehash: false } - const saltB64 = parts[2] - const hashB64 = parts[3] - if (!saltB64 || !hashB64) return { ok: false, needsRehash: false } - let salt: Buffer - let expected: Buffer - try { - salt = Buffer.from(saltB64, 'base64') - expected = Buffer.from(hashB64, 'base64') - } catch { - return { ok: false, needsRehash: false } - } - const derived = crypto.scryptSync(password, salt, expected.length, { - N: DASHBOARD_SCRYPT_COST, - maxmem: DASHBOARD_SCRYPT_MAXMEM, - }) - if (derived.length !== expected.length) return { ok: false, needsRehash: false } - return { ok: crypto.timingSafeEqual(derived, expected), needsRehash: false } - } - - // Legacy SHA-256 hex format — accept once for migration, then rehash. - if (/^[a-f0-9]{64}$/i.test(storedHash)) { - const candidate = Buffer.from(hashApiKey(password), 'hex') - const expected = Buffer.from(storedHash, 'hex') - if (candidate.length !== expected.length) return { ok: false, needsRehash: false } - const ok = crypto.timingSafeEqual(candidate, expected) - return { ok, needsRehash: ok } - } - - return { ok: false, needsRehash: false } -} - -function parseCookies(header: string | undefined): Record { - if (!header) return {} - - return header - .split(';') - .map(part => part.trim()) - .filter(Boolean) - .reduce>((cookies, part) => { - const eqIdx = part.indexOf('=') - if (eqIdx <= 0) return cookies - const name = part.slice(0, eqIdx).trim() - const value = part.slice(eqIdx + 1).trim() - if (!name) return cookies - try { - cookies[name] = decodeURIComponent(value) - } catch { - cookies[name] = value - } - return cookies - }, {}) -} - -function serializeSessionCookie(opts: { - name: string - value: string | null - path: string - secure: boolean - ttlMs: number -}): string { - const parts = [ - `${opts.name}=${opts.value ? encodeURIComponent(opts.value) : ''}`, - `Path=${opts.path}`, - 'HttpOnly', - 'SameSite=Lax', - ] - - if (opts.value) { - parts.push(`Max-Age=${Math.floor(opts.ttlMs / 1000)}`) - } else { - parts.push('Max-Age=0') - } - - if (opts.secure) { - parts.push('Secure') - } - - return parts.join('; ') -} - /** * One-time migration: persist Google OAuth tokens and GA4 service account keys * extracted from the legacy DB columns into config.yaml. Skips any connection @@ -833,182 +708,45 @@ export async function createServer(opts: { } } + // Cookie-backed browser session — extracted to api-routes so apps/api + // and `canonry serve` share the implementation. Dashboard password lives + // in `~/.canonry/config.yaml`; the store adapter below wires the plugin + // to that source-of-truth (apps/api wires a DB-backed adapter instead). const sessionCookiePath = basePath ?? '/' const sessionCookieSecure = Boolean( opts.config.publicUrl?.startsWith('https://') || opts.config.apiUrl?.startsWith('https://'), ) - const sessions = new Map() - - const pruneExpiredSessions = () => { - const now = Date.now() - for (const [sessionId, session] of sessions.entries()) { - if (session.expiresAt <= now) { - sessions.delete(sessionId) - } - } - } - - const createSession = (apiKeyId: string) => { - pruneExpiredSessions() - const sessionId = crypto.randomBytes(32).toString('hex') - sessions.set(sessionId, { - apiKeyId, - expiresAt: Date.now() + SESSION_TTL_MS, - }) - return sessionId - } - - const resolveSessionApiKeyId = (sessionId: string) => { - pruneExpiredSessions() - const session = sessions.get(sessionId) - if (!session) return null - if (session.expiresAt <= Date.now()) { - sessions.delete(sessionId) - return null - } - return session.apiKeyId - } - - const clearSession = (sessionId: string | undefined) => { - if (sessionId) { - sessions.delete(sessionId) - } - } - - // Resolve the default API key record once — used by password-based sessions - // to bind the session to the server's configured key. - const getDefaultApiKey = () => { - if (!opts.config.apiKey) return undefined - return opts.db - .select() - .from(apiKeys) - .where(eq(apiKeys.keyHash, hashApiKey(opts.config.apiKey))) - .get() - } - - const createPasswordSession = (reply: FastifyReply) => { - const key = getDefaultApiKey() - if (!key || key.revokedAt) return false - - const sessionId = createSession(key.id) - reply.header('set-cookie', serializeSessionCookie({ - name: SESSION_COOKIE_NAME, - value: sessionId, - path: sessionCookiePath, - secure: sessionCookieSecure, - ttlMs: SESSION_TTL_MS, - })) - return true - } - - app.get(apiPrefix + '/session', async (request, reply) => { - const sessionId = parseCookies(request.headers.cookie)[SESSION_COOKIE_NAME] - return reply.send({ - authenticated: Boolean(sessionId && resolveSessionApiKeyId(sessionId)), - setupRequired: !opts.config.dashboardPasswordHash, + const sessionStore = createSessionStore() + // Arrow-wrap so `this` stays bound when the auth plugin invokes the + // resolver detached from the store (eslint @typescript-eslint/unbound-method). + const resolveSessionApiKeyId = (sid: string) => sessionStore.resolveSessionApiKeyId(sid) + + await app.register(async (scope) => { + await sessionRoutes(scope, { + db: opts.db, + store: sessionStore, + cookieName: SESSION_COOKIE_NAME, + cookiePath: sessionCookiePath, + cookieSecure: sessionCookieSecure, + ttlMs: sessionStore.ttlMs, + dashboardPassword: { + get: () => opts.config.dashboardPasswordHash, + set: (hash) => { + opts.config.dashboardPasswordHash = hash + saveConfigPatch(opts.config) + }, + }, + getDefaultApiKey: () => { + if (!opts.config.apiKey) return undefined + return opts.db + .select() + .from(apiKeys) + .where(eq(apiKeys.keyHash, hashApiKey(opts.config.apiKey))) + .get() + }, }) - }) - - // One-time password setup — only works when no password is configured yet. - app.post<{ - Body: { password?: string } - }>(apiPrefix + '/session/setup', async (request, reply) => { - if (opts.config.dashboardPasswordHash) { - const err = validationError('Dashboard password is already configured') - return reply.status(err.statusCode).send(err.toJSON()) - } - - const password = request.body?.password?.trim() - if (!password || password.length < 8) { - const err = validationError('Password must be at least 8 characters') - return reply.status(err.statusCode).send(err.toJSON()) - } - - opts.config.dashboardPasswordHash = hashDashboardPassword(password) - saveConfigPatch(opts.config) - - if (!createPasswordSession(reply)) { - const err = authInvalid() - return reply.status(err.statusCode).send(err.toJSON()) - } - return reply.send({ authenticated: true }) - }) - - // Login with dashboard password or API key. - app.post<{ - Body: { password?: string; apiKey?: string } - }>(apiPrefix + '/session', async (request, reply) => { - const password = request.body?.password?.trim() - const apiKey = request.body?.apiKey?.trim() - - if (password) { - if (!opts.config.dashboardPasswordHash) { - const err = validationError('No dashboard password configured — use /session/setup first') - return reply.status(err.statusCode).send(err.toJSON()) - } - const verification = verifyDashboardPassword(password, opts.config.dashboardPasswordHash) - if (!verification.ok) { - return reply.status(401).send({ error: { code: 'AUTH_INVALID', message: 'Incorrect password' } }) - } - // Transparent migration: a successful login against the legacy - // unsalted SHA-256 hash rewrites the config with a fresh scrypt hash - // so the next login no longer needs the legacy fallback path. - if (verification.needsRehash) { - opts.config.dashboardPasswordHash = hashDashboardPassword(password) - saveConfigPatch(opts.config) - } - if (!createPasswordSession(reply)) { - return reply.status(401).send({ error: { code: 'AUTH_INVALID', message: 'Server API key not found — re-run canonry init' } }) - } - return reply.send({ authenticated: true }) - } - - if (apiKey) { - const key = opts.db - .select() - .from(apiKeys) - .where(eq(apiKeys.keyHash, hashApiKey(apiKey))) - .get() - - if (!key || key.revokedAt) { - const err = authInvalid() - return reply.status(err.statusCode).send(err.toJSON()) - } - - opts.db - .update(apiKeys) - .set({ lastUsedAt: new Date().toISOString() }) - .where(eq(apiKeys.id, key.id)) - .run() - - const sessionId = createSession(key.id) - reply.header('set-cookie', serializeSessionCookie({ - name: SESSION_COOKIE_NAME, - value: sessionId, - path: sessionCookiePath, - secure: sessionCookieSecure, - ttlMs: SESSION_TTL_MS, - })) - return reply.send({ authenticated: true }) - } - - const err = validationError('Either password or apiKey is required') - return reply.status(err.statusCode).send(err.toJSON()) - }) - - app.delete(apiPrefix + '/session', async (request, reply) => { - const sessionId = parseCookies(request.headers.cookie)[SESSION_COOKIE_NAME] - clearSession(sessionId) - reply.header('set-cookie', serializeSessionCookie({ - name: SESSION_COOKIE_NAME, - value: null, - path: sessionCookiePath, - secure: sessionCookieSecure, - ttlMs: SESSION_TTL_MS, - })) - return reply.status(204).send() - }) + }, { prefix: apiPrefix }) const LATEST_RELEASE_TTL_MS = 5 * 60 * 1000 let latestReleaseCache: { value: import('@ainyc/canonry-contracts').CcAvailableRelease | null; expiresAt: number } | null = null @@ -1083,6 +821,11 @@ export async function createServer(opts: { // installer; cloud deployments inherit the secure default of `false` by // not passing this option. Override with CANONRY_ALLOW_LOOPBACK_WEBHOOKS=0. allowLoopbackWebhooks: process.env.CANONRY_ALLOW_LOOPBACK_WEBHOOKS !== '0', + // Hosted v1 (spec §1.1) targets a Docker-internal control plane hostname + // like `canonry-control-plane:8080` that resolves to a 172.x bridge address. + // Operators opt in by setting `CANONRY_ALLOW_PRIVATE_WEBHOOKS=1` in the + // tenant container env. OSS deployments leave it unset. + allowPrivateNetworkWebhooks: process.env.CANONRY_ALLOW_PRIVATE_WEBHOOKS === '1', // Local-only Aero agent routes. Registered here so they inherit api-routes' // auth plugin — bare `registerAgentRoutes(app, ...)` would skip auth. registerAuthenticatedRoutes: async (scope) => { diff --git a/packages/canonry/src/telemetry.ts b/packages/canonry/src/telemetry.ts index 3a2129ed..33569034 100644 --- a/packages/canonry/src/telemetry.ts +++ b/packages/canonry/src/telemetry.ts @@ -59,10 +59,24 @@ export interface TelemetryEvent { arch: string /** Stable error classifier when the event represents a failure. */ errorCode?: string + /** + * Cloud-mode tag (Track 1). Present only when + * `CANONRY_RUNTIME_MODE=cloud` is set on the tenant container. Lets the + * telemetry receiver filter cloud-runtime emissions from OSS noise. + * Absent in OSS deployments. + */ + runtimeMode?: 'cloud' /** Free-shape per-event payload. */ properties?: TelemetryProperties } +/** + * Read at module load so it doesn't change between events in one process — + * env vars don't mutate at runtime in either deployment shape. + */ +const RUNTIME_MODE_TAG: 'cloud' | undefined = + process.env.CANONRY_RUNTIME_MODE?.trim().toLowerCase() === 'cloud' ? 'cloud' : undefined + export interface TrackEventOptions { /** Override the global default source — used by `canonry serve` to flip * to `'cli-server'` and by tests. */ @@ -293,6 +307,7 @@ export function trackEvent( arch: process.arch, ...(options?.sourceContext ? { sourceContext: options.sourceContext } : {}), ...(options?.errorCode ? { errorCode: options.errorCode } : {}), + ...(RUNTIME_MODE_TAG ? { runtimeMode: RUNTIME_MODE_TAG } : {}), ...(properties ? { properties } : {}), } diff --git a/packages/canonry/src/token-usage.ts b/packages/canonry/src/token-usage.ts new file mode 100644 index 00000000..89d6c237 --- /dev/null +++ b/packages/canonry/src/token-usage.ts @@ -0,0 +1,151 @@ +/** + * Token-cost telemetry extractor (Track 1 — Canonry Hosted). + * + * Each provider exposes a slightly different shape for the usage block on + * its raw API response. This module is a pure mapping from a stored + * `query_snapshots.raw_response` JSON to a normalized `{ inputTokens, + * outputTokens, cachedInputTokens }` triple. Called from `RunCoordinator` + * on every `run.completed` to persist a row into `provider_token_usage`. + * + * Returns `null` when the response doesn't carry a usage block (e.g. CDP / + * browser providers, or older snapshots from before instrumentation + * shipped). Callers should skip persistence in that case rather than + * writing zero rows that would dilute downstream cost dashboards. + * + * Provider documentation references: + * • Anthropic: https://platform.claude.com/docs/en/build-with-claude/token-counting + * • OpenAI: https://platform.openai.com/docs/api-reference/responses/object + * • Gemini: https://ai.google.dev/api/generate-content#usagemetadata + * • Perplexity: https://docs.perplexity.ai/api-reference/chat-completions-post + */ + +export interface TokenUsage { + inputTokens: number + outputTokens: number + cachedInputTokens: number +} + +type RawObject = Record + +function asObject(value: unknown): RawObject | null { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null + return value as RawObject +} + +function asInt(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return 0 + return Math.max(0, Math.floor(value)) +} + +/** + * Find the provider-native API response inside the stored snapshot wrapper. + * + * `JobRunner` wraps each provider response under `{ model, groundingSources, + * searchQueries, apiResponse: ... }` before storing it in + * `query_snapshots.raw_response`. The provider's own usage block lives on + * `apiResponse`. When the wrapper is absent (older rows, direct stores), + * fall back to the top-level object. + */ +function unwrapApiResponse(raw: RawObject): RawObject { + const nested = asObject(raw.apiResponse) + return nested ?? raw +} + +function extractAnthropicUsage(api: RawObject): TokenUsage | null { + const usage = asObject(api.usage) + if (!usage) return null + return { + inputTokens: asInt(usage.input_tokens), + outputTokens: asInt(usage.output_tokens), + // Anthropic exposes both `cache_read_input_tokens` (paid 90% discount) + // and `cache_creation_input_tokens` (initial cache write at full price). + // The proxy treats the cache-read count as the "cached" surface so + // billing dashboards can credit the discount. Cache-creation tokens + // remain in `inputTokens` since they're charged at full price. + cachedInputTokens: asInt(usage.cache_read_input_tokens), + } +} + +function extractOpenAIUsage(api: RawObject): TokenUsage | null { + const usage = asObject(api.usage) + if (!usage) return null + + // The Responses API uses `input_tokens` / `output_tokens` (rename of the + // older `prompt_tokens` / `completion_tokens`). Accept either so the same + // extractor works against stored snapshots regardless of when they were + // written. https://platform.openai.com/docs/api-reference/responses/object + const input = asInt(usage.input_tokens) || asInt(usage.prompt_tokens) + const output = asInt(usage.output_tokens) || asInt(usage.completion_tokens) + + // Cached tokens surface on the prompt-side details object in both APIs. + const inputDetails = asObject(usage.input_tokens_details) ?? asObject(usage.prompt_tokens_details) + const cached = inputDetails ? asInt(inputDetails.cached_tokens) : 0 + + if (input === 0 && output === 0) return null + return { inputTokens: input, outputTokens: output, cachedInputTokens: cached } +} + +function extractGeminiUsage(api: RawObject): TokenUsage | null { + const metadata = asObject(api.usageMetadata) + if (!metadata) return null + const input = asInt(metadata.promptTokenCount) + const output = asInt(metadata.candidatesTokenCount) + // Gemini exposes a `cachedContentTokenCount` field for grounded prompts; + // older payloads omit it entirely. + const cached = asInt(metadata.cachedContentTokenCount) + + if (input === 0 && output === 0) return null + return { inputTokens: input, outputTokens: output, cachedInputTokens: cached } +} + +function extractPerplexityUsage(api: RawObject): TokenUsage | null { + // Perplexity returns OpenAI-compatible usage on chat.completions. + const usage = asObject(api.usage) + if (!usage) return null + const input = asInt(usage.prompt_tokens) || asInt(usage.input_tokens) + const output = asInt(usage.completion_tokens) || asInt(usage.output_tokens) + if (input === 0 && output === 0) return null + return { inputTokens: input, outputTokens: output, cachedInputTokens: 0 } +} + +/** + * Extract usage from a stored `query_snapshots.raw_response`. + * + * @param provider The adapter name (`'openai'` / `'claude'` / `'gemini'` / + * `'perplexity'` / `'local'` / `'cdp:...'`). + * @param rawJson The string blob written by `JobRunner` (or a pre-parsed + * object, for unit tests). + * @returns null when no usage block is found — callers MUST skip persistence + * rather than writing zero-counter rows. + */ +export function extractTokenUsage(provider: string, rawJson: string | RawObject): TokenUsage | null { + let parsed: RawObject | null + if (typeof rawJson === 'string') { + try { + const data = JSON.parse(rawJson) as unknown + parsed = asObject(data) + } catch { + return null + } + } else { + parsed = asObject(rawJson) + } + if (!parsed) return null + + const api = unwrapApiResponse(parsed) + + switch (provider) { + case 'claude': + return extractAnthropicUsage(api) + case 'openai': + return extractOpenAIUsage(api) + case 'gemini': + return extractGeminiUsage(api) + case 'perplexity': + return extractPerplexityUsage(api) + default: + // CDP / browser providers, custom local providers, future adapters — + // no documented usage shape yet, so we skip rather than guess. + return null + } +} diff --git a/packages/canonry/test/notifier-fanout.test.ts b/packages/canonry/test/notifier-fanout.test.ts index 365e10d7..53b3a76b 100644 --- a/packages/canonry/test/notifier-fanout.test.ts +++ b/packages/canonry/test/notifier-fanout.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, afterEach } from 'vitest' import { createClient, migrate, + notifications, projects, queries, querySnapshots, @@ -139,6 +140,12 @@ function callCompute(notifier: Notifier, runId: string, projectId: string) { }).computeTransitions(runId, projectId) } +function callListEnabledNotifications(notifier: Notifier, projectId: string) { + return (notifier as unknown as { + listEnabledNotifications: (projectId: string) => Array<{ id: string; projectId: string | null }> + }).listEnabledNotifications(projectId) +} + describe('Notifier multi-location fan-out (#480)', () => { it('returns no transitions while a sibling run is still pending', () => { // michigan still running when florida fires its onRunCompleted → @@ -278,4 +285,66 @@ describe('Notifier multi-location fan-out (#480)', () => { const transitions = callCompute(notifier, latestMiId, projectId) expect(transitions).toEqual([]) }) + + it('includes tenant-scoped notification subscribers for cloud bootstrap webhooks', () => { + const { db, projectId } = seedFanOutScenario() + const otherProjectId = crypto.randomUUID() + db.insert(projects).values({ + id: otherProjectId, + name: 'other-project', + displayName: 'Other Project', + canonicalDomain: 'other.example', + country: 'US', + language: 'en', + ownedDomains: '[]', + tags: '[]', + providers: '[]', + locations: '[]', + createdAt: '2026-05-10T00:00:00.000Z', + updatedAt: '2026-05-10T00:00:00.000Z', + }).run() + db.insert(notifications).values([ + { + id: 'notif-project', + projectId, + channel: 'webhook', + config: { url: 'https://example.com/project', events: ['run.completed'] }, + enabled: true, + createdAt: '2026-05-10T00:00:00.000Z', + updatedAt: '2026-05-10T00:00:00.000Z', + }, + { + id: 'notif-tenant', + projectId: null, + channel: 'webhook', + config: { url: 'https://example.com/tenant', events: ['run.completed'] }, + enabled: true, + createdAt: '2026-05-10T00:00:00.000Z', + updatedAt: '2026-05-10T00:00:00.000Z', + }, + { + id: 'notif-other-project', + projectId: otherProjectId, + channel: 'webhook', + config: { url: 'https://example.com/other', events: ['run.completed'] }, + enabled: true, + createdAt: '2026-05-10T00:00:00.000Z', + updatedAt: '2026-05-10T00:00:00.000Z', + }, + { + id: 'notif-disabled-tenant', + projectId: null, + channel: 'webhook', + config: { url: 'https://example.com/disabled', events: ['run.completed'] }, + enabled: false, + createdAt: '2026-05-10T00:00:00.000Z', + updatedAt: '2026-05-10T00:00:00.000Z', + }, + ]).run() + + const notifier = new Notifier(db, 'http://localhost:4100') + const ids = callListEnabledNotifications(notifier, projectId).map(n => n.id).sort() + + expect(ids).toEqual(['notif-project', 'notif-tenant']) + }) }) diff --git a/packages/canonry/test/notify-events.test.ts b/packages/canonry/test/notify-events.test.ts index 4f6f2d90..74f40274 100644 --- a/packages/canonry/test/notify-events.test.ts +++ b/packages/canonry/test/notify-events.test.ts @@ -32,6 +32,6 @@ it('listEvents outputs valid JSON with --format json', () => { const parsed = JSON.parse(logs.join('\n')) expect(Array.isArray(parsed)).toBeTruthy() - expect(parsed.length).toBe(6) + expect(parsed.length).toBe(12) expect(parsed.every((e: { event: string; description: string }) => e.event && e.description)).toBeTruthy() }) diff --git a/packages/canonry/test/quota-client.test.ts b/packages/canonry/test/quota-client.test.ts new file mode 100644 index 00000000..cfb01311 --- /dev/null +++ b/packages/canonry/test/quota-client.test.ts @@ -0,0 +1,401 @@ +/** + * Tests for the lease-aware quota client (Track 1 — Canonry Hosted). + * + * Every test mocks `fetch` with `vi.fn()` so no live control-plane call + * leaves the process. The vitest-defaults setup file already blocks + * non-localhost requests; we use a localhost-shaped base URL for the + * happy paths to keep that guard satisfied. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + QuotaClient, + QuotaExceededError, + QuotaUnavailableError, + createQuotaClientFromEnv, +} from '../src/quota/index.js' + +const BASE = 'http://localhost:18080' +const KEY = 'cnry_test' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('QuotaClient.check (RPC mode)', () => { + it('issues a POST to /quota/check with the documented body shape', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ allowed: true, remaining: 99 })) + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + const result = await client.check({ + tenantId: 't1', + projectSlug: 'p1', + scope: 'sweeps_per_tenant_per_month', + metricKey: '*', + amount: 1, + }) + + expect(result).toEqual({ allowed: true, remaining: 99 }) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe(`${BASE}/quota/check`) + expect(init.method).toBe('POST') + expect(init.headers).toMatchObject({ + 'content-type': 'application/json', + authorization: `Bearer ${KEY}`, + }) + expect(JSON.parse(init.body as string)).toEqual({ + tenant_id: 't1', + project_slug: 'p1', + scope: 'sweeps_per_tenant_per_month', + metric_key: '*', + amount: 1, + }) + }) + + it('parses an HTTP 429 body as allowed=false + resetsAt', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ resets_at: '2026-06-01T00:00:00Z', remaining: 0 }, 429), + ) + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + const result = await client.check({ + tenantId: 't1', + scope: 'sweeps_per_tenant_per_month', + metricKey: '*', + amount: 1, + }) + + expect(result).toEqual({ + allowed: false, + remaining: 0, + resetsAt: '2026-06-01T00:00:00Z', + }) + }) + + it('parses {allowed:false} from a 200 response', async () => { + // Newer control-plane builds prefer a 200 envelope with allowed:false + // (clearer signalling than HTTP 429 which clients sometimes coerce + // into "retry"). Spec accepts both. + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ allowed: false, remaining: 0 })) + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + const result = await client.check({ + tenantId: 't1', + scope: 'discovery_per_tenant_per_month', + metricKey: '*', + amount: 1, + }) + expect(result.allowed).toBe(false) + }) + + it('fails closed when the control plane is unreachable', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + await expect(client.check({ + tenantId: 't1', + scope: 'sweeps_per_tenant_per_month', + metricKey: '*', + amount: 1, + })).rejects.toBeInstanceOf(QuotaUnavailableError) + }) + + it('fails closed when controlPlaneUrl is missing', async () => { + const client = new QuotaClient({ controlPlaneUrl: undefined, apiKey: KEY }) + await expect(client.check({ + tenantId: 't1', + scope: 'sweeps_per_tenant_per_month', + metricKey: '*', + amount: 1, + })).rejects.toBeInstanceOf(QuotaUnavailableError) + }) + + it('checkOrThrow rejects with QuotaExceededError on allowed=false', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ allowed: false, remaining: 0, resets_at: '2026-07-01T00:00:00Z' }), + ) + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + await expect(client.checkOrThrow({ + tenantId: 't1', + scope: 'action_executions_per_tenant_per_month', + metricKey: 'github-pr', + amount: 1, + })).rejects.toMatchObject({ + name: 'QuotaExceededError', + scope: 'action_executions_per_tenant_per_month', + resetsAt: '2026-07-01T00:00:00Z', + }) + }) +}) + +describe('QuotaClient.acquireLease (lease mode)', () => { + it('issues a POST to /quota/lease and returns the granted lease', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ + lease_id: 'lease_xxx', + granted_amount: 100_000, + expires_at: '2026-06-01T00:15:00Z', + })) + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + const grant = await client.acquireLease({ + tenantId: 't1', + scope: 'provider_tokens_per_tenant_per_month', + metricKey: '*', + requestedAmount: 100_000, + maxDurationSeconds: 900, + }) + + expect(grant).toEqual({ + leaseId: 'lease_xxx', + grantedAmount: 100_000, + expiresAt: '2026-06-01T00:15:00Z', + }) + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe(`${BASE}/quota/lease`) + expect(JSON.parse(init.body as string)).toMatchObject({ + tenant_id: 't1', + scope: 'provider_tokens_per_tenant_per_month', + metric_key: '*', + requested_amount: 100_000, + max_duration_seconds: 900, + }) + }) + + it('forwards idempotencyKey to the server', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ + lease_id: 'lease_xxx', granted_amount: 100, expires_at: '2026-06-01T00:00:00Z', + })) + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + await client.acquireLease({ + tenantId: 't1', + scope: 'gsc_per_site_per_day', + metricKey: 'site:example.com', + requestedAmount: 100, + maxDurationSeconds: 60, + idempotencyKey: 'idem-1', + }) + + expect(JSON.parse(fetchMock.mock.calls[0]![1].body as string).idempotency_key).toBe('idem-1') + }) + + it('throws QuotaExceededError on HTTP 429', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ resets_at: '2026-07-01T00:00:00Z' }, 429), + ) + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + await expect(client.acquireLease({ + tenantId: 't1', + scope: 'provider_tokens_per_tenant_per_month', + metricKey: '*', + requestedAmount: 1_000, + maxDurationSeconds: 60, + })).rejects.toBeInstanceOf(QuotaExceededError) + }) + + describe('degraded-mode reserve (fail-open)', () => { + it('falls back to 10% of last grant when the control plane is unreachable', async () => { + const fetchMock = vi.fn() + // First call succeeds — primes the reserve. + fetchMock.mockResolvedValueOnce(jsonResponse({ + lease_id: 'lease_1', granted_amount: 10_000, expires_at: '2026-06-01T00:15:00Z', + })) + // Second call fails with a network error. + fetchMock.mockRejectedValueOnce(new Error('ECONNREFUSED')) + + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + const first = await client.acquireLease({ + tenantId: 't1', + scope: 'provider_tokens_per_tenant_per_month', + metricKey: '*', + requestedAmount: 10_000, + maxDurationSeconds: 900, + }) + expect(first.grantedAmount).toBe(10_000) + + // Outage path — get 10% of last grant = 1000 tokens. + const degraded = await client.acquireLease({ + tenantId: 't1', + scope: 'provider_tokens_per_tenant_per_month', + metricKey: '*', + requestedAmount: 5_000, + maxDurationSeconds: 900, + }) + expect(degraded.grantedAmount).toBe(1_000) + expect(degraded.leaseId.startsWith('degraded-')).toBe(true) + }) + + it('exhausts the reserve across multiple degraded-mode calls', async () => { + const fetchMock = vi.fn() + fetchMock.mockResolvedValueOnce(jsonResponse({ + lease_id: 'lease_1', granted_amount: 1_000, expires_at: '2026-06-01T00:15:00Z', + })) + // All subsequent calls fail — operator never sees the control plane recover. + fetchMock.mockRejectedValue(new Error('ECONNREFUSED')) + + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + await client.acquireLease({ + tenantId: 't1', + scope: 'provider_tokens_per_tenant_per_month', + metricKey: '*', + requestedAmount: 1_000, + maxDurationSeconds: 900, + }) + // Reserve is now 100 (10% of 1000). + const first = await client.acquireLease({ + tenantId: 't1', + scope: 'provider_tokens_per_tenant_per_month', + metricKey: '*', + requestedAmount: 60, + maxDurationSeconds: 60, + }) + expect(first.grantedAmount).toBe(60) + // 40 left. + const second = await client.acquireLease({ + tenantId: 't1', + scope: 'provider_tokens_per_tenant_per_month', + metricKey: '*', + requestedAmount: 40, + maxDurationSeconds: 60, + }) + expect(second.grantedAmount).toBe(40) + // 0 left — next call must throw. + await expect(client.acquireLease({ + tenantId: 't1', + scope: 'provider_tokens_per_tenant_per_month', + metricKey: '*', + requestedAmount: 1, + maxDurationSeconds: 60, + })).rejects.toBeInstanceOf(QuotaUnavailableError) + }) + + it('throws QuotaUnavailableError when no prior grant exists', async () => { + // First-call outage — no reserve to fall back on yet. + const fetchMock = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + await expect(client.acquireLease({ + tenantId: 't1', + scope: 'provider_tokens_per_tenant_per_month', + metricKey: '*', + requestedAmount: 1_000, + maxDurationSeconds: 60, + })).rejects.toMatchObject({ + name: 'QuotaUnavailableError', + reason: 'lease-reserve-exhausted', + }) + }) + + it('reserve is per (scope, metricKey) — two sites do not share', async () => { + const fetchMock = vi.fn() + fetchMock.mockResolvedValueOnce(jsonResponse({ + lease_id: 'l1', granted_amount: 500, expires_at: '2026-06-01T00:15:00Z', + })) + // Second metricKey: control plane fails immediately. + fetchMock.mockRejectedValueOnce(new Error('ECONNREFUSED')) + + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + // Prime site A. + await client.acquireLease({ + tenantId: 't1', + scope: 'gsc_per_site_per_day', + metricKey: 'site:a.com', + requestedAmount: 500, + maxDurationSeconds: 900, + }) + + // Site B has no prior grant — must throw, not borrow site A's reserve. + await expect(client.acquireLease({ + tenantId: 't1', + scope: 'gsc_per_site_per_day', + metricKey: 'site:b.com', + requestedAmount: 100, + maxDurationSeconds: 900, + })).rejects.toBeInstanceOf(QuotaUnavailableError) + }) + }) +}) + +describe('QuotaClient.closeLease', () => { + it('posts to /quota/lease/{id}/close with the used amount', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ refunded: 250 })) + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + const result = await client.closeLease('lease_xxx', { usedAmount: 750 }) + expect(result).toEqual({ refunded: 250 }) + + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe(`${BASE}/quota/lease/lease_xxx/close`) + expect(JSON.parse(init.body as string)).toEqual({ used_amount: 750 }) + }) + + it('url-encodes the lease id (defensive)', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ refunded: 0 })) + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + await client.closeLease('weird/lease id', { usedAmount: 0 }) + const [url] = fetchMock.mock.calls[0]! + expect(url).toBe(`${BASE}/quota/lease/weird%2Flease%20id/close`) + }) + + it('synthetic degraded-* leases skip the network round-trip', async () => { + const fetchMock = vi.fn() + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + const result = await client.closeLease('degraded-x-1234-abcd', { usedAmount: 10 }) + expect(result).toEqual({ refunded: 0 }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('treats a transport error as best-effort (returns refunded: 0)', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) + const client = new QuotaClient({ controlPlaneUrl: BASE, apiKey: KEY, fetch: fetchMock }) + + const result = await client.closeLease('lease_xxx', { usedAmount: 100 }) + expect(result).toEqual({ refunded: 0 }) + }) +}) + +describe('createQuotaClientFromEnv', () => { + const originalUrl = process.env.CANONRY_CONTROL_PLANE_URL + const originalKey = process.env.CANONRY_API_KEY + + beforeEach(() => { + delete process.env.CANONRY_CONTROL_PLANE_URL + delete process.env.CANONRY_API_KEY + }) + + afterEach(() => { + if (originalUrl !== undefined) process.env.CANONRY_CONTROL_PLANE_URL = originalUrl + if (originalKey !== undefined) process.env.CANONRY_API_KEY = originalKey + }) + + it('reads CANONRY_CONTROL_PLANE_URL and CANONRY_API_KEY from env', () => { + const client = createQuotaClientFromEnv({ + CANONRY_CONTROL_PLANE_URL: 'http://canonry-control-plane:8080/', + CANONRY_API_KEY: 'cnry_xxx', + }) + // Trailing slashes are stripped so paths concat cleanly. + expect((client as unknown as { controlPlaneUrl: string }).controlPlaneUrl).toBe( + 'http://canonry-control-plane:8080', + ) + }) + + it('returns a client whose calls fail closed when both env vars are missing', async () => { + const client = createQuotaClientFromEnv({}) + await expect(client.check({ + tenantId: 't1', + scope: 'sweeps_per_tenant_per_month', + metricKey: '*', + amount: 1, + })).rejects.toBeInstanceOf(QuotaUnavailableError) + }) +}) diff --git a/packages/canonry/test/run-coordinator.test.ts b/packages/canonry/test/run-coordinator.test.ts index 50474a09..da11722a 100644 --- a/packages/canonry/test/run-coordinator.test.ts +++ b/packages/canonry/test/run-coordinator.test.ts @@ -4,11 +4,11 @@ import os from 'node:os' import path from 'node:path' import { describe, it, expect, vi, onTestFinished } from 'vitest' import { eq } from 'drizzle-orm' -import { createClient, discoverySessions, migrate, projects, runs, queries, querySnapshots, healthSnapshots, insights, gbpLocations, gbpLodgingSnapshots } from '@ainyc/canonry-db' +import { createClient, discoverySessions, migrate, projects, runs, queries, querySnapshots, healthSnapshots, insights, gbpLocations, gbpLodgingSnapshots, providerTokenUsage } from '@ainyc/canonry-db' import type { AnalysisResult } from '@ainyc/canonry-intelligence' import { Notifier } from '../src/notifier.js' import { IntelligenceService } from '../src/intelligence-service.js' -import { RunCoordinator, type AeroEventContext } from '../src/run-coordinator.js' +import { RunCoordinator, stableEventId, type AeroEventContext } from '../src/run-coordinator.js' function createTempDb(prefix: string) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) @@ -438,4 +438,235 @@ describe('RunCoordinator', () => { expect(captured!.buckets).toEqual({ cited: 7, aspirational: 1, 'wasted-surface': 2 }) expect(captured!.probeCount).toBe(10) }) + + describe('token-cost telemetry (Track 1)', () => { + // RunCoordinator persists a row into `provider_token_usage` for every + // (provider, model) combination present in the run's snapshots that + // carries a recognized usage block. Verified by inspecting the table + // after onRunCompleted resolves. + + function seedRunWithUsage( + db: ReturnType, + snapshots: Array<{ provider: string; model: string | null; usage: Record }>, + ) { + const now = new Date().toISOString() + const projectId = crypto.randomUUID() + db.insert(projects).values({ + id: projectId, name: `tok-${projectId.slice(0, 6)}`, + displayName: 'Tok', canonicalDomain: 'example.com', + country: 'US', language: 'en', createdAt: now, updatedAt: now, + }).run() + const queryId = crypto.randomUUID() + db.insert(queries).values({ id: queryId, projectId, query: 'q', createdAt: now }).run() + const runId = crypto.randomUUID() + db.insert(runs).values({ + id: runId, projectId, kind: 'answer-visibility', status: 'completed', + trigger: 'manual', createdAt: now, finishedAt: now, + }).run() + for (const snap of snapshots) { + // Mirror the JobRunner storage envelope: + // {model, groundingSources, searchQueries, apiResponse: } + const rawResponse = JSON.stringify({ + model: snap.model, + groundingSources: [], + searchQueries: [], + apiResponse: snap.usage, + }) + db.insert(querySnapshots).values({ + id: crypto.randomUUID(), runId, queryId, + provider: snap.provider, model: snap.model, + citationState: 'cited', citedDomains: ['example.com'], + competitorOverlap: [], rawResponse, createdAt: now, + }).run() + } + return { projectId, runId } + } + + it('persists one row per (provider, model) for a fan-out run', async () => { + const { db } = createTempDb('coord-tokens-') + const { projectId, runId } = seedRunWithUsage(db, [ + { + provider: 'openai', + model: 'gpt-5.4', + usage: { usage: { input_tokens: 100, output_tokens: 50, input_tokens_details: { cached_tokens: 20 } } }, + }, + { + provider: 'claude', + model: 'claude-sonnet-4-6', + usage: { usage: { input_tokens: 200, output_tokens: 75, cache_read_input_tokens: 30 } }, + }, + { + provider: 'gemini', + model: 'gemini-2.5-flash', + usage: { usageMetadata: { promptTokenCount: 80, candidatesTokenCount: 25, cachedContentTokenCount: 5 } }, + }, + ]) + + const notifier = createMockNotifier() + const service = new IntelligenceService(db) + const coordinator = new RunCoordinator(db, notifier as Notifier, service) + await coordinator.onRunCompleted(runId, projectId) + + const rows = db.select().from(providerTokenUsage).where(eq(providerTokenUsage.runId, runId)).all() + expect(rows).toHaveLength(3) + const byProvider = Object.fromEntries(rows.map((r) => [r.provider, r])) + expect(byProvider.openai).toMatchObject({ + provider: 'openai', model: 'gpt-5.4', inputTokens: 100, outputTokens: 50, cachedInputTokens: 20, + }) + expect(byProvider.claude).toMatchObject({ + provider: 'claude', model: 'claude-sonnet-4-6', inputTokens: 200, outputTokens: 75, cachedInputTokens: 30, + }) + expect(byProvider.gemini).toMatchObject({ + provider: 'gemini', model: 'gemini-2.5-flash', inputTokens: 80, outputTokens: 25, cachedInputTokens: 5, + }) + }) + + it('aggregates multiple snapshots from the same (provider, model) into one row', async () => { + const { db } = createTempDb('coord-tokens-agg-') + const { projectId, runId } = seedRunWithUsage(db, [ + { provider: 'claude', model: 'claude-sonnet-4-6', usage: { usage: { input_tokens: 100, output_tokens: 25 } } }, + { provider: 'claude', model: 'claude-sonnet-4-6', usage: { usage: { input_tokens: 200, output_tokens: 50 } } }, + { provider: 'claude', model: 'claude-sonnet-4-6', usage: { usage: { input_tokens: 300, output_tokens: 75, cache_read_input_tokens: 10 } } }, + ]) + + const notifier = createMockNotifier() + const service = new IntelligenceService(db) + const coordinator = new RunCoordinator(db, notifier as Notifier, service) + await coordinator.onRunCompleted(runId, projectId) + + const rows = db.select().from(providerTokenUsage).where(eq(providerTokenUsage.runId, runId)).all() + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + provider: 'claude', + model: 'claude-sonnet-4-6', + inputTokens: 600, + outputTokens: 150, + cachedInputTokens: 10, + }) + }) + + it('skips snapshots without a recognized usage block (no zero-counter rows)', async () => { + const { db } = createTempDb('coord-tokens-skip-') + const { projectId, runId } = seedRunWithUsage(db, [ + // Browser provider — no documented usage shape, should be skipped. + { provider: 'cdp:chatgpt', model: null, usage: {} }, + // Real provider but no usage block — also skipped. + { provider: 'openai', model: 'gpt-5.4', usage: { output: [] } }, + ]) + + const notifier = createMockNotifier() + const service = new IntelligenceService(db) + const coordinator = new RunCoordinator(db, notifier as Notifier, service) + await coordinator.onRunCompleted(runId, projectId) + + expect(db.select().from(providerTokenUsage).where(eq(providerTokenUsage.runId, runId)).all()).toEqual([]) + }) + + it('does NOT block notifier when token persistence fails', async () => { + const { db } = createTempDb('coord-tokens-fail-') + const { projectId, runId } = seedRunWithUsage(db, [ + { provider: 'claude', model: 'claude-sonnet-4-6', usage: { usage: { input_tokens: 10, output_tokens: 5 } } }, + ]) + + // Sabotage the snapshot read so persistTokenUsage throws — we wrap + // the table reads in try/catch precisely so persistence errors don't + // starve downstream subscribers. + const notifier = createMockNotifier() + const service = new IntelligenceService(db) + const dbProxy = new Proxy(db, { + get(target, prop, receiver) { + if (prop === 'select') { + return (...args: unknown[]) => { + const builder = Reflect.get(target, prop, receiver).apply(target, args) as { + from: (table: unknown) => unknown + } + return { + from(table: unknown) { + // Throw only on the snapshot read inside persistTokenUsage. + // Other selects (run row, project row, baseline detection) + // must still work. + if (table === querySnapshots) { + throw new Error('boom') + } + return builder.from(table) + }, + } + } + } + return Reflect.get(target, prop, receiver) + }, + }) + const coordinator = new RunCoordinator(dbProxy as typeof db, notifier as Notifier, service) + await coordinator.onRunCompleted(runId, projectId) + expect(notifier.onRunCompleted).toHaveBeenCalled() + }) + + it('skips probe-trigger runs (post-run pipeline short-circuit)', async () => { + // Probe runs short-circuit the entire post-run pipeline (intelligence, + // notifier, token telemetry, aero). Regression: token persistence + // must NOT run for probes. + const { db } = createTempDb('coord-tokens-probe-') + const now = new Date().toISOString() + const projectId = crypto.randomUUID() + db.insert(projects).values({ + id: projectId, name: 'tok-probe', displayName: 'tp', + canonicalDomain: 'example.com', country: 'US', language: 'en', + createdAt: now, updatedAt: now, + }).run() + const queryId = crypto.randomUUID() + db.insert(queries).values({ id: queryId, projectId, query: 'q', createdAt: now }).run() + const runId = crypto.randomUUID() + db.insert(runs).values({ + id: runId, projectId, kind: 'answer-visibility', status: 'completed', + trigger: 'probe', createdAt: now, finishedAt: now, + }).run() + db.insert(querySnapshots).values({ + id: crypto.randomUUID(), runId, queryId, + provider: 'claude', model: 'claude-sonnet-4-6', + citationState: 'cited', citedDomains: ['example.com'], competitorOverlap: [], + rawResponse: JSON.stringify({ apiResponse: { usage: { input_tokens: 50, output_tokens: 5 } } }), + createdAt: now, + }).run() + + const notifier = createMockNotifier() + const service = new IntelligenceService(db) + const coordinator = new RunCoordinator(db, notifier as Notifier, service) + await coordinator.onRunCompleted(runId, projectId) + + expect(db.select().from(providerTokenUsage).where(eq(providerTokenUsage.runId, runId)).all()).toEqual([]) + }) + }) + + describe('stableEventId (Track 3 baseline dedup)', () => { + // The `(eventType, projectId)` -> UUID derivation is the only line of + // defense against the concurrent-run race where two answer-visibility + // sweeps both decide they're the "first" baseline. The control plane + // dedupes its `event_idempotency` table on `event_id`, so identical + // inputs MUST produce identical UUIDs across processes / restarts. + + it('returns the same UUID for the same (eventType, projectId)', () => { + const a = stableEventId('baseline.completed', 'proj-xyz') + const b = stableEventId('baseline.completed', 'proj-xyz') + expect(a).toBe(b) + }) + + it('returns a different UUID for a different projectId', () => { + const a = stableEventId('baseline.completed', 'proj-a') + const b = stableEventId('baseline.completed', 'proj-b') + expect(a).not.toBe(b) + }) + + it('returns a different UUID for a different eventType', () => { + const a = stableEventId('baseline.completed', 'proj-x') + const b = stableEventId('connection.created', 'proj-x') + expect(a).not.toBe(b) + }) + + it('returns a valid RFC 4122 v5 UUID', () => { + const id = stableEventId('baseline.completed', 'proj-x') + // 8-4-4-4-12 hex with version 5 in the 13th hex char and variant + // 10xx (8/9/a/b) in the 17th. + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + }) + }) }) diff --git a/packages/canonry/test/scheduler-cloud-mode.test.ts b/packages/canonry/test/scheduler-cloud-mode.test.ts new file mode 100644 index 00000000..960c1b37 --- /dev/null +++ b/packages/canonry/test/scheduler-cloud-mode.test.ts @@ -0,0 +1,125 @@ +/** + * Cloud-mode flag tests for the Scheduler (Track 1 — Canonry Hosted). + * + * The `CANONRY_SCHEDULER=external` flag is read at module load and frozen + * for the lifetime of the process — that's intentional, so the in-process + * scheduler can be deterministically disabled in cloud deployments. + * + * Vitest gives us module isolation through `vi.resetModules()` plus a + * pre-import env mutation, so each test gets a fresh Scheduler with the + * env it set. + */ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createClient, migrate, projects, schedules } from '@ainyc/canonry-db' + +function createTempDb() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'canonry-cloud-sched-')) + const dbPath = path.join(tmpDir, 'test.db') + const db = createClient(dbPath) + migrate(db) + return { db, tmpDir } +} + +function seedEnabledSchedule(db: ReturnType) { + const now = new Date().toISOString() + db.insert(projects).values({ + id: 'proj_1', name: 'cloud-sched', + displayName: 'Cloud Sched', canonicalDomain: 'example.com', + country: 'US', language: 'en', createdAt: now, updatedAt: now, + }).run() + db.insert(schedules).values({ + id: 'sched_1', projectId: 'proj_1', cronExpr: '* * * * *', + timezone: 'UTC', enabled: true, providers: [], + createdAt: now, updatedAt: now, + }).run() +} + +describe('Scheduler — CANONRY_SCHEDULER=external (Track 1)', () => { + const originalEnv = process.env.CANONRY_SCHEDULER + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.CANONRY_SCHEDULER + } else { + process.env.CANONRY_SCHEDULER = originalEnv + } + vi.resetModules() + }) + + it('registers cron tasks when CANONRY_SCHEDULER is unset (OSS default)', async () => { + delete process.env.CANONRY_SCHEDULER + vi.resetModules() + const { Scheduler } = await import('../src/scheduler.js') + const { db, tmpDir } = createTempDb() + seedEnabledSchedule(db) + + const scheduler = new Scheduler(db, { onRunCreated: () => {} }) + scheduler.start() + try { + expect((scheduler as unknown as { tasks: Map }).tasks.size).toBe(1) + } finally { + scheduler.stop() + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('does NOT register cron tasks when CANONRY_SCHEDULER=external', async () => { + process.env.CANONRY_SCHEDULER = 'external' + vi.resetModules() + const { Scheduler } = await import('../src/scheduler.js') + const { db, tmpDir } = createTempDb() + seedEnabledSchedule(db) + + const scheduler = new Scheduler(db, { onRunCreated: () => {} }) + scheduler.start() + try { + // Empty tasks map proves no cron registration happened — the control + // plane is responsible for firing runs in cloud mode. + expect((scheduler as unknown as { tasks: Map }).tasks.size).toBe(0) + } finally { + scheduler.stop() + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('upsert is a no-op in cloud mode (does not register new tasks)', async () => { + process.env.CANONRY_SCHEDULER = 'external' + vi.resetModules() + const { Scheduler } = await import('../src/scheduler.js') + const { db, tmpDir } = createTempDb() + seedEnabledSchedule(db) + + const scheduler = new Scheduler(db, { onRunCreated: () => {} }) + scheduler.upsert('proj_1', 'answer-visibility') + try { + expect((scheduler as unknown as { tasks: Map }).tasks.size).toBe(0) + } finally { + scheduler.stop() + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('treats arbitrary non-"external" values as the OSS default', async () => { + // Operator typo guard: only the exact string `external` flips the + // scheduler off. Anything else (`disabled`, `cloud`, `1`) preserves + // OSS behavior so a misconfigured tenant doesn't silently stop running + // sweeps. + process.env.CANONRY_SCHEDULER = 'cloud' + vi.resetModules() + const { Scheduler } = await import('../src/scheduler.js') + const { db, tmpDir } = createTempDb() + seedEnabledSchedule(db) + + const scheduler = new Scheduler(db, { onRunCreated: () => {} }) + scheduler.start() + try { + expect((scheduler as unknown as { tasks: Map }).tasks.size).toBe(1) + } finally { + scheduler.stop() + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/canonry/test/token-usage.test.ts b/packages/canonry/test/token-usage.test.ts new file mode 100644 index 00000000..b6256f64 --- /dev/null +++ b/packages/canonry/test/token-usage.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest' +import { extractTokenUsage } from '../src/token-usage.js' + +describe('extractTokenUsage', () => { + // Each adapter wraps its provider response under + // { model, groundingSources, searchQueries, apiResponse: } + // before storing it in `query_snapshots.raw_response`. The extractor must + // unwrap that envelope before reading the provider-specific usage block. + + describe('Anthropic / Claude', () => { + it('reads usage.input_tokens / output_tokens / cache_read_input_tokens', () => { + const stored = { + model: 'claude-sonnet-4-6', + groundingSources: [], + searchQueries: [], + apiResponse: { + id: 'msg_xxx', + content: [], + usage: { + input_tokens: 1200, + output_tokens: 300, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 100, + }, + }, + } + expect(extractTokenUsage('claude', JSON.stringify(stored))).toEqual({ + inputTokens: 1200, + outputTokens: 300, + cachedInputTokens: 100, + }) + }) + + it('defaults cachedInputTokens to 0 when cache_read_input_tokens is absent', () => { + const stored = { + apiResponse: { usage: { input_tokens: 50, output_tokens: 10 } }, + } + expect(extractTokenUsage('claude', stored)).toEqual({ + inputTokens: 50, + outputTokens: 10, + cachedInputTokens: 0, + }) + }) + + it('returns null when the response has no usage block', () => { + expect(extractTokenUsage('claude', { apiResponse: { content: [] } })).toBeNull() + }) + }) + + describe('OpenAI', () => { + it('reads usage.input_tokens / output_tokens on Responses API payloads', () => { + const stored = { + apiResponse: { + output: [], + usage: { + input_tokens: 800, + output_tokens: 150, + input_tokens_details: { cached_tokens: 200 }, + }, + }, + } + expect(extractTokenUsage('openai', stored)).toEqual({ + inputTokens: 800, + outputTokens: 150, + cachedInputTokens: 200, + }) + }) + + it('accepts legacy usage.prompt_tokens / completion_tokens shape', () => { + const stored = { + apiResponse: { + usage: { + prompt_tokens: 60, + completion_tokens: 40, + prompt_tokens_details: { cached_tokens: 12 }, + }, + }, + } + expect(extractTokenUsage('openai', stored)).toEqual({ + inputTokens: 60, + outputTokens: 40, + cachedInputTokens: 12, + }) + }) + + it('returns null when both counters are zero/absent', () => { + expect(extractTokenUsage('openai', { apiResponse: { usage: {} } })).toBeNull() + }) + }) + + describe('Gemini', () => { + it('reads usageMetadata.promptTokenCount / candidatesTokenCount / cachedContentTokenCount', () => { + const stored = { + apiResponse: { + candidates: [], + usageMetadata: { + promptTokenCount: 500, + candidatesTokenCount: 75, + cachedContentTokenCount: 40, + totalTokenCount: 575, + }, + }, + } + expect(extractTokenUsage('gemini', stored)).toEqual({ + inputTokens: 500, + outputTokens: 75, + cachedInputTokens: 40, + }) + }) + + it('handles missing cachedContentTokenCount', () => { + const stored = { + apiResponse: { + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 }, + }, + } + expect(extractTokenUsage('gemini', stored)).toEqual({ + inputTokens: 10, + outputTokens: 5, + cachedInputTokens: 0, + }) + }) + + it('returns null when usageMetadata is missing entirely', () => { + expect(extractTokenUsage('gemini', { apiResponse: { candidates: [] } })).toBeNull() + }) + }) + + describe('Perplexity', () => { + it('reads OpenAI-compatible usage.prompt_tokens / completion_tokens', () => { + const stored = { + apiResponse: { + choices: [], + usage: { + prompt_tokens: 80, + completion_tokens: 120, + total_tokens: 200, + }, + }, + } + expect(extractTokenUsage('perplexity', stored)).toEqual({ + inputTokens: 80, + outputTokens: 120, + cachedInputTokens: 0, + }) + }) + + it('returns null when usage block is missing', () => { + expect(extractTokenUsage('perplexity', { apiResponse: { choices: [] } })).toBeNull() + }) + }) + + describe('unrecognized providers', () => { + it('returns null for browser/CDP providers (no documented usage shape)', () => { + expect(extractTokenUsage('cdp:chatgpt', { apiResponse: { usage: { input_tokens: 1 } } })) + .toBeNull() + }) + + it('returns null for local provider snapshots', () => { + expect(extractTokenUsage('local', { apiResponse: { usage: { input_tokens: 1 } } })) + .toBeNull() + }) + }) + + describe('robustness', () => { + it('returns null for invalid JSON strings', () => { + expect(extractTokenUsage('claude', 'not-json')).toBeNull() + }) + + it('returns null for empty string', () => { + expect(extractTokenUsage('claude', '')).toBeNull() + }) + + it('clamps negative numbers to 0 (defensive)', () => { + // Shouldn't ever happen in practice but guard against weird upstream + // payloads — billing dashboards rely on non-negative counts. + const stored = { + apiResponse: { usage: { input_tokens: -10, output_tokens: 5 } }, + } + expect(extractTokenUsage('claude', stored)).toEqual({ + inputTokens: 0, + outputTokens: 5, + cachedInputTokens: 0, + }) + }) + + it('truncates fractional token counts to integers', () => { + const stored = { + apiResponse: { usage: { input_tokens: 12.7, output_tokens: 3.9 } }, + } + expect(extractTokenUsage('claude', stored)).toEqual({ + inputTokens: 12, + outputTokens: 3, + cachedInputTokens: 0, + }) + }) + + it('unwraps the apiResponse envelope (canonry storage shape)', () => { + // Anthropic's raw API response is what callers see if they hit + // anthropic.com directly. canonry wraps it in {apiResponse: ...}. + // The extractor must handle both shapes — unwrapped (direct API + // response) and wrapped (stored snapshot). + const direct = { usage: { input_tokens: 1, output_tokens: 2 } } + const wrapped = { apiResponse: direct } + expect(extractTokenUsage('claude', direct)).toEqual({ + inputTokens: 1, + outputTokens: 2, + cachedInputTokens: 0, + }) + expect(extractTokenUsage('claude', wrapped)).toEqual({ + inputTokens: 1, + outputTokens: 2, + cachedInputTokens: 0, + }) + }) + }) +}) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 2c93aea5..df4bb5cf 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,6 +1,46 @@ import { providerQuotaPolicySchema, type ProviderQuotaPolicy } from '@ainyc/canonry-contracts' import { z } from 'zod' +/** + * Cloud-mode flag set (Track 1 of the Canonry Hosted v1 spec). + * + * Each flag is independent and defaults off so OSS deployments are unaffected. + * See `.context/control-plane-spec.md` §16 for the full set. + */ +export type CanonryRuntimeMode = 'oss' | 'cloud' + +/** + * `external` disables the in-process node-cron scheduler in + * `packages/canonry/src/scheduler.ts` — the control plane owns scheduling + * in cloud deployments. Schedule API endpoints still accept writes so the + * UI can mirror the control-plane state. + */ +export type CanonrySchedulerMode = 'internal' | 'external' + +export interface CloudModeFlags { + /** + * Telemetry source flag. When `cloud`, log lines and telemetry events + * are tagged with `runtime_mode=cloud` for filtering. No behavior change. + */ + runtimeMode: CanonryRuntimeMode + /** + * `external` short-circuits the in-process scheduler (control plane + * dispatches runs instead). Schedule writes still accepted. + */ + scheduler: CanonrySchedulerMode + /** + * When true, `/settings/*` write endpoints return 403. Read endpoints + * stay available so the UI can show the managed values. + */ + managedSettings: boolean + /** + * When true, the `/cloud/*` admin-scope endpoints are un-gated (added in + * Track 3). When false, `requireCloudBootstrap()` throws `notFound()` so + * the endpoints don't appear. + */ + enableCloudBootstrap: boolean +} + const envSchema = z.object({ DATABASE_URL: z.string().default('postgresql://aeo:aeo@postgres:5432/aeo_platform'), API_PORT: z.coerce.number().int().positive().default(3000), @@ -8,6 +48,23 @@ const envSchema = z.object({ WEB_PORT: z.coerce.number().int().positive().default(4173), BOOTSTRAP_SECRET: z.string().default('change-me'), CANONRY_BASE_PATH: z.string().default('/'), + // Default cnry_ bearer the cloud instance authenticates with — seeded into + // the api_keys table on startup. Required for the dashboard-password + // session flow (/session/setup binds the session to this key). Optional + // when the instance is consumed exclusively via external bearer tokens. + CANONRY_API_KEY: z.string().optional(), + // Public URL the deployment is reachable at — sets the `Secure` flag on + // the session cookie when the URL is HTTPS. Without this, the cookie + // works fine over HTTP (dev) but is not flagged Secure in production. + CANONRY_PUBLIC_URL: z.string().optional(), + // Cloud-mode flag set — see Track 1 of the Canonry Hosted v1 spec. + // All four default off so OSS deployments are unaffected. + CANONRY_RUNTIME_MODE: z.string().optional(), + CANONRY_SCHEDULER: z.string().optional(), + CANONRY_MANAGED_SETTINGS: z.string().optional(), + CANONRY_ENABLE_CLOUD_BOOTSTRAP: z.string().optional(), + // Control-plane integration — used by the lease-aware quota client. + CANONRY_CONTROL_PLANE_URL: z.string().optional(), // Gemini GEMINI_API_KEY: z.string().optional(), GEMINI_MODEL: z.string().optional(), @@ -80,6 +137,20 @@ export interface PlatformEnv { webPort: number basePath: string bootstrapSecret: string + /** + * The default `cnry_…` API key for this cloud instance. Sourced from + * `CANONRY_API_KEY`. When set, the api-routes session plugin binds + * dashboard-password sessions to this key on first login; without it, + * `/session/setup` cannot mint sessions because there's no key for + * the operator to act as. Cloud Run deployments should always set this. + */ + apiKey?: string + /** + * Public-facing URL of the deployment (no trailing slash). Used to set + * `Secure` on the session cookie when the public URL is HTTPS so + * production browsers store and replay it correctly. + */ + publicUrl?: string /** * Required for cloud deployments that expose Google OAuth routes. Sourced * from `GOOGLE_STATE_SECRET`. Undefined when unset — the api-routes plugin @@ -87,6 +158,13 @@ export interface PlatformEnv { * default. */ googleStateSecret?: string + /** Cloud-mode flags. All four default off so OSS deployments are unaffected. */ + cloud: CloudModeFlags + /** + * Control-plane base URL (no trailing slash). Used by the lease-aware + * quota client. Undefined when canonry runs in OSS mode. + */ + controlPlaneUrl?: string providers: { gemini?: ProviderEnvConfig openai?: ProviderEnvConfig @@ -95,6 +173,35 @@ export interface PlatformEnv { } } +/** + * Parse a raw env value as a boolean cloud-mode flag. Accepts `1`, `true`, + * `yes`, `on` (case-insensitive) as truthy. Anything else is falsy. + */ +function parseBooleanFlag(value: string | undefined): boolean { + if (!value) return false + const v = value.trim().toLowerCase() + return v === '1' || v === 'true' || v === 'yes' || v === 'on' +} + +function parseRuntimeMode(value: string | undefined): CanonryRuntimeMode { + if (!value) return 'oss' + return value.trim().toLowerCase() === 'cloud' ? 'cloud' : 'oss' +} + +function parseSchedulerMode(value: string | undefined): CanonrySchedulerMode { + if (!value) return 'internal' + return value.trim().toLowerCase() === 'external' ? 'external' : 'internal' +} + +export function readCloudModeFlags(source: NodeJS.ProcessEnv): CloudModeFlags { + return { + runtimeMode: parseRuntimeMode(source.CANONRY_RUNTIME_MODE), + scheduler: parseSchedulerMode(source.CANONRY_SCHEDULER), + managedSettings: parseBooleanFlag(source.CANONRY_MANAGED_SETTINGS), + enableCloudBootstrap: parseBooleanFlag(source.CANONRY_ENABLE_CLOUD_BOOTSTRAP), + } +} + const bootstrapEnvSchema = z.object({ CANONRY_API_KEY: z.string().optional(), CANONRY_API_URL: z.string().optional(), @@ -180,7 +287,11 @@ export function getPlatformEnv(source: NodeJS.ProcessEnv): PlatformEnv { webPort: parsed.WEB_PORT, basePath: parsed.CANONRY_BASE_PATH, bootstrapSecret: parsed.BOOTSTRAP_SECRET, + apiKey: parsed.CANONRY_API_KEY, + publicUrl: parsed.CANONRY_PUBLIC_URL?.replace(/\/+$/, ''), googleStateSecret: parsed.GOOGLE_STATE_SECRET, + cloud: readCloudModeFlags(source), + controlPlaneUrl: parsed.CANONRY_CONTROL_PLANE_URL?.replace(/\/+$/, ''), providers, } } diff --git a/packages/config/test/index.test.ts b/packages/config/test/index.test.ts index 469b13a3..f2591291 100644 --- a/packages/config/test/index.test.ts +++ b/packages/config/test/index.test.ts @@ -1,6 +1,6 @@ import { test, expect } from 'vitest' -import { getBootstrapEnv, getPlatformEnv } from '../src/index.js' +import { getBootstrapEnv, getPlatformEnv, readCloudModeFlags } from '../src/index.js' test('getPlatformEnv returns defaults when no env vars set', () => { const env = getPlatformEnv({}) @@ -105,6 +105,69 @@ test('getBootstrapEnv configures Gemini via Vertex AI env vars', () => { expect(env.providers.gemini!.model).toBe('gemini-2.5-flash') }) +test('readCloudModeFlags returns OSS defaults when env is empty', () => { + const flags = readCloudModeFlags({}) + + expect(flags.runtimeMode).toBe('oss') + expect(flags.scheduler).toBe('internal') + expect(flags.managedSettings).toBe(false) + expect(flags.enableCloudBootstrap).toBe(false) +}) + +test('readCloudModeFlags honours CANONRY_RUNTIME_MODE=cloud', () => { + expect(readCloudModeFlags({ CANONRY_RUNTIME_MODE: 'cloud' }).runtimeMode).toBe('cloud') + expect(readCloudModeFlags({ CANONRY_RUNTIME_MODE: 'CLOUD' }).runtimeMode).toBe('cloud') + expect(readCloudModeFlags({ CANONRY_RUNTIME_MODE: 'oss' }).runtimeMode).toBe('oss') + expect(readCloudModeFlags({ CANONRY_RUNTIME_MODE: 'something-else' }).runtimeMode).toBe('oss') +}) + +test('readCloudModeFlags honours CANONRY_SCHEDULER=external', () => { + expect(readCloudModeFlags({ CANONRY_SCHEDULER: 'external' }).scheduler).toBe('external') + expect(readCloudModeFlags({ CANONRY_SCHEDULER: 'EXTERNAL' }).scheduler).toBe('external') + expect(readCloudModeFlags({ CANONRY_SCHEDULER: 'internal' }).scheduler).toBe('internal') + expect(readCloudModeFlags({ CANONRY_SCHEDULER: 'noop' }).scheduler).toBe('internal') +}) + +test('readCloudModeFlags parses boolean flags', () => { + expect(readCloudModeFlags({ CANONRY_MANAGED_SETTINGS: '1' }).managedSettings).toBe(true) + expect(readCloudModeFlags({ CANONRY_MANAGED_SETTINGS: 'true' }).managedSettings).toBe(true) + expect(readCloudModeFlags({ CANONRY_MANAGED_SETTINGS: 'YES' }).managedSettings).toBe(true) + expect(readCloudModeFlags({ CANONRY_MANAGED_SETTINGS: '0' }).managedSettings).toBe(false) + expect(readCloudModeFlags({ CANONRY_MANAGED_SETTINGS: 'false' }).managedSettings).toBe(false) + expect(readCloudModeFlags({ CANONRY_MANAGED_SETTINGS: '' }).managedSettings).toBe(false) + + expect(readCloudModeFlags({ CANONRY_ENABLE_CLOUD_BOOTSTRAP: '1' }).enableCloudBootstrap).toBe(true) + expect(readCloudModeFlags({ CANONRY_ENABLE_CLOUD_BOOTSTRAP: 'on' }).enableCloudBootstrap).toBe(true) + expect(readCloudModeFlags({}).enableCloudBootstrap).toBe(false) +}) + +test('getPlatformEnv exposes cloud flags and control plane URL', () => { + const env = getPlatformEnv({ + CANONRY_RUNTIME_MODE: 'cloud', + CANONRY_SCHEDULER: 'external', + CANONRY_MANAGED_SETTINGS: '1', + CANONRY_ENABLE_CLOUD_BOOTSTRAP: '1', + CANONRY_CONTROL_PLANE_URL: 'http://canonry-control-plane:8080/', + }) + + expect(env.cloud.runtimeMode).toBe('cloud') + expect(env.cloud.scheduler).toBe('external') + expect(env.cloud.managedSettings).toBe(true) + expect(env.cloud.enableCloudBootstrap).toBe(true) + // Trailing slashes are stripped so callers can append paths cleanly. + expect(env.controlPlaneUrl).toBe('http://canonry-control-plane:8080') +}) + +test('getPlatformEnv defaults cloud flags to OSS posture', () => { + const env = getPlatformEnv({}) + + expect(env.cloud.runtimeMode).toBe('oss') + expect(env.cloud.scheduler).toBe('internal') + expect(env.cloud.managedSettings).toBe(false) + expect(env.cloud.enableCloudBootstrap).toBe(false) + expect(env.controlPlaneUrl).toBeUndefined() +}) + test('getBootstrapEnv parses hosted Canonry env vars', () => { const env = getBootstrapEnv({ CANONRY_API_KEY: 'cnry_test', diff --git a/packages/contracts/src/guest-report.ts b/packages/contracts/src/guest-report.ts new file mode 100644 index 00000000..05827dd4 --- /dev/null +++ b/packages/contracts/src/guest-report.ts @@ -0,0 +1,103 @@ +/** + * Guest report DTOs — the anonymous /aero owner-view flow. + * + * Visitors drop a domain at `/aero`, get a free first report (audit + AI + * visibility sweep), then optionally sign up + claim it into their + * workspace. These DTOs describe the four endpoints that surface the + * report state: + * + * POST /api/v1/guest/report → guestReportCreateResponseSchema + * GET /api/v1/guest/report/:id → guestReportDtoSchema + * GET /api/v1/guest/report/:id/stream → SSE (no JSON DTO) + * POST /api/v1/guest/report/:id/claim → guestReportClaimResponseSchema + */ + +import { z } from 'zod' + +export const guestReportStatusSchema = z.enum([ + 'pending', + 'auditing', + 'sweeping', + 'completed', + 'failed', +]) +export type GuestReportStatus = z.infer + +export const guestReportProgressEventSchema = z.object({ + at: z.string(), + type: z.enum([ + 'sitemap-pulled', + 'page-audited', + 'audit-complete', + 'sweep-started', + 'provider-checked', + 'overall-complete', + 'failed', + ]), + payload: z.record(z.string(), z.unknown()), +}) +export type GuestReportProgressEventDto = z.infer + +const auditFindingSchema = z.object({ + severity: z.string(), + title: z.string(), + url: z.string(), + pointsLost: z.number(), +}) + +const proposedPlanItemSchema = z.object({ + label: z.string(), + pointsImpact: z.number(), + rationale: z.string(), +}) + +/** + * Full guest report row as returned by GET /guest/report/:id. Reflects the + * `serializeGuestReport` shape in `packages/api-routes/src/guest-report.ts`. + * The `progressEvents` array doubles as an SSE replay buffer. + */ +export const guestReportDtoSchema = z.object({ + id: z.string(), + domain: z.string(), + projectId: z.string(), + status: guestReportStatusSchema, + auditScore: z.number().nullable(), + auditPagesCrawled: z.number(), + auditFindingsCount: z.number(), + auditTopFindings: z.array(auditFindingSchema), + overallScore: z.number().nullable(), + aiCitedCount: z.number().nullable(), + aiQueryCount: z.number().nullable(), + aiMentionedCount: z.number().nullable(), + topCompetitor: z.string().nullable(), + topCompetitorCitedCount: z.number().nullable(), + proposedPlan: z.array(proposedPlanItemSchema), + progressEvents: z.array(guestReportProgressEventSchema), + errorMessage: z.string().nullable(), + createdAt: z.string(), + expiresAt: z.string(), + claimedAt: z.string().nullable(), +}) +export type GuestReportDto = z.infer + +/** Slim stub returned by POST /guest/report — just enough to start polling. */ +export const guestReportCreateResponseSchema = z.object({ + id: z.string(), + domain: z.string(), + status: guestReportStatusSchema, + expiresAt: z.string(), +}) +export type GuestReportCreateResponseDto = z.infer + +/** + * Claim response — returned by POST /guest/report/:id/claim. Either + * `claimed: true` (just-now claim) or `alreadyClaimed: true` (idempotent + * re-claim). Both carry the project the report was promoted into. + */ +export const guestReportClaimResponseSchema = z.object({ + claimed: z.literal(true).optional(), + alreadyClaimed: z.literal(true).optional(), + projectName: z.string().nullable(), + projectId: z.string(), +}) +export type GuestReportClaimResponseDto = z.infer diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 019863e8..15631b4c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -35,3 +35,4 @@ export * from './discovery.js' export * from './embeddings.js' export * from './formatting.js' export * from './ai-engines.js' +export * from './guest-report.js' diff --git a/packages/contracts/src/notification.ts b/packages/contracts/src/notification.ts index c874e5d5..ddb48ec0 100644 --- a/packages/contracts/src/notification.ts +++ b/packages/contracts/src/notification.ts @@ -7,12 +7,42 @@ export const notificationEventSchema = z.enum([ 'run.failed', 'insight.critical', 'insight.high', + // Cloud event types (Track 3). Additive — opt-in by webhook subscribers. + // These ride the same `notifications` table as the existing events but use + // the `CloudWebhookPayload` envelope (source: 'canonry-cloud') when emitted. + // Most are emitted by the tenant runtime (`baseline.completed`, + // `connection.created`, `connection.revoked`, eventually `digest.generated`). + // The two action events are emitted by the cloud control plane, not the + // tenant — but the enum value lives here so the control plane can sign and + // dispatch them with the same convention. + 'baseline.completed', + 'digest.generated', + 'action.created', + 'action.completed', + 'connection.created', + 'connection.revoked', ]) export type NotificationEvent = z.infer +/** + * Subset of `NotificationEvent` covering the six new cloud event types + * introduced in Track 3. Used to type the `CloudWebhookPayload.event` field + * narrowly so the legacy + cloud envelopes can be discriminated at the type + * level. + */ +export const cloudNotificationEventSchema = z.enum([ + 'baseline.completed', + 'digest.generated', + 'action.created', + 'action.completed', + 'connection.created', + 'connection.revoked', +]) +export type CloudNotificationEvent = z.infer + export const notificationDtoSchema = z.object({ id: z.string(), - projectId: z.string(), + projectId: z.string().nullable(), channel: z.literal('webhook'), url: z.string().url(), urlDisplay: z.string(), @@ -73,3 +103,34 @@ export interface WebhookPayload { }> dashboardUrl: string } + +/** + * Cloud webhook payload — additive Track 3 envelope used for the six new + * cloud event types. Lives alongside the legacy `WebhookPayload` so existing + * subscribers receive the same shape they always have. The control plane is + * the primary consumer; OSS deployments will only see these envelopes if + * something registers a webhook subscriber for the new events. + * + * Differences from `WebhookPayload`: + * - `source: 'canonry-cloud'` (vs `'canonry'`) so consumers can route + * dispatch by envelope. + * - Carries an `event_id` (UUID) used as the idempotency key on the control + * plane's `event_idempotency` table. Legacy runs key off `run.id`; the + * new envelope is run-agnostic, so we need a stable per-event id. + * - `payload` is event-specific and intentionally typed as + * `Record` so each event can ship its own shape without + * a discriminated-union blow-up. Per-event payloads are documented in + * the spec §12 table. + */ +export const cloudWebhookPayloadSchema = z.object({ + source: z.literal('canonry-cloud'), + event: cloudNotificationEventSchema, + event_id: z.string().uuid(), + project: z.object({ + name: z.string(), + canonicalDomain: z.string(), + }), + payload: z.record(z.string(), z.unknown()), + occurred_at: z.string(), +}) +export type CloudWebhookPayload = z.infer diff --git a/packages/contracts/test/index.test.ts b/packages/contracts/test/index.test.ts index 9bd2390a..00f6c0dd 100644 --- a/packages/contracts/test/index.test.ts +++ b/packages/contracts/test/index.test.ts @@ -452,10 +452,80 @@ test('AppError is an instance of Error', () => { expect(err.name).toBe('AppError') }) +import { cloudNotificationEventSchema, cloudWebhookPayloadSchema } from '../src/notification.js' + +describe('cloudWebhookPayloadSchema (Track 3)', () => { + +test('cloudWebhookPayloadSchema accepts a baseline.completed envelope', () => { + const payload = cloudWebhookPayloadSchema.parse({ + source: 'canonry-cloud', + event: 'baseline.completed', + event_id: '8df9b3e0-9c4e-4f1b-b9d7-2c1f9b4c1234', + project: { name: 'acme', canonicalDomain: 'acme.com' }, + payload: { runId: 'run-1', reportSummary: { status: 'completed' } }, + occurred_at: '2026-05-22T12:00:00.000Z', + }) + expect(payload.source).toBe('canonry-cloud') + expect(payload.event).toBe('baseline.completed') +}) + +test('cloudWebhookPayloadSchema rejects a legacy `source: canonry`', () => { + expect(() => + cloudWebhookPayloadSchema.parse({ + source: 'canonry', + event: 'baseline.completed', + event_id: '8df9b3e0-9c4e-4f1b-b9d7-2c1f9b4c1234', + project: { name: 'acme', canonicalDomain: 'acme.com' }, + payload: {}, + occurred_at: '2026-05-22T12:00:00.000Z', + }), + ).toThrow() +}) + +test('cloudWebhookPayloadSchema rejects a legacy notification event', () => { + expect(() => + cloudWebhookPayloadSchema.parse({ + source: 'canonry-cloud', + event: 'run.completed', + event_id: '8df9b3e0-9c4e-4f1b-b9d7-2c1f9b4c1234', + project: { name: 'acme', canonicalDomain: 'acme.com' }, + payload: {}, + occurred_at: '2026-05-22T12:00:00.000Z', + }), + ).toThrow() +}) + +test('cloudNotificationEventSchema enumerates exactly the six new events', () => { + const all = cloudNotificationEventSchema.options + expect(all.sort()).toEqual([ + 'action.completed', + 'action.created', + 'baseline.completed', + 'connection.created', + 'connection.revoked', + 'digest.generated', + ]) +}) + +}) // end cloudWebhookPayloadSchema + describe('notificationEventSchema', () => { -test('notificationEventSchema accepts valid events', () => { - for (const event of ['citation.lost', 'citation.gained', 'run.completed', 'run.failed']) { +test('notificationEventSchema accepts valid legacy events', () => { + for (const event of ['citation.lost', 'citation.gained', 'run.completed', 'run.failed', 'insight.critical', 'insight.high']) { + expect(notificationEventSchema.parse(event)).toBe(event) + } +}) + +test('notificationEventSchema accepts new cloud event types (Track 3)', () => { + for (const event of [ + 'baseline.completed', + 'digest.generated', + 'action.created', + 'action.completed', + 'connection.created', + 'connection.revoked', + ]) { expect(notificationEventSchema.parse(event)).toBe(event) } }) diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 8072e982..47a9d83a 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -1567,6 +1567,194 @@ export const MIGRATION_VERSIONS: ReadonlyArray = [ `CREATE INDEX IF NOT EXISTS idx_gbp_place_details_loc ON gbp_place_details(project_id, location_name, synced_at)`, ], }, + { + version: 73, + name: 'cloud-metadata-singleton', + // Track 3 (Canonry Hosted) — singleton tenant-side row populated by + // `POST /api/v1/cloud/bootstrap`. Purely additive: OSS deployments will + // never write this row because the route returns 404 when + // `CANONRY_ENABLE_CLOUD_BOOTSTRAP` is unset. + // + // The CHECK constraint enforces a single-row invariant — every UPSERT + // targets `id = 'singleton'`. Idempotent: `CREATE TABLE IF NOT EXISTS` + // is safe to re-run. + statements: [ + `CREATE TABLE IF NOT EXISTS cloud_metadata ( + id TEXT PRIMARY KEY DEFAULT 'singleton', + tenant_id TEXT NOT NULL, + account_id TEXT NOT NULL, + plan TEXT NOT NULL, + control_plane_callback_url TEXT NOT NULL, + webhook_secret TEXT NOT NULL, + managed_google_client_id TEXT, + managed_google_redirect_url TEXT, + bootstrapped_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (id = 'singleton') + )`, + ], + }, + { + version: 74, + name: 'provider-token-usage', + // Track 1 (Canonry Hosted) — per-(run, provider, model) token-cost + // telemetry. Populated by `RunCoordinator.onRunCompleted` from the usage + // counters that providers expose on their raw responses: + // • Anthropic: `usage.input_tokens` / `usage.output_tokens` / + // `usage.cache_read_input_tokens` + // • OpenAI: `usage.prompt_tokens` / `usage.completion_tokens` / + // `usage.prompt_tokens_details.cached_tokens` + // • Gemini: `usageMetadata.promptTokenCount` / + // `usageMetadata.candidatesTokenCount` / + // `usageMetadata.cachedContentTokenCount` + // • Perplexity: `usage.prompt_tokens` / `usage.completion_tokens` + // + // Purely additive: rows are only written when the matching usage block is + // present on the snapshot's stored raw response, so OSS deployments + // silently accumulate token telemetry without any behavior change. The + // table is read by the cloud control plane (via webhook + tenant API) for + // billing, and by future doctor / agent surfaces for cost diagnostics. + statements: [ + `CREATE TABLE IF NOT EXISTS provider_token_usage ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + model TEXT, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cached_input_tokens INTEGER NOT NULL DEFAULT 0, + occurred_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_provider_token_usage_run ON provider_token_usage(run_id)`, + `CREATE INDEX IF NOT EXISTS idx_provider_token_usage_project ON provider_token_usage(project_id)`, + `CREATE INDEX IF NOT EXISTS idx_provider_token_usage_provider ON provider_token_usage(provider)`, + `CREATE INDEX IF NOT EXISTS idx_provider_token_usage_occurred ON provider_token_usage(occurred_at)`, + ], + }, + { + version: 75, + name: 'notifications-project-id-nullable', + // Track 3 (Canonry Hosted): the control-plane webhook subscriber + // registered at bootstrap is tenant-scoped, not project-scoped. The + // legacy NOT NULL constraint on `notifications.project_id` would force + // a sentinel value or block bootstrap entirely (bootstrap runs before + // any projects exist). Rebuild the table with the column nullable. + // + // SQLite doesn't support `ALTER COLUMN ... DROP NOT NULL`, so we use + // the canonical table-rebuild pattern (mirrors v53 / v58 / v60). + // Idempotent: a re-run drops + recreates the staging table cleanly; + // the runner wraps every version in a transaction so a partial + // failure rolls back. + statements: [ + // Defense in depth: the column was added by v3, but very old DBs that jump + // straight from bootstrap here (e.g. test fixtures) may not have it. The + // runner swallows duplicate-column errors so this is safe to re-run. + `ALTER TABLE notifications ADD COLUMN webhook_secret TEXT`, + `DROP TABLE IF EXISTS notifications_rebuild`, + `CREATE TABLE notifications_rebuild ( + id TEXT PRIMARY KEY, + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + channel TEXT NOT NULL, + config TEXT NOT NULL, + webhook_secret TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `INSERT INTO notifications_rebuild ( + id, project_id, channel, config, webhook_secret, enabled, created_at, updated_at + ) + SELECT id, project_id, channel, config, webhook_secret, enabled, created_at, updated_at + FROM notifications`, + `DROP TABLE notifications`, + `ALTER TABLE notifications_rebuild RENAME TO notifications`, + `CREATE INDEX IF NOT EXISTS idx_notifications_project ON notifications(project_id)`, + ], + }, + { + version: 76, + name: 'users-and-guest-reports', + // Aero owner-view onboarding (the /aero flow): two new tables. + // + // `users` — identity records for Google-OAuth signup. OSS canonry stays + // single-tenant in its deployment posture; this table fills in only + // when someone signs up via Google. Existing password-auth users keep + // working untouched (no `users` row needed for them — the + // `dashboardPasswordHash` + `api_keys` flow is intact). + // + // `guest_reports` — the anonymous free-first-report record. A visitor + // drops a domain on /aero, we create a row + a transient guest + // project, the audit + sweep workers populate the row, and the + // visitor watches their score reveal. After signup the report is + // claimed into the user's workspace; unclaimed rows expire in 7 + // days. The `progress_events` JSON column doubles as an SSE + // replay buffer so a flaky network mid-audit doesn't lose state. + statements: [ + `CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + google_sub TEXT UNIQUE, + display_name TEXT, + api_key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + last_seen_at TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_users_google_sub ON users(google_sub)`, + + `CREATE TABLE IF NOT EXISTS guest_reports ( + id TEXT PRIMARY KEY, + domain TEXT NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + audit_score INTEGER, + audit_top_findings TEXT NOT NULL DEFAULT '[]', + audit_pages_crawled INTEGER NOT NULL DEFAULT 0, + audit_findings_count INTEGER NOT NULL DEFAULT 0, + overall_score INTEGER, + ai_cited_count INTEGER, + ai_query_count INTEGER, + ai_mentioned_count INTEGER, + top_competitor TEXT, + top_competitor_cited_count INTEGER, + progress_events TEXT NOT NULL DEFAULT '[]', + proposed_plan TEXT NOT NULL DEFAULT '[]', + error_message TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + claimed_at TEXT, + claimed_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_guest_reports_status ON guest_reports(status)`, + `CREATE INDEX IF NOT EXISTS idx_guest_reports_expires ON guest_reports(expires_at)`, + `CREATE INDEX IF NOT EXISTS idx_guest_reports_claimed ON guest_reports(claimed_by_user_id)`, + ], + }, + { + version: 77, + name: 'app-settings-kv', + // Generic key/value store for instance-wide configuration that the + // local canonry serve keeps in `~/.canonry/config.yaml` but the cloud + // `apps/api` (Cloud Run, no local config file) needs a place for. + // + // First user: the dashboard password hash, persisted under the + // `dashboard_password_hash` key by the api-routes session plugin so + // the cloud entry can support the same `/session/setup` + login flow + // as the local daemon. Future entries should follow the same shape — + // one row per setting, value is opaque (JSON or raw string, store + // decides). + // + // Single-tenant by design: the table is keyed only on `key`, NOT + // `(owner_id, key)`. Matches the broader deployment posture in + // root AGENTS.md ("Deployment Posture") — one instance per team. + statements: [ + `CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + ], + }, ] /** diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 6535bc21..5df3a294 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -143,6 +143,123 @@ export const apiKeys = sqliteTable('api_keys', { index('idx_api_keys_prefix').on(table.keyPrefix), ]) +/** + * Users — identity records for the operator(s) of this canonry deployment. + * + * OSS canonry is single-tenant by deployment posture; one row is the typical + * shape (the operator who installed canonry). The table exists so we can + * carry Google-OAuth identities alongside the existing password-auth flow: + * a user signs up with Google, we create a row + bind an API key to them + * via `api_key_id`. Subsequent sign-ins lookup by `google_sub` and re-issue + * a session bound to the same key. + * + * Password-only deployments don't need a `users` row — the existing + * `api_keys` + `dashboardPasswordHash` config flow continues to work + * untouched. This table only fills in when Google OAuth is used. + */ +export const users = sqliteTable('users', { + id: text('id').primaryKey(), + email: text('email').notNull().unique(), + /** Google OIDC `sub` (subject identifier) — stable per-user across sessions. + * Unique so we can find-by-sub on every callback. NULL on rows created by + * some other identity provider in the future. */ + googleSub: text('google_sub').unique(), + displayName: text('display_name'), + /** Backing API key — every user is bound to exactly one. The session cookie + * resolves to this key id via the existing session table. */ + apiKeyId: text('api_key_id').notNull().references(() => apiKeys.id, { onDelete: 'cascade' }), + createdAt: text('created_at').notNull(), + lastSeenAt: text('last_seen_at'), +}, (table) => [ + index('idx_users_google_sub').on(table.googleSub), +]) + +/** + * Guest reports — the anonymous free-first-report record that powers the + * /aero onboarding flow. A visitor drops a domain, this row is created + * (no auth), the audit + sweep workers populate it, and an SSE stream + * emits progress events as data lands. + * + * Once the visitor signs up (Google or password), `/claim` transfers the + * report into a real `projects` row owned by the new user, stamps + * `claimed_at` + `claimed_by_user_id`, and the guest row stops accepting + * writes. Unclaimed reports expire after 7 days. + * + * `project_id` is the internal project the audit runs against — we create + * a transient one named `guest-` so we can reuse the existing audit / + * sweep pipelines. After claim, the project is renamed (or kept) and + * exposed in the user's workspace. + */ +export const guestReports = sqliteTable('guest_reports', { + id: text('id').primaryKey(), + domain: text('domain').notNull(), + /** Internal project the audit + sweep ran against. Cascades on project delete + * so cleanup is automatic when a guest project is purged. */ + projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }), + status: text('status').notNull().default('pending'), + /** AEO Audit Score 0-100 — structural site health from the crawl. NULL while + * the audit is still running. */ + auditScore: integer('audit_score'), + /** Compact JSON of the top audit findings the score reveal renders. */ + auditTopFindings: text('audit_top_findings', { mode: 'json' }).$type>().notNull().default([]), + auditPagesCrawled: integer('audit_pages_crawled').notNull().default(0), + auditFindingsCount: integer('audit_findings_count').notNull().default(0), + /** Overall AEO Score 0-100 — composite of audit + AI visibility + competitor + * gap. NULL until the AI sweep completes. */ + overallScore: integer('overall_score'), + aiCitedCount: integer('ai_cited_count'), + aiQueryCount: integer('ai_query_count'), + aiMentionedCount: integer('ai_mentioned_count'), + topCompetitor: text('top_competitor'), + topCompetitorCitedCount: integer('top_competitor_cited_count'), + /** SSE progress events captured for replay if the client reconnects. */ + progressEvents: text('progress_events', { mode: 'json' }).$type + }>>().notNull().default([]), + /** Aero's proposed plan after the full analysis — a small structured array + * the reveal screen renders as "I can close X points by doing Y." */ + proposedPlan: text('proposed_plan', { mode: 'json' }).$type>().notNull().default([]), + errorMessage: text('error_message'), + createdAt: text('created_at').notNull(), + expiresAt: text('expires_at').notNull(), + claimedAt: text('claimed_at'), + claimedByUserId: text('claimed_by_user_id').references(() => users.id, { onDelete: 'set null' }), +}, (table) => [ + index('idx_guest_reports_status').on(table.status), + index('idx_guest_reports_expires').on(table.expiresAt), + index('idx_guest_reports_claimed').on(table.claimedByUserId), +]) + +/** + * Generic key/value store for instance-wide configuration. + * + * Local `canonry serve` already keeps most config in `~/.canonry/config.yaml`, + * but the cloud `apps/api` runs on Cloud Run with no writable local config + * file. This table fills that gap: the `apps/api` session plugin persists the + * dashboard password hash here so it survives container restarts, while the + * local daemon keeps using config.yaml as its source of truth. + * + * Single-tenant by design (no `owner_id`) — matches the broader deployment + * posture documented in the root `AGENTS.md`. Each value is opaque to the + * runner; callers JSON-encode if they need structure. + */ +export const appSettings = sqliteTable('app_settings', { + key: text('key').primaryKey(), + value: text('value').notNull(), + updatedAt: text('updated_at').notNull(), +}) + export const schedules = sqliteTable('schedules', { id: text('id').primaryKey(), projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }), @@ -168,7 +285,15 @@ export const schedules = sqliteTable('schedules', { export const notifications = sqliteTable('notifications', { id: text('id').primaryKey(), - projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }), + /** + * Project this notification belongs to. NULL means a tenant-scoped + * webhook (no specific project). Track 3 (Canonry Hosted) introduced + * the NULL form so the control-plane callback registered at bootstrap + * — which fires for events across every project on the instance — + * doesn't need a sentinel project to satisfy the FK. Legacy rows are + * always non-null and keep the cascading delete behavior. + */ + projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }), channel: text('channel').notNull(), config: text('config', { mode: 'json' }).$type<{ url: string; events: string[] }>().notNull(), webhookSecret: text('webhook_secret'), @@ -847,6 +972,71 @@ export const recommendationExplanations = sqliteTable('recommendation_explanatio index('idx_recommendation_explanations_project').on(table.projectId), ]) +/** + * Singleton tenant-side row that records the tenant has been bootstrapped + * by a control plane. Track 3 (Canonry Hosted). Populated by + * `POST /api/v1/cloud/bootstrap` and read nowhere else in the tenant runtime + * today — the row's value to the operator is that it lets `canonry doctor` + * (and future cloud checks) verify the tenant knows it's hosted and which + * managed-OAuth client it's been wired against. + * + * One row max — `id` is fixed to `'singleton'` via the CHECK constraint in + * the migration. Upsert semantics: re-running bootstrap with the same + * `tenant_id` is idempotent and refreshes the row. + */ +export const cloudMetadata = sqliteTable('cloud_metadata', { + id: text('id').primaryKey().default('singleton'), + tenantId: text('tenant_id').notNull(), + accountId: text('account_id').notNull(), + plan: text('plan').notNull(), + controlPlaneCallbackUrl: text('control_plane_callback_url').notNull(), + webhookSecret: text('webhook_secret').notNull(), + managedGoogleClientId: text('managed_google_client_id'), + managedGoogleRedirectUrl: text('managed_google_redirect_url'), + bootstrappedAt: text('bootstrapped_at').notNull(), + updatedAt: text('updated_at').notNull(), +}) + +/** + * Per-(run, provider, model) token-cost telemetry, populated by + * `RunCoordinator.onRunCompleted` from the usage block on each query + * snapshot's stored raw response. Track 1 (Canonry Hosted). + * + * Three counters per row: + * - `inputTokens` — prompt-side tokens (Anthropic `usage.input_tokens`, + * OpenAI `usage.prompt_tokens`, Gemini `usageMetadata.promptTokenCount`, + * Perplexity `usage.prompt_tokens`). + * - `outputTokens` — completion-side tokens (Anthropic + * `usage.output_tokens`, OpenAI `usage.completion_tokens`, Gemini + * `usageMetadata.candidatesTokenCount`, Perplexity + * `usage.completion_tokens`). + * - `cachedInputTokens` — input tokens served from prompt cache (Anthropic + * `usage.cache_read_input_tokens`, OpenAI + * `usage.prompt_tokens_details.cached_tokens`, Gemini + * `usageMetadata.cachedContentTokenCount`). Defaults to 0 when the + * provider doesn't return a cache-read field. + * + * Rows are written best-effort: persistence failures log but never block the + * run-completion pipeline. OSS deployments accumulate data silently — the + * cloud control plane is the primary consumer (billing + cost dashboards). + */ +export const providerTokenUsage = sqliteTable('provider_token_usage', { + id: text('id').primaryKey(), + runId: text('run_id').notNull().references(() => runs.id, { onDelete: 'cascade' }), + projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }), + provider: text('provider').notNull(), + model: text('model'), + inputTokens: integer('input_tokens').notNull().default(0), + outputTokens: integer('output_tokens').notNull().default(0), + cachedInputTokens: integer('cached_input_tokens').notNull().default(0), + occurredAt: text('occurred_at').notNull(), +}, (table) => [ + index('idx_provider_token_usage_run').on(table.runId), + index('idx_provider_token_usage_project').on(table.projectId), + index('idx_provider_token_usage_provider').on(table.provider), + index('idx_provider_token_usage_occurred').on(table.occurredAt), +]) + /** * Internal bookkeeping for the migration runner. One row per applied * `MIGRATION_VERSIONS` entry. The migrator reads `MAX(version)` on boot and diff --git a/packages/db/test/index.test.ts b/packages/db/test/index.test.ts index 415b0644..eeca506c 100644 --- a/packages/db/test/index.test.ts +++ b/packages/db/test/index.test.ts @@ -36,6 +36,7 @@ import { gaTrafficSummaries, insights, healthSnapshots, + providerTokenUsage, } from '../src/index.js' function createTempDb() { @@ -868,3 +869,93 @@ test('_migrations table is created on first migrate', () => { const tableInfo = sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='_migrations'").all() expect(tableInfo.length).toBe(1) }) + +test('v68 creates provider_token_usage table with the documented columns', () => { + const { dbPath, tmpDir } = createTempDb() + onTestFinished(() => cleanup(tmpDir)) + + const sqlite = new Database(dbPath, { readonly: true }) + onTestFinished(() => sqlite.close()) + + const cols = sqlite + .prepare(`PRAGMA table_info(provider_token_usage)`) + .all() as Array<{ name: string; notnull: number; dflt_value: string | null }> + + const names = cols.map(c => c.name).sort() + expect(names).toEqual([ + 'cached_input_tokens', + 'id', + 'input_tokens', + 'model', + 'occurred_at', + 'output_tokens', + 'project_id', + 'provider', + 'run_id', + ]) + + // Every counter has a NOT NULL DEFAULT 0 so a row with zero usage is + // valid — providers that don't expose `cache_read_input_tokens` should + // still produce a token-usage row. + const counterCols = new Set(['input_tokens', 'output_tokens', 'cached_input_tokens']) + for (const col of cols) { + if (counterCols.has(col.name)) { + expect(col.notnull, `${col.name} should be NOT NULL`).toBe(1) + expect(col.dflt_value, `${col.name} should default to 0`).toBe('0') + } + } +}) + +test('provider_token_usage round-trips a row with all counters', () => { + const { db, tmpDir } = createTempDb() + onTestFinished(() => cleanup(tmpDir)) + + const now = new Date().toISOString() + db.insert(projects).values({ + id: 'proj_tok', name: 'tok', displayName: 'tok', + canonicalDomain: 'example.com', country: 'US', language: 'en', + createdAt: now, updatedAt: now, + }).run() + db.insert(runs).values({ + id: 'run_tok', projectId: 'proj_tok', kind: 'answer-visibility', status: 'completed', + trigger: 'manual', startedAt: now, finishedAt: now, createdAt: now, + }).run() + + db.insert(providerTokenUsage).values({ + id: 'tok_1', runId: 'run_tok', projectId: 'proj_tok', + provider: 'claude', model: 'claude-sonnet-4-6', + inputTokens: 1234, outputTokens: 567, cachedInputTokens: 89, + occurredAt: now, + }).run() + + const [row] = db.select().from(providerTokenUsage).where(eq(providerTokenUsage.runId, 'run_tok')).all() + expect(row.provider).toBe('claude') + expect(row.model).toBe('claude-sonnet-4-6') + expect(row.inputTokens).toBe(1234) + expect(row.outputTokens).toBe(567) + expect(row.cachedInputTokens).toBe(89) +}) + +test('provider_token_usage cascades on run delete', () => { + const { db, tmpDir } = createTempDb() + onTestFinished(() => cleanup(tmpDir)) + + const now = new Date().toISOString() + db.insert(projects).values({ + id: 'proj_tok', name: 'tok', displayName: 'tok', + canonicalDomain: 'example.com', country: 'US', language: 'en', + createdAt: now, updatedAt: now, + }).run() + db.insert(runs).values({ + id: 'run_tok', projectId: 'proj_tok', kind: 'answer-visibility', status: 'completed', + trigger: 'manual', createdAt: now, + }).run() + db.insert(providerTokenUsage).values({ + id: 'tok_1', runId: 'run_tok', projectId: 'proj_tok', + provider: 'openai', inputTokens: 10, outputTokens: 20, cachedInputTokens: 0, + occurredAt: now, + }).run() + + db.delete(runs).where(eq(runs.id, 'run_tok')).run() + expect(db.select().from(providerTokenUsage).all()).toEqual([]) +}) diff --git a/packages/provider-claude/src/adapter.ts b/packages/provider-claude/src/adapter.ts index ddfec77c..abbae521 100644 --- a/packages/provider-claude/src/adapter.ts +++ b/packages/provider-claude/src/adapter.ts @@ -20,6 +20,7 @@ function toClaudeConfig(config: ProviderConfig): ClaudeConfig { apiKey: config.apiKey ?? '', model: config.model, quotaPolicy: config.quotaPolicy, + ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), } } diff --git a/packages/provider-claude/src/normalize.ts b/packages/provider-claude/src/normalize.ts index e3eff508..3b988477 100644 --- a/packages/provider-claude/src/normalize.ts +++ b/packages/provider-claude/src/normalize.ts @@ -14,6 +14,19 @@ import type { const DEFAULT_MODEL = 'claude-sonnet-4-6' const VALIDATION_PATTERN = /^claude-/ +/** + * Construct a configured Anthropic client. Threads the optional `baseUrl` + * override through so Canonry Hosted can route Anthropic traffic via the + * per-tenant LLM proxy. Used by every call site in this adapter and + * exported so tests can assert the URL is threaded through. + */ +export function createAnthropicClient(config: ClaudeConfig): Anthropic { + return new Anthropic({ + apiKey: config.apiKey, + ...(config.baseUrl ? { baseURL: config.baseUrl } : {}), + }) +} + /** * Resolve the effective model name, validating that it is a recognised Claude * model identifier (must start with "claude-"). If an invalid name is stored @@ -53,7 +66,7 @@ export async function healthcheck(config: ClaudeConfig): Promise client.messages.create({ model, @@ -80,7 +93,7 @@ export async function healthcheck(config: ClaudeConfig): Promise { const model = resolveModel(input.config) - const client = new Anthropic({ apiKey: input.config.apiKey }) + const client = createAnthropicClient(input.config) const webSearchTool: Record = { type: 'web_search_20250305', @@ -333,7 +346,7 @@ function extractDomainFromUri(uri: string): string | null { export async function generateText(prompt: string, config: ClaudeConfig): Promise { const model = resolveModel(config) - const client = new Anthropic({ apiKey: config.apiKey }) + const client = createAnthropicClient(config) const response = await withRetry(() => client.messages.create({ model, diff --git a/packages/provider-claude/src/types.ts b/packages/provider-claude/src/types.ts index 281c4190..237c65fe 100644 --- a/packages/provider-claude/src/types.ts +++ b/packages/provider-claude/src/types.ts @@ -6,6 +6,13 @@ export interface ClaudeConfig { apiKey: string quotaPolicy: ProviderQuotaPolicy model?: string + /** + * Optional base URL override. When set, every `new Anthropic(...)` call in + * this adapter passes it as `baseURL`. Defaults to Anthropic's public API + * endpoint when omitted. Used by Canonry Hosted to route Anthropic calls + * through the per-tenant LLM proxy (Track 1). + */ + baseUrl?: string } export interface ClaudeHealthcheckResult { diff --git a/packages/provider-claude/test/index.test.ts b/packages/provider-claude/test/index.test.ts index 6a463dfb..06d7db7d 100644 --- a/packages/provider-claude/test/index.test.ts +++ b/packages/provider-claude/test/index.test.ts @@ -1,6 +1,11 @@ import { test, expect } from 'vitest' -import { validateConfig, normalizeResult, reparseStoredResult } from '../src/index.js' +import { + validateConfig, + normalizeResult, + reparseStoredResult, + createAnthropicClient, +} from '../src/index.js' import type { ClaudeRawResult } from '../src/index.js' const validConfig = { @@ -331,3 +336,25 @@ test('normalizeResult prefers reparsed citations over stale extracted fields whe expect(result.citedDomains).toEqual(['canonry.ai']) expect(result.searchQueries).toEqual(['canonry reviews']) }) + +test('createAnthropicClient threads baseUrl through to the SDK as baseURL', () => { + // Canonry Hosted routes Anthropic traffic through a per-tenant LLM proxy + // by setting `ProviderConfig.baseUrl`. The Anthropic SDK exposes the + // resolved base URL as a public `baseURL` property — asserting on it + // proves the override is honoured end-to-end. + const proxyUrl = 'http://canonry-llm-proxy:9200/anthropic' + const client = createAnthropicClient({ + apiKey: 'sk-test', + quotaPolicy: validConfig.quotaPolicy, + baseUrl: proxyUrl, + }) + expect((client as unknown as { baseURL: string }).baseURL).toContain('canonry-llm-proxy:9200/anthropic') +}) + +test('createAnthropicClient defaults to api.anthropic.com when no baseUrl is provided', () => { + const client = createAnthropicClient({ + apiKey: 'sk-test', + quotaPolicy: validConfig.quotaPolicy, + }) + expect((client as unknown as { baseURL: string }).baseURL).toContain('api.anthropic.com') +}) diff --git a/packages/provider-gemini/src/adapter.ts b/packages/provider-gemini/src/adapter.ts index 8e7a4b80..30e2d7e3 100644 --- a/packages/provider-gemini/src/adapter.ts +++ b/packages/provider-gemini/src/adapter.ts @@ -23,6 +23,7 @@ function toGeminiConfig(config: ProviderConfig): GeminiConfig { vertexProject: config.vertexProject, vertexRegion: config.vertexRegion, vertexCredentials: config.vertexCredentials, + ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), } } diff --git a/packages/provider-gemini/src/normalize.ts b/packages/provider-gemini/src/normalize.ts index 2c7c964e..5a240388 100644 --- a/packages/provider-gemini/src/normalize.ts +++ b/packages/provider-gemini/src/normalize.ts @@ -32,8 +32,18 @@ function resolveModel(config: GeminiConfig): string { /** * Create a GoogleGenAI client — works for both AI Studio (apiKey) and * Vertex AI (project + location + optional service account credentials). + * + * For AI Studio configs we honour `config.baseUrl` by threading it into + * `httpOptions.baseUrl` (note camelCase — `@google/genai` differs from the + * OpenAI / Anthropic SDKs, which use `baseURL`). Used by Canonry Hosted to + * route AI Studio calls through the per-tenant LLM proxy (Track 1). + * + * Vertex AI configs ignore `baseUrl` — they go through GCP's managed + * endpoint which already authenticates via the operator's GCP project. + * + * Exported so adapter tests can assert the URL is threaded through. */ -function createClient(config: GeminiConfig): GoogleGenAI { +export function createGeminiClient(config: GeminiConfig): GoogleGenAI { if (isVertexConfig(config)) { return new GoogleGenAI({ vertexai: true, @@ -44,9 +54,15 @@ function createClient(config: GeminiConfig): GoogleGenAI { : {}), }) } - return new GoogleGenAI({ apiKey: config.apiKey }) + return new GoogleGenAI({ + apiKey: config.apiKey, + ...(config.baseUrl ? { httpOptions: { baseUrl: config.baseUrl } } : {}), + }) } +// Backwards-compatible alias for the internal call sites. +const createClient = createGeminiClient + export function validateConfig(config: GeminiConfig): GeminiHealthcheckResult { // Check for explicitly provided (but empty) Vertex project — user intended Vertex AI // but forgot to fill in the project ID. 'vertexProject' in config distinguishes diff --git a/packages/provider-gemini/src/types.ts b/packages/provider-gemini/src/types.ts index c36cb2fe..414c3c9a 100644 --- a/packages/provider-gemini/src/types.ts +++ b/packages/provider-gemini/src/types.ts @@ -6,6 +6,15 @@ export interface GeminiConfig { apiKey: string quotaPolicy: ProviderQuotaPolicy model?: string + /** + * Optional base URL override. When set, every `new GoogleGenAI(...)` call + * in this adapter passes it as `httpOptions.baseUrl` (note: `@google/genai` + * uses camelCase `baseUrl`, not `baseURL` like the other SDKs). Used by + * Canonry Hosted to route Gemini calls through the per-tenant LLM proxy + * (Track 1). Ignored on Vertex AI configs — Vertex routes through GCP's + * managed endpoint. + */ + baseUrl?: string /** Vertex AI GCP project ID — when set, uses Vertex AI instead of AI Studio */ vertexProject?: string /** Vertex AI region (default: "us-central1") */ diff --git a/packages/provider-gemini/test/index.test.ts b/packages/provider-gemini/test/index.test.ts index d3dc3877..fa0e002e 100644 --- a/packages/provider-gemini/test/index.test.ts +++ b/packages/provider-gemini/test/index.test.ts @@ -1,6 +1,11 @@ import { test, expect } from 'vitest' -import { validateConfig, normalizeResult, reparseStoredResult } from '../src/index.js' +import { + validateConfig, + normalizeResult, + reparseStoredResult, + createGeminiClient, +} from '../src/index.js' import type { GeminiRawResult } from '../src/index.js' const validConfig = { @@ -302,3 +307,69 @@ test('normalizeResult prefers reparsed grounding metadata over stale extracted f expect(result.citedDomains).toEqual(['canonry.ai']) expect(result.searchQueries).toEqual(['answer visibility software']) }) + +test('createGeminiClient with baseUrl routes API calls through the override host', async () => { + // @google/genai uses camelCase `httpOptions.baseUrl` (not `baseURL` like + // OpenAI / Anthropic). The SDK keeps it private so we can't read it back + // off the instance — instead we mock fetch, fire one request, and assert + // the destination URL hit our proxy. Canonry Hosted routes Gemini calls + // through the per-tenant LLM proxy this way. + const proxyUrl = 'http://localhost:9200/gemini' + const observed: string[] = [] + const originalFetch = globalThis.fetch + globalThis.fetch = ((input: string | URL | Request) => { + const url = typeof input === 'string' + ? input + : input instanceof URL ? input.toString() : input.url + observed.push(url) + // Resolve with a minimal valid response so the SDK doesn't retry. + return Promise.resolve(new Response(JSON.stringify({ candidates: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + }) as typeof globalThis.fetch + + try { + const client = createGeminiClient({ + apiKey: 'gemini-key', + quotaPolicy: validConfig.quotaPolicy, + baseUrl: proxyUrl, + }) + await client.models.generateContent({ model: 'gemini-2.5-flash', contents: 'ping' }).catch(() => undefined) + } finally { + globalThis.fetch = originalFetch + } + + expect(observed.length).toBeGreaterThan(0) + expect(observed[0]).toContain('localhost:9200/gemini') +}) + +test('createGeminiClient without baseUrl hits the public Gemini endpoint', async () => { + const observed: string[] = [] + const originalFetch = globalThis.fetch + globalThis.fetch = ((input: string | URL | Request) => { + const url = typeof input === 'string' + ? input + : input instanceof URL ? input.toString() : input.url + observed.push(url) + return Promise.resolve(new Response(JSON.stringify({ candidates: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + }) as typeof globalThis.fetch + + try { + const client = createGeminiClient({ + apiKey: 'gemini-key', + quotaPolicy: validConfig.quotaPolicy, + }) + await client.models.generateContent({ model: 'gemini-2.5-flash', contents: 'ping' }).catch(() => undefined) + } finally { + globalThis.fetch = originalFetch + } + + expect(observed.length).toBeGreaterThan(0) + // The Gemini public API lives under generativelanguage.googleapis.com when + // not using Vertex AI. + expect(observed[0]).toContain('generativelanguage.googleapis.com') +}) diff --git a/packages/provider-openai/src/adapter.ts b/packages/provider-openai/src/adapter.ts index d5ef4e85..91397e86 100644 --- a/packages/provider-openai/src/adapter.ts +++ b/packages/provider-openai/src/adapter.ts @@ -20,6 +20,7 @@ function toOpenAIConfig(config: ProviderConfig): OpenAIConfig { apiKey: config.apiKey ?? '', model: config.model, quotaPolicy: config.quotaPolicy, + ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), } } diff --git a/packages/provider-openai/src/normalize.ts b/packages/provider-openai/src/normalize.ts index 9791f864..410d3078 100644 --- a/packages/provider-openai/src/normalize.ts +++ b/packages/provider-openai/src/normalize.ts @@ -12,6 +12,19 @@ import type { const DEFAULT_MODEL = 'gpt-5.4' +/** + * Construct a configured OpenAI client. Threads the optional `baseUrl` + * override through so Canonry Hosted can route OpenAI traffic via the + * per-tenant LLM proxy. Used by every call site in this adapter and + * exported so tests can assert the URL is threaded through. + */ +export function createOpenAIClient(config: OpenAIConfig): OpenAI { + return new OpenAI({ + apiKey: config.apiKey, + ...(config.baseUrl ? { baseURL: config.baseUrl } : {}), + }) +} + export function validateConfig(config: OpenAIConfig): OpenAIHealthcheckResult { if (!config.apiKey || config.apiKey.length === 0) { return { ok: false, provider: 'openai', message: 'missing api key' } @@ -29,7 +42,7 @@ export async function healthcheck(config: OpenAIConfig): Promise client.responses.create({ model: config.model ?? DEFAULT_MODEL, @@ -55,7 +68,7 @@ export async function healthcheck(config: OpenAIConfig): Promise { const model = input.config.model ?? DEFAULT_MODEL - const client = new OpenAI({ apiKey: input.config.apiKey }) + const client = createOpenAIClient(input.config) const webSearchTool: Record = { type: 'web_search' } if (input.location) { @@ -285,7 +298,7 @@ function extractDomainFromUri(uri: string): string | null { export async function generateText(prompt: string, config: OpenAIConfig): Promise { const model = config.model ?? DEFAULT_MODEL - const client = new OpenAI({ apiKey: config.apiKey }) + const client = createOpenAIClient(config) const response = await withRetry(() => client.responses.create({ model, diff --git a/packages/provider-openai/src/types.ts b/packages/provider-openai/src/types.ts index 4abde0c3..d373d6c5 100644 --- a/packages/provider-openai/src/types.ts +++ b/packages/provider-openai/src/types.ts @@ -6,6 +6,13 @@ export interface OpenAIConfig { apiKey: string quotaPolicy: ProviderQuotaPolicy model?: string + /** + * Optional base URL override. When set, every `new OpenAI(...)` call in + * this adapter passes it as `baseURL`. Defaults to OpenAI's public API + * endpoint when omitted. Used by Canonry Hosted to route OpenAI calls + * through the per-tenant LLM proxy (Track 1). + */ + baseUrl?: string } export interface OpenAIHealthcheckResult { diff --git a/packages/provider-openai/test/index.test.ts b/packages/provider-openai/test/index.test.ts index c666c129..4f4f92c3 100644 --- a/packages/provider-openai/test/index.test.ts +++ b/packages/provider-openai/test/index.test.ts @@ -1,6 +1,13 @@ import { test, expect } from 'vitest' -import { validateConfig, normalizeResult, buildPrompt, reparseStoredResult } from '../src/index.js' +import { + validateConfig, + normalizeResult, + buildPrompt, + reparseStoredResult, + createOpenAIClient, +} from '../src/index.js' +import { openaiAdapter } from '../src/adapter.js' import type { OpenAIRawResult } from '../src/index.js' const validConfig = { @@ -231,6 +238,53 @@ test('buildPrompt returns the query verbatim', () => { expect(buildPrompt('')).toBe('') }) +test('createOpenAIClient threads baseUrl through to the SDK as baseURL', () => { + const proxyUrl = 'http://canonry-llm-proxy:9200/openai' + const client = createOpenAIClient({ + apiKey: 'sk-test', + quotaPolicy: validConfig.quotaPolicy, + baseUrl: proxyUrl, + }) + + // The OpenAI SDK exposes the resolved base URL as a public `baseURL` + // property. Asserting on it proves Canonry Hosted can route OpenAI + // traffic through the per-tenant LLM proxy. + expect((client as unknown as { baseURL: string }).baseURL).toContain('canonry-llm-proxy:9200/openai') +}) + +test('createOpenAIClient defaults to OpenAI when no baseUrl is provided', () => { + const client = createOpenAIClient({ + apiKey: 'sk-test', + quotaPolicy: validConfig.quotaPolicy, + }) + expect((client as unknown as { baseURL: string }).baseURL).toContain('api.openai.com') +}) + +test('openaiAdapter validateConfig threads ProviderConfig.baseUrl through to OpenAIConfig', () => { + // The adapter accepts a ProviderConfig (shared type) and translates it to + // OpenAIConfig. Without baseUrl threading the value would silently be + // dropped here, so this test guards against regression by re-using the + // same toOpenAIConfig path indirectly through validateConfig, then + // verifying the constructed client honours the baseURL. + const proxyUrl = 'http://canonry-llm-proxy:9200/openai/v1' + const result = openaiAdapter.validateConfig({ + provider: 'openai', + apiKey: 'sk-test', + baseUrl: proxyUrl, + quotaPolicy: validConfig.quotaPolicy, + }) + expect(result.ok).toBe(true) + + // The adapter's downstream code uses createOpenAIClient with the same + // OpenAIConfig — we verify by instantiating the client directly. + const client = createOpenAIClient({ + apiKey: 'sk-test', + quotaPolicy: validConfig.quotaPolicy, + baseUrl: proxyUrl, + }) + expect((client as unknown as { baseURL: string }).baseURL).toContain('canonry-llm-proxy:9200/openai/v1') +}) + test('reparseStoredResult extracts search queries from web_search_call actions', () => { const result = reparseStoredResult({ output: [ diff --git a/packages/provider-perplexity/src/adapter.ts b/packages/provider-perplexity/src/adapter.ts index 8075cd57..beb7980a 100644 --- a/packages/provider-perplexity/src/adapter.ts +++ b/packages/provider-perplexity/src/adapter.ts @@ -20,6 +20,10 @@ function toPerplexityConfig(config: ProviderConfig): PerplexityConfig { apiKey: config.apiKey ?? '', model: config.model, quotaPolicy: config.quotaPolicy, + // Perplexity's adapter has always pointed at https://api.perplexity.ai; + // honour an explicit override (e.g. the canonry-hosted LLM proxy) if + // provided. Defaults to the public Sonar endpoint when omitted. + ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), } } diff --git a/packages/provider-perplexity/src/normalize.ts b/packages/provider-perplexity/src/normalize.ts index 81b005f5..3b38e2bf 100644 --- a/packages/provider-perplexity/src/normalize.ts +++ b/packages/provider-perplexity/src/normalize.ts @@ -13,6 +13,21 @@ import type { const DEFAULT_MODEL = 'sonar' const BASE_URL = 'https://api.perplexity.ai' +/** + * Construct a configured OpenAI-compatible client pointed at the Perplexity + * Sonar API. Threads the optional `baseUrl` override through so Canonry + * Hosted can route Perplexity traffic via the per-tenant LLM proxy. Same + * wire protocol either way — Perplexity exposes an OpenAI-compatible + * `chat.completions` surface. Exported so adapter tests can assert the URL + * is threaded through. + */ +export function createPerplexityClient(config: PerplexityConfig): OpenAI { + return new OpenAI({ + apiKey: config.apiKey, + baseURL: config.baseUrl ?? BASE_URL, + }) +} + export function validateConfig(config: PerplexityConfig): PerplexityHealthcheckResult { if (!config.apiKey || config.apiKey.length === 0) { return { ok: false, provider: 'perplexity', message: 'missing api key' } @@ -30,7 +45,7 @@ export async function healthcheck(config: PerplexityConfig): Promise client.chat.completions.create({ model: config.model ?? DEFAULT_MODEL, @@ -56,7 +71,7 @@ export async function healthcheck(config: PerplexityConfig): Promise { const model = input.config.model ?? DEFAULT_MODEL - const client = new OpenAI({ apiKey: input.config.apiKey, baseURL: BASE_URL }) + const client = createPerplexityClient(input.config) const prompt = buildPrompt(input.query, input.location) @@ -270,7 +285,7 @@ function extractDomainFromUri(uri: string): string | null { export async function generateText(prompt: string, config: PerplexityConfig): Promise { const model = config.model ?? DEFAULT_MODEL - const client = new OpenAI({ apiKey: config.apiKey, baseURL: BASE_URL }) + const client = createPerplexityClient(config) const response = await withRetry(() => client.chat.completions.create({ model, diff --git a/packages/provider-perplexity/src/types.ts b/packages/provider-perplexity/src/types.ts index c844ecb7..4c04f7a2 100644 --- a/packages/provider-perplexity/src/types.ts +++ b/packages/provider-perplexity/src/types.ts @@ -6,6 +6,13 @@ export interface PerplexityConfig { apiKey: string quotaPolicy: ProviderQuotaPolicy model?: string + /** + * Optional base URL override. Defaults to `https://api.perplexity.ai` (the + * Perplexity Sonar OpenAI-compatible endpoint). Used by Canonry Hosted to + * route Perplexity calls through the per-tenant LLM proxy (Track 1). + * Same wire format — Perplexity uses the OpenAI SDK in compatibility mode. + */ + baseUrl?: string } export interface PerplexityHealthcheckResult { diff --git a/packages/provider-perplexity/test/normalize.test.ts b/packages/provider-perplexity/test/normalize.test.ts index bf7770f3..308e9671 100644 --- a/packages/provider-perplexity/test/normalize.test.ts +++ b/packages/provider-perplexity/test/normalize.test.ts @@ -1,7 +1,20 @@ -import { describe, it, expect } from 'vitest' -import { extractCitations, extractCitedDomains, validateConfig, normalizeResult, reparseStoredResult } from '../src/normalize.js' +import { describe, it, expect, test } from 'vitest' +import { + extractCitations, + extractCitedDomains, + validateConfig, + normalizeResult, + reparseStoredResult, + createPerplexityClient, +} from '../src/normalize.js' import type { PerplexityRawResult, GroundingSource } from '../src/types.js' +const QUOTA = { + maxConcurrency: 2, + maxRequestsPerMinute: 10, + maxRequestsPerDay: 1000, +} + describe('extractCitations', () => { it('extracts string citations from response', () => { const raw = { @@ -262,3 +275,23 @@ describe('reparseStoredResult', () => { ]) }) }) + +test('createPerplexityClient honours an explicit baseUrl override', () => { + // Perplexity uses the OpenAI SDK in compatibility mode; the SDK exposes + // the resolved base URL as a public `baseURL` property. Asserting on it + // proves Canonry Hosted can route Perplexity traffic through the + // per-tenant LLM proxy via the same `ProviderConfig.baseUrl` knob the + // other adapters use. + const proxyUrl = 'http://canonry-llm-proxy:9200/perplexity' + const client = createPerplexityClient({ + apiKey: 'pplx-test', + quotaPolicy: QUOTA, + baseUrl: proxyUrl, + }) + expect((client as unknown as { baseURL: string }).baseURL).toContain('canonry-llm-proxy:9200/perplexity') +}) + +test('createPerplexityClient defaults to api.perplexity.ai when no override is set', () => { + const client = createPerplexityClient({ apiKey: 'pplx-test', quotaPolicy: QUOTA }) + expect((client as unknown as { baseURL: string }).baseURL).toContain('api.perplexity.ai') +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 816cab54..71f6c67c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,9 @@ importers: '@ainyc/canonry-db': specifier: workspace:* version: link:../../packages/db + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.2) fastify: specifier: ^5.8.5 version: 5.8.5 @@ -218,6 +221,9 @@ importers: '@ainyc/canonry-intelligence': specifier: workspace:* version: link:../intelligence + '@fastify/rate-limit': + specifier: ^10.2.2 + version: 10.3.0 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.2) @@ -1256,6 +1262,9 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/rate-limit@10.3.0': + resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} + '@fastify/send@4.1.0': resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} @@ -5779,6 +5788,12 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 + '@fastify/rate-limit@10.3.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + '@fastify/send@4.1.0': dependencies: '@lukeed/ms': 2.0.2