Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
102 changes: 98 additions & 4 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -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')

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 +44,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
32 changes: 8 additions & 24 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>/"] --> API["Fastify API<br/>/api/v1/*"]
API --> JobRunner["In-process<br/>job runner"]
API --> SQLite["SQLite"]
JobRunner --> Registry["Provider\nRegistry"]
JobRunner --> Registry["Provider<br/>Registry"]
Registry --> Gemini["provider-gemini"]
Registry --> OpenAI["provider-openai"]
Registry --> Claude["provider-claude"]
Expand All @@ -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/`.
Expand All @@ -61,8 +61,6 @@ flowchart LR
```mermaid
flowchart TD
subgraph Apps
api["apps/api"]
worker["apps/worker"]
web["apps/web"]
end

Expand Down Expand Up @@ -91,10 +89,6 @@ flowchart TD
wp["integration-wordpress"]
end

api --> routes
api --> db
worker --> routes
worker --> db
web -.-> contracts

canonry --> routes
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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.

Loading
Loading