From 75a616e2d44d3091a017ab905db380c12067ada3 Mon Sep 17 00:00:00 2001 From: Joris Gallot Date: Sun, 28 Jun 2026 18:26:53 +0200 Subject: [PATCH 01/13] feat(server): KINORA_DEMO read-only mode + auto-session --- packages/server/src/app.ts | 4 ++ packages/server/src/lib/demo-guard.ts | 16 ++++++++ packages/server/src/lib/env.ts | 4 ++ packages/server/src/router/config.ts | 4 +- packages/server/src/trpc/context.ts | 29 ++++++++++++++- packages/server/src/trpc/index.ts | 4 ++ packages/server/test/demo.test.ts | 53 +++++++++++++++++++++++++++ packages/server/test/test-env.ts | 1 + 8 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/lib/demo-guard.ts create mode 100644 packages/server/test/demo.test.ts diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index fedfa83..7ec26d9 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -10,6 +10,7 @@ import { db } from './db' import { accessLog } from './lib/access-log' import { verifyArtifactSignature } from './lib/artifact-url' import { auth } from './lib/auth' +import { blockAuthWritesInDemo, blockInDemo } from './lib/demo-guard' import { env } from './lib/env' import { logger } from './lib/logger' import { clientIp, rateLimit } from './lib/rate-limit' @@ -50,6 +51,7 @@ app.get('/healthcheck', async (c) => { } }) +app.use('/api/auth/*', blockAuthWritesInDemo) app.on(['POST', 'GET'], '/api/auth/*', c => auth.handler(c.req.raw)) app.use('/trpc/*', rateLimit({ windowMs: 60_000, limit: 300 })) @@ -57,6 +59,8 @@ app.use('/trpc/*', trpcServer({ router: appRouter, createContext })) // Access log first in the chain so rate-limit (429) / body-limit (413) rejections are logged too. app.use('/api/v1/*', accessLog) +// Read-only demo: reject all ingest writes (logged by accessLog above). +app.use('/api/v1/*', blockInDemo) // Per-IP, before the body is read, so a flood is cheap to reject. Keyed by IP (not token) so // sharded CI spreads across runner IPs instead of summing into one bucket. Real throttle is // nginx limit_req (unspoofable IP); this is the backstop. diff --git a/packages/server/src/lib/demo-guard.ts b/packages/server/src/lib/demo-guard.ts new file mode 100644 index 0000000..9686d76 --- /dev/null +++ b/packages/server/src/lib/demo-guard.ts @@ -0,0 +1,16 @@ +import { createMiddleware } from 'hono/factory' +import { demo } from './env' + +// Read-only demo: block the public ingest API entirely (the dashboard uses tRPC, gated separately). +export const blockInDemo = createMiddleware(async (c, next) => { + if (demo) + return c.json({ error: 'This is a read-only demo' }, 403) + return next() +}) + +// Block auth writes (sign-up/password/delete) but keep reads; the demo auto-sessions so no login is needed. +export const blockAuthWritesInDemo = createMiddleware(async (c, next) => { + if (demo && c.req.method === 'POST') + return c.json({ error: 'This is a read-only demo' }, 403) + return next() +}) diff --git a/packages/server/src/lib/env.ts b/packages/server/src/lib/env.ts index 5c8cb17..2117b06 100644 --- a/packages/server/src/lib/env.ts +++ b/packages/server/src/lib/env.ts @@ -20,6 +20,8 @@ const envSchema = z.object({ GITHUB_CLIENT_ID: z.string(), GITHUB_CLIENT_SECRET: z.string(), KINORA_CLOUD: z.stringbool().default(false), + // Public demo instance: auto-session as the seeded demo user + read-only (no mutations/ingest/auth writes). + KINORA_DEMO: z.stringbool().default(false), POLAR_ACCESS_TOKEN: z.string().optional(), POLAR_WEBHOOK_SECRET: z.string().optional(), POLAR_PRODUCT_TEAM_ID: z.string().optional(), @@ -79,6 +81,8 @@ function resolveCloud(): CloudConfig | null { export const cloud = resolveCloud() +export const demo = env.KINORA_DEMO + export interface S3Config { endpoint: string region: string diff --git a/packages/server/src/router/config.ts b/packages/server/src/router/config.ts index 48a0708..f5b17dc 100644 --- a/packages/server/src/router/config.ts +++ b/packages/server/src/router/config.ts @@ -1,4 +1,4 @@ -import { slackApp } from '../lib/env' +import { demo, slackApp } from '../lib/env' import { mailerEnabled } from '../lib/mailer' import { publicProcedure, router } from '../trpc/index' @@ -9,5 +9,7 @@ export const configRouter = router({ mailerEnabled, // Slack OAuth app present? front shows "Add to Slack" vs manual webhook paste. slackOauthEnabled: slackApp !== null, + // Public read-only demo? front shows a banner + hides mutation UI. + demo, })), }) diff --git a/packages/server/src/trpc/context.ts b/packages/server/src/trpc/context.ts index 8cc070f..506e8f5 100644 --- a/packages/server/src/trpc/context.ts +++ b/packages/server/src/trpc/context.ts @@ -1,8 +1,26 @@ import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch' -import { and, eq } from 'drizzle-orm' +import type { AuthType } from '../lib/auth' +import { and, asc, eq } from 'drizzle-orm' import { db } from '../db' -import { member } from '../db/schemas/index' +import { member, user as userTable } from '../db/schemas/index' import { auth } from '../lib/auth' +import { demo } from '../lib/env' + +// Public demo: resolve the seeded primary account (earliest owner) so visitors browse with no login. +// Looked up per-request (not cached) so the daily reseed's new ids are picked up without a restart. +async function demoSession() { + const [owner] = await db + .select({ userId: member.userId, organizationId: member.organizationId }) + .from(member) + .innerJoin(userTable, eq(member.userId, userTable.id)) + .where(eq(member.role, 'owner')) + .orderBy(asc(userTable.createdAt)) + .limit(1) + if (!owner) + return null + const u = await db.query.user.findFirst({ where: eq(userTable.id, owner.userId) }) + return u ? { user: u, organizationId: owner.organizationId } : null +} export async function createContext({ req }: FetchCreateContextFnOptions) { const session = await auth.api.getSession({ headers: req.headers }) @@ -18,6 +36,13 @@ export async function createContext({ req }: FetchCreateContextFnOptions) { organizationId = owned?.organizationId ?? null } + if (!user && demo) { + const d = await demoSession() + // db row is structurally the session user; cast to the auth user type for ctx consistency. + if (d) + return { user: d.user as unknown as AuthType['user'], organizationId: d.organizationId, req } + } + return { user, organizationId, req } } diff --git a/packages/server/src/trpc/index.ts b/packages/server/src/trpc/index.ts index 295803d..a3af550 100644 --- a/packages/server/src/trpc/index.ts +++ b/packages/server/src/trpc/index.ts @@ -4,11 +4,15 @@ import { initTRPC, TRPCError } from '@trpc/server' import { and, eq } from 'drizzle-orm' import { db } from '../db' import { member } from '../db/schemas/index' +import { demo } from '../lib/env' import { logger } from '../lib/logger' export const t = initTRPC.context().create() const loggedProcedure = t.procedure.use(async (opts) => { + // Public demo is browse-only: reject every mutation (one gate for all routers). + if (demo && opts.type === 'mutation') + throw new TRPCError({ code: 'FORBIDDEN', message: 'This is a read-only demo' }) const start = Date.now() const result = await opts.next() const ms = Date.now() - start diff --git a/packages/server/test/demo.test.ts b/packages/server/test/demo.test.ts new file mode 100644 index 0000000..453dc6b --- /dev/null +++ b/packages/server/test/demo.test.ts @@ -0,0 +1,53 @@ +import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// `demo` is a module const read from env. setupFiles already loaded the real graph (demo=false), +// so flip the flag AND reset the module registry, then re-import so the graph re-evaluates with it. +vi.mock('../src/lib/env', async importOriginal => ({ ...(await importOriginal()), demo: true })) +vi.resetModules() + +const { app } = await import('../src/app') +const { createContext } = await import('../src/trpc/context') +const { caller, createUser, ownedOrgId, resetDb } = await import('./helpers') + +beforeEach(resetDb) + +function ctxReq(): FetchCreateContextFnOptions { + return { req: new Request('http://test') } as FetchCreateContextFnOptions +} + +describe('demo mode', () => { + it('config.get reports demo: true', async () => { + const u = await createUser() + expect((await (await caller(u)).config.get()).demo).toBe(true) + }) + + it('auto-sessions as the seeded primary owner without a cookie', async () => { + const u = await createUser() + const orgId = await ownedOrgId(u.id) + + const ctx = await createContext(ctxReq()) + expect(ctx.user?.id).toBe(u.id) + expect(ctx.organizationId).toBe(orgId) + }) + + it('rejects tRPC mutations (read-only)', async () => { + const u = await createUser() + await expect((await caller(u)).project.rename({ projectId: 'web-app', name: 'x' })).rejects.toThrow(/read-only demo/i) + }) + + it('still allows tRPC queries', async () => { + const u = await createUser() + await expect((await caller(u)).dashboard.manifest()).resolves.toBeTruthy() + }) + + it('blocks the public ingest API', async () => { + const res = await app.request('/api/v1/runs', { method: 'POST', headers: { Authorization: 'Bearer x' } }) + expect(res.status).toBe(403) + }) + + it('blocks auth writes (sign-up)', async () => { + const res = await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) + expect(res.status).toBe(403) + }) +}) diff --git a/packages/server/test/test-env.ts b/packages/server/test/test-env.ts index a9799ef..058e112 100644 --- a/packages/server/test/test-env.ts +++ b/packages/server/test/test-env.ts @@ -19,6 +19,7 @@ export const TEST_ENV: Record = { GITHUB_CLIENT_ID: 'test', GITHUB_CLIENT_SECRET: 'test', KINORA_CLOUD: 'false', + KINORA_DEMO: 'false', INGEST_RATE_LIMIT: '600', POLAR_ACCESS_TOKEN: '', POLAR_WEBHOOK_SECRET: '', From a0276fa0c81c43bf7c350414ed5a640c3c2e874f Mon Sep 17 00:00:00 2001 From: Joris Gallot Date: Sun, 28 Jun 2026 18:59:47 +0200 Subject: [PATCH 02/13] feat(demo): real read-only session so better-auth org reads work --- packages/server/src/app.ts | 4 +++ packages/server/src/demo/session.ts | 33 +++++++++++++++++++ packages/server/src/index.ts | 2 +- packages/server/test/demo.test.ts | 8 +++++ packages/web/src/App.vue | 2 ++ .../web/src/components/app/DemoBanner.vue | 25 ++++++++++++++ packages/web/src/composables/queries.ts | 15 ++++++++- packages/web/src/lib/session.ts | 3 ++ 8 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/demo/session.ts create mode 100644 packages/web/src/components/app/DemoBanner.vue diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 7ec26d9..6c4f75e 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -7,6 +7,7 @@ import { bodyLimit } from 'hono/body-limit' import { cors } from 'hono/cors' import { secureHeaders } from 'hono/secure-headers' import { db } from './db' +import { demoApp } from './demo/session' import { accessLog } from './lib/access-log' import { verifyArtifactSignature } from './lib/artifact-url' import { auth } from './lib/auth' @@ -75,6 +76,9 @@ app.route('/api/v1', publicApi) app.use('/api/slack/*', accessLog) app.route('/api/slack', slackOAuth) +// Demo-only: establishes the shared read-only session cookie (no-op otherwise). +app.route('/api/demo', demoApp) + app.onError((err, c) => { logger.error({ err }, 'unhandled request error') Sentry.captureException(err) diff --git a/packages/server/src/demo/session.ts b/packages/server/src/demo/session.ts new file mode 100644 index 0000000..bf00664 --- /dev/null +++ b/packages/server/src/demo/session.ts @@ -0,0 +1,33 @@ +import { asc, eq } from 'drizzle-orm' +import { Hono } from 'hono' +import { db } from '../db' +import { member, user as userTable } from '../db/schemas/index' +import { auth } from '../lib/auth' +import { demo } from '../lib/env' + +// The seed gives the demo account this password; signing it in here gives the browser a real +// (read-only) session cookie so better-auth client calls (org, members, role) work too. +const DEMO_PASSWORD = 'password123' + +export const demoApp = new Hono() + +demoApp.get('/session', async (c) => { + if (!demo) + return c.body(null, 204) + + // The seeded primary account = earliest owner (same rule as the tRPC auto-session). + const [owner] = await db + .select({ email: userTable.email }) + .from(member) + .innerJoin(userTable, eq(member.userId, userTable.id)) + .where(eq(member.role, 'owner')) + .orderBy(asc(userTable.createdAt)) + .limit(1) + if (!owner) + return c.body(null, 204) + + const res = await auth.api.signInEmail({ body: { email: owner.email, password: DEMO_PASSWORD }, asResponse: true }) + for (const cookie of res.headers.getSetCookie()) + c.header('set-cookie', cookie, { append: true }) + return c.body(null, 204) +}) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 53cc35e..177c9ed 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -16,7 +16,7 @@ process.on('uncaughtException', (err) => { }) const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => { - logger.info(`kinora server running on port ${info.port}`) + logger.info(`${env.KINORA_DEMO ? '[DEMO] ' : ''}kinora server running on port ${info.port}`) }) let shuttingDown = false diff --git a/packages/server/test/demo.test.ts b/packages/server/test/demo.test.ts index 453dc6b..cd9462d 100644 --- a/packages/server/test/demo.test.ts +++ b/packages/server/test/demo.test.ts @@ -50,4 +50,12 @@ describe('demo mode', () => { const res = await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) expect(res.status).toBe(403) }) + + it('/api/demo/session signs in the seeded owner and sets a cookie', async () => { + await createUser() // seeded with the demo password + + const res = await app.request('/api/demo/session') + expect(res.status).toBe(204) + expect(res.headers.get('set-cookie')).toBeTruthy() + }) }) diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue index a684596..2f8ffc9 100644 --- a/packages/web/src/App.vue +++ b/packages/web/src/App.vue @@ -2,6 +2,7 @@ import { Toaster } from '@kinora/ui/sonner' import { RouterView } from 'vue-router' import AppHeader from '@/components/app/AppHeader.vue' +import DemoBanner from '@/components/app/DemoBanner.vue' import LoadingScreen from '@/components/app/LoadingScreen.vue' import { session } from '@/lib/session' // Boot useColorMode at startup so system/dark applies before any page mounts @@ -24,6 +25,7 @@ const ready = session.ready
+