Skip to content
RELKIT
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 sqlite

This 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" >> .env

Review 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 check

2. 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 row

Rows 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-1

Expect sign-up and POST success, followed by found: true. A different signed-in account receives found: false for private-1.

Reference and source