Protect your orders
Add email and password sign-in, bind new orders to the session, and keep each customer's orders private.
This chapter extends the same SQLite-backed relkit-orders app. You will add a local Better Auth
service, then require a session for both Orders routes. A caller cannot choose an order owner in
request JSON; the server gets that identity from the session. Stop bun run dev before migrating.
1. Add authentication to the existing database
The auth scaffold installs Better Auth and creates its service and /api/auth route. It reuses
the database service already in the app. It does not apply a migration or create a secret.
bun run relkit add auth --adapter better-auth --database-dialect sqliteThis includes the native SDK, development declaration peers and selected repairs. If you pass
--no-install, finish with bun install and bun run check before continuing. The
manual auth setup is an alternative, not another required installation.
Application HTTP fixtures must now import createTestApplication from
@relkit/testing/better-auth, including the existing Hello test. The native helper activates both
auth and its database; see the complete updated fixtures.
Extend src/services/database/schema/index.ts with the auth tables. The complete source below
retains orders and adds the user, session, account, and verification exports Better Auth
requires. Do not replace an existing business table when adapting this to your own application.
/** Registers tutorial order ownership columns alongside native Better Auth user/session/account/verification tables. */
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const orders = sqliteTable("orders", {
orderId: text().primaryKey(),
sku: text().notNull(),
quantity: integer().notNull(),
totalCents: integer().notNull(),
ownerId: text(),
});
const timestamps = {
createdAt: integer({ mode: "timestamp" }).notNull(),
updatedAt: integer({ mode: "timestamp" }).notNull(),
};
export const user = sqliteTable("user", {
id: text().primaryKey(),
name: text().notNull(),
email: text().notNull().unique(),
emailVerified: integer({ mode: "boolean" }).notNull(),
image: text(),
...timestamps,
});
export const session = sqliteTable("session", {
id: text().primaryKey(),
expiresAt: integer({ mode: "timestamp" }).notNull(),
token: text().notNull().unique(),
ipAddress: text(),
userAgent: text(),
userId: text().notNull(),
...timestamps,
});
export const account = sqliteTable(
"account",
{
id: text().primaryKey(),
accountId: text().notNull(),
issuer: text().notNull(),
providerId: text().notNull(),
userId: text().notNull(),
accessToken: text(),
refreshToken: text(),
idToken: text(),
accessTokenExpiresAt: integer({ mode: "timestamp" }),
refreshTokenExpiresAt: integer({ mode: "timestamp" }),
scope: text(),
password: text(),
...timestamps,
},
(table) => [uniqueIndex("account_issuer_account_id").on(table.issuer, table.accountId)],
);
export const verification = sqliteTable("verification", {
id: text().primaryKey(),
identifier: text().notNull(),
value: text().notNull(),
expiresAt: integer({ mode: "timestamp" }).notNull(),
...timestamps,
});Replace src/platform/env.ts with the existing fields plus the auth secret and base URL:
import { defineEnv, env } from "@relkit/app/config";
export default defineEnv({
LOG_LEVEL: env.literal("trace", "debug", "info", "warn", "error").default("info"),
DATABASE_PATH: env.string().default("./orders.sqlite"),
BETTER_AUTH_SECRET: env.secret(),
BETTER_AUTH_URL: env.url().default(new URL("http://127.0.0.1:3000")),
});Set a stable local secret in the terminal that starts the app, then append it to the ignored
.env for later restarts. Never commit that file.
export DATABASE_PATH=./orders.sqlite
export BETTER_AUTH_URL=http://127.0.0.1:3000
export BETTER_AUTH_SECRET="$(openssl rand -base64 32)"
printf 'BETTER_AUTH_URL=%s\nBETTER_AUTH_SECRET=%s\n' \
"$BETTER_AUTH_URL" "$BETTER_AUTH_SECRET" >> .envReview src/services/auth/service.ts and make it match the email/password configuration below:
/** Declares email/password Better Auth using the configured URL and the sole database owner's native tables. */
import { defineBetterAuthService } from "@relkit/better-auth";
export default defineBetterAuthService({
baseURL: process.env.BETTER_AUTH_URL ?? "http://127.0.0.1:3000",
emailAndPassword: { enabled: true },
});The auth mount handles /api/auth and rejects signed-out calls to both /orders and
/orders/*. Replace the scaffolded route with:
/** Mounts native Better Auth once for all methods and protects both root and descendant order routes. */
import { defineRoute } from "@relkit/app/routes";
import auth from "@app/services/auth/service.js";
export const ALL = defineRoute({
handler: auth.handler,
auth: { protected: ["/orders", "/orders/*"] },
});Generate a second migration and inspect its SQL before applying it. This keeps the existing Orders table and adds auth tables; do not regenerate the initial migration.
bun --bun drizzle-kit generate --name=auth
bun run relkit db migrate
bun run check2. Associate orders with the signed-in user
Replace the two Orders functions below. createOrder obtains the user ID from the session
and stores it with the row. getOrder matches both orderId and the session’s user ID;
someone else’s order returns found: false.
/** Prices validated order input and persists it with the authenticated session user's ownership. */
import { defineFunction } from "@relkit/app/functions";
import { z } from "@relkit/app/schema";
import priceOrder from "@app/services/orders/functions/price-order.function.js";
const order = z.object({
orderId: z.string().min(1),
sku: z.string().min(1),
quantity: z.number().int().positive(),
});
export default defineFunction({
input: order,
output: z.object({
orderId: z.string(),
sku: z.string(),
quantity: z.number().int().positive(),
totalCents: z.number().int().nonnegative(),
}),
handler: async (input, context) => {
const session = await context.auth.getSession();
if (session === null) throw new Error("Authentication required");
const { totalCents } = await priceOrder.invoke({ quantity: input.quantity });
const saved = await context.database.orders.insert({
data: { ...input, totalCents, ownerId: session.user.id },
});
return {
orderId: saved.orderId,
sku: saved.sku,
quantity: saved.quantity,
totalCents: saved.totalCents,
};
},
});/** Reads a validated order ID only within the authenticated user's ownership and returns a concealed missing result otherwise. */
import { defineFunction } from "@relkit/app/functions";
import { z } from "@relkit/app/schema";
export default defineFunction({
input: z.object({ orderId: z.string().min(1) }),
output: z.object({
found: z.boolean(),
order: z
.object({
orderId: z.string(),
sku: z.string(),
quantity: z.number(),
totalCents: z.number(),
})
.optional(),
}),
handler: async ({ orderId }, context) => {
const session = await context.auth.getSession();
if (session === null) throw new Error("Authentication required");
const row = await context.database.orders.findOne({
where: { orderId, ownerId: session.user.id },
});
return row === null
? { found: false }
: {
found: true,
order: {
orderId: row.orderId,
sku: row.sku,
quantity: row.quantity,
totalCents: row.totalCents,
},
};
},
});request cookie → session → user ID → Orders query → owner-matching rowRows created in the previous chapter have no owner. These protected reads do not reveal them to new accounts; this tutorial does not guess who owned an anonymous order.
3. Sign in and verify access
Run bun run typecheck and bun run dev in the terminal with the auth environment set. In
another terminal, first call GET /orders/saved-1 without a cookie; expect HTTP 401.
Then create a local account and save its cookie:
auth_cookies=$(mktemp -t relkit-orders-cookies)
curl -i -c "$auth_cookies" http://127.0.0.1:3000/api/auth/sign-up/email \
-H 'content-type: application/json' -H 'origin: http://127.0.0.1:3000' \
--data '{"name":"Demo","email":"demo@example.com","password":"local-demo-password-123"}'
curl -i -b "$auth_cookies" -X POST http://127.0.0.1:3000/orders \
-H 'content-type: application/json' \
-d '{"orderId":"private-1","sku":"book","quantity":3}'
curl -b "$auth_cookies" http://127.0.0.1:3000/orders/private-1Expect sign-up and POST success, followed by found: true. A different signed-in account
receives found: false for private-1.