Compare commits
No commits in common. "7b26d57c4d543ebe5b67c12d8700222a3e82c09b" and "5754b7ca25504dd8ee45d2197debaf14538c3cb6" have entirely different histories.
7b26d57c4d
...
5754b7ca25
27
DECISIONS.md
27
DECISIONS.md
|
|
@ -225,30 +225,3 @@ 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.
|
||||
|
|
|
|||
|
|
@ -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", "./src/db/auth-schema.ts"],
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
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
|
|
@ -8,13 +8,6 @@
|
|||
"when": 1790991444530,
|
||||
"tag": "0000_init",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1790991751364,
|
||||
"tag": "0001_auth",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -16,7 +16,6 @@
|
|||
"@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",
|
||||
|
|
|
|||
|
|
@ -1,73 +1,29 @@
|
|||
import { randomInt } from "node:crypto";
|
||||
import { content } from "@deadhead/content";
|
||||
import { routes } from "@deadhead/schema";
|
||||
import { routes, type ServerTime } 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" }));
|
||||
|
||||
registerRoute(app, routes.getServerTime, options, async () => {
|
||||
app.get(routes.getServerTime.path, async (): Promise<ServerTime> => {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
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>;
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
// 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],
|
||||
}),
|
||||
}));
|
||||
|
|
@ -1,9 +1,6 @@
|
|||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import pg from "pg";
|
||||
import * as authSchema from "./auth-schema";
|
||||
import * as gameSchema from "./schema";
|
||||
|
||||
const schema = { ...gameSchema, ...authSchema };
|
||||
import * as schema from "./schema";
|
||||
|
||||
export type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ 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" });
|
||||
|
|
@ -22,11 +21,6 @@ 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),
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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.")),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -33,7 +33,6 @@ describe("database migrations", () => {
|
|||
await runMigrations(url);
|
||||
expect(await tables()).toEqual([
|
||||
"accepted_contracts",
|
||||
"account",
|
||||
"bays",
|
||||
"companies",
|
||||
"contract_boards",
|
||||
|
|
@ -41,10 +40,7 @@ describe("database migrations", () => {
|
|||
"dispatches",
|
||||
"pilots",
|
||||
"players",
|
||||
"session",
|
||||
"ships",
|
||||
"user",
|
||||
"verification",
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -65,7 +65,7 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
|
|||
|
||||
| Table | Purpose | Key constraints |
|
||||
|---|---|---|
|
||||
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location | pk `id`, unique `user_id` → `user.id` |
|
||||
| `players` | World seed, tutorial flag, loaner location | pk `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,11 +74,10 @@ 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` (game tables) and `apps/server/src/db/auth-schema.ts` (Better Auth); update both together.
|
||||
Copied from `apps/server/src/db/schema.ts`; update both together.
|
||||
|
||||
```ts
|
||||
import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
|
||||
|
|
@ -94,7 +93,6 @@ 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" });
|
||||
|
|
@ -105,11 +103,6 @@ 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),
|
||||
|
|
@ -262,117 +255,3 @@ 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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- [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`.
|
||||
- [ ] Step 5.4 — Test database harness
|
||||
- [ ] Step 5.5 — Guest accounts
|
||||
- [ ] Step 5.6 — Request validation from the route catalog
|
||||
|
|
|
|||
302
pnpm-lock.yaml
302
pnpm-lock.yaml
|
|
@ -50,12 +50,9 @@ 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)(kysely@0.29.6)(pg@8.23.1)
|
||||
version: 0.45.3(@types/pg@8.23.1)(pg@8.23.1)
|
||||
fastify:
|
||||
specifier: ^5.12.5
|
||||
version: 5.12.5
|
||||
|
|
@ -125,85 +122,6 @@ 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==}
|
||||
|
||||
|
|
@ -761,18 +679,6 @@ 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==}
|
||||
|
||||
|
|
@ -878,9 +784,6 @@ 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==}
|
||||
|
||||
|
|
@ -1040,73 +943,6 @@ 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}
|
||||
|
|
@ -1141,9 +977,6 @@ 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'}
|
||||
|
|
@ -1437,9 +1270,6 @@ 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==}
|
||||
|
||||
|
|
@ -1458,10 +1288,6 @@ 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'}
|
||||
|
|
@ -1565,10 +1391,6 @@ 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==}
|
||||
|
||||
|
|
@ -1723,9 +1545,6 @@ 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
|
||||
|
|
@ -1745,9 +1564,6 @@ 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'}
|
||||
|
|
@ -1960,63 +1776,6 @@ 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
|
||||
|
|
@ -2353,12 +2112,6 @@ 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': {}
|
||||
|
|
@ -2410,8 +2163,6 @@ snapshots:
|
|||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
|
|
@ -2600,42 +2351,6 @@ 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
|
||||
|
|
@ -2666,8 +2381,6 @@ snapshots:
|
|||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
defu@6.1.7: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
|
@ -2679,10 +2392,9 @@ snapshots:
|
|||
esbuild: 0.25.12
|
||||
tsx: 4.23.15
|
||||
|
||||
drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1):
|
||||
drizzle-orm@0.45.3(@types/pg@8.23.1)(pg@8.23.1):
|
||||
optionalDependencies:
|
||||
'@types/pg': 8.23.1
|
||||
kysely: 0.29.6
|
||||
pg: 8.23.1
|
||||
|
||||
es-module-lexer@2.3.2: {}
|
||||
|
|
@ -2957,8 +2669,6 @@ snapshots:
|
|||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
jose@6.2.12: {}
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
json-schema-ref-resolver@3.0.0:
|
||||
|
|
@ -2975,8 +2685,6 @@ snapshots:
|
|||
dependencies:
|
||||
'@keyv/serialize': 1.1.1
|
||||
|
||||
kysely@0.29.6: {}
|
||||
|
||||
levn@0.4.1:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
|
@ -3059,8 +2767,6 @@ snapshots:
|
|||
|
||||
nanoid@3.3.19: {}
|
||||
|
||||
nanostores@1.5.4: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
obug@2.2.1: {}
|
||||
|
|
@ -3212,8 +2918,6 @@ 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
|
||||
|
|
@ -3226,8 +2930,6 @@ snapshots:
|
|||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
set-cookie-parser@3.1.2: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
|
|
|||
Loading…
Reference in New Issue