Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
96 changes: 92 additions & 4 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
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'

function hashApiKey(key: string): string {
return crypto.createHash('sha256').update(key).digest('hex')

Check failure

Code scanning / CodeQL

Use of password hash with insufficient computational effort High

Password from
an access to apiKey
is hashed insecurely.
Password from
an access to apiKey
is hashed insecurely.
Password from
an access to CANONRY_API_KEY
is hashed insecurely.
}

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,
Expand All @@ -21,10 +38,81 @@
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',
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "canonry",
"private": true,
"version": "4.67.0",
"version": "4.68.0",
"type": "module",
"packageManager": "pnpm@10.28.2",
"scripts": {
Expand Down

Large diffs are not rendered by default.

48 changes: 47 additions & 1 deletion packages/api-client-generated/src/generated/sdk.gen.ts

Large diffs are not rendered by default.

150 changes: 148 additions & 2 deletions packages/api-client-generated/src/generated/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down
6 changes: 6 additions & 0 deletions packages/api-routes/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
26 changes: 26 additions & 0 deletions packages/api-routes/src/bing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
})

Expand Down
Loading
Loading