import { FastifyInstance } from 'fastify'; import { DatabaseClient } from '@ainyc/canonry-db'; import { ProviderQuotaPolicy, SchedulableRunKind, EmbedConfigEntry, AgentPluginState, GoogleAdsCampaignMetricsQuery, GoogleAdsRawSnapshotDto, GoogleAdsEffectiveGoalGraphDto, GoogleMarketingProvider, GoogleAdsAccessibleCustomersResponse, GoogleAdsAccessibleCustomerDto, GtmAccountsResponse, GtmContainerListResponse, GtmWorkspaceListResponse, GtmRawSnapshotDto } from '@ainyc/canonry-contracts'; import { GoogleTokenResponse } from '@ainyc/canonry-integration-google'; type GoogleConnectionType = 'gsc' | 'ga4' | 'gbp'; interface ProviderConfigEntry { apiKey?: string; baseUrl?: string; model?: string; quota?: ProviderQuotaPolicy; /** Vertex AI GCP project ID (Gemini provider only) */ vertexProject?: string; /** Vertex AI region, e.g. "us-central1" (Gemini provider only) */ vertexRegion?: string; /** Path to service account JSON for Vertex AI auth (falls back to ADC) */ vertexCredentials?: string; } interface CdpConfigEntry { host?: string; port?: number; quota?: ProviderQuotaPolicy; } interface GoogleConnectionConfigEntry { domain: string; connectionType: GoogleConnectionType; propertyId?: string | null; sitemapUrl?: string | null; accessToken?: string; refreshToken?: string | null; tokenExpiresAt?: string | null; scopes?: string[]; /** * Project ID that first established this connection. Mirrors the * `google_connections.created_by_project_id` DB column — written when the * OAuth callback completes from a known project. Null/undefined for legacy * connections written before the column existed; the API treats those as * unowned (claimable by any project's next connect). */ createdByProjectId?: string | null; gbpAccountName?: string | null; createdAt: string; updatedAt: string; } interface GoogleConfigEntry { clientId?: string; clientSecret?: string; connections?: GoogleConnectionConfigEntry[]; } interface BingConnectionConfigEntry { domain: string; apiKey: string; siteUrl?: string | null; /** * Project ID that first established this connection. Mirrors the * `bing_connections.created_by_project_id` DB column. */ createdByProjectId?: string | null; createdAt: string; updatedAt: string; } interface BingConfigEntry { apiKey?: string; connections?: BingConnectionConfigEntry[]; } interface Ga4ConnectionConfigEntry { projectName: string; propertyId: string; clientEmail: string; privateKey: string; createdAt: string; updatedAt: string; } interface Ga4ConfigEntry { connections?: Ga4ConnectionConfigEntry[]; } type CloudRunAuthMode = 'oauth' | 'service-account'; interface CloudRunConnectionConfigEntry { projectName: string; gcpProjectId: string; serviceName?: string; location?: string; authMode: CloudRunAuthMode; clientEmail?: string; privateKey?: string; refreshToken?: string; tokenExpiresAt?: string; scopes?: string[]; createdAt: string; updatedAt: string; } interface CloudRunConfigEntry { connections?: CloudRunConnectionConfigEntry[]; } /** * Per-project OpenAI Advertiser API (ChatGPT ads) connection. The "SDK key" * is minted in OpenAI Ads Manager and scoped to one ad account; ad accounts * are not domain-bound, so the connection keys on the project name. The * `ads_connections` DB row holds metadata only — the key lives here. */ interface OpenAiAdsConnectionConfigEntry { projectName: string; apiKey: string; adAccountId?: string | null; createdAt: string; updatedAt: string; } interface OpenAiAdsConfigEntry { connections?: OpenAiAdsConnectionConfigEntry[]; } /** * Private OAuth credentials are bound to the immutable `projects.id`. * `projectName` is descriptive only and must never be used for lookup. */ interface GoogleAdsConnectionConfigEntry { projectId: string; projectName: string; /** * Opaque, secret-free nonce minted on every OAuth connect/reconnect. * Refresh uses it as a compare-and-swap generation so an in-flight token * refresh can never restore an older Google principal. */ credentialGeneration?: string; accessToken?: string; refreshToken?: string | null; tokenExpiresAt?: string | null; scopes?: string[]; createdAt: string; updatedAt: string; } interface GoogleAdsConfigEntry { developerToken?: string; /** Optional dedicated OAuth app. Falls back to the shared `google` app. */ clientId?: string; clientSecret?: string; connections?: GoogleAdsConnectionConfigEntry[]; } /** * Private OAuth credentials are bound to the immutable `projects.id`. * `projectName` is descriptive only and must never be used for lookup. */ interface GtmConnectionConfigEntry { projectId: string; projectName: string; /** See GoogleAdsConnectionConfigEntry.credentialGeneration. */ credentialGeneration?: string; accessToken?: string; refreshToken?: string | null; tokenExpiresAt?: string | null; scopes?: string[]; createdAt: string; updatedAt: string; } interface GtmConfigEntry { /** Optional dedicated OAuth app. Falls back to the shared `google` app. */ clientId?: string; clientSecret?: string; connections?: GtmConnectionConfigEntry[]; } type WordpressEnv = 'live' | 'staging'; interface WordpressConnectionConfigEntry { projectName: string; url: string; stagingUrl?: string; username: string; appPassword: string; defaultEnv: WordpressEnv; createdAt: string; updatedAt: string; } interface WordpressConfigEntry { connections?: WordpressConnectionConfigEntry[]; } /** * Per-project WordPress traffic-logger connection. Separate from `wordpress.connections`, * which is the content-publishing client. Authenticates against the WP traffic plugin's * REST endpoint using a WordPress Application Password. */ interface WordpressTrafficConnectionConfigEntry { projectName: string; baseUrl: string; username: string; applicationPassword: string; createdAt: string; updatedAt: string; } interface WordpressTrafficConfigEntry { connections?: WordpressTrafficConnectionConfigEntry[]; } type VercelTrafficEnvironment = 'production' | 'preview'; /** * Per-project Vercel traffic connection. Authenticates against Vercel's * internal `request-logs` endpoint using a Vercel API token. The project id, * team id, and environment are non-secret and also mirrored onto the * `traffic_sources` row; the token lives only here. */ interface VercelTrafficConnectionConfigEntry { projectName: string; projectId: string; teamId: string; token: string; environment: VercelTrafficEnvironment; createdAt: string; updatedAt: string; } interface VercelTrafficConfigEntry { connections?: VercelTrafficConnectionConfigEntry[]; } interface CloudflareTrafficConnectionConfigBase { projectName: string; /** `traffic_sources.id` for this connection — pairs the credential row with the DB row. */ sourceId: string; /** Semver of the Worker script bundle that was generated at connect/rotate time. */ workerVersion: string; /** Identifier of the bot/referer keyword set baked into the deployed Worker. */ expectedBotListVersion: string; /** Target zone retained for manual route instructions. Wrangler does not attach the route. */ zoneId: string | null; /** Optional Cloudflare account id emitted as top-level Wrangler configuration. */ accountId: string | null; createdAt: string; updatedAt: string; } /** * Direct-push credentials. The Worker reads both values from secret bindings; * generated source and Wrangler configuration never contain either cleartext * value. The DB stores only the bearer hash. */ interface CloudflareDirectPushConnectionConfigEntry extends CloudflareTrafficConnectionConfigBase { /** Explicit transport discriminator. Missing legacy values normalize to direct-push. */ deliveryMode: 'direct-push'; /** Bearer token authenticating ingest requests. Verified server-side via sha256(bearer) === ingestTokenHash. */ bearerToken: string; /** HMAC-SHA256 shared secret. Worker signs `timestamp + "." + body` with it; server verifies. */ hmacSecret: string; } /** * Queue-pull credentials. The account-scoped token is used only by the local * Canonry server to pull and acknowledge Queue messages. It never enters the * traffic source row, generated Worker artifacts, CLI argv, or MCP output. */ interface CloudflareQueuePullConnectionConfigEntry extends CloudflareTrafficConnectionConfigBase { deliveryMode: 'queue-pull'; apiToken: string; accountId: string; queueId: string; queueName: string; retentionSeconds: number; } type CloudflareTrafficConnectionConfigEntry = CloudflareDirectPushConnectionConfigEntry | CloudflareQueuePullConnectionConfigEntry; interface CloudflareTrafficConfigEntry { connections?: CloudflareTrafficConnectionConfigEntry[]; } /** * One injected remote MCP server Aero loads read-only tools from (OSS-A). * Generic shape: `{ url, token, label? }`. The transport is bearer-gated MCP * Streamable HTTP (the contract is frozen in `agent/remote-mcp.ts`). The * server is remote, never co-located in the OSS container; per-tenant * isolation is the token's responsibility, not the container boundary. */ interface ExternalMcpServerConfig { /** Streamable HTTP endpoint of the remote MCP server. */ url: string; /** Bearer token sent as `Authorization: Bearer `. */ token: string; /** Optional human label used in logs. Defaults to `url`. */ label?: string; } interface AgentConfigEntry { /** * Agent mode. `'disabled'` turns the built-in Aero agent OFF entirely — the * proactive auto-wake on run completion does not fire, the `SessionRegistry` * is not constructed, and the interactive agent routes (`/projects/:name/ * agent/*`) plus the `canonry agent ask` CLI (a thin client of those routes) * are not served. Absent (the default) leaves Aero enabled. Resolved, with * the `CANONRY_AGENT_DISABLED` env override, by `resolveAgentEnabled` in * agent-config.ts. */ mode?: 'disabled'; } interface DashboardConfigEntry { /** * First-open dashboard experience. `legacy` preserves the existing setup * wizard, `platform` enables the domain-first Site Health launchpad, and * `auto` enables the launchpad only when the authoritative project list is * empty. Omitted defaults to `auto`: fresh installs use the launchpad after * an authoritative empty project-list response, while existing installs * retain the legacy wizard. */ onboardingMode?: 'legacy' | 'platform' | 'auto'; /** * Whether the browser dashboard requires Canonry's built-in password/session * gate. Defaults to true. Set false only when an upstream layer enforces auth * and the engine is not directly internet-reachable. */ requirePassword?: boolean; /** * Whether the dashboard sidebar and page footer show the Canonry GitHub, * documentation, and changelog links. Defaults to true. Disable for a * quieter branded UI. */ showResourceLinks?: boolean; /** * Whether the dashboard sidebar shows the available-version notification. * Defaults to true. Disable without turning off the underlying update check * or CLI update notice. */ showUpdateNotification?: boolean; /** Legacy alias for managedRunKinds: ['answer-visibility']. */ managedSweeps?: boolean | null; /** Presentation only. Managed sweeps hide all launches; managed scans hide viewer launches. */ managedRunKinds?: SchedulableRunKind[] | null; } interface ResearchConfigEntry { /** Allow signed-in viewers to run paid research queries. Defaults to false. */ allowViewers?: boolean | null; /** Viewer-created research runs allowed per project and UTC day. Defaults to 20. */ viewerDailyRunLimit?: number | null; } /** * Google Places API config — supplemental rendered-listing data for GBP * lodging locations (#648). The API key authenticates Place Details calls * (`X-Goog-Api-Key`); it is NOT OAuth and is unrelated to `google.clientId`. * - `tier`: 'atmosphere' (default; amenity booleans for the cross-reference, * 1k free calls/month) | 'pro' (cheaper, accessibility-only) | 'off'. * - `refreshIntervalDays`: minimum age before a location's Place Details is * re-fetched during gbp-sync (default 7) — the cost lever, since amenities * change rarely. */ interface PlacesConfigEntry { apiKey?: string; tier?: 'atmosphere' | 'pro' | 'off'; refreshIntervalDays?: number; } interface CanonryConfig { apiUrl: string; publicUrl?: string; /** Sub-path prefix when canonry is served behind a reverse proxy (e.g. "/canonry/"). */ basePath?: string; database: string; apiKey: string; port?: number; geminiApiKey?: string; geminiModel?: string; geminiQuota?: ProviderQuotaPolicy; providers?: Record; cdp?: CdpConfigEntry; google?: GoogleConfigEntry; bing?: BingConfigEntry; ga4?: Ga4ConfigEntry; cloudRun?: CloudRunConfigEntry; wordpress?: WordpressConfigEntry; wordpressTraffic?: WordpressTrafficConfigEntry; vercelTraffic?: VercelTrafficConfigEntry; cloudflareTraffic?: CloudflareTrafficConfigEntry; openaiAds?: OpenAiAdsConfigEntry; googleAds?: GoogleAdsConfigEntry; gtm?: GtmConfigEntry; dashboardPasswordHash?: string; dashboard?: DashboardConfigEntry; /** Paid research access and budget controls. */ research?: ResearchConfigEntry; telemetry?: boolean; anonymousId?: string; lastSeenVersion?: string; /** * The engine version the installed skill trees were last synced against. * * Deliberately separate from `lastSeenVersion`, which telemetry owns. Both * fields answer "have we seen this build before?", but they are consumed by * different subsystems and whichever writes first silences the other: * `detectAndTrackUpgrade` returns early on `lastSeenVersion === VERSION`, so * when auto-sync shared that field it permanently suppressed `cli.upgraded`. */ lastSkillsSyncedVersion?: string; /** * When the installed skill trees were last verified against the bundled * copies. Drives the interval half of the skills auto-sync: a version bump * is not the only way an installed copy goes wrong (hand-deleted files, a * partial install, a `$HOME` shared across machines), so the check also runs * on a timer. The comparison is local hash vs local hash, so it costs no * network. See `skills-autosync.ts`. */ lastSkillsVerifiedAt?: string; /** Set once when the first-activation notice has been shown; never unset. */ activationNoticeShown?: boolean; updateCheck?: boolean; lastUpdateCheckAt?: string; lastKnownLatestVersion?: string; agent?: AgentConfigEntry; externalMcpServers?: ExternalMcpServerConfig[]; places?: PlacesConfigEntry; embed?: EmbedConfigEntry; } declare function loadConfig(): CanonryConfig; declare function createServer(opts: { config: CanonryConfig; db: DatabaseClient; open?: boolean; logger?: boolean; /** * The network interface the server will bind to (from `canonry serve`). * Used to gate the unauthenticated first-run dashboard password setup: on a * loopback bind only local processes can reach `/session/setup`, so claiming * the initial password without the API key is safe. On a non-loopback bind * (`0.0.0.0`, a LAN IP) the setup endpoint additionally requires a valid * bearer key so a remote first-visitor cannot mint a full-access session. * Defaults to loopback when unset (programmatic/test callers). */ host?: string; /** * Override for the directory the pre-built SPA is served from. Defaults to * the package's bundled `assets/` (resolved from `import.meta.url`). Exposed * so tests can point at a temp dir containing a fixture `index.html` and * assert the injected config + framing header on the served document. */ assetsDir?: string; /** Live user-global native Canonry plugin state for agent-skills doctor checks. */ getAgentPluginState?: () => AgentPluginState; }): Promise; interface GoogleMarketingProjectRef { id: string; name: string; } interface GoogleMarketingRuntimeOptions { config: CanonryConfig; saveConfigPatch: (patch: Partial) => void | Promise; env?: Readonly>; fetch?: typeof globalThis.fetch; now?: () => Date; randomUUID?: () => string; refreshAccessToken?: (clientId: string, clientSecret: string, refreshToken: string) => Promise; } interface GoogleAdsCustomerDiscoveryOptions { loginCustomerId?: string | null; maxCustomers?: number; } interface GoogleAdsCustomerDetailsInput { customerId: string; loginCustomerId?: string | null; } interface GoogleMarketingListOptions { maxResults?: number; } interface GoogleAdsSyncInput { project: GoogleMarketingProjectRef; connectionId: string; runId: string; selection: { customerId: string; loginCustomerId?: string | null; selectedAt?: string | null; }; /** Omit to use the trailing 31 customer-local calendar days (UTC fallback) and at most 50 campaigns. */ metricsQuery?: GoogleAdsCampaignMetricsQuery; } interface GoogleAdsSyncResult { accessibleCustomers: GoogleAdsRawSnapshotDto; inventory: GoogleAdsRawSnapshotDto; metrics: GoogleAdsRawSnapshotDto | null; effectiveGoalGraph: GoogleAdsEffectiveGoalGraphDto; } interface GtmSyncInput { project: GoogleMarketingProjectRef; connectionId: string; runId: string; selection: { accountId: string; containerId: string; workspaceId?: string | null; }; expectedEventName?: string; expectedHostname?: string; } interface GoogleMarketingRuntime { listGoogleAdsCustomers(project: GoogleMarketingProjectRef, options?: GoogleAdsCustomerDiscoveryOptions): Promise; getGoogleAdsCustomer(project: GoogleMarketingProjectRef, input: GoogleAdsCustomerDetailsInput): Promise; listGtmAccounts(project: GoogleMarketingProjectRef, options?: GoogleMarketingListOptions): Promise; listGtmContainers(project: GoogleMarketingProjectRef, accountId: string, options?: GoogleMarketingListOptions): Promise; listGtmWorkspaces(project: GoogleMarketingProjectRef, accountId: string, containerId: string, options?: GoogleMarketingListOptions): Promise; syncGoogleAds(input: GoogleAdsSyncInput): Promise; syncGtm(input: GtmSyncInput): Promise; } declare class GoogleMarketingRuntimeError extends Error { readonly code: 'INVALID_INPUT' | 'NOT_CONFIGURED' | 'CONNECTION_NOT_FOUND' | 'TOKEN_REFRESH_REQUIRED' | 'INVALID_TOKEN_RESPONSE' | 'RESOURCE_NOT_FOUND'; readonly name = "GoogleMarketingRuntimeError"; constructor(message: string, code: 'INVALID_INPUT' | 'NOT_CONFIGURED' | 'CONNECTION_NOT_FOUND' | 'TOKEN_REFRESH_REQUIRED' | 'INVALID_TOKEN_RESPONSE' | 'RESOURCE_NOT_FOUND'); } interface PrivateCredential { accessToken: string; } /** Private runtime seam. Values returned here must never cross an API response boundary. */ interface GoogleMarketingCredentialStore { getCredential(project: GoogleMarketingProjectRef, provider: GoogleMarketingProvider): Promise; } declare function createGoogleMarketingCredentialStore(options: GoogleMarketingRuntimeOptions): GoogleMarketingCredentialStore; declare function createGoogleMarketingRuntime(options: GoogleMarketingRuntimeOptions): GoogleMarketingRuntime; export { type CanonryConfig, type GoogleAdsCustomerDetailsInput, type GoogleAdsCustomerDiscoveryOptions, type GoogleAdsSyncInput, type GoogleAdsSyncResult, type GoogleMarketingCredentialStore, type GoogleMarketingListOptions, type GoogleMarketingProjectRef, type GoogleMarketingRuntime, GoogleMarketingRuntimeError, type GoogleMarketingRuntimeOptions, type GtmSyncInput, createGoogleMarketingCredentialStore, createGoogleMarketingRuntime, createServer, loadConfig };