Step 5.5: add guest accounts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bcae75d822
commit
768c893130
11
DECISIONS.md
11
DECISIONS.md
|
|
@ -233,3 +233,14 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **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).
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@ import { defineConfig } from "drizzle-kit";
|
||||||
// `pnpm --filter @deadhead/server db:generate` writes SQL migrations to ./drizzle.
|
// `pnpm --filter @deadhead/server db:generate` writes SQL migrations to ./drizzle.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
schema: "./src/db/schema.ts",
|
schema: ["./src/db/schema.ts", "./src/db/auth-schema.ts"],
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -8,6 +8,13 @@
|
||||||
"when": 1790991444530,
|
"when": 1790991444530,
|
||||||
"tag": "0000_init",
|
"tag": "0000_init",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790991751364,
|
||||||
|
"tag": "0001_auth",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"@deadhead/content": "workspace:*",
|
"@deadhead/content": "workspace:*",
|
||||||
"@deadhead/schema": "workspace:*",
|
"@deadhead/schema": "workspace:*",
|
||||||
"@deadhead/sim": "workspace:*",
|
"@deadhead/sim": "workspace:*",
|
||||||
|
"better-auth": "^1.7.7",
|
||||||
"drizzle-orm": "^0.45.3",
|
"drizzle-orm": "^0.45.3",
|
||||||
"fastify": "^5.12.5",
|
"fastify": "^5.12.5",
|
||||||
"pg": "^8.23.1",
|
"pg": "^8.23.1",
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,37 @@
|
||||||
|
import { randomInt } from "node:crypto";
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { routes, type ServerTime } from "@deadhead/schema";
|
import { routes, type PlayerState, type ServerTime } from "@deadhead/schema";
|
||||||
import { realToGame } from "@deadhead/sim";
|
import { realToGame } from "@deadhead/sim";
|
||||||
|
import { fromNodeHeaders } from "better-auth/node";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { createAuth } from "./auth";
|
||||||
import { type Clock, systemClock } from "./clock";
|
import { type Clock, systemClock } from "./clock";
|
||||||
import type { ServerConfig } from "./config";
|
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 { requirePlayerId } from "./http/session";
|
||||||
|
|
||||||
export interface AppDeps {
|
export interface AppDeps {
|
||||||
clock?: Clock;
|
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. */
|
/** Builds the Fastify app without listening, so tests can start it on any port. */
|
||||||
export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInstance {
|
export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInstance {
|
||||||
const clock = deps.clock ?? systemClock;
|
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({
|
const app = Fastify({
|
||||||
// Structured JSON logs (pino); silenced in tests unless LOG_LEVEL says otherwise.
|
// Structured JSON logs (pino); silenced in tests unless LOG_LEVEL says otherwise.
|
||||||
logger: { level: config.logLevel },
|
logger: { level: config.logLevel },
|
||||||
});
|
});
|
||||||
|
if (!deps.database) app.addHook("onClose", () => database.close());
|
||||||
|
registerErrorHandler(app);
|
||||||
|
|
||||||
// Under /api so it is reachable through the same-domain proxy (decision 0.5.20).
|
// Under /api so it is reachable through the same-domain proxy (decision 0.5.20).
|
||||||
app.get("/api/health", async () => ({ status: "ok" }));
|
app.get("/api/health", async () => ({ status: "ok" }));
|
||||||
|
|
@ -25,5 +41,28 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta
|
||||||
return { realTime, gameTime: realToGame(realTime, content.config) };
|
return { realTime, gameTime: realToGame(realTime, content.config) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post(routes.createGuest.path, async (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) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(routes.getPlayerState.path, async (request): Promise<PlayerState> => {
|
||||||
|
const playerId = await requirePlayerId(request, auth, db);
|
||||||
|
return loadPlayerState(db, playerId);
|
||||||
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import pg from "pg";
|
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>;
|
export type Db = NodePgDatabase<typeof schema>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
text,
|
text,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { user } from "./auth-schema";
|
||||||
|
|
||||||
// All times are Unix milliseconds (bigint), matching sim and the API.
|
// All times are Unix milliseconds (bigint), matching sim and the API.
|
||||||
const time = (name: string) => bigint(name, { mode: "number" });
|
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", {
|
export const players = pgTable("players", {
|
||||||
id: text("id").primaryKey(),
|
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). */
|
/** 32-bit unsigned seed for boards, dispatches and names (decision 0.5.10). */
|
||||||
worldSeed: bigint("world_seed", { mode: "number" }).notNull(),
|
worldSeed: bigint("world_seed", { mode: "number" }).notNull(),
|
||||||
tutorialActive: boolean("tutorial_active").notNull().default(true),
|
tutorialActive: boolean("tutorial_active").notNull().default(true),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ export interface NewPlayer {
|
||||||
*/
|
*/
|
||||||
export async function createPlayer(
|
export async function createPlayer(
|
||||||
db: Db,
|
db: Db,
|
||||||
args: { worldSeed: Seed; now: RealTime; playerId?: string },
|
args: { userId: string; worldSeed: Seed; now: RealTime; playerId?: string },
|
||||||
): Promise<NewPlayer> {
|
): Promise<NewPlayer> {
|
||||||
const { worldSeed, now } = args;
|
const { worldSeed, now } = args;
|
||||||
const playerId = args.playerId ?? randomUUID();
|
const playerId = args.playerId ?? randomUUID();
|
||||||
|
|
@ -30,6 +30,7 @@ export async function createPlayer(
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.insert(players).values({
|
await tx.insert(players).values({
|
||||||
id: playerId,
|
id: playerId,
|
||||||
|
userId: args.userId,
|
||||||
worldSeed,
|
worldSeed,
|
||||||
tutorialActive: true,
|
tutorialActive: true,
|
||||||
loanerLocationId: "earth",
|
loanerLocationId: "earth",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
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.")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { eq, sql } from "drizzle-orm";
|
||||||
import { afterAll, beforeAll, beforeEach } from "vitest";
|
import { afterAll, beforeAll, beforeEach } from "vitest";
|
||||||
import { createDatabase, type Database, type Db } from "../src/db/client";
|
import { createDatabase, type Database, type Db } from "../src/db/client";
|
||||||
import { runMigrations } from "../src/db/migrate";
|
import { runMigrations } from "../src/db/migrate";
|
||||||
|
import { user } from "../src/db/auth-schema";
|
||||||
import { bays, companies, pilots, players, ships } from "../src/db/schema";
|
import { bays, companies, pilots, players, ships } from "../src/db/schema";
|
||||||
import { createPlayer, type NewPlayer } from "../src/game/new-player";
|
import { createPlayer, type NewPlayer } from "../src/game/new-player";
|
||||||
import { testConfig } from "./helpers";
|
import { testConfig } from "./helpers";
|
||||||
|
|
@ -62,7 +63,18 @@ export async function seedPlayer(
|
||||||
state: SeedState,
|
state: SeedState,
|
||||||
options: { worldSeed?: number; credits?: number } = {},
|
options: { worldSeed?: number; credits?: number } = {},
|
||||||
): Promise<SeededPlayer> {
|
): Promise<SeededPlayer> {
|
||||||
const player = await createPlayer(db, { worldSeed: options.worldSeed ?? 12345, now: TEST_NOW });
|
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) {
|
if (options.credits !== undefined) {
|
||||||
await db
|
await db
|
||||||
.update(companies)
|
.update(companies)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -33,6 +33,7 @@ describe("database migrations", () => {
|
||||||
await runMigrations(url);
|
await runMigrations(url);
|
||||||
expect(await tables()).toEqual([
|
expect(await tables()).toEqual([
|
||||||
"accepted_contracts",
|
"accepted_contracts",
|
||||||
|
"account",
|
||||||
"bays",
|
"bays",
|
||||||
"companies",
|
"companies",
|
||||||
"contract_boards",
|
"contract_boards",
|
||||||
|
|
@ -40,7 +41,10 @@ describe("database migrations", () => {
|
||||||
"dispatches",
|
"dispatches",
|
||||||
"pilots",
|
"pilots",
|
||||||
"players",
|
"players",
|
||||||
|
"session",
|
||||||
"ships",
|
"ships",
|
||||||
|
"user",
|
||||||
|
"verification",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
|
||||||
|
|
||||||
| Table | Purpose | Key constraints |
|
| 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` |
|
| `companies` | Name and credits (the row locked for money changes) | unique `player_id` |
|
||||||
| `ships` | Owned ships: model, name, status, location (null in flight) | — |
|
| `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) |
|
| `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`) |
|
| `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`) |
|
| `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`) |
|
| `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
|
### 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
|
```ts
|
||||||
import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
|
import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
|
||||||
|
|
@ -93,6 +94,7 @@ import {
|
||||||
text,
|
text,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { user } from "./auth-schema";
|
||||||
|
|
||||||
// All times are Unix milliseconds (bigint), matching sim and the API.
|
// All times are Unix milliseconds (bigint), matching sim and the API.
|
||||||
const time = (name: string) => bigint(name, { mode: "number" });
|
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", {
|
export const players = pgTable("players", {
|
||||||
id: text("id").primaryKey(),
|
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). */
|
/** 32-bit unsigned seed for boards, dispatches and names (decision 0.5.10). */
|
||||||
worldSeed: bigint("world_seed", { mode: "number" }).notNull(),
|
worldSeed: bigint("world_seed", { mode: "number" }).notNull(),
|
||||||
tutorialActive: boolean("tutorial_active").notNull().default(true),
|
tutorialActive: boolean("tutorial_active").notNull().default(true),
|
||||||
|
|
@ -255,3 +262,106 @@ export const acceptedContracts = pgTable(
|
||||||
(t) => [primaryKey({ columns: [t.playerId, t.contractId] })],
|
(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`.
|
||||||
|
|
|
||||||
|
|
@ -49,5 +49,5 @@
|
||||||
- [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.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.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.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.
|
||||||
- [ ] Step 5.5 — Guest accounts
|
- [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.
|
||||||
- [ ] Step 5.6 — Request validation from the route catalog
|
- [ ] Step 5.6 — Request validation from the route catalog
|
||||||
|
|
|
||||||
302
pnpm-lock.yaml
302
pnpm-lock.yaml
|
|
@ -50,9 +50,12 @@ importers:
|
||||||
'@deadhead/sim':
|
'@deadhead/sim':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/sim
|
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:
|
drizzle-orm:
|
||||||
specifier: ^0.45.3
|
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:
|
fastify:
|
||||||
specifier: ^5.12.5
|
specifier: ^5.12.5
|
||||||
version: 5.12.5
|
version: 5.12.5
|
||||||
|
|
@ -122,6 +125,85 @@ packages:
|
||||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||||
engines: {node: '>=18'}
|
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':
|
'@cacheable/memory@2.2.0':
|
||||||
resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==}
|
resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==}
|
||||||
|
|
||||||
|
|
@ -679,6 +761,18 @@ packages:
|
||||||
'@keyv/serialize@1.1.1':
|
'@keyv/serialize@1.1.1':
|
||||||
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
|
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':
|
'@oxc-project/types@0.152.0':
|
||||||
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
|
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
|
||||||
|
|
||||||
|
|
@ -784,6 +878,9 @@ packages:
|
||||||
'@rolldown/pluginutils@1.0.1':
|
'@rolldown/pluginutils@1.0.1':
|
||||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
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':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
|
|
@ -943,6 +1040,73 @@ packages:
|
||||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
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:
|
brace-expansion@5.0.12:
|
||||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
@ -977,6 +1141,9 @@ packages:
|
||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
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:
|
dequal@2.0.3:
|
||||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -1270,6 +1437,9 @@ packages:
|
||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
jose@6.2.12:
|
||||||
|
resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
|
||||||
|
|
||||||
js-tokens@10.0.0:
|
js-tokens@10.0.0:
|
||||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||||
|
|
||||||
|
|
@ -1288,6 +1458,10 @@ packages:
|
||||||
keyv@5.6.0:
|
keyv@5.6.0:
|
||||||
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
|
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:
|
levn@0.4.1:
|
||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
@ -1391,6 +1565,10 @@ packages:
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nanostores@1.5.4:
|
||||||
|
resolution: {integrity: sha512-4UXY7OAJEK0Y6V3SudBU7cXjAzTDc189R78dDOh0Xbm19gpKYto6WizSg58LJf12lTsRxKSLGyE117OiLpM4DQ==}
|
||||||
|
engines: {node: ^20.0.0 || >=22.0.0}
|
||||||
|
|
||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
|
|
@ -1545,6 +1723,9 @@ packages:
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
rou3@0.9.2:
|
||||||
|
resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==}
|
||||||
|
|
||||||
safe-regex2@5.1.1:
|
safe-regex2@5.1.1:
|
||||||
resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
|
resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
@ -1564,6 +1745,9 @@ packages:
|
||||||
set-cookie-parser@2.7.2:
|
set-cookie-parser@2.7.2:
|
||||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
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:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -1776,6 +1960,63 @@ snapshots:
|
||||||
|
|
||||||
'@bcoe/v8-coverage@1.0.2': {}
|
'@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':
|
'@cacheable/memory@2.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cacheable/utils': 2.5.0
|
'@cacheable/utils': 2.5.0
|
||||||
|
|
@ -2112,6 +2353,12 @@ snapshots:
|
||||||
|
|
||||||
'@keyv/serialize@1.1.1': {}
|
'@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': {}
|
'@oxc-project/types@0.152.0': {}
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
@ -2163,6 +2410,8 @@ snapshots:
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.1': {}
|
'@rolldown/pluginutils@1.0.1': {}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
|
|
@ -2351,6 +2600,42 @@ snapshots:
|
||||||
|
|
||||||
balanced-match@4.0.4: {}
|
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:
|
brace-expansion@5.0.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 4.0.4
|
balanced-match: 4.0.4
|
||||||
|
|
@ -2381,6 +2666,8 @@ snapshots:
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
|
defu@6.1.7: {}
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
@ -2392,9 +2679,10 @@ snapshots:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
tsx: 4.23.15
|
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:
|
optionalDependencies:
|
||||||
'@types/pg': 8.23.1
|
'@types/pg': 8.23.1
|
||||||
|
kysely: 0.29.6
|
||||||
pg: 8.23.1
|
pg: 8.23.1
|
||||||
|
|
||||||
es-module-lexer@2.3.2: {}
|
es-module-lexer@2.3.2: {}
|
||||||
|
|
@ -2669,6 +2957,8 @@ snapshots:
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
jose@6.2.12: {}
|
||||||
|
|
||||||
js-tokens@10.0.0: {}
|
js-tokens@10.0.0: {}
|
||||||
|
|
||||||
json-schema-ref-resolver@3.0.0:
|
json-schema-ref-resolver@3.0.0:
|
||||||
|
|
@ -2685,6 +2975,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@keyv/serialize': 1.1.1
|
'@keyv/serialize': 1.1.1
|
||||||
|
|
||||||
|
kysely@0.29.6: {}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
|
|
@ -2767,6 +3059,8 @@ snapshots:
|
||||||
|
|
||||||
nanoid@3.3.19: {}
|
nanoid@3.3.19: {}
|
||||||
|
|
||||||
|
nanostores@1.5.4: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
obug@2.2.1: {}
|
obug@2.2.1: {}
|
||||||
|
|
@ -2918,6 +3212,8 @@ snapshots:
|
||||||
'@rolldown/binding-win32-arm64-msvc': 1.2.12
|
'@rolldown/binding-win32-arm64-msvc': 1.2.12
|
||||||
'@rolldown/binding-win32-x64-msvc': 1.2.12
|
'@rolldown/binding-win32-x64-msvc': 1.2.12
|
||||||
|
|
||||||
|
rou3@0.9.2: {}
|
||||||
|
|
||||||
safe-regex2@5.1.1:
|
safe-regex2@5.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ret: 0.5.0
|
ret: 0.5.0
|
||||||
|
|
@ -2930,6 +3226,8 @@ snapshots:
|
||||||
|
|
||||||
set-cookie-parser@2.7.2: {}
|
set-cookie-parser@2.7.2: {}
|
||||||
|
|
||||||
|
set-cookie-parser@3.1.2: {}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue