Skip to content
Closed
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
15 changes: 15 additions & 0 deletions apps/api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ Cloud API entry point. A thin Fastify server that imports and mounts `packages/a
| `src/plugins/` | Cloud-specific Fastify plugins (auth, CORS, etc.) |
| `src/routes/` | Cloud-only routes (if any) |

## Environment

Beyond the provider keys parsed by `@ainyc/canonry-config` (`getPlatformEnv`):

| Var | Default | Purpose |
|-----|---------|---------|
| `CANONRY_API_KEY` | — | Default `cnry_` bearer, seeded into `api_keys` on boot; password sessions bind to it. Also required to pass the `/session/setup` gate (always on here — Cloud Run is network-reachable) |
| `CANONRY_PUBLIC_URL` | — | Sets `Secure` on session cookies when `https://`. Boot warns when unset |
| `CANONRY_TRUST_PROXY_HOPS` | `1` | Trusted reverse-proxy hop count → `request.ip` is the rightmost X-Forwarded-For entry, so rate limiting keys per client (not per proxy). 0 = direct connections |
| `CANONRY_ENABLE_GUEST_REPORTS` | off | Enables the anonymous `/guest/report*` funnel (404s when unset) |
| `CANONRY_ENABLE_CLOUD_BOOTSTRAP` | off | Enables the `/cloud/*` bridge (404s when unset) |
| `CANONRY_ALLOW_PRIVATE_WEBHOOKS` | off | Allows webhook targets resolving to private ranges (Docker-internal control-plane callbacks) |

All boolean flags parse through `parseBooleanFlag` (`1/true/yes/on`).

## Patterns

- This app is intentionally thin. All shared route logic lives in `packages/api-routes`.
Expand Down
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"dependencies": {
"@ainyc/canonry-api-routes": "workspace:*",
"@ainyc/canonry-config": "workspace:*",
"@ainyc/canonry-contracts": "workspace:*",
"@ainyc/canonry-db": "workspace:*",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"tsx": "^4.20.5"
}
Expand Down
130 changes: 126 additions & 4 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,50 @@
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 { parseBooleanFlag } from '@ainyc/canonry-contracts'
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,
// Cloud Run sits behind Google's front end, which appends the real
// client IP as the rightmost X-Forwarded-For entry. Trusting exactly
// that hop count (default 1, CANONRY_TRUST_PROXY_HOPS) makes
// `request.ip` the per-client address, so the session + guest-report
// rate limiters key per client instead of pooling everyone into the
// proxy's single bucket. `trustProxy: true` would be wrong here — it
// takes the leftmost XFF entry, which the client controls.
trustProxy: env.trustProxyHops > 0 ? env.trustProxyHops : false,
})

// 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,16 +53,106 @@
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://'))
if (!env.publicUrl) {
// Without CANONRY_PUBLIC_URL the session cookie ships without `Secure`.
// Fine for local docker (plain http); on a real HTTPS deployment the
// env var must be set or browsers will replay the cookie over http too.
app.log.warn('CANONRY_PUBLIC_URL is not set — session cookies will not carry the Secure flag. Set it to the https:// deployment URL in production.')
}
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()
},
// Cloud Run is always network-reachable — and deliberately public for
// the guest-report funnel — so the first-run password setup must
// always demand a valid bearer key. Without this, any first visitor
// to a fresh deployment could claim the dashboard password and mint
// a full-access `*` session (#690).
setupRequiresApiKey: true,
})
}, { 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',
},
providerSummary,
googleStateSecret: env.googleStateSecret,
// Reported by POST /cloud/bootstrap so the control plane records what
// runtime it provisioned. apps/api versions independently of the
// published @ainyc/canonry package.
canonryVersion: '0.1.0',
// The hosted control-plane callback typically resolves to a private
// address (Docker bridge / VPC) — same env opt-in as `canonry serve`.
allowPrivateNetworkWebhooks: parseBooleanFlag(process.env.CANONRY_ALLOW_PRIVATE_WEBHOOKS),
})

registerHealthRoutes(app, env)
Expand Down
79 changes: 79 additions & 0 deletions apps/api/test/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,85 @@ test('buildApp registers health and API routes', async () => {
expect(openApiResponse.json().info.version).toBe('0.1.0')
})

test('first-run /session/setup always requires a bearer key (Cloud Run is network-reachable)', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'api-setup-gate-'))
const dbPath = path.join(tmpDir, 'test.db')
onTestFinished(() => fs.rmSync(tmpDir, { recursive: true, force: true }))

const db = createClient(dbPath)
migrate(db)

const env = getPlatformEnv({
DATABASE_URL: dbPath,
API_PORT: '3000',
WORKER_PORT: '3001',
CANONRY_API_KEY: 'cnry_setup_gate_test_key',
})
const app = buildApp(env)
onTestFinished(async () => { await app.close() })

// No bearer — the pre-auth escalation is closed (#690 posture).
const unauth = await app.inject({
method: 'POST',
url: '/api/v1/session/setup',
payload: { password: 'a-strong-password' },
})
expect(unauth.statusCode).toBe(401)

// With the instance's own key (seeded into api_keys on boot), setup works.
const ok = await app.inject({
method: 'POST',
url: '/api/v1/session/setup',
headers: { authorization: 'Bearer cnry_setup_gate_test_key' },
payload: { password: 'a-strong-password' },
})
expect(ok.statusCode).toBe(200)
})

test('rate limiting keys per client via the trusted proxy hop, not per proxy', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'api-trustproxy-'))
const dbPath = path.join(tmpDir, 'test.db')
onTestFinished(() => fs.rmSync(tmpDir, { recursive: true, force: true }))

const db = createClient(dbPath)
migrate(db)

// Default CANONRY_TRUST_PROXY_HOPS=1 — the Cloud Run topology, where the
// platform appends the client IP as the rightmost X-Forwarded-For entry.
const env = getPlatformEnv({
DATABASE_URL: dbPath,
API_PORT: '3000',
WORKER_PORT: '3001',
})
expect(env.trustProxyHops).toBe(1)
const app = buildApp(env)
onTestFinished(async () => { await app.close() })

// Client A exhausts the 10/min login budget…
let sawLimit = false
for (let i = 0; i < 12; i++) {
const res = await app.inject({
method: 'POST',
url: '/api/v1/session',
headers: { 'x-forwarded-for': '198.51.100.1' },
payload: { password: 'wrong-guess-xxxx' },
})
if (res.statusCode === 429) { sawLimit = true; break }
}
expect(sawLimit).toBe(true)

// …client B (different forwarded IP through the same proxy socket) is NOT
// throttled — without trustProxy both would share one bucket and this
// request would be 429.
const other = await app.inject({
method: 'POST',
url: '/api/v1/session',
headers: { 'x-forwarded-for': '198.51.100.2' },
payload: { password: 'wrong-guess-xxxx' },
})
expect(other.statusCode).not.toBe(429)
})

test('loadApiEnv delegates to shared platform config', () => {
const env = loadApiEnv({
DATABASE_URL: 'postgresql://aeo:aeo@localhost:5432/aeo_platform',
Expand Down
10 changes: 10 additions & 0 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,16 @@ Local-AEO signals. The OAuth connection reuses `google_connections` with `connec
|-------|---------|
| **api_keys** | API authentication. Unique: `keyHash` |
| **usage_counters** | Rate limiting and usage tracking. Unique: `(scope, period, metric)` |
| **app_settings** | Generic instance-wide key/value store for deployments without a local config file (`apps/api` on Cloud Run). First user: `dashboard_password_hash` written by the shared session plugin. Keyed on `key` only (single-tenant by design) |

### Hosted (Canonry Hosted v1)

| Table | Purpose |
|-------|---------|
| **cloud_metadata** | Singleton row recording that a control plane bootstrapped this tenant (`POST /api/v1/cloud/bootstrap`): tenant/account ids, plan, callback URL, webhook secret, managed Google OAuth client id + redirect. CHECK constraint pins `id='singleton'`. Never written on OSS deployments (routes 404 unless `CANONRY_ENABLE_CLOUD_BOOTSTRAP` is set) |
| **provider_token_usage** | Per-(run, provider, model) token-cost telemetry extracted from stored raw responses after each answer-visibility run — including probe runs (cost accounting is exempt from the probe-exclusion rule). Consumed by the control plane for billing/cost dashboards; accumulates silently on OSS. FK: runId → runs, projectId → projects (both CASCADE) |
| **users** | Identity records for Google-OAuth signup (guest-report claim flow). Each user binds to exactly one API key (`api_key_id`, CASCADE); password-auth operators never get a row. Unique: `email`, `google_sub` |
| **guest_reports** | Anonymous free-first-report rows for the /aero onboarding funnel: scores, findings, progress-event SSE replay buffer, expiry + claim state. `project_id` references the transient guest project (CASCADE); unclaimed rows are reaped after expiry, claimed rows persist. FK: claimed_by_user_id → users (SET NULL) |

### Agent

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.76.1",
"version": "4.77.0",
"type": "module",
"packageManager": "pnpm@10.28.2",
"scripts": {
Expand Down
Loading
Loading