Compare commits

...

3 Commits

Author SHA1 Message Date
fireshaper 7b26d57c4d Step 5.6: validate requests and responses from the route catalog
CI / check (push) Successful in 1m1s Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:47:00 -04:00
fireshaper 768c893130 Step 5.5: add guest accounts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:45:12 -04:00
fireshaper bcae75d822 Step 5.4: add test database harness
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:41:00 -04:00
25 changed files with 2593 additions and 11 deletions

View File

@ -225,3 +225,30 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Migrations are generated SQL** (`drizzle-kit generate`) committed in `apps/server/drizzle/`, which Prettier ignores. They are applied with the drizzle-orm migrator (`db:migrate`, or `runMigrations(url)` in tests).
- **Server test files run one at a time** (`apps/server/vitest.config.ts`) because integration tests share one test database.
- **CI runs a `postgres:18` service container** and points `TEST_DATABASE_URL` at it; locally the default is the compose database `deadhead_test`.
## Step 5.4
- **The harness truncates every table in the `public` schema before each test**, rather than wrapping tests in rolled-back transactions. It is simpler, and it works for code that opens its own transactions. Because test files run one at a time, this is safe.
- **`createPlayer` is real game code** (`src/game/new-player.ts`), used both by the harness and, from Step 5.5, by guest sign-up. A new player gets starting credits, the starting-discovered locations, the avatar, and tutorial mode with the loaner at Earth.
- **The avatar is named "Captain"** and the company "Deadhead Freight" for now; there is no naming step in the base game. Hired pilots get generated names (Step 6.8).
- **During the tutorial the avatar is unassigned and stores its location** (decision 0.5.27). The tutorial dispatch code keeps it equal to the loaner's location.
- **Seed states:** `tutorial` (a brand-new player) and `owner` (tutorial over, one Courier at Earth crewed by the avatar, an Earth bay). Later steps add states as their tests need them.
## Step 5.5
- **Better Auth's tables are generated by its CLI** (`@better-auth/cli generate` with the anonymous plugin) into `src/db/auth-schema.ts` and migrated by drizzle-kit (`0001_auth`). They keep Better Auth's own conventions (Postgres `timestamp` columns, singular table names) rather than ours.
- **`players.user_id` links each player to one Better Auth user** (unique, cascade on delete).
- **Guest creation uses Better Auth's server API, not its HTTP routes.** `POST /api/guest` calls `auth.api.signInAnonymous`, forwards the `Set-Cookie` header, and creates the player in the same request. `/api/auth/*` is not mounted, since nothing in the base game needs it yet (upgrading guests to full accounts is not in scope).
- **World seeds come from `crypto.randomInt(0, 2³²)`.**
- **Better Auth uses real time for session expiry**, not the injectable game clock. Auth is not game logic, so this does not affect deterministic tests.
- **Errors:** an `ApiError(status, code, message)` thrown anywhere becomes `{ error: { code, message } }`. Unknown routes return `404 not-found`, and unexpected exceptions are logged and return `500 internal-error`.
- **`GET /api/state` is implemented now without lazy resolution.** It reads ships, pilots (level and skill points derived with sim), bays, all locations with their discovered flag, tutorial state, in-flight dispatches and unacknowledged summaries. Step 6.1 adds resolution, and Step 6.2 its fuller tests.
- **`buildApp` takes an optional `database`.** Tests pass the harness's connection; otherwise the app opens its own and closes it on shutdown.
## Step 5.6
- **The catalog is wired in with a small `registerRoute` helper**, not `fastify-type-provider-zod`. The catalog merges path parameters with the body into one request schema, which the type provider's separate params/body/query slots do not model.
- **Request validation runs before the session check,** so a malformed request always gets `400 validation-error`, even without a session.
- **Requests with no fields reject unexpected ones** (`emptyRequestSchema` is strict), including query parameters.
- **Responses are validated everywhere except production.** A violation is logged and returned as `500 internal-error`, which makes tests fail loudly. Production skips the check to save work.
- **Fastify's own 4xx errors** (malformed JSON, wrong content type) are reported as `400 validation-error` in the documented format.

View File

@ -3,6 +3,6 @@ import { defineConfig } from "drizzle-kit";
// `pnpm --filter @deadhead/server db:generate` writes SQL migrations to ./drizzle.
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
schema: ["./src/db/schema.ts", "./src/db/auth-schema.ts"],
out: "./drizzle",
});

View File

@ -0,0 +1,57 @@
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"is_anonymous" boolean DEFAULT false,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "user_id" text NOT NULL;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");--> statement-breakpoint
ALTER TABLE "players" ADD CONSTRAINT "players_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "players" ADD CONSTRAINT "players_user_id_unique" UNIQUE("user_id");

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,13 @@
"when": 1790991444530,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1790991751364,
"tag": "0001_auth",
"breakpoints": true
}
]
}

View File

@ -16,6 +16,7 @@
"@deadhead/content": "workspace:*",
"@deadhead/schema": "workspace:*",
"@deadhead/sim": "workspace:*",
"better-auth": "^1.7.7",
"drizzle-orm": "^0.45.3",
"fastify": "^5.12.5",
"pg": "^8.23.1",

View File

@ -1,29 +1,73 @@
import { randomInt } from "node:crypto";
import { content } from "@deadhead/content";
import { routes, type ServerTime } from "@deadhead/schema";
import { routes } from "@deadhead/schema";
import { realToGame } from "@deadhead/sim";
import { fromNodeHeaders } from "better-auth/node";
import Fastify, { type FastifyInstance } from "fastify";
import { createAuth } from "./auth";
import { type Clock, systemClock } from "./clock";
import type { ServerConfig } from "./config";
import { createDatabase, type Database } from "./db/client";
import { createPlayer } from "./game/new-player";
import { loadPlayerState } from "./game/player-state";
import { ApiError, registerErrorHandler } from "./http/errors";
import { registerRoute, type RouteOptions } from "./http/route";
import { requirePlayerId } from "./http/session";
export interface AppDeps {
clock?: Clock;
/** An existing database (tests). Otherwise the app opens one and closes it on shutdown. */
database?: Database;
}
/** Builds the Fastify app without listening, so tests can start it on any port. */
export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInstance {
const clock = deps.clock ?? systemClock;
const database = deps.database ?? createDatabase(config.databaseUrl);
const { db } = database;
const auth = createAuth(db, { secret: config.authSecret, baseUrl: config.authUrl });
const app = Fastify({
// Structured JSON logs (pino); silenced in tests unless LOG_LEVEL says otherwise.
logger: { level: config.logLevel },
});
if (!deps.database) app.addHook("onClose", () => database.close());
registerErrorHandler(app);
const options: RouteOptions = {
requirePlayer: (request) => requirePlayerId(request, auth, db),
validateResponses: config.env !== "production",
};
// Under /api so it is reachable through the same-domain proxy (decision 0.5.20).
app.get("/api/health", async () => ({ status: "ok" }));
app.get(routes.getServerTime.path, async (): Promise<ServerTime> => {
registerRoute(app, routes.getServerTime, options, async () => {
const realTime = clock();
return { realTime, gameTime: realToGame(realTime, content.config) };
});
registerRoute(app, routes.createGuest, options, async (_input, { request, reply }) => {
const signIn = await auth.api.signInAnonymous({
headers: fromNodeHeaders(request.headers),
returnHeaders: true,
});
if (!signIn.response) throw new ApiError(500, "internal-error", "Could not create a guest.");
const { playerId } = await createPlayer(db, {
userId: signIn.response.user.id,
// A 32-bit unsigned seed (decision 0.5.10).
worldSeed: randomInt(0, 2 ** 32),
now: clock(),
});
// Pass Better Auth's session cookie through to the client.
reply.header("set-cookie", signIn.headers.getSetCookie());
return { playerId, state: await loadPlayerState(db, playerId) };
});
registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) =>
loadPlayerState(db, playerId),
);
return app;
}

23
apps/server/src/auth.ts Normal file
View File

@ -0,0 +1,23 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { anonymous } from "better-auth/plugins/anonymous";
import type { Db } from "./db/client";
import * as authSchema from "./db/auth-schema";
export interface AuthSettings {
secret: string;
baseUrl: string;
}
/** Better Auth with guest (anonymous) sign-in, storing users and sessions in Postgres. */
export function createAuth(db: Db, settings: AuthSettings) {
return betterAuth({
database: drizzleAdapter(db, { provider: "pg", schema: authSchema }),
secret: settings.secret,
baseURL: settings.baseUrl,
basePath: "/api/auth",
plugins: [anonymous()],
});
}
export type Auth = ReturnType<typeof createAuth>;

View File

@ -0,0 +1,96 @@
// Better Auth tables, generated by `@better-auth/cli generate` (anonymous plugin).
// Regenerate rather than hand-edit when Better Auth or its plugins change.
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
isAnonymous: boolean("is_anonymous").default(false),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));

View File

@ -1,6 +1,9 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import pg from "pg";
import * as schema from "./schema";
import * as authSchema from "./auth-schema";
import * as gameSchema from "./schema";
const schema = { ...gameSchema, ...authSchema };
export type Db = NodePgDatabase<typeof schema>;

View File

@ -11,6 +11,7 @@ import {
text,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { user } from "./auth-schema";
// All times are Unix milliseconds (bigint), matching sim and the API.
const time = (name: string) => bigint(name, { mode: "number" });
@ -21,6 +22,11 @@ export const boardKind = pgEnum("board_kind", ["bay", "tutorial"]);
export const players = pgTable("players", {
id: text("id").primaryKey(),
/** The Better Auth user (a guest account in the base game). */
userId: text("user_id")
.notNull()
.unique()
.references(() => user.id, { onDelete: "cascade" }),
/** 32-bit unsigned seed for boards, dispatches and names (decision 0.5.10). */
worldSeed: bigint("world_seed", { mode: "number" }).notNull(),
tutorialActive: boolean("tutorial_active").notNull().default(true),

View File

@ -0,0 +1,17 @@
import type { Dispatch } from "@deadhead/schema";
import type { dispatches } from "../db/schema";
/** Database status values use `in_flight`; the API uses `in-flight`. */
export function toApiStatus<S extends "idle" | "in_flight" | "resolved">(
status: S,
): S extends "in_flight" ? "in-flight" : S {
return (status === "in_flight" ? "in-flight" : status) as S extends "in_flight" ? "in-flight" : S;
}
export function toApiDispatch(row: typeof dispatches.$inferSelect): Dispatch {
return {
...row.record,
status: toApiStatus(row.status),
resolvedAt: row.resolvedAt,
};
}

View File

@ -0,0 +1,63 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import type { RealTime, Seed } from "@deadhead/sim";
import type { Db } from "../db/client";
import { companies, discoveredLocations, pilots, players } from "../db/schema";
export const AVATAR_NAME = "Captain";
export const COMPANY_NAME = "Deadhead Freight";
export interface NewPlayer {
playerId: string;
companyId: string;
avatarId: string;
}
/**
* Creates a fresh player in tutorial mode: starting credits, every location
* that starts discovered, the avatar (level 0, unassigned), and the loaner at
* Earth. The avatar's position comes from the loaner while the tutorial runs.
*/
export async function createPlayer(
db: Db,
args: { userId: string; worldSeed: Seed; now: RealTime; playerId?: string },
): Promise<NewPlayer> {
const { worldSeed, now } = args;
const playerId = args.playerId ?? randomUUID();
const companyId = randomUUID();
const avatarId = randomUUID();
await db.transaction(async (tx) => {
await tx.insert(players).values({
id: playerId,
userId: args.userId,
worldSeed,
tutorialActive: true,
loanerLocationId: "earth",
createdAt: now,
});
await tx.insert(companies).values({
id: companyId,
playerId,
name: COMPANY_NAME,
credits: content.config.startingCredits,
});
await tx
.insert(discoveredLocations)
.values(
content.locations
.filter((l) => l.startsDiscovered)
.map((l) => ({ playerId, locationId: l.id, discoveredAt: now })),
);
await tx.insert(pilots).values({
id: avatarId,
playerId,
name: AVATAR_NAME,
isAvatar: true,
locationId: "earth",
createdAt: now,
});
});
return { playerId, companyId, avatarId };
}

View File

@ -0,0 +1,107 @@
import { content } from "@deadhead/content";
import type { PlayerState } from "@deadhead/schema";
import { pilotLevel, skillPointsAvailable } from "@deadhead/sim";
import { and, eq, isNull } from "drizzle-orm";
import type { Db } from "../db/client";
import {
bays,
companies,
discoveredLocations,
dispatches,
pilots,
players,
ships,
} from "../db/schema";
import { toApiDispatch, toApiStatus } from "./mappers";
/** Reads everything the client needs about one player. */
export async function loadPlayerState(db: Db, playerId: string): Promise<PlayerState> {
const [player] = await db.select().from(players).where(eq(players.id, playerId));
const [company] = await db.select().from(companies).where(eq(companies.playerId, playerId));
if (!player || !company) throw new Error(`Player ${playerId} not found`);
const shipRows = await db.select().from(ships).where(eq(ships.playerId, playerId));
const pilotRows = await db.select().from(pilots).where(eq(pilots.playerId, playerId));
const bayRows = await db.select().from(bays).where(eq(bays.playerId, playerId));
const discovered = await db
.select({ locationId: discoveredLocations.locationId })
.from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId));
const active = await db
.select()
.from(dispatches)
.where(and(eq(dispatches.playerId, playerId), eq(dispatches.status, "in_flight")));
const unacknowledged = await db
.select()
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.status, "resolved"),
isNull(dispatches.acknowledgedAt),
),
);
const discoveredIds = new Set(discovered.map((d) => d.locationId));
const shipById = new Map(shipRows.map((s) => [s.id, s]));
const activeByShip = new Map(active.filter((d) => d.shipId).map((d) => [d.shipId!, d.id]));
const pilotByShip = new Map(
pilotRows.filter((p) => p.assignedShipId).map((p) => [p.assignedShipId!, p.id]),
);
return {
company: { id: company.id, name: company.name, credits: company.credits },
ships: shipRows.map((s) => ({
id: s.id,
modelId: s.modelId,
name: s.name,
status: toApiStatus(s.status),
locationId: s.status === "in_flight" ? null : s.locationId,
dispatchId: activeByShip.get(s.id) ?? null,
pilotId: pilotByShip.get(s.id) ?? null,
})),
pilots: pilotRows.map((p) => {
const level = pilotLevel(p.xp, content.config);
const locationId =
p.status === "in_flight"
? null
: p.assignedShipId
? (shipById.get(p.assignedShipId)?.locationId ?? null)
: p.locationId;
return {
id: p.id,
name: p.name,
isAvatar: p.isAvatar,
xp: p.xp,
level,
skills: p.skills,
skillPointsAvailable: skillPointsAvailable(level, p.skillPointsSpent, content.config),
assignedShipId: p.assignedShipId,
status: toApiStatus(p.status),
locationId,
};
}),
bays: bayRows.map((b) => ({ id: b.id, locationId: b.locationId })),
locations: content.locations.map((l) => ({ id: l.id, discovered: discoveredIds.has(l.id) })),
tutorial:
player.tutorialActive && player.loanerLocationId
? {
loanerLocationId: player.loanerLocationId as "earth" | "moon",
runInFlight: active.some((d) => d.tutorial),
}
: null,
activeDispatches: active.map(toApiDispatch),
summaries: unacknowledged.map((d) => ({
dispatchId: d.id,
shipId: d.record.shipId,
pilotId: d.pilotId,
originId: d.record.originId,
destinationId: d.record.destinationId,
tutorial: d.tutorial,
credits: d.resolution?.credits ?? 0,
pilotXp: d.resolution?.pilotXp ?? 0,
discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null,
resolvedAt: d.resolvedAt ?? d.arrivalAt,
})),
};
}

View File

@ -0,0 +1,36 @@
import type { ErrorCode, ErrorResponse } from "@deadhead/schema";
import type { FastifyInstance } from "fastify";
/** An expected failure, sent as `{ error: { code, message } }` with its HTTP status. */
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: ErrorCode,
message: string,
) {
super(message);
}
}
export function errorBody(code: ErrorCode, message: string): ErrorResponse {
return { error: { code, message } };
}
/** Turns thrown `ApiError`s into the documented error format; anything else is a 500. */
export function registerErrorHandler(app: FastifyInstance): void {
app.setErrorHandler((error, request, reply) => {
if (error instanceof ApiError) {
return reply.status(error.status).send(errorBody(error.code, error.message));
}
// Fastify's own 4xx errors, e.g. a body that is not valid JSON.
const status = (error as { statusCode?: number }).statusCode;
if (status !== undefined && status >= 400 && status < 500) {
return reply.status(400).send(errorBody("validation-error", (error as Error).message));
}
request.log.error(error);
return reply.status(500).send(errorBody("internal-error", "Something went wrong."));
});
app.setNotFoundHandler((_request, reply) =>
reply.status(404).send(errorBody("not-found", "No such endpoint.")),
);
}

View File

@ -0,0 +1,72 @@
import type { RouteDef } from "@deadhead/schema";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import type { z } from "zod";
import { ApiError } from "./errors";
export interface RouteContext<Auth extends boolean> {
/** The signed-in player; only present on routes that require a session. */
playerId: Auth extends true ? string : null;
request: FastifyRequest;
reply: FastifyReply;
}
export interface RouteOptions {
/** Resolves the session to a player id, or throws `401 unauthorized`. */
requirePlayer: (request: FastifyRequest) => Promise<string>;
/** Check responses against their schema (everywhere except production). */
validateResponses: boolean;
}
function describeIssues(error: z.ZodError): string {
return error.issues
.map((issue) => (issue.path.length ? `${issue.path.join(".")}: ` : "") + issue.message)
.join("; ");
}
/**
* Registers one catalog route. The request (path parameters, query and body
* merged) is validated before anything else, then the session is checked if the
* route needs one, and the response is checked against its schema.
*/
export function registerRoute<R extends RouteDef>(
app: FastifyInstance,
route: R,
options: RouteOptions,
handler: (
input: z.output<R["request"]>,
context: RouteContext<R["auth"]>,
) => Promise<z.input<R["response"]>>,
): void {
app.route({
method: route.method,
url: route.path,
handler: async (request, reply) => {
const raw = {
...(request.query as object),
...(typeof request.body === "object" && request.body !== null ? request.body : {}),
...(request.params as object),
};
const parsed = route.request.safeParse(raw);
if (!parsed.success) {
throw new ApiError(400, "validation-error", describeIssues(parsed.error));
}
const playerId = route.auth ? await options.requirePlayer(request) : null;
const result = await handler(parsed.data as z.output<R["request"]>, {
playerId: playerId as RouteContext<R["auth"]>["playerId"],
request,
reply,
});
if (options.validateResponses) {
const checked = route.response.safeParse(result);
if (!checked.success) {
throw new Error(
`Response for ${route.method} ${route.path} violates its schema: ${describeIssues(checked.error)}`,
);
}
}
return result;
},
});
}

View File

@ -0,0 +1,24 @@
import { eq } from "drizzle-orm";
import { fromNodeHeaders } from "better-auth/node";
import type { FastifyRequest } from "fastify";
import type { Auth } from "../auth";
import type { Db } from "../db/client";
import { players } from "../db/schema";
import { ApiError } from "./errors";
/** The signed-in player's id, or a 401 `unauthorized` error. */
export async function requirePlayerId(
request: FastifyRequest,
auth: Auth,
db: Db,
): Promise<string> {
const session = await auth.api.getSession({ headers: fromNodeHeaders(request.headers) });
if (!session) throw new ApiError(401, "unauthorized", "Sign in to continue.");
const [player] = await db
.select({ id: players.id })
.from(players)
.where(eq(players.userId, session.user.id));
if (!player) throw new ApiError(401, "unauthorized", "No player for this account.");
return player.id;
}

112
apps/server/test/db.ts Normal file
View File

@ -0,0 +1,112 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, beforeEach } from "vitest";
import { createDatabase, type Database, type Db } from "../src/db/client";
import { runMigrations } from "../src/db/migrate";
import { user } from "../src/db/auth-schema";
import { bays, companies, pilots, players, ships } from "../src/db/schema";
import { createPlayer, type NewPlayer } from "../src/game/new-player";
import { testConfig } from "./helpers";
export const TEST_NOW = Date.parse("2100-01-01T00:00:00Z");
/**
* Connects a test file to the test database: migrates once, empties every
* table before each test, and closes the pool at the end. Use `database()`
* inside tests.
*/
export function useTestDatabase(): () => Database {
let database: Database | undefined;
const url = testConfig().databaseUrl;
beforeAll(async () => {
await runMigrations(url);
database = createDatabase(url);
});
beforeEach(async () => {
await resetDatabase(database!.db);
});
afterAll(async () => {
await database?.close();
});
return () => {
if (!database) throw new Error("useTestDatabase: database not ready (use it inside tests)");
return database;
};
}
/** Empties every table in the public schema. */
export async function resetDatabase(db: Db): Promise<void> {
const result = await db.execute<{ tablename: string }>(
sql`select tablename from pg_tables where schemaname = 'public'`,
);
const names = result.rows.map((r) => `"${r.tablename}"`);
if (names.length > 0) await db.execute(sql.raw(`truncate ${names.join(", ")} cascade`));
}
export type SeedState = "tutorial" | "owner";
export interface SeededPlayer extends NewPlayer {
/** The Courier owned in the "owner" state. */
shipId?: string;
}
/**
* Creates a player in a named state:
* - `tutorial`: brand new player (what guest sign-up creates).
* - `owner`: tutorial over, owns one Courier at Earth crewed by the avatar, has an Earth bay.
*/
export async function seedPlayer(
db: Db,
state: SeedState,
options: { worldSeed?: number; credits?: number } = {},
): Promise<SeededPlayer> {
const userId = randomUUID();
await db.insert(user).values({
id: userId,
name: "Guest",
email: `${userId}@guest.test`,
isAnonymous: true,
});
const player = await createPlayer(db, {
userId,
worldSeed: options.worldSeed ?? 12345,
now: TEST_NOW,
});
if (options.credits !== undefined) {
await db
.update(companies)
.set({ credits: options.credits })
.where(eq(companies.playerId, player.playerId));
}
if (state === "tutorial") return player;
const shipId = randomUUID();
await db.transaction(async (tx) => {
await tx
.update(players)
.set({ tutorialActive: false, loanerLocationId: null })
.where(eq(players.id, player.playerId));
await tx.insert(ships).values({
id: shipId,
playerId: player.playerId,
modelId: content.shipModels[0]!.id,
name: "Test Courier",
locationId: "earth",
createdAt: TEST_NOW,
});
await tx
.update(pilots)
.set({ assignedShipId: shipId, locationId: null })
.where(eq(pilots.id, player.avatarId));
await tx.insert(bays).values({
id: randomUUID(),
playerId: player.playerId,
locationId: "earth",
createdAt: TEST_NOW,
});
});
return { ...player, shipId };
}

View File

@ -0,0 +1,88 @@
import { content } from "@deadhead/content";
import {
createGuestResponseSchema,
errorResponseSchema,
playerStateSchema,
} from "@deadhead/schema";
import { beforeEach, describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { useTestDatabase } from "./db";
import { testConfig } from "./helpers";
const database = useTestDatabase();
/** Turns Set-Cookie headers into a Cookie request header. */
function cookieFrom(setCookie: string | string[] | undefined): string {
const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
return values.map((c) => c.split(";")[0]).join("; ");
}
describe("guest accounts", () => {
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
app = buildApp(testConfig(), { database: database() });
return () => app.close();
});
it("creates a guest with a session cookie and a new tutorial player", async () => {
const response = await app.inject({ method: "POST", url: "/api/guest" });
expect(response.statusCode).toBe(200);
expect(cookieFrom(response.headers["set-cookie"])).toMatch(/session_token=/);
const body = createGuestResponseSchema.parse(response.json());
expect(body.state.company.credits).toBe(content.config.startingCredits);
expect(body.state.tutorial).toEqual({ loanerLocationId: "earth", runInFlight: false });
});
it("returns the new player's state for that session", async () => {
const guest = await app.inject({ method: "POST", url: "/api/guest" });
const cookie = cookieFrom(guest.headers["set-cookie"]);
const response = await app.inject({ method: "GET", url: "/api/state", headers: { cookie } });
expect(response.statusCode).toBe(200);
const state = playerStateSchema.parse(response.json());
expect(state.company.credits).toBe(content.config.startingCredits);
expect(
state.locations
.filter((l) => l.discovered)
.map((l) => l.id)
.sort(),
).toEqual(["earth", "moon"]);
expect(state.pilots).toHaveLength(1);
expect(state.pilots[0]).toMatchObject({
isAvatar: true,
level: 0,
xp: 0,
locationId: "earth",
status: "idle",
});
expect(state.ships).toHaveLength(0);
expect(state.tutorial).not.toBeNull();
});
it("gives each guest their own player", async () => {
const a = createGuestResponseSchema.parse(
(await app.inject({ method: "POST", url: "/api/guest" })).json(),
);
const b = createGuestResponseSchema.parse(
(await app.inject({ method: "POST", url: "/api/guest" })).json(),
);
expect(a.playerId).not.toBe(b.playerId);
});
it("rejects a state request with no session", async () => {
const response = await app.inject({ method: "GET", url: "/api/state" });
expect(response.statusCode).toBe(401);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("unauthorized");
});
it("rejects a state request with a forged session cookie", async () => {
const response = await app.inject({
method: "GET",
url: "/api/state",
headers: { cookie: "better-auth.session_token=forged.value" },
});
expect(response.statusCode).toBe(401);
});
});

View File

@ -0,0 +1,60 @@
import { content } from "@deadhead/content";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { bays, companies, discoveredLocations, pilots, players, ships } from "../src/db/schema";
import { seedPlayer, useTestDatabase } from "./db";
const database = useTestDatabase();
describe("test database harness", () => {
it("isolates tests: first test sees only its own player", async () => {
const { db } = database();
await seedPlayer(db, "tutorial");
expect(await db.select().from(players)).toHaveLength(1);
});
it("isolates tests: second test also sees only its own player", async () => {
const { db } = database();
await seedPlayer(db, "tutorial");
expect(await db.select().from(players)).toHaveLength(1);
});
it("seeds a new tutorial player", async () => {
const { db } = database();
const { playerId, avatarId } = await seedPlayer(db, "tutorial");
const [player] = await db.select().from(players).where(eq(players.id, playerId));
const [company] = await db.select().from(companies).where(eq(companies.playerId, playerId));
const discovered = await db
.select()
.from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId));
const [avatar] = await db.select().from(pilots).where(eq(pilots.id, avatarId));
expect(player).toMatchObject({ tutorialActive: true, loanerLocationId: "earth" });
expect(company!.credits).toBe(content.config.startingCredits);
expect(discovered.map((d) => d.locationId).sort()).toEqual(["earth", "moon"]);
expect(avatar).toMatchObject({ isAvatar: true, xp: 0, assignedShipId: null });
expect(await db.select().from(ships)).toHaveLength(0);
});
it("seeds an owner with one crewed Courier and an Earth bay", async () => {
const { db } = database();
const { playerId, avatarId, shipId } = await seedPlayer(db, "owner", { credits: 777 });
const [player] = await db.select().from(players).where(eq(players.id, playerId));
const [avatar] = await db.select().from(pilots).where(eq(pilots.id, avatarId));
expect(player!.tutorialActive).toBe(false);
expect(await db.select().from(ships)).toEqual([
expect.objectContaining({
id: shipId,
modelId: "courier",
locationId: "earth",
status: "idle",
}),
]);
expect(avatar!.assignedShipId).toBe(shipId);
expect((await db.select().from(bays)).map((b) => b.locationId)).toEqual(["earth"]);
const [company] = await db.select().from(companies);
expect(company!.credits).toBe(777);
});
});

View File

@ -33,6 +33,7 @@ describe("database migrations", () => {
await runMigrations(url);
expect(await tables()).toEqual([
"accepted_contracts",
"account",
"bays",
"companies",
"contract_boards",
@ -40,7 +41,10 @@ describe("database migrations", () => {
"dispatches",
"pilots",
"players",
"session",
"ships",
"user",
"verification",
]);
});

View File

@ -0,0 +1,84 @@
import { errorResponseSchema, routes } from "@deadhead/schema";
import Fastify from "fastify";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { registerErrorHandler } from "../src/http/errors";
import { registerRoute } from "../src/http/route";
import { useTestDatabase } from "./db";
import { testConfig } from "./helpers";
const database = useTestDatabase();
describe("request validation from the route catalog", () => {
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
app = buildApp(testConfig(), { database: database() });
});
afterEach(() => app.close());
const expectValidationError = (response: { statusCode: number; json(): unknown }) => {
expect(response.statusCode).toBe(400);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("validation-error");
};
it("rejects unexpected fields on a route that takes none", async () => {
expectValidationError(
await app.inject({ method: "POST", url: "/api/guest", payload: { surprise: true } }),
);
});
it("rejects a body that is not valid JSON", async () => {
expectValidationError(
await app.inject({
method: "POST",
url: "/api/guest",
headers: { "content-type": "application/json" },
payload: "{not json",
}),
);
});
it("rejects unexpected query parameters on an authenticated route before checking the session", async () => {
expectValidationError(await app.inject({ method: "GET", url: "/api/state?evil=1" }));
});
it("still answers valid requests", async () => {
expect((await app.inject({ method: "GET", url: "/api/time" })).statusCode).toBe(200);
});
it("returns not-found in the error format for unknown endpoints", async () => {
const response = await app.inject({ method: "GET", url: "/api/nope" });
expect(response.statusCode).toBe(404);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("not-found");
});
});
describe("response validation", () => {
const build = (validateResponses: boolean) => {
const app = Fastify({ logger: false });
registerErrorHandler(app);
// A handler that breaks the server-time response schema.
registerRoute(
app,
routes.getServerTime,
{ requirePlayer: async () => "unused", validateResponses },
async () => ({ realTime: "not a number" }) as never,
);
return app;
};
it("fails a response that violates its schema in test mode", async () => {
const app = build(true);
const response = await app.inject({ method: "GET", url: "/api/time" });
expect(response.statusCode).toBe(500);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("internal-error");
await app.close();
});
it("does not check responses when validation is off (production)", async () => {
const app = build(false);
const response = await app.inject({ method: "GET", url: "/api/time" });
expect(response.statusCode).toBe(200);
await app.close();
});
});

View File

@ -65,7 +65,7 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
| Table | Purpose | Key constraints |
|---|---|---|
| `players` | World seed, tutorial flag, loaner location | pk `id` |
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location | pk `id`, unique `user_id` → `user.id` |
| `companies` | Name and credits (the row locked for money changes) | unique `player_id` |
| `ships` | Owned ships: model, name, status, location (null in flight) | — |
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status | unique `assigned_ship_id` (one pilot per ship) |
@ -74,10 +74,11 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
| `contract_boards` | Stored bay boards and tutorial boards per period (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`) |
| `dispatches` | Full sim record (jsonb, with contract snapshot), status, arrival, resolution (jsonb), resolved-at, acknowledged-at | index (`status`, `arrival_at`), (`player_id`, `status`) |
| `accepted_contracts` | Contracts taken from a board, linked to their dispatch | pk (`player_id`, `contract_id`) |
| `user`, `session`, `account`, `verification` | Better Auth tables (`src/db/auth-schema.ts`, generated by its CLI; `user.is_anonymous` from the anonymous plugin). Timestamps here are Postgres `timestamp`, Better Auth's convention. | — |
### Drizzle schema
Copied from `apps/server/src/db/schema.ts`; update both together.
Copied from `apps/server/src/db/schema.ts` (game tables) and `apps/server/src/db/auth-schema.ts` (Better Auth); update both together.
```ts
import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
@ -93,6 +94,7 @@ import {
text,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { user } from "./auth-schema";
// All times are Unix milliseconds (bigint), matching sim and the API.
const time = (name: string) => bigint(name, { mode: "number" });
@ -103,6 +105,11 @@ export const boardKind = pgEnum("board_kind", ["bay", "tutorial"]);
export const players = pgTable("players", {
id: text("id").primaryKey(),
/** The Better Auth user (a guest account in the base game). */
userId: text("user_id")
.notNull()
.unique()
.references(() => user.id, { onDelete: "cascade" }),
/** 32-bit unsigned seed for boards, dispatches and names (decision 0.5.10). */
worldSeed: bigint("world_seed", { mode: "number" }).notNull(),
tutorialActive: boolean("tutorial_active").notNull().default(true),
@ -255,3 +262,117 @@ export const acceptedContracts = pgTable(
(t) => [primaryKey({ columns: [t.playerId, t.contractId] })],
);
```
```ts
// Better Auth tables, generated by `@better-auth/cli generate` (anonymous plugin).
// Regenerate rather than hand-edit when Better Auth or its plugins change.
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
isAnonymous: boolean("is_anonymous").default(false),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
```
## Auth and sessions
Better Auth (`apps/server/src/auth.ts`) with the anonymous plugin, mounted only through its server API (no `/api/auth/*` HTTP handler yet). `POST /api/guest` calls `signInAnonymous`, forwards its session cookie, and creates the player (`createPlayer`) linked to the new user. Authenticated routes call `requirePlayerId`, which resolves the cookie to a session, then to `players.user_id`; failures are `401 unauthorized`.
## Request pipeline (`apps/server/src/http`)
Every catalog endpoint is registered with `registerRoute(app, routes.<name>, options, handler)`:
1. Merge path parameters, query and body, and validate them against the route's `request` schema → `400 validation-error` on failure (also for malformed JSON).
2. If `route.auth`, resolve the session to a player id → `401 unauthorized` on failure.
3. Run the handler with the typed input and `{ playerId, request, reply }`.
4. Outside production, validate the result against the route's `response` schema; a mismatch is logged and returned as `500 internal-error`.
Handlers throw `ApiError(status, code, message)` for expected failures. Unknown paths return `404 not-found`.

View File

@ -48,6 +48,6 @@
- [x] **Step 5.1 — Fastify skeleton.** `apps/server`: Zod-validated env config (`src/config.ts`), `buildApp` with pino JSON logging and `GET /api/health` (`src/app.ts`), entry point `src/main.ts` (loads repo-root `.env` if present). `pnpm --filter @deadhead/server dev|start` run it with tsx. Integration test starts the app on an ephemeral port and calls health over HTTP.
- [x] **Step 5.2 — Server time endpoint.** `GET /api/time` returns `{ realTime, gameTime }` from an injectable `Clock` (`src/clock.ts`, passed via `buildApp(config, { clock })`).
- [x] **Step 5.3 — Database schema and migrations.** Drizzle schema (`apps/server/src/db/schema.ts`, copied into `architecture.md`), first migration `drizzle/0000_init.sql`, `db:generate` / `db:migrate` scripts. Migration test runs against an emptied test database twice. CI now has a Postgres 18 service; server tests run one file at a time.
- [ ] Step 5.4 — Test database harness
- [ ] Step 5.5 — Guest accounts
- [ ] Step 5.6 — Request validation from the route catalog
- [x] **Step 5.4 — Test database harness.** `apps/server/test/db.ts`: `useTestDatabase()` (migrate once, truncate all tables before each test) and `seedPlayer(db, "tutorial" | "owner")`. New-player creation lives in `src/game/new-player.ts` (`createPlayer`), shared with guest sign-up.
- [x] **Step 5.5 — Guest accounts.** Better Auth with the anonymous plugin (`src/auth.ts`, tables in `src/db/auth-schema.ts`, migration `0001_auth`). `POST /api/guest` creates a guest session and a tutorial player; `GET /api/state` returns player state for the session or `401 unauthorized`. Error handler sends the documented error format.
- [x] **Step 5.6 — Request validation from the route catalog.** `registerRoute` (`src/http/route.ts`) validates merged params/query/body before auth, checks sessions for `auth` routes, and checks responses outside production. Malformed JSON and unexpected fields return `400 validation-error`.

View File

@ -50,9 +50,12 @@ importers:
'@deadhead/sim':
specifier: workspace:*
version: link:../../packages/sim
better-auth:
specifier: ^1.7.7
version: 1.7.7(drizzle-kit@0.31.11)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1))(pg@8.23.1)(vitest@5.0.3)
drizzle-orm:
specifier: ^0.45.3
version: 0.45.3(@types/pg@8.23.1)(pg@8.23.1)
version: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1)
fastify:
specifier: ^5.12.5
version: 5.12.5
@ -122,6 +125,85 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@better-auth/core@1.7.7':
resolution: {integrity: sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==}
peerDependencies:
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.2
'@opentelemetry/api': ^1.9.0
better-call: 1.4.0
jose: ^6.1.0
kysely: ^0.28.5 || ^0.29.0
nanostores: ^1.0.1
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@better-auth/drizzle-adapter@1.7.7':
resolution: {integrity: sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==}
peerDependencies:
'@better-auth/core': ^1.7.7
'@better-auth/utils': 0.4.2
drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
peerDependenciesMeta:
drizzle-orm:
optional: true
'@better-auth/kysely-adapter@1.7.7':
resolution: {integrity: sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==}
peerDependencies:
'@better-auth/core': ^1.7.7
'@better-auth/utils': 0.4.2
kysely: ^0.28.17 || ^0.29.0
peerDependenciesMeta:
kysely:
optional: true
'@better-auth/memory-adapter@1.7.7':
resolution: {integrity: sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==}
peerDependencies:
'@better-auth/core': ^1.7.7
'@better-auth/utils': 0.4.2
'@better-auth/mongo-adapter@1.7.7':
resolution: {integrity: sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==}
peerDependencies:
'@better-auth/core': ^1.7.7
'@better-auth/utils': 0.4.2
mongodb: ^6.0.0 || ^7.0.0
peerDependenciesMeta:
mongodb:
optional: true
'@better-auth/prisma-adapter@1.7.7':
resolution: {integrity: sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==}
peerDependencies:
'@better-auth/core': ^1.7.7
'@better-auth/utils': 0.4.2
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
peerDependenciesMeta:
'@prisma/client':
optional: true
prisma:
optional: true
'@better-auth/telemetry@1.7.7':
resolution: {integrity: sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==}
peerDependencies:
'@better-auth/core': ^1.7.7
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.2
'@better-auth/utils@0.4.2':
resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==}
'@better-auth/utils@0.5.0':
resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==}
'@better-fetch/fetch@1.3.2':
resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==}
'@cacheable/memory@2.2.0':
resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==}
@ -679,6 +761,18 @@ packages:
'@keyv/serialize@1.1.1':
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
'@noble/ciphers@2.4.0':
resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@2.4.0':
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
engines: {node: '>= 20.19.0'}
'@opentelemetry/semantic-conventions@1.43.0':
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
engines: {node: '>=14'}
'@oxc-project/types@0.152.0':
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
@ -784,6 +878,9 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -943,6 +1040,73 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
better-auth@1.7.7:
resolution: {integrity: sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==}
peerDependencies:
'@lynx-js/react': '*'
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
'@sveltejs/kit': ^2.0.0
'@tanstack/react-start': ^1.0.0
'@tanstack/solid-start': ^1.0.0
drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1'
drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
mongodb: ^6.0.0 || ^7.0.0
mysql2: ^3.0.0
next: ^14.0.0 || ^15.0.0 || ^16.0.0
pg: ^8.0.0
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
solid-js: ^1.0.0
svelte: ^4.0.0 || ^5.0.0
vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0
vue: ^3.0.0
peerDependenciesMeta:
'@lynx-js/react':
optional: true
'@prisma/client':
optional: true
'@sveltejs/kit':
optional: true
'@tanstack/react-start':
optional: true
'@tanstack/solid-start':
optional: true
drizzle-kit:
optional: true
drizzle-orm:
optional: true
mongodb:
optional: true
mysql2:
optional: true
next:
optional: true
pg:
optional: true
prisma:
optional: true
react:
optional: true
react-dom:
optional: true
solid-js:
optional: true
svelte:
optional: true
vitest:
optional: true
vue:
optional: true
better-call@1.4.0:
resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==}
peerDependencies:
zod: ^4.0.0
peerDependenciesMeta:
zod:
optional: true
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22}
@ -977,6 +1141,9 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@ -1270,6 +1437,9 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jose@6.2.12:
resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
@ -1288,6 +1458,10 @@ packages:
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
kysely@0.29.6:
resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==}
engines: {node: '>=22.0.0'}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@ -1391,6 +1565,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanostores@1.5.4:
resolution: {integrity: sha512-4UXY7OAJEK0Y6V3SudBU7cXjAzTDc189R78dDOh0Xbm19gpKYto6WizSg58LJf12lTsRxKSLGyE117OiLpM4DQ==}
engines: {node: ^20.0.0 || >=22.0.0}
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@ -1545,6 +1723,9 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
rou3@0.9.2:
resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==}
safe-regex2@5.1.1:
resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
hasBin: true
@ -1564,6 +1745,9 @@ packages:
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
set-cookie-parser@3.1.2:
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@ -1776,6 +1960,63 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)':
dependencies:
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.2
'@opentelemetry/semantic-conventions': 1.43.0
'@standard-schema/spec': 1.1.0
better-call: 1.4.0(zod@4.6.5)
jose: 6.2.12
kysely: 0.29.6
nanostores: 1.5.4
zod: 4.6.5
'@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1))':
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/utils': 0.4.2
optionalDependencies:
drizzle-orm: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1)
'@better-auth/kysely-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(kysely@0.29.6)':
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/utils': 0.4.2
optionalDependencies:
kysely: 0.29.6
'@better-auth/memory-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)':
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/utils': 0.4.2
'@better-auth/mongo-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)':
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/utils': 0.4.2
'@better-auth/prisma-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)':
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/utils': 0.4.2
'@better-auth/telemetry@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)':
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.2
'@better-auth/utils@0.4.2':
dependencies:
'@noble/hashes': 2.4.0
'@better-auth/utils@0.5.0':
dependencies:
'@noble/hashes': 2.4.0
'@better-fetch/fetch@1.3.2': {}
'@cacheable/memory@2.2.0':
dependencies:
'@cacheable/utils': 2.5.0
@ -2112,6 +2353,12 @@ snapshots:
'@keyv/serialize@1.1.1': {}
'@noble/ciphers@2.4.0': {}
'@noble/hashes@2.4.0': {}
'@opentelemetry/semantic-conventions@1.43.0': {}
'@oxc-project/types@0.152.0': {}
'@pinojs/redact@0.4.0': {}
@ -2163,6 +2410,8 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
'@standard-schema/spec@1.1.0': {}
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@ -2351,6 +2600,42 @@ snapshots:
balanced-match@4.0.4: {}
better-auth@1.7.7(drizzle-kit@0.31.11)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1))(pg@8.23.1)(vitest@5.0.3):
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1))
'@better-auth/kysely-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(kysely@0.29.6)
'@better-auth/memory-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)
'@better-auth/mongo-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)
'@better-auth/prisma-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)
'@better-auth/telemetry': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.2
'@noble/ciphers': 2.4.0
'@noble/hashes': 2.4.0
better-call: 1.4.0(zod@4.6.5)
defu: 6.1.7
jose: 6.2.12
kysely: 0.29.6
nanostores: 1.5.4
zod: 4.6.5
optionalDependencies:
drizzle-kit: 0.31.11
drizzle-orm: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1)
pg: 8.23.1
vitest: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.15))
transitivePeerDependencies:
- '@opentelemetry/api'
better-call@1.4.0(zod@4.6.5):
dependencies:
'@better-auth/utils': 0.5.0
'@better-fetch/fetch': 1.3.2
rou3: 0.9.2
set-cookie-parser: 3.1.2
optionalDependencies:
zod: 4.6.5
brace-expansion@5.0.12:
dependencies:
balanced-match: 4.0.4
@ -2381,6 +2666,8 @@ snapshots:
deep-is@0.1.4: {}
defu@6.1.7: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {}
@ -2392,9 +2679,10 @@ snapshots:
esbuild: 0.25.12
tsx: 4.23.15
drizzle-orm@0.45.3(@types/pg@8.23.1)(pg@8.23.1):
drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1):
optionalDependencies:
'@types/pg': 8.23.1
kysely: 0.29.6
pg: 8.23.1
es-module-lexer@2.3.2: {}
@ -2669,6 +2957,8 @@ snapshots:
isexe@2.0.0: {}
jose@6.2.12: {}
js-tokens@10.0.0: {}
json-schema-ref-resolver@3.0.0:
@ -2685,6 +2975,8 @@ snapshots:
dependencies:
'@keyv/serialize': 1.1.1
kysely@0.29.6: {}
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@ -2767,6 +3059,8 @@ snapshots:
nanoid@3.3.19: {}
nanostores@1.5.4: {}
natural-compare@1.4.0: {}
obug@2.2.1: {}
@ -2918,6 +3212,8 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.12
'@rolldown/binding-win32-x64-msvc': 1.2.12
rou3@0.9.2: {}
safe-regex2@5.1.1:
dependencies:
ret: 0.5.0
@ -2930,6 +3226,8 @@ snapshots:
set-cookie-parser@2.7.2: {}
set-cookie-parser@3.1.2: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0