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