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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,11 @@
# SPDX-License-Identifier: Apache-2.0

DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime

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

# 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.
BETTER_AUTH_SECRET=
3 changes: 3 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"recommendations": ["VoidZero.vite-plus-extension-pack", "TypeScriptTeam.native-preview"]
}
18 changes: 18 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"editor.defaultFormatter": "oxc.oxc-vscode",
"[javascript]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[javascriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[typescript]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[typescriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[json]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[jsonc]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"oxc.disableNestedConfig": true,
"oxc.fmt.disableNestedConfig": true,
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit"
},
"js/ts.experimental.useTsgo": true,
"js/ts.tsdk.path": "./node_modules/typescript"
}
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Read `ARCHITECTURE.md` before making structural changes.

Deployment adapters may depend on core code; core code must not depend on deployment adapters or provider-specific concerns such as Cloudflare.

Apps go in `apps/`, shared code in `packages/` — extract a package only when the boundary is real.
Apps go in `apps/`, shared code in `packages/` — extract a package only when the boundary is real, and keep layouts shallow until more structure earns its keep.

## Changes

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Quality Runtime is being built primarily with:
- TypeScript
- Bun
- PostgreSQL
- Drizzle ORM
- React
- Vite+
- Hono
Expand Down
18 changes: 18 additions & 0 deletions apps/server/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: 2026 Quality Runtime contributors
// SPDX-License-Identifier: Apache-2.0

import { Hono } from "hono";
import type { Auth } from "./auth.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.
*/
export function createApp(auth: Auth) {
return new Hono().all("/api/auth/*", (c) => auth.handler(c.req.raw));
}

export type App = ReturnType<typeof createApp>;
146 changes: 146 additions & 0 deletions apps/server/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: 2026 Quality Runtime contributors
// SPDX-License-Identifier: Apache-2.0

/**
* Checks this configuration against the schema in `packages/db`.
*
* Structurally: every model `authOptions` causes Better Auth to write must be
* exported under the name its adapter looks up, with a column for every field.
* Behaviourally: representative flows — credential sign-up and creating an
* organization — run against migrated PostgreSQL, so Better Auth's own writes
* are shown to satisfy the identifier CHECK constraints. Verification,
* invitation, and two-factor writes are not exercised here.
*
* Both of those derive from `authOptions`, so a plugin change moves the
* expectation with it rather than leaving a second list to update.
*/

import { fileURLToPath } from "node:url";
import { PGlite } from "@electric-sql/pglite";
import { schema } from "@qualityruntime/db";
import { getAuthTables } from "better-auth/db";
import { eq, getTableColumns, is, Table } from "drizzle-orm";
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 { type Auth, authOptions, createAuth } from "./auth.ts";

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

let db: ReturnType<typeof drizzle>;
let auth: Auth;
let app: ReturnType<typeof createApp>;

beforeAll(async () => {
db = drizzle(new PGlite(), { schema });
await migrate(db, { migrationsFolder });
auth = createAuth(db, {
baseURL: "http://localhost",
secret: "test-secret-of-at-least-32-characters",
});
app = createApp(auth);
}, 60_000);

/** Drops the response attributes so the value is a valid `Cookie` request header. */
const toCookieHeader = (response: Response) =>
response.headers
.getSetCookie()
.map((value) => value.split(";", 1)[0])
.join("; ");

const signUp = (email: string) =>
app.request("/api/auth/sign-up/email", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Ada", email, password: "correct horse battery" }),
});

const json = async <T>(response: Response): Promise<T> => (await response.json()) as T;

describe.each(Object.entries(getAuthTables(authOptions)))("%s", (key, table) => {
const name = table.modelName || key;
// Better Auth's Drizzle adapter finds a table by the key it is exported
// under, not by its physical name.
const exported = (schema as Record<string, unknown>)[name];

it("is exported under the name the adapter looks up", () => {
expect(is(exported, Table)).toBe(true);
});

it("has a column for every field Better Auth writes", () => {
const columns = Object.keys(getTableColumns(exported as Table));
const written = Object.entries(table.fields).map(
([field, attribute]) => attribute.fieldName || field,
);
expect(columns).toEqual(expect.arrayContaining(written));
});

it("does not require a column Better Auth never writes", () => {
const columns = getTableColumns(exported as Table);
const written = new Set([
"id",
...Object.entries(table.fields).map(([field, attribute]) => attribute.fieldName || field),
]);
const unsatisfiable = Object.entries(columns)
.filter(([column]) => !written.has(column))
.filter(([, column]) => column.notNull && !column.hasDefault)
.map(([column]) => column);
expect(unsatisfiable).toEqual([]);
});
});

describe("configuration", () => {
it("refuses a secret shorter than Better Auth's documented minimum", () => {
// Better Auth only warns, so this guard is the thing that stops a short
// secret reaching production.
expect(() => createAuth(db, { baseURL: "http://localhost", secret: "too-short" })).toThrow(
/at least 32 characters/,
);
});
});

describe("Better Auth writes against the migrated schema", () => {
it("creates a user, its credential account, and a session", async () => {
const response = await signUp("ada@example.test");
expect(response.status).toBe(200);
const { user } = await json<{ user: { id: string } }>(response);

expect(user.id).toMatch(/^usr_[0-9a-z]{16}$/);
const [account] = await db
.select()
.from(schema.account)
.where(eq(schema.account.userId, user.id));
expect(account?.id).toMatch(/^acc_[0-9a-z]{16}$/);
expect(account?.providerId).toBe("credential");

const [session] = await db
.select()
.from(schema.session)
.where(eq(schema.session.userId, user.id));
expect(session?.id).toMatch(/^ses_[0-9a-z]{16}$/);
});

it("creates an organization with its owner membership", async () => {
const signedUp = await signUp("owner@example.test");
expect(signedUp.status).toBe(200);
const userId = (await json<{ user: { id: string } }>(signedUp)).user.id;

const created = await app.request("/api/auth/organization/create", {
method: "POST",
headers: { "content-type": "application/json", cookie: toCookieHeader(signedUp) },
body: JSON.stringify({ name: "Acme", slug: "acme" }),
});
expect(created.status).toBe(200);
const organization = await json<{ id: string }>(created);

expect(organization.id).toMatch(/^org_[0-9a-z]{16}$/);
const [member] = await db
.select()
.from(schema.member)
.where(eq(schema.member.organizationId, organization.id));
expect(member?.id).toMatch(/^mem_[0-9a-z]{16}$/);
expect(member?.userId).toBe(userId);
expect(member?.role).toBe("owner");
});
});
63 changes: 63 additions & 0 deletions apps/server/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2026 Quality Runtime contributors
// SPDX-License-Identifier: Apache-2.0

import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { generateId, schema } from "@qualityruntime/db";
// `minimal` leaves out the bundled Kysely path, which the Drizzle adapter
// replaces; it keeps the Workers bundle smaller.
import { betterAuth } from "better-auth/minimal";
import type { BetterAuthOptions } from "better-auth/types";
import { admin, organization, twoFactor } from "better-auth/plugins";

/** Whatever the Drizzle adapter accepts: any Drizzle PostgreSQL driver, so
* broader than the node-postgres `Database` query code uses. */
type AuthDatabase = Parameters<typeof drizzleAdapter>[0];

/**
* The static Better Auth configuration, shared by the runtime and the
* compatibility test.
*
* Some of it decides which tables and columns exist — the plugin list above
* all — so `auth.test.ts` derives Better Auth's expected schema from this same
* object rather than a second copy. Teams and dynamic access control stay off;
* both add tables (ADR 0001).
*/
export const authOptions = {
// Authenticator apps show this as the TOTP issuer.
appName: "Quality Runtime",
emailAndPassword: { enabled: true },
plugins: [organization(), 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 } },
} satisfies BetterAuthOptions;

export interface AuthEnvironment {
/** Public origin the instance is reached at, used for callbacks and cookies. */
baseURL: string;
/** At least 32 high-entropy characters; used for encryption, signing, and hashing. */
secret: string;
}

/**
* Builds the Better Auth instance for a database handle.
*
* Takes the handle rather than opening one so a test can bind it to a
* throwaway database, matching `createDatabase` (ARCH-01).
*/
export function createAuth(db: AuthDatabase, { baseURL, secret }: AuthEnvironment) {
// Better Auth logs a warning below this length and carries on; a short secret
// should not be able to reach production quietly.
if (secret.length < 32) {
throw new Error("BETTER_AUTH_SECRET must be at least 32 characters.");
}

return betterAuth({
...authOptions,
baseURL,
secret,
database: drizzleAdapter(db, { provider: "pg", schema }),
});
}

export type Auth = ReturnType<typeof createAuth>;
34 changes: 34 additions & 0 deletions apps/server/bun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2026 Quality Runtime contributors
// SPDX-License-Identifier: Apache-2.0

/**
* Bun entry point: the deployment adapter for a long-lived server process.
*
* Deployment-specific by design — it reads the environment and owns a
* connection pool for the life of the process. A Workers entry would sit
* beside this file and build a per-request client behind Hyperdrive instead
* (ARCH-01).
*/

import { createDatabase } from "@qualityruntime/db";
import { Pool } from "pg";
import { createApp } from "./app.ts";
import { createAuth } from "./auth.ts";

/** Fails at start-up rather than on the first request that needs the value. */
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is not set.`);
return value;
}

// This process owns the pool; `packages/db` only binds Drizzle to it.
const pool = new Pool({ connectionString: requireEnv("DATABASE_URL") });
const db = createDatabase(pool);

export default createApp(
createAuth(db, {
baseURL: requireEnv("BETTER_AUTH_URL"),
secret: requireEnv("BETTER_AUTH_SECRET"),
}),
);
19 changes: 19 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "@qualityruntime/server",
"version": "0.0.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@better-auth/drizzle-adapter": "1.7.5",
"@qualityruntime/db": "workspace:*",
"better-auth": "1.7.5",
"drizzle-orm": "^0.45.2",
"hono": "^4.10.7",
"pg": "^8.23.0"
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@types/pg": "^8.23.1"
}
}
21 changes: 20 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading