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
27 changes: 27 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,33 @@ MIGRATION_DATABASE_URL=postgres://qualityruntime_migrator:qualityruntime@localho
# is wiped on every run, so its name must end in `_test`.
# TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime_test

# Where file bytes are kept: an S3-compatible bucket, which is now part of the
# deployment contract rather than a mounted directory (ADR 0021). Clients upload
# to it and download from it directly, with short-lived URLs this server signs,
# so the bucket stays private and needs CORS for the application's own origin.
# `docs/deployment.md` has the policy, the CORS rules and the lifecycle rule;
# `docs/development.md` starts a MinIO matching the values below.
STORAGE_BUCKET=qualityruntime
STORAGE_REGION=us-east-1
STORAGE_ACCESS_KEY_ID=qualityruntime
STORAGE_SECRET_ACCESS_KEY=qualityruntime

# Where that bucket is. Unset, AWS S3 itself is addressed by virtual host; set,
# it is the base URL of anything speaking the same protocol — MinIO here,
# Cloudflare R2 or Backblaze B2 in a deployment.
STORAGE_ENDPOINT=http://localhost:9000

# An S3-compatible store for the optional storage integration suite, which asks
# the same contract of a real store that the rest of the suite asks of one
# answering in memory. Unset, those tests are skipped and `bun run test` still
# needs nothing running. It creates and removes only its own keys, so it does
# not need a bucket of its own — though a bucket of its own is still wiser.
# TEST_STORAGE_ENDPOINT=http://localhost:9000
# TEST_STORAGE_BUCKET=qualityruntime
# TEST_STORAGE_REGION=us-east-1
# TEST_STORAGE_ACCESS_KEY_ID=qualityruntime
# TEST_STORAGE_SECRET_ACCESS_KEY=qualityruntime

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

Expand Down
46 changes: 42 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,51 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- run: bun install --frozen-lockfile
- run: bun run check
# 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).
# MinIO is a step rather than a service because the image needs a command
# of its own, which `services:` cannot give it. The client is a second
# image for the reason `docs/development.md` gives: the server image is
# not guaranteed to carry one.
#
# From quay.io and pinned, both deliberately — `docs/development.md`
# says why, and changing either of these without reading it is how CI
# stops being able to pull a store at all.
- name: Start an S3-compatible store
run: |
docker run -d --name qualityruntime-storage -p 9000:9000 \
-e MINIO_ROOT_USER=qualityruntime -e MINIO_ROOT_PASSWORD=qualityruntime \
quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
# `if` rather than `curl … && break`: a failing poll inside a `&&`
# list is the whole command failing, and the step runs under `set -e`.
ready=
for attempt in $(seq 1 30); do
if curl -sf http://localhost:9000/minio/health/live >/dev/null; then
ready=yes
break
fi
sleep 1
done
if [ -z "$ready" ]; then
echo "The store never became ready."
docker logs qualityruntime-storage
exit 1
fi
docker run --rm --network host --entrypoint sh quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z -c \
"mc alias set local http://localhost:9000 qualityruntime qualityruntime \
&& mc mb --ignore-existing local/qualityruntime"
# PGlite runs PostgreSQL in-process and an S3 answering in memory stands
# in for a bucket, so almost nothing here needs either of the above. The
# exceptions are the two suites that cannot be honest without them: the
# concurrency suite, because PGlite is a single connection and a lock
# cannot be exercised on one (ADR 0020), and the storage integration
# suite, because a signature is only correct if a real server says so
# (ADR 0021). Without these a change breaking either would pass CI.
- run: bun run test
env:
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/qualityruntime_test
TEST_STORAGE_ENDPOINT: http://localhost:9000
TEST_STORAGE_BUCKET: qualityruntime
TEST_STORAGE_ACCESS_KEY_ID: qualityruntime
TEST_STORAGE_SECRET_ACCESS_KEY: qualityruntime

dco:
# Trust is PR-level: the GitHub App is the only identity that can open a PR
Expand Down
72 changes: 72 additions & 0 deletions .github/workflows/storage-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# SPDX-FileCopyrightText: 2026 Quality Runtime contributors
# SPDX-License-Identifier: Apache-2.0

name: Storage compatibility

# The question `ci.yml` cannot ask. It runs `storage-integration.test.ts`
# against MinIO on every push, which settles what this product signs and sends
# and nothing about any other provider — and one precondition is load-bearing:
# a store that accepted `x-amz-copy-source-if-match` and ignored it would
# promote bytes the client swapped for the ones inspected (ADR 0021).
# Only the provider can answer that, on its release cadence rather than ours.
#
# Separate from `ci.yml` because repository secrets are withheld from a pull
# request opened from a fork, so a credentialed job there would skip on exactly
# the contributions most worth checking — and skip *silently*, since the suite
# skips without an endpoint. A green tick for a question nobody asked is worse
# than no job, which is why the endpoint is checked below.
#
# The jobs here are the compatibility claim. Naming a provider in
# `docs/deployment.md` as an example of an S3-compatible configuration is not
# one; calling it supported is, and that needs a job here — or, for MinIO, the
# run `ci.yml` already makes.

on:
schedule:
# Weekly: nothing else notices a provider changing, and a daily green tick
# is one people stop reading. GitHub disables a schedule after 60 days
# without repository activity, so one gone quiet means the schedule.
- cron: "0 6 * * 1"
workflow_dispatch:

permissions:
contents: read

jobs:
r2:
name: Cloudflare R2
runs-on: ubuntu-latest
# One at a time. `ci.yml` starts a container and throws it away; this
# writes to a bucket that is shared, durable and outside this repository.
concurrency: storage-compatibility-r2
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

# Without an endpoint the suite skips every case and passes having asked
# nothing. The other settings it demands itself.
- name: Refuse to pass without credentials
env:
endpoint: ${{ secrets.R2_STORAGE_ENDPOINT }}
run: |
if [ -z "$endpoint" ]; then
echo "R2_STORAGE_ENDPOINT is not set, so this run would prove nothing."
echo "Set it, R2_STORAGE_BUCKET, R2_STORAGE_ACCESS_KEY_ID and"
echo "R2_STORAGE_SECRET_ACCESS_KEY, or delete this job."
exit 1
fi

# Only this suite, so a failure means the store and nothing else. Point
# it at a dedicated, empty bucket: the suite removes the keys it creates,
# but it lists `files/` to the end.
- run: bun run test apps/server/storage-integration.test.ts
env:
TEST_STORAGE_ENDPOINT: ${{ secrets.R2_STORAGE_ENDPOINT }}
TEST_STORAGE_BUCKET: ${{ secrets.R2_STORAGE_BUCKET }}
# R2 accepts one region, and a signature is computed against it.
TEST_STORAGE_REGION: auto
TEST_STORAGE_ACCESS_KEY_ID: ${{ secrets.R2_STORAGE_ACCESS_KEY_ID }}
TEST_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.R2_STORAGE_SECRET_ACCESS_KEY }}
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Apps go in `apps/`, shared code in `packages/` — extract a package only when t
- Do not add speculative extension points.
- Add or update tests for important behavior.
- Never weaken tenant isolation, authorization, auditability, or data integrity for convenience.
- Never modify an existing applied database migration; add a new one.
- Never modify an existing applied database migration; add a new one. Until the first release there is no such migration — no deployment is supported yet, so the schema is edited in place and databases are rebuilt.
- Sign off commits with `git commit -s` (Developer Certificate of Origin). Commits authored as `quality-runtime[bot]` are not signed off — a bot cannot make the certification — and pull requests it opens are exempt from the check.

## Licensing
Expand Down
8 changes: 5 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Quality Runtime is being designed as a TypeScript application with these layers:
email, AI, etc.
```

Domain mutation rules still live in route handlers, which use a Hono context to resolve the tenant and attribute changes. Extract them when a non-HTTP caller needs them, into functions taking explicit inputs and actor context; PostgreSQL tenant scoping and audit recording are already available independently of Hono.
Domain mutation rules still live in route handlers, which use a Hono context to resolve the tenant and attribute changes. File verification already runs outside HTTP through `integrity.ts`, taking a database handle and a store ([ADR 0016](docs/adr/0016-verifying-stored-bytes.md)). Extract mutation rules when a non-HTTP caller needs them, into functions taking explicit inputs and actor context; PostgreSQL tenant scoping and audit recording are already available independently of Hono.

Deployment environments sit outside the core application:

Expand Down Expand Up @@ -168,7 +168,7 @@ Jobs must tolerate retries and duplicate execution.

## Storage

PostgreSQL is the source of truth for file metadata, relationships, and access-control state; durable storage owns the bytes. The application authorizes file access from PostgreSQL-backed state, never from storage location alone.
PostgreSQL is the source of truth for file metadata, relationships, and access-control state; object storage owns the bytes. The application authorizes file access from PostgreSQL-backed state, never from storage location alone, and then issues a short-lived signed URL rather than carrying the bytes itself ([ADR 0021](docs/adr/0021-file-bytes-in-object-storage.md)).

Persistent file storage must not rely on process memory or ephemeral local storage. Vendor-specific storage concepts stay outside domain logic.

Expand All @@ -191,7 +191,7 @@ Deployment-specific and private extensions add behavior without requiring change
The intended minimal self-hosted production deployment requires only:

```text
Quality Runtime + PostgreSQL + durable file storage (a mounted volume is enough)
Quality Runtime + PostgreSQL + an S3-compatible object store
```

Additional services must not become mandatory without strong operational justification.
Expand Down Expand Up @@ -240,6 +240,8 @@ Controlled or finalized records must not silently lose historical state.
**EXT-01 — Extensions add rather than patch**
Customization prefers explicit composition points over modifications to core implementation.

Nothing implements this yet: there is no extension mechanism, and the only composition point that exists is the `ObjectStore` interface a deployment supplies. It is a rule for when one arrives, not a description of something here.

## Changing the architecture

Evolve the architecture when concrete product or operational needs justify it. Before introducing a new service, abstraction, package, datastore, queue, or extension mechanism, ask:
Expand Down
11 changes: 10 additions & 1 deletion apps/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import { controls } from "./controls.ts";
import { failure } from "./responses.ts";
import { openApiDocument, openApiPath, referencePath } from "./openapi.ts";
import { organizationContext } from "./organization.ts";
import type { ObjectStore } from "./objects.ts";
import { evidence } from "./evidence.ts";
import { history } from "./history.ts";
import { files } from "./files.ts";
import { requirements } from "./requirements.ts";
import { standards } from "./standards.ts";

Expand All @@ -37,10 +39,13 @@ import { standards } from "./standards.ts";
export function createApp<Q extends PgQueryResultHKT>({
auth,
db,
store,
apiReferenceBundleUrl,
}: {
auth: Auth;
db: RootDatabase<Q>;
/** Where file bytes live, supplied by the deployment (ADR 0021). */
store: ObjectStore;
/**
* Where the rendered reference loads its bundle from, when not the CDN.
*
Expand Down Expand Up @@ -70,7 +75,6 @@ export function createApp<Q extends PgQueryResultHKT>({
const standardImport = bodyLimit({ maxSize: 1024 * 1024, onError: tooLarge });
const importsAStandard = (c: Context) =>
c.req.method === "POST" && /^\/api\/v1\/organizations\/[^/]+\/standards$/.test(c.req.path);

return (
new Hono()
.notFound((c) => c.json(failure("not_found", "No such endpoint."), 404))
Expand Down Expand Up @@ -103,12 +107,17 @@ export function createApp<Q extends PgQueryResultHKT>({
...(apiReferenceBundleUrl ? { cdn: apiReferenceBundleUrl } : {}),
}),
)
// Every body under `/api/v1` is JSON a handler will parse, so one figure
// fits all of them bar a standard. File bytes never arrive here at all:
// they go to object storage directly (ADR 0021), which is what removed
// the exception this used to carry for uploads.
.use("/api/v1/*", (c, next) => (importsAStandard(c) ? standardImport : ordinary)(c, next))
.use(`${tenant}/*`, organizationContext({ auth, db }))
.route(tenant, controls)
.route(tenant, history)
.route(tenant, standards)
.route(tenant, requirements)
.route(tenant, evidence)
.route(tenant, files(store))
);
}
3 changes: 3 additions & 0 deletions apps/server/audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/

import { fileURLToPath } from "node:url";

import { PGlite } from "@electric-sql/pglite";
import { schema, withOrganization } from "@qualityruntime/db";
import { and, desc, eq, sql } from "drizzle-orm";
Expand All @@ -22,6 +23,7 @@ import { migrate } from "drizzle-orm/pglite/migrator";
import { beforeAll, describe, expect, it } from "vite-plus/test";
import { createApp } from "./app.ts";
import { createAuth } from "./auth.ts";
import { inMemoryObjectStore } from "./s3-in-memory.ts";

const migrationsFolder = fileURLToPath(new URL("../../packages/db/migrations", import.meta.url));

Expand Down Expand Up @@ -113,6 +115,7 @@ beforeAll(async () => {
await migrate(db, { migrationsFolder });
app = createApp({
db,
store: inMemoryObjectStore().store,
auth: createAuth(db, {
baseURL: "http://localhost",
secret: "test-secret-of-at-least-32-characters",
Expand Down
3 changes: 2 additions & 1 deletion apps/server/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ type Records = { resourceType: ResourceType; resourceId: string };
* a deletion an `after`, and nothing would object.
*
* `updated` keeps `before` optional because not every change is a replacement:
* one that only adds something has no previous value to name.
* attaching a file to evidence adds something that was not there, and has no
* previous value to name.
*/
export type Change = Records &
(
Expand Down
23 changes: 22 additions & 1 deletion apps/server/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/

import { fileURLToPath } from "node:url";

import { PGlite } from "@electric-sql/pglite";
import { schema } from "@qualityruntime/db";
import { getAuthTables } from "better-auth/db";
Expand All @@ -24,6 +25,7 @@ import { drizzle } from "drizzle-orm/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
import { beforeAll, describe, expect, it } from "vite-plus/test";
import { createApp } from "./app.ts";
import { inMemoryObjectStore } from "./s3-in-memory.ts";
import { type Auth, authOptions, createAuth } from "./auth.ts";

const migrationsFolder = fileURLToPath(new URL("../../packages/db/migrations", import.meta.url));
Expand All @@ -41,7 +43,7 @@ beforeAll(async () => {
baseURL: "http://localhost",
secret: "test-secret-of-at-least-32-characters",
});
app = createApp({ auth, db });
app = createApp({ auth, db, store: inMemoryObjectStore().store });
}, 60_000);

/** Drops the response attributes so the value is a valid `Cookie` request header. */
Expand Down Expand Up @@ -123,6 +125,25 @@ describe("Better Auth writes against the migrated schema", () => {
expect(session?.id).toMatch(/^ses_[0-9a-z]{16}$/);
});

it("keeps the cookies a sign-in sets to /api, and to this host", async () => {
// Both halves of the rule a storage hostname of its own relies on: the
// path, and the absence of a `Domain`, which is what leaves this cookie
// host-only (`assertStorageOutsideCookiePath`). `defaultCookieAttributes`
// is where both live, so this checks it is in force rather than
// enumerating every flow a plugin may add.
const response = await signUp("paths@example.test");
const cookies = response.headers.getSetCookie();

expect(cookies.length).toBeGreaterThan(0);
for (const cookie of cookies) {
// Split into attributes rather than searched: `Path=/api/v1` contains
// `path=/api` and is a different rule, and so is `Path=/api2`.
const attributes = cookie.split(";").map((part) => part.trim().toLowerCase());
expect(attributes).toContain("path=/api");
expect(attributes.some((attribute) => attribute.startsWith("domain="))).toBe(false);
}
});

it("creates an organization with its owner membership", async () => {
const signedUp = await signUp("owner@example.test");
expect(signedUp.status).toBe(200);
Expand Down
23 changes: 20 additions & 3 deletions apps/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ import { admin, organization, twoFactor } from "better-auth/plugins";
* broader than the node-postgres `Database` query code uses. */
type AuthDatabase = Parameters<typeof drizzleAdapter>[0];

/**
* The only path a browser sends the session cookie to.
*
* Exported because it is a security boundary rather than a route detail: the
* object store is refused the moment it answers inside this path
* (`assertStorageOutsideCookiePath`), and that check must be judging the same
* string this sets.
*/
export const sessionCookiePath = "/api";

/**
* The static Better Auth configuration, shared by the runtime and the
* compatibility test.
Expand All @@ -37,9 +47,16 @@ export const authOptions = {
admin(),
twoFactor(),
],
// Identifiers are prefixed and CHECK-enforced, so Better Auth must generate
// them through `@qualityruntime/db` or every insert is rejected (ADR 0002).
advanced: { database: { generateId } },
advanced: {
// Identifiers are prefixed and CHECK-enforced, so Better Auth must
// generate them through `@qualityruntime/db` or every insert is rejected
// (ADR 0002).
database: { generateId },
// Both attributes keep this cookie off the object store a download
// redirects to: the path is every route here, and no `domain` leaves the
// cookie host-only. Depth rather than a boundary (ADR 0021).
defaultCookieAttributes: { path: sessionCookiePath },
},
} satisfies BetterAuthOptions;

export interface AuthEnvironment {
Expand Down
Loading
Loading