Build guides

Check a ride-share driver's vehicle

Last updated:

Build Hollin, a ride-share operator's vehicle check on Next.js and better-auth: VIN entry, a decode and spec sheet turned into facts, rules with versions, and an ops queue. See the use case.

Hollin: a driver's 2023 Honda Accord checked rule by rule and placed in the Green ride tier, beside the pane that lists the Cardog calls behind it

You'll build Hollin, a ride-share operator's app: drivers add a car by VIN and see every rule it passes. See what it does.

  • Time: About 45 minutes, or 5 to clone and run.
  • You need: Node 20 or later and pnpm, A Cardog API key, Some Next.js
  • Cardog SDK: cardog.vin.image, cardog.v2.vin.getByVin, cardog.v2.specs.sheetByBuild, cardog.v2.recalls.entity
  • Clone the finished app: cardog-ai/example-rideshare-vehicle-check, or build it step by step below.
git clone https://github.com/cardog-ai/example-rideshare-vehicle-check.git

1. Get an API key

Every request to Cardog carries a key. Create one under Account → API, then copy .env.example to .env.local. The app reads the key on the server; the browser never sees it.

The example values run as shipped. Without a key the app runs on responses stored for six sample cars, so you can follow along before you sign up.

BETTER_AUTH_SECRET ships as a development placeholder, so a fresh clone builds and runs. Replace it with your own (openssl rand -base64 32) before you deploy: a production server will not start without one, and warns on every start while the placeholder is set.

.env.example

# Copy to .env.local. With these values the app runs on stored fixtures
# and a development auth secret.

# Your Cardog API key (https://cardog.app/account/api). Server-side only; it
# never reaches the browser. Leave empty for fixture mode.
CARDOG_API_KEY=

# Signs sessions. This value is a DEVELOPMENT PLACEHOLDER so a fresh clone
# builds and runs; the app warns on every production start while it is set.
# Replace it before deploying: openssl rand -base64 32
BETTER_AUTH_SECRET=hollin-dev-only-secret-replace-before-deploying

# Where the app is served. Needed when it is not http://localhost:3024.
BETTER_AUTH_URL=http://localhost:3024

# SQLite file. Created on first run.
DATABASE_PATH=data/hollin.db

# Seeded review logins (created by `pnpm db:setup` on an empty database):
#   driver  driver@hollin.example / hollin-driver
#   ops     ops@hollin.example    / hollin-ops

2. Create the Next.js app

Hollin is one Next.js app: the driver's screens, the ops queue, the rules editor, and the server code that talks to Cardog. SQLite keeps it to one process. better-sqlite3 is a native module, so the config loads it at runtime.

terminal

pnpm create next-app@latest hollin --ts --app --tailwind --src-dir
cd hollin
pnpm add @cardog/api better-auth better-sqlite3 drizzle-orm server-only
pnpm add -D drizzle-kit tsx @types/better-sqlite3

next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // better-sqlite3 is a native module: load it from node_modules at runtime.
  serverExternalPackages: ["better-sqlite3"],
  // This app is its own root, even when it sits inside another repository.
  outputFileTracingRoot: process.cwd(),
};

export default nextConfig;

3. Add sign-in and roles

Drivers and ops sign in with email and password. role is a user field sign-up cannot set, so every sign-up is a driver and nobody makes themselves ops from the form.

authSecret() picks the secret that signs sessions: yours whenever BETTER_AUTH_SECRET is set, and the development placeholder only on your machine and during next build. A production server with no secret stops, and one still on the placeholder logs a warning.

src/lib/auth.ts

function authSecret(): string {
  const set = process.env.BETTER_AUTH_SECRET?.trim();
  const production = process.env.NODE_ENV === "production" && process.env.NEXT_PHASE !== "phase-production-build";
  if (set) {
    if (production && set === DEV_SECRET) {
      console.warn("[hollin] BETTER_AUTH_SECRET is the development placeholder. Replace it before deploying.");
    }
    return set;
  }
  if (!production) return DEV_SECRET;
  throw new Error("[hollin] BETTER_AUTH_SECRET is not set. Generate one with `openssl rand -base64 32`.");
}

src/lib/auth.ts

export const auth = betterAuth({
  appName: "Hollin",
  baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3024",
  secret: authSecret(),
  database: drizzleAdapter(db, {
    provider: "sqlite",
    schema: { user: schema.user, session: schema.session, account: schema.account, verification: schema.verification },
  }),
  emailAndPassword: { enabled: true, minPasswordLength: 8, autoSignIn: true },
  user: {
    additionalFields: {
      role: { type: "string", required: false, defaultValue: "driver", input: false },
    },
  },
  plugins: [nextCookies()],
});

src/app/api/auth/[...all]/route.ts

import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);

4. Construct the Cardog client on the server

One file holds every Cardog call. The client is built once from the key, and the server-only import fails the build if a browser bundle reaches for it. Each call is timed and logged, which is what the "What the app called" pane shows.

SDK call: new CardogClient({ apiKey, appId })

src/lib/cardog.ts

const apiKey = process.env.CARDOG_API_KEY?.trim();

/** The one Cardog client. Server-side only. */
export const cardog: CardogClient | null = apiKey
  ? new CardogClient({ apiKey, appId: "hollin-vehicle-check", baseUrl: process.env.CARDOG_BASE_URL || undefined })
  : null;

export const dataMode: "live" | "fixture" = cardog ? "live" : "fixture";
export { FIXTURE_VINS };

// The response types, read off the client's own method signatures.
type V2 = CardogClient["v2"];
export type VinIdentity = Awaited<ReturnType<V2["vin"]["getByVin"]>>;
export type BuildSpecSheet = Awaited<ReturnType<V2["specs"]["sheetByBuild"]>>;
export type ModelYearRecalls = Awaited<ReturnType<V2["recalls"]["entity"]>>;
export type VinImageRead = Awaited<ReturnType<CardogClient["vin"]["image"]>>;

export interface Result<T> {
  data: T | null;
  call: CallRecord;
  error: string | null;
}

5. Check the VIN as it is typed

Count to seventeen and check the ninth character before spending a request. An I or O is almost always a 1 or 0, and a check digit that does not add up means a character is wrong.

src/lib/vin.ts

export function checkVin(vin: string): VinCheck {
  if (!vin) return { state: "empty" };
  const bad = vin.match(/[IOQ]/);
  if (bad) {
    const hint = bad[0] === "I" ? "1" : "0";
    return { state: "invalid", message: `VINs never use the letter ${bad[0]}. It's probably the number ${hint}.` };
  }
  if (/[^A-HJ-NPR-Z0-9]/.test(vin)) return { state: "invalid", message: "Only letters and numbers." };
  if (vin.length < 17) return { state: "typing", remaining: 17 - vin.length };
  const expected = expectedCheckDigit(vin);
  if (expected && expected !== vin[8]) {
    return {
      state: "check-mismatch",
      message: `The 9th character should be ${expected} for this VIN. Check for a mistyped character.`,
    };
  }
  return { state: "ok", message: "17 characters, check digit matches." };
}

Hollin's add-a-car screen with a VIN entered

6. Read the VIN from a photo

Typing seventeen characters on a phone is where drivers give up. Let them scan the door-jamb sticker instead; the route sends the image from the server and returns the VIN.

SDK call: cardog.vin.image(base64)

What comes back. success and result, the VIN read from the photo.

src/lib/cardog.ts

export function readVinFromImage(base64: string, step: string): Promise<Result<VinImageRead>> {
  return run<VinImageRead>({
    step,
    vin: FIXTURE_VINS[0].vin,
    key: "image",
    method: "cardog.vin.image",
    args: [`<${Math.round((base64.length * 3) / 4 / 1024)} KB image, base64>`],
    http: "POST /vin/image",
    returns: "The VIN read from the photo",
    live: (c) => c.vin.image(base64),
  });
}

src/app/api/vehicle/photo/route.ts

import { NextResponse } from "next/server";
import { getViewer } from "@/lib/session";
import { readVinFromImage } from "@/lib/cardog";
import { normalizeVin } from "@/lib/vin";

const MAX_BYTES = 8 * 1024 * 1024;

/** POST multipart { photo } → the VIN read from it. Signed-in users only. */
export async function POST(req: Request) {
  const me = await getViewer();
  if (!me) return NextResponse.json({ ok: false, error: "Sign in first." }, { status: 401 });
  const form = await req.formData().catch(() => null);
  const file = form?.get("photo");
  if (!(file instanceof File)) return NextResponse.json({ ok: false, error: "No photo received." }, { status: 400 });
  if (file.size > MAX_BYTES) return NextResponse.json({ ok: false, error: "That photo is over 8 MB." }, { status: 413 });

  const base64 = Buffer.from(await file.arrayBuffer()).toString("base64");
  const r = await readVinFromImage(base64, "Scan your VIN");
  const vin = r.data?.success ? normalizeVin(r.data.result) : null;
  const found = Boolean(vin && vin.length === 17);
  return NextResponse.json({
    ok: found,
    vin: found ? vin : null,
    error: found ? null : (r.error ?? "We couldn't find a VIN in that photo. Get closer, or type it in."),
    calls: [r.call],
  });
}

7. Decode the VIN

Send the VIN from a route handler, never from the browser. The decode returns the facts most rules read: the model year, the doors, and refs for body style, vehicle type, weight class, electrification and fuel.

SDK call: cardog.v2.vin.getByVin(vin)

What comes back. Year, make, model, trim and doors, refs such as bodyStyle, vehicleType, gvwr and electrificationLevel, and nano, the build code.

src/lib/cardog.ts

export function decodeVin(vin: string, step: string) {
  return run<VinIdentity>({
    step,
    vin,
    key: "decode",
    method: "cardog.v2.vin.getByVin",
    args: [vin],
    http: `GET /v2/vin/${vin}`,
    returns: "Identity card: year, make, model, doors, body, vehicle type, GVWR class, electrification",
    live: (c) => c.v2.vin.getByVin(vin),
  });
}

8. Read seating from the spec sheet

A VIN does not encode seats; the spec catalogue does. Read the build's spec sheet with the nano the decode returned. It also backs up doors and weight class when the decode has none.

Then turn both responses into facts. Each fact keeps its value, a label and its source, so the check can say which one decided a rule.

SDK call: cardog.v2.specs.sheetByBuild(nano)

What comes back. Spec values by section, each with its source; the app reads sections.identity.seatingCapacity.

src/lib/cardog.ts

export function readBuildSpecs(nano: string, vin: string, step: string) {
  return run<BuildSpecSheet>({
    step,
    vin,
    key: "specs",
    method: "cardog.v2.specs.sheetByBuild",
    args: [nano],
    http: `GET /v2/specs/${nano}`,
    returns: "Spec sheet for the build: seating capacity, doors, body, GVWR",
    live: (c) => c.v2.specs.sheetByBuild(nano),
  });
}

src/lib/vehicle-read.ts

export async function readCar(userId: string, vin: string, step: string): Promise<ReadResult> {
  const decoded = await decodeVin(vin, step);
  if (!decoded.data) return { ok: false, error: decoded.error, facts: null, calls: [decoded.call] };

  const calls = [decoded.call];
  let specs: unknown = null;
  if (decoded.data.nano) {
    const s = await readBuildSpecs(decoded.data.nano, vin, step);
    calls.push(s.call);
    specs = s.data;
  }
  const facts = buildFacts(vin, {
    decode: decoded.data as unknown as Record<string, unknown>,
    specs: specs as Record<string, unknown> | null,
  });
  held.set(keyOf(userId, vin), { facts, calls, at: Date.now() });
  return { ok: true, error: null, facts, calls };
}

src/app/api/vehicle/check/route.ts

import { NextResponse } from "next/server";
import { getViewer } from "@/lib/session";
import { readCar } from "@/lib/vehicle-read";
import { normalizeVin } from "@/lib/vin";

/** POST { vin } → the car's facts (decode + build spec sheet) and the call log. */
export async function POST(req: Request) {
  const me = await getViewer();
  if (!me) return NextResponse.json({ ok: false, error: "Sign in first." }, { status: 401 });
  const { vin } = (await req.json().catch(() => ({}))) as { vin?: string };
  const v = normalizeVin(vin ?? "");
  if (v.length !== 17) return NextResponse.json({ ok: false, error: "A VIN has 17 characters." }, { status: 400 });
  return NextResponse.json(await readCar(me.id, v, "Check your car"));
}

src/lib/vehicle-facts.ts

export function buildFacts(vin: string, raw: { decode: Loose | null; specs: Loose | null }): VehicleFacts {
  const d = raw.decode ?? {};
  const refs: Loose = d.refs ?? {};
  const attrs: Loose = d.attributes ?? {};
  const identity: Loose = raw.specs?.sections?.identity ?? {};

  const bodyRef: string | null = refs.bodyStyle ?? identity.bodyStyle?.value ?? null;
  const typeRef: string | null = refs.vehicleType ?? identity.vehicleType?.value ?? null;
  const gvwrRef: string | null = refs.gvwr ?? raw.specs?.sections?.dimensions?.gvwr?.value ?? null;
  const elecRef: string | null = refs.electrificationLevel ?? null;
  const fuelRef: string | null = refs.fuelType ?? null;

  // Doors: the decode first, then the build's spec sheet.
  const decodedDoors = typeof d.doors === "number" ? d.doors : attrs.doors?.value;
  const specDoors = identity.doors?.value;
  const doors: Fact<number> | null =
    typeof decodedDoors === "number"
      ? { value: decodedDoors, label: `${decodedDoors} doors`, source: "VIN decode" }
      : typeof specDoors === "number"
        ? { value: specDoors, label: `${specDoors} doors`, source: "Spec catalogue" }
        : null;

  // Seats come from the catalogue: a VIN does not encode seating.
  const seatValue = identity.seatingCapacity?.value;
  const seats: Fact<number> | null =
    typeof seatValue === "number" ? { value: seatValue, label: `${seatValue} seats`, source: "Spec catalogue" } : null;

  return {
    vin,
    year: typeof d.year === "number" ? d.year : (raw.specs?.year ?? null),
    make: d.make ?? raw.specs?.make ?? null,
    model: d.model ?? raw.specs?.model ?? null,
    trim: d.trim ?? identity.trim?.value ?? null,
    body: bodyRef ? { value: bodyRef, ref: bodyRef, label: labelOf(BODY_STYLES, bodyRef), source: "VIN decode" } : null,
    vehicleType: typeRef
      ? { value: typeRef, ref: typeRef, label: VEHICLE_TYPES[typeRef] ?? fromRef(typeRef) ?? typeRef, source: "VIN decode" }
      : null,
    doors,
    seats,
    gvwr: gvwrRef
      ? {
          value: gvwrRef,
          ref: gvwrRef,
          label: attrs.gvwr?.toName ?? fromRef(gvwrRef) ?? gvwrRef,
          source: "VIN decode",
          ...gvwrRange(gvwrRef),
        }
      : null,
    electrification: elecRef
      ? { value: elecRef, ref: elecRef, label: labelOf(ELECTRIFICATION_LEVELS, elecRef), source: "VIN decode" }
      : null,
    fuel: fuelRef ? { value: fuelRef, ref: fuelRef, label: FUELS[fuelRef] ?? fromRef(fuelRef) ?? fuelRef, source: "VIN decode" } : null,
    refs: { modelYear: refs.modelYear ?? raw.specs?.modelYearRef ?? null, nano: d.nano ?? null },
    read: { decode: Boolean(raw.decode), specs: Boolean(raw.specs) },
  };
}

Hollin asking the driver to confirm the decoded car and its plates

9. Write the rules

The rules are one config and one pure function. The same evaluate() runs on the driver's phone, in the ops queue and in the rules editor's preview.

A fact Cardog did not return never passes: the rule shows Check and the car needs a look from a person. Plates are the one thing the driver answers, because nothing in a VIN says how a car is plated.

src/lib/rules.ts

export interface RuleConfig {
  /** Oldest model year allowed, as an age in years: 2026 − 10 → 2016 or newer. */
  maxAgeYears: number;
  minDoors: number;
  minSeats: number;
  /** Body-style refs a car may have. */
  bodies: string[];
  /** Heaviest gross vehicle weight rating allowed, in pounds. */
  maxGvwrLb: number;
  /** Refuse vehicle types plated as commercial (truck, bus, incomplete). */
  blockCommercialTypes: boolean;
  /** Ask the driver about plates and refuse commercial ones. */
  requirePersonalPlates: boolean;
  /** Electrification levels that put a car in the Green ride tier. */
  greenLevels: string[];
}

src/lib/rules.ts

export function evaluate(f: VehicleFacts, a: Answers, rules: RuleConfig, now = new Date()): Evaluation {
  const results: RuleResult[] = [];
  const thisYear = now.getFullYear();
  const oldest = thisYear - rules.maxAgeYears;

  // Age, from the decoded model year.
  results.push(
    f.year == null
      ? { id: "age", label: `${oldest} or newer`, outcome: "look", fact: "Model year not decoded", source: "Not decoded" }
      : {
          id: "age",
          label: `${oldest} or newer`,
          outcome: f.year >= oldest ? "pass" : "fail",
          fact: `${f.year} model year, ${Math.max(0, thisYear - f.year)} ${thisYear - f.year === 1 ? "year" : "years"} old`,
          source: "VIN decode",
        }
  );

  // Doors.
  results.push(
    f.doors
      ? {
          id: "doors",
          label: `${rules.minDoors} doors or more`,
          outcome: f.doors.value >= rules.minDoors ? "pass" : "fail",
          fact: f.doors.label,
          source: f.doors.source,
        }
      : { id: "doors", label: `${rules.minDoors} doors or more`, outcome: "look", fact: "Door count not decoded", source: "Not decoded" }
  );

  // Seats, including the driver.
  results.push(
    f.seats
      ? {
          id: "seats",
          label: `${rules.minSeats} seats or more`,
          outcome: f.seats.value >= rules.minSeats ? "pass" : "fail",
          fact: `${f.seats.label}, driver included`,
          source: f.seats.source,
        }
      : { id: "seats", label: `${rules.minSeats} seats or more`, outcome: "look", fact: "Seating not in the catalogue", source: "Not decoded" }
  );

  // Body style.
  results.push(
    f.body
      ? {
          id: "body",
          label: "An allowed body style",
          outcome: rules.bodies.includes(f.body.value) ? "pass" : "fail",
          fact: f.body.label,
          source: f.body.source,
        }
      : { id: "body", label: "An allowed body style", outcome: "look", fact: "Body style not decoded", source: "Not decoded" }
  );

  // Weight: the GVWR class, or failing that a passenger-car vehicle type.
  const gvwrLabel = `GVWR ${fmt(rules.maxGvwrLb)} lb or less`;
  if (f.gvwr && (f.gvwr.maxLb != null || f.gvwr.minLb != null)) {
    const outcome: Outcome =
      f.gvwr.maxLb != null && f.gvwr.maxLb <= rules.maxGvwrLb
        ? "pass"
        : f.gvwr.minLb != null && f.gvwr.minLb > rules.maxGvwrLb
          ? "fail"
          : "look";
    results.push({ id: "gvwr", label: gvwrLabel, outcome, fact: f.gvwr.label, source: f.gvwr.source });
  } else if (f.vehicleType?.value === "vehicle-type:passenger-car") {
    results.push({
      id: "gvwr",
      label: gvwrLabel,
      outcome: "pass",
      fact: "No GVWR class decoded; a passenger car, so light duty",
      source: "VIN decode",
    });
  } else {
    results.push({ id: "gvwr", label: gvwrLabel, outcome: "look", fact: "GVWR not decoded", source: "Not decoded" });
  }

  // Commercial vehicle class.
  if (rules.blockCommercialTypes) {
    results.push(
      f.vehicleType
        ? {
            id: "commercial",
            label: "Not a commercial vehicle type",
            outcome: COMMERCIAL_TYPES.has(f.vehicleType.value) ? "fail" : "pass",
            fact: f.vehicleType.label,
            source: f.vehicleType.source,
          }
        : { id: "commercial", label: "Not a commercial vehicle type", outcome: "look", fact: "Vehicle type not decoded", source: "Not decoded" }
    );
  }

  // Plates: the driver says; a VIN cannot.
  if (rules.requirePersonalPlates) {
    results.push({
      id: "plates",
      label: "Personal plates",
      outcome: a.plates === "personal" ? "pass" : a.plates === "commercial" ? "fail" : "look",
      fact: PLATES[a.plates],
      source: "Driver",
    });
  }

  const counts = { pass: 0, fail: 0, look: 0 } as Record<Outcome, number>;
  for (const r of results) counts[r.outcome]++;
  const verdict: Verdict = counts.fail > 0 ? "not-eligible" : counts.look > 0 ? "look" : "eligible";

  // Green ride: electrified at a level ops counts.
  const green = f.electrification
    ? {
        tier: rules.greenLevels.includes(f.electrification.value),
        fact: f.electrification.label,
        source: f.electrification.source,
      }
    : f.fuel
      ? { tier: false, fact: `${f.fuel.label}, not electrified`, source: f.fuel.source }
      : { tier: false, fact: "Powertrain not decoded", source: "Not decoded" as const };

  return { verdict, results, green, counts };
}

Hollin's check: every rule passed and the Green ride tier

10. Save the car for review

Sending reuses the reads the driver just saw, held for an hour, so a car is not paid for twice. The row keeps the facts, the driver's answer, the call log, and the verdict and rules version it was sent under.

src/app/actions.ts

export async function sendCar(input: { vin: string; plates: Plates }): Promise<Done> {
  const me = await getViewer();
  if (!me || me.role !== "driver") return { ok: false, error: "Sign in as a driver first." };
  const vin = normalizeVin(input.vin);
  if (vin.length !== 17) return { ok: false, error: "A VIN has 17 characters." };
  if (!(input.plates in PLATES)) return { ok: false, error: "Tell us about the plates." };

  const existing = db
    .select({ id: schema.car.id })
    .from(schema.car)
    .where(and(eq(schema.car.userId, me.id), eq(schema.car.vin, vin)))
    .get();
  if (existing) return { ok: false, error: "This car is already on your account." };

  // Save what the driver saw; read again only if that has expired.
  let h = heldRead(me.id, vin);
  if (!h) {
    const r = await readCar(me.id, vin, "Send for review");
    if (!r.ok || !r.facts) return { ok: false, error: r.error ?? "We couldn't read that VIN.", calls: r.calls };
    h = { facts: r.facts, calls: r.calls, at: Date.now() };
  }
  const rules = currentRules();
  const answers = { plates: input.plates };
  const e = evaluate(h.facts, answers, rules.config);
  const id = crypto.randomUUID();
  db.insert(schema.car)
    .values({
      id,
      userId: me.id,
      vin,
      year: h.facts.year,
      make: h.facts.make,
      model: h.facts.model,
      trim: h.facts.trim,
      facts: h.facts,
      answers,
      calls: h.calls,
      verdictAtSubmit: e.verdict,
      rulesVersion: rules.version,
      green: e.green.tier,
      status: "waiting",
      readAt: new Date(h.at),
    })
    .run();
  dropRead(me.id, vin);
  revalidatePath("/drive");
  revalidatePath("/ops");
  return { ok: true, id };
}

11. Build the ops queue

Ops sees the waiting cars ranked by today's result. Opening one reads the recalls issued for its model and year; drivers never see them, and the screen says they are matched by model year.

Ops approves or declines with a note the driver reads.

SDK call: cardog.v2.recalls.entity(modelYearRef)

What comes back. Transport Canada and NHTSA campaigns issued for the model year, with total.

src/lib/cardog.ts

export function readModelYearRecalls(modelYearRef: string, vin: string, step: string) {
  return run<ModelYearRecalls>({
    step,
    vin,
    key: "recalls",
    method: "cardog.v2.recalls.entity",
    args: [modelYearRef, { limit: 25 }],
    http: `GET /v2/recalls/entity/${modelYearRef}?limit=25`,
    returns: "Recall campaigns issued for the model year (Transport Canada and NHTSA)",
    live: (c) => c.v2.recalls.entity(modelYearRef, { limit: 25 }),
  });
}

src/app/actions.ts

export async function decide(input: { carId: string; status: "approved" | "declined"; note: string }): Promise<Done> {
  const me = await getViewer();
  if (!me || me.role !== "ops") return { ok: false, error: "Only ops can decide." };
  if (input.status !== "approved" && input.status !== "declined") return { ok: false, error: "Approve or decline." };
  const note = input.note.trim().slice(0, 500);
  if (input.status === "declined" && !note) return { ok: false, error: "Tell the driver why in a line." };
  db.update(schema.car)
    .set({
      status: input.status,
      decisionNote: note || null,
      decidedAt: new Date(),
      decidedBy: me.name,
      updatedAt: new Date(),
    })
    .where(eq(schema.car.id, input.carId))
    .run();
  revalidatePath("/ops");
  revalidatePath(`/ops/${input.carId}`);
  return { ok: true };
}

Hollin's ops view of a car that needs a look, with the recalls issued for its model and year

12. Let ops edit the rules

The rules live in the database with versions. The editor re-runs evaluate() over the waiting cars as ops changes a stepper or a switch, so they see the effect before saving; saving writes a new version and the old ones stay.

src/lib/data.ts

export function currentRules(): Rules {
  const row = db.select().from(schema.ruleset).orderBy(desc(schema.ruleset.version)).limit(1).get();
  return row
    ? toRules(row)
    : { version: 0, config: DEFAULT_RULES, note: null, createdBy: "Hollin", createdAt: new Date(0) };
}

src/app/actions.ts

export async function saveRules(input: { config: Partial<RuleConfig>; note: string }): Promise<Done> {
  const me = await getViewer();
  if (!me || me.role !== "ops") return { ok: false, error: "Only ops can change the rules." };
  const config = normalizeRules(input.config);
  if (config.bodies.length === 0) return { ok: false, error: "Allow at least one body style." };
  const version = currentRules().version + 1;
  db.insert(schema.ruleset)
    .values({ version, config, note: input.note.trim().slice(0, 200) || null, createdBy: me.name })
    .run();
  revalidatePath("/", "layout");
  return { ok: true };
}

Hollin's rules editor with steppers, body styles and the effect on waiting cars

13. Deploy

Hollin ships as one container with SQLite on a volume. Build the image, mount the volume, and set the key, the auth secret and the public URL in the host's environment. On a serverless host, swap SQLite for a hosted database; the Cardog code does not change.

Dockerfile

# Hollin vehicle check: one container, SQLite on a mounted volume.
#   docker build -t hollin .
#   docker run -p 3024:3024 -v hollin-data:/app/data \
#     -e CARDOG_API_KEY=... -e BETTER_AUTH_SECRET=... -e BETTER_AUTH_URL=https://your.host hollin
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable
COPY --from=build /app ./
VOLUME /app/data
EXPOSE 3024
CMD ["pnpm", "start"]

terminal

docker build -t hollin .
docker run -p 3024:3024 -v hollin-data:/app/data \
  -e CARDOG_API_KEY=your_cardog_api_key \
  -e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
  -e BETTER_AUTH_URL=https://drive.example.com \
  hollin

Next steps

  • VIN decode: Every field the decode returns.
  • Recalls: Campaigns by model year, from each authority.
  • Entity refs: How refs like body style and weight class are named.