Skip to content
Merged
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
24 changes: 20 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
# SPDX-FileCopyrightText: 2026 Quality Runtime contributors
# SPDX-License-Identifier: Apache-2.0

DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime
# The server's own connection. This role owns nothing: it holds the privileges
# to read and write rows and no more, so the guarantees the policies make hold
# against it too. Neither a superuser nor BYPASSRLS — the server refuses to
# start as either. `docs/development.md` creates both roles.
DATABASE_URL=postgres://qualityruntime:qualityruntime@localhost:5432/qualityruntime

# The role migrations are applied as, which owns the schema. Required by
# `db:migrate`, which never falls back to DATABASE_URL.
MIGRATION_DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime
# Applying migrations needs a role that owns the schema, which the server's must
# not. Required by `db:migrate`, which never falls back to DATABASE_URL.
MIGRATION_DATABASE_URL=postgres://qualityruntime_migrator:qualityruntime@localhost:5432/qualityruntime

# A scratch database for the concurrency tests, which need two connections at
# once and so cannot run on PGlite like the rest of the suite. Unset, those
# tests are skipped and `bun run test` still needs nothing running. The database
# is wiped on every run, so its name must end in `_test`.
# TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime_test

# Public origin the server is reached at.
BETTER_AUTH_URL=http://localhost:3000

# The rendered API reference at /api/v1/reference loads its JavaScript from a
# public CDN. A deployment that cannot reach one points this at its own copy of
# @scalar/api-reference. Unset, the CDN is used; /api/v1/openapi.json is
# unaffected either way.
# API_REFERENCE_BUNDLE_URL=

# Used for encryption, signing, and hashing. At least 32 high-entropy
# characters; generate one with `openssl rand -base64 32`. Left empty on
# purpose, so a copied example cannot become a known production secret.
Expand Down
24 changes: 22 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,36 @@ permissions:
jobs:
check:
runs-on: ubuntu-latest
services:
# The version `docs/development.md` tells a developer to run, and the one
# the concurrency suite has actually been exercised against.
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: postgres
# The suite wipes this database, and refuses a name not ending `_test`.
POSTGRES_DB: qualityruntime_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- run: bun install --frozen-lockfile
- run: bun run check
# PGlite runs PostgreSQL in-process, so the migration and schema tests
# need no service container.
# PGlite runs PostgreSQL in-process, so almost nothing here needs a
# service. The exception is the concurrency suite: PGlite is a single
# connection, so a lock cannot be exercised on it, and without this a
# change that breaks one would pass CI (ADR 0020).
- run: bun run test
env:
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/qualityruntime_test

dco:
# Trust is PR-level: the GitHub App is the only identity that can open a PR
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Quality Runtime aims to make standards, controls, procedures, evidence, audits,
The project is built around a few principles:

- **Open source and self-hostable** — the core product should be genuinely useful without a hosted service.
- **AI-native** — AI should be able to understand and act across the system through clear models, APIs, and tools.
- **AI-native** — AI should be able to understand and act across the system through clear models, APIs, and tools. The API describes itself: `GET /api/v1/openapi.json`.
- **Deterministic where it matters** — critical enforcement should remain explicit, testable, and auditable.
- **Composable** — customization should happen through stable models, workflows, APIs, SDKs, and extensions rather than permanent forks.
- **Understandable** — prefer simple architecture and boring primitives over unnecessary infrastructure and abstraction.
Expand Down Expand Up @@ -59,8 +59,10 @@ Development setup is documented in [docs/development.md](./docs/development.md).

If you are exploring the codebase, also read:

- [docs/product.md](./docs/product.md) — why the product exists, and what it deliberately is not
- [AGENTS.md](./AGENTS.md) — repository rules for humans and coding agents
- [ARCHITECTURE.md](./ARCHITECTURE.md) — architecture and system invariants
- [docs/adr/](./docs/adr/) — the decisions behind both, one file each

## Hosted service

Expand Down
99 changes: 91 additions & 8 deletions apps/server/app.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,101 @@
// SPDX-FileCopyrightText: 2026 Quality Runtime contributors
// SPDX-License-Identifier: Apache-2.0

import { Hono } from "hono";
import type { Auth } from "./auth.ts";
import type { RootDatabase } from "@qualityruntime/db";
import type { PgQueryResultHKT } from "drizzle-orm/pg-core";
import { Scalar } from "@scalar/hono-api-reference";
import { type Context, Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { HTTPException } from "hono/http-exception";
import { type Auth, sessionCookieName } from "./auth.ts";
import { controls } from "./controls.ts";
import { failure } from "./responses.ts";
import { openApiDocument, openApiPath, referencePath } from "./openapi.ts";
import { organizationContext } from "./organization.ts";
import { history } from "./history.ts";

/**
* The HTTP surface.
*
* Better Auth owns every route under `/api/auth` and validates methods itself,
* so every method is forwarded. Domain routes mount alongside it as they
* arrive; `/api/auth` is Better Auth's own API, not this product's public one.
* so every method is forwarded. That is Better Auth's own API, not this
* product's public one, which lives under `/api/v1`.
*
* Every tenant-owned resource sits under `/api/v1/organizations/:organizationId`
* behind `organizationContext`, which resolves the caller's membership before
* any handler runs (ADR 0004). Mounted anywhere else, a handler would find no
* `withOrganization` on its context and fail rather than serve unscoped rows.
*
* Unmatched paths and uncaught errors answer in the same failure shape as the
* routes, so a client has one thing to parse. Better Auth is untouched by any
* of it: it answers `/api/auth/*` itself, so no request there reaches the
* not-found handler, and its own responses and limits are its contract.
*/
export function createApp(auth: Auth) {
return new Hono().all("/api/auth/*", (c) => auth.handler(c.req.raw));
}
export function createApp<Q extends PgQueryResultHKT>({
auth,
db,
apiReferenceBundleUrl,
}: {
auth: Auth;
db: RootDatabase<Q>;
/**
* Where the rendered reference loads its bundle from, when not the CDN.
*
* Passed in rather than read from `process.env` here: this is core, and core
* must not know how a deployment keeps its configuration (ARCH-01). A Workers
* entry has no `process.env` at all — its environment arrives per request.
*/
apiReferenceBundleUrl?: string;
}) {
const tenant = "/api/v1/organizations/:organizationId";

export type App = ReturnType<typeof createApp>;
/**
* How much of a body a route may read, decided before any of it is read.
*
* A body is read and parsed in full before a validator sees it, so the field
* bounds in a route schema do not bound the work a request costs. Chosen
* here rather than on the route: a limiter with no `Content-Length` to go on
* buffers the stream before passing it down, so a route that needs a
* different allowance has to be told apart here, ahead of this one.
*/
const tooLarge = (c: Context) =>
c.json(failure("payload_too_large", "The request body is too large."), 413);

return (
new Hono()
.notFound((c) => c.json(failure("not_found", "No such endpoint."), 404))
.onError((error, c) => {
if (error instanceof HTTPException) {
// Hono raises these before a handler runs — a malformed JSON body, for
// one. Its own response is plain text, so the envelope is rebuilt here
// unless the thrower supplied a response of its own.
if (error.res) return error.res;
const code = error.status >= 500 ? "internal" : "invalid_request";
return c.json(failure(code, error.message), error.status);
}
// Anything else is a bug here: logged in full, reported without details.
console.error(error);
return c.json(failure("internal", "The request could not be completed."), 500);
})
.all("/api/auth/*", (c) => auth.handler(c.req.raw))
// Ahead of the organization prefix and outside it: the document describes
// the API, not anyone's data, and belongs to no tenant.
.get(openApiPath, (c) => c.json(openApiDocument(sessionCookieName(auth))))
// The same document, rendered. Reads the JSON above by URL, so nothing
// about how it is built depends on this (ADR 0007).
.get(
referencePath,
Scalar({
url: openApiPath,
pageTitle: "Quality Runtime API",
// Scalar loads its own bundle from a CDN. A deployment that cannot
// reach one points this at its own copy (ADR 0015).
...(apiReferenceBundleUrl ? { cdn: apiReferenceBundleUrl } : {}),
}),
)
.use("/api/v1/*", bodyLimit({ maxSize: 64 * 1024, onError: tooLarge }))
.use(`${tenant}/*`, organizationContext({ auth, db }))
.route(tenant, controls)
.route(tenant, history)
);
}
Loading
Loading