feat(dm-dashboard): Wave 1 — auth fixes, DB schema, Open5e interfaces, AI clients

- Fix auth anti-patterns (JWT, OAuth, logout, middleware, url.origin)
- Extend DB schema with tier, npcs, counters, translations, notes, initiative
- Expand Open5e TypeScript interfaces (Monster, Spell) to full V2
- Build OpenRouter API client for FREE tier
- Build Kimi API client for PRO tier
- Add comprehensive tests for all modules
This commit is contained in:
emil
2026-05-15 21:28:42 +03:00
parent 1f09270329
commit 25f43ec4e2
29 changed files with 3649 additions and 50 deletions
+48 -8
View File
@@ -1,6 +1,6 @@
{
"schema_version": 2,
"active_work_id": "dm-dashboard-redesign-a1bfaddc",
"active_work_id": "dm-dashboard-ai-c738b11e",
"works": {
"critical-refactor-f063daf0": {
"work_id": "critical-refactor-f063daf0",
@@ -387,19 +387,59 @@
},
"agent": "atlas",
"task_sessions": {}
},
"dm-dashboard-ai-c738b11e": {
"work_id": "dm-dashboard-ai-c738b11e",
"active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/dm-dashboard-ai.md",
"plan_name": "dm-dashboard-ai",
"status": "active",
"started_at": "2026-05-15T17:58:09.859Z",
"updated_at": "2026-05-15T18:24:29.818Z",
"session_ids": [
"ses_1d3921469ffeQox08ZjNWWJq7u"
],
"session_origins": {
"ses_1d3921469ffeQox08ZjNWWJq7u": "direct"
},
"agent": "atlas",
"task_sessions": {
"final-wave:f1": {
"task_key": "final-wave:f1",
"task_label": "F1",
"task_title": "**Plan Compliance Audit** — `oracle`",
"session_id": "ses_1d333a383ffeOqEVZptN3EbGAY",
"agent": "Sisyphus-Junior",
"category": "quick",
"updated_at": "2026-05-15T18:24:29.818Z",
"started_at": "2026-05-15T18:24:29.818Z",
"status": "running"
}
}
}
},
"active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/dm-dashboard-redesign.md",
"started_at": "2026-05-15T12:32:10.008Z",
"active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/dm-dashboard-ai.md",
"started_at": "2026-05-15T17:58:09.859Z",
"status": "active",
"updated_at": "2026-05-15T12:32:10.008Z",
"updated_at": "2026-05-15T18:24:29.818Z",
"session_ids": [
"ses_1d6b615cbffe5pqd0AObdil6OK"
"ses_1d3921469ffeQox08ZjNWWJq7u"
],
"session_origins": {
"ses_1d6b615cbffe5pqd0AObdil6OK": "direct"
"ses_1d3921469ffeQox08ZjNWWJq7u": "direct"
},
"plan_name": "dm-dashboard-ai",
"task_sessions": {
"final-wave:f1": {
"task_key": "final-wave:f1",
"task_label": "F1",
"task_title": "**Plan Compliance Audit** — `oracle`",
"session_id": "ses_1d333a383ffeOqEVZptN3EbGAY",
"agent": "Sisyphus-Junior",
"category": "quick",
"updated_at": "2026-05-15T18:24:29.818Z",
"started_at": "2026-05-15T18:24:29.818Z",
"status": "running"
}
},
"plan_name": "dm-dashboard-redesign",
"task_sessions": {},
"agent": "atlas"
}
@@ -0,0 +1,45 @@
# Auth Anti-Pattern Fixes — Learnings
## Date: 2026-05-15
### Patterns Applied
1. **Fail-fast env validation with Zod**
- Created `src/lib/auth/env.ts` with a `z.object()` schema that validates `JWT_SECRET`, `VK_CLIENT_ID`, `VK_CLIENT_SECRET`, `YANDEX_CLIENT_ID`, `YANDEX_CLIENT_SECRET`, and `PUBLIC_APP_URL` at module load time.
- `process.env.JWT_SECRET!` non-null assertion replaced with `authEnv.JWT_SECRET` which throws a descriptive error if the env var is missing.
- `requireEnv()` helper provides typed access to validated env values.
2. **SameSite cookie fix**
- Changed `sameSite: 'strict'` to `sameSite: 'lax'` in `setAuthCookie()` (`src/lib/auth/jwt.ts`).
- `SameSite=Strict` breaks cross-site OAuth redirects because the browser doesn't send the cookie on the redirect back from the identity provider. `Lax` + `Secure` is the correct combination for OAuth session cookies.
3. **Removed all `|| ''` fallbacks**
- `src/lib/auth/oauth.ts` had 5 instances of `process.env.XXX || ''`.
- Replaced with `authEnv.XXX` references. Empty-string fallbacks silently produce invalid OAuth requests (e.g., `client_id: ''` causes 400 errors from VK/Yandex).
4. **Logout session cleanup**
- `src/pages/api/auth/logout.ts` now reads the `auth_token` cookie and calls `deleteSession(token)` before clearing the cookie.
- Previously the session row remained in the DB indefinitely.
5. **Middleware stale cookie cleanup**
- `src/middleware/index.ts` now calls `context.cookies.delete('auth_token', { path: '/' })` in two places:
- When `verifyToken()` throws (invalid/expired JWT)
- When `getSession()` returns `null` (session expired or deleted from DB)
- Previously the stale cookie stayed in the browser, causing repeated failed auth attempts on every request.
6. **PUBLIC_APP_URL exclusivity**
- `src/pages/api/auth/callback/vk.ts`, `yandex.ts`, and the login handlers (`login/vk.ts`, `login/yandex.ts`) all used `process.env.PUBLIC_APP_URL || url.origin`.
- Replaced with `authEnv.PUBLIC_APP_URL` exclusively.
- `url.origin` breaks behind nginx reverse proxy because it returns `http://localhost:4321` instead of the public HTTPS URL.
### Testing Notes
- happy-dom strips `Set-Cookie` headers from `Response` objects (both via `Headers` instance and array entries). This is a known limitation — cookie clearing behavior must be tested via integration/Playwright instead of unit tests.
- `vi.mock` with alias paths (`@/...`) does not reliably match dynamically imported modules after `vi.resetModules()` in vitest. Using `__mocks__` directories or avoiding deep DB mocking in unit tests is more stable.
- `crypto.subtle` needs Node.js `webcrypto` for `jose` to work in vitest. Added `node:crypto` webcrypto to `tests/setup.ts`.
- `astro:middleware` is a virtual module that doesn't resolve in vitest. Added an alias in `vitest.config.ts` pointing to `tests/mocks/astro-middleware.ts`.
### Verification
- `npx tsc --noEmit`: 0 errors
- `npx vitest run src/lib/auth/`: 18/18 tests passed
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
{
"sessionID": "ses_1d389e69bffedksVYoEM6rnr6t",
"updatedAt": "2026-05-15T16:29:01.586Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-05-15T16:29:01.586Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_1d3921469ffeQox08ZjNWWJq7u",
"updatedAt": "2026-05-15T16:20:30.027Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-05-15T16:20:30.027Z"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { vi } from 'vitest';
export const db = {
insert: vi.fn(() => ({ values: vi.fn(() => Promise.resolve()) })),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(() => Promise.resolve([])),
})),
})),
})),
delete: vi.fn(() => ({ where: vi.fn(() => Promise.resolve()) })),
};
+59 -2
View File
@@ -1,4 +1,5 @@
import { pgTable, serial, varchar, timestamp, integer } from 'drizzle-orm/pg-core';
import { pgTable, serial, varchar, timestamp, integer, text, jsonb, index, uniqueIndex, check } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
@@ -7,8 +8,12 @@ export const users = pgTable('users', {
email: varchar('email', { length: 255 }),
name: varchar('name', { length: 255 }).notNull(),
avatar: varchar('avatar', { length: 500 }),
tier: varchar('tier', { length: 20 }).default('free'),
boostyVerifiedAt: timestamp('boosty_verified_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
}, (table) => [
check('users_tier_check', sql`${table.tier} IN ('free', 'pro')`),
]);
export const sessions = pgTable('sessions', {
id: serial('id').primaryKey(),
@@ -16,3 +21,55 @@ export const sessions = pgTable('sessions', {
token: varchar('token', { length: 500 }).notNull(),
expiresAt: timestamp('expires_at').notNull(),
});
export const npcs = pgTable('npcs', {
id: serial('id').primaryKey(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
race: varchar('race', { length: 100 }),
role: varchar('role', { length: 100 }),
level: integer('level'),
tone: varchar('tone', { length: 100 }),
content: jsonb('content'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const generationCounters = pgTable('generation_counters', {
id: serial('id').primaryKey(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
hourWindow: timestamp('hour_window').notNull(),
count: integer('count').notNull(),
model: varchar('model', { length: 50 }).notNull(),
}, (table) => [
uniqueIndex('generation_counters_user_window_model_idx').on(table.userId, table.hourWindow, table.model),
]);
export const translations = pgTable('translations', {
id: serial('id').primaryKey(),
slug: varchar('slug', { length: 255 }).notNull().unique(),
type: varchar('type', { length: 100 }).notNull(),
language: varchar('language', { length: 10 }).default('ru').notNull(),
content: jsonb('content'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [
index('translations_slug_type_language_idx').on(table.slug, table.type, table.language),
]);
export const notes = pgTable('notes', {
id: serial('id').primaryKey(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
title: varchar('title', { length: 255 }).notNull(),
content: text('content'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const initiativeSessions = pgTable('initiative_sessions', {
id: serial('id').primaryKey(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
participants: jsonb('participants'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
+295
View File
@@ -0,0 +1,295 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { generateNPC, KimiClientError } from "./kimi";
import type { NPCParams, Open5eMonster } from "./types";
const validNPCResponse = {
name: "Tharok Stonehelm",
race: "dwarf",
role: "fighter",
level: 3,
hp: 45,
ac: 18,
cr: "2",
speed: "25 ft.",
appearance: "A stout dwarf with a braided red beard and burn scars across his forearms.",
trait: "Never breaks a promise, no matter the cost.",
motivation: "Seeking to reclaim his ancestral forge from a fire giant.",
secret: "He forged the weapon that killed his own brother by accident.",
history: "Once a royal smith, exiled after a catastrophic forging accident.",
};
function mockFetchResponse(body: unknown, status = 200) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
statusText: status === 429 ? "Too Many Requests" : "Error",
text: () => Promise.resolve(JSON.stringify(body)),
json: () => Promise.resolve(body),
} as Response);
}
function mockFetchTextResponse(text: string, status = 200) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
statusText: "OK",
text: () => Promise.resolve(text),
json: () => Promise.resolve(text).then((t) => JSON.parse(t)),
} as Response);
}
describe("generateNPC", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetAllMocks();
process.env = { ...originalEnv, KIMI_API_KEY: "test-api-key" };
globalThis.fetch = vi.fn();
});
afterEach(() => {
process.env = originalEnv;
vi.restoreAllMocks();
});
it("generates an NPC successfully", async () => {
vi.mocked(globalThis.fetch).mockReturnValue(
mockFetchResponse({
choices: [
{
message: {
content: JSON.stringify(validNPCResponse),
},
},
],
})
);
const params: NPCParams = {
race: "dwarf",
role: "fighter",
level: 3,
tone: "heroic",
};
const result = await generateNPC(params);
expect(result).toEqual(validNPCResponse);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const callArgs = vi.mocked(globalThis.fetch).mock.calls[0];
const requestInit = callArgs[1] as RequestInit;
const body = JSON.parse(requestInit.body as string);
expect(body.model).toBe("moonshot-v1-8k");
expect(body.messages[0].role).toBe("system");
expect(body.messages[1].role).toBe("user");
expect(body.response_format.type).toBe("json_schema");
expect(body.temperature).toBe(0.7);
expect(requestInit.headers).toMatchObject({
"Content-Type": "application/json",
Authorization: "Bearer test-api-key",
});
});
it("includes Open5e reference in the prompt when provided", async () => {
vi.mocked(globalThis.fetch).mockReturnValue(
mockFetchResponse({
choices: [
{
message: {
content: JSON.stringify(validNPCResponse),
},
},
],
})
);
const reference: Open5eMonster = {
name: "Dwarf Warrior",
size: "Medium",
type: "humanoid",
armor_class: 16,
hit_points: 30,
challenge_rating_decimal: 1,
speed: { walk: "25 ft." },
actions: [{ name: "War Pick", desc: "Melee Weapon Attack: +4 to hit" }],
};
const params: NPCParams = {
race: "dwarf",
role: "fighter",
level: 3,
tone: "heroic",
};
await generateNPC(params, reference);
const callArgs = vi.mocked(globalThis.fetch).mock.calls[0];
const body = JSON.parse((callArgs[1] as RequestInit).body as string);
const userContent = body.messages[1].content as string;
expect(userContent).toContain("Dwarf Warrior");
expect(userContent).toContain("AC: 16");
expect(userContent).toContain("HP: 30");
expect(userContent).toContain("War Pick");
});
it("throws KimiClientError when KIMI_API_KEY is missing", async () => {
delete process.env.KIMI_API_KEY;
await expect(
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
).rejects.toThrow(KimiClientError);
await expect(
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
).rejects.toThrow("KIMI_API_KEY is not configured");
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("throws KimiClientError on API error (500)", async () => {
vi.mocked(globalThis.fetch).mockReturnValue(
mockFetchResponse({ error: { message: "Internal server error" } }, 500)
);
await expect(
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
).rejects.toThrow(KimiClientError);
await expect(
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
).rejects.toThrow("Kimi API error: 500 Error");
});
it("throws KimiClientError on rate limit (429)", async () => {
vi.mocked(globalThis.fetch).mockReturnValue(
mockFetchResponse({ error: { message: "Rate limit exceeded" } }, 429)
);
await expect(
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
).rejects.toThrow(KimiClientError);
await expect(
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
).rejects.toThrow("Kimi API rate limit exceeded");
});
it("throws KimiClientError when API returns invalid JSON", async () => {
vi.mocked(globalThis.fetch).mockReturnValue(
mockFetchTextResponse("This is not json")
);
await expect(
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
).rejects.toThrow("Kimi API returned invalid JSON");
});
it("throws KimiClientError when response fails zod validation", async () => {
const invalidResponse = { ...validNPCResponse, name: 123 };
vi.mocked(globalThis.fetch).mockReturnValue(
mockFetchResponse({
choices: [
{
message: {
content: JSON.stringify(invalidResponse),
},
},
],
})
);
await expect(
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
).rejects.toThrow(KimiClientError);
await expect(
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
).rejects.toThrow("validation failed");
});
it("throws KimiClientError when API returns empty content", async () => {
vi.mocked(globalThis.fetch).mockReturnValue(
mockFetchResponse({
choices: [
{
message: {
content: null,
},
},
],
})
);
await expect(
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
).rejects.toThrow("Kimi API returned empty content");
});
it("throws KimiClientError on request timeout", async () => {
vi.mocked(globalThis.fetch).mockImplementation(
() =>
new Promise((_, reject) => {
setTimeout(() => reject(new Error("AbortError")), 50);
})
);
await expect(
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
).rejects.toThrow(KimiClientError);
});
it("throws KimiClientError when API returns error in response body", async () => {
vi.mocked(globalThis.fetch).mockReturnValue(
mockFetchResponse({
error: { message: "Invalid API key" },
})
);
await expect(
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
).rejects.toThrow("Invalid API key");
});
it("validates params with zod before making request", async () => {
await expect(
generateNPC({
race: "elf",
role: "wizard",
level: 25,
tone: "dark",
} as NPCParams)
).rejects.toThrow();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("handles markdown-wrapped JSON gracefully", async () => {
vi.mocked(globalThis.fetch).mockReturnValue(
mockFetchTextResponse(
'```json\n' + JSON.stringify(validNPCResponse) + '\n```'
)
);
await expect(
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
).rejects.toThrow("Kimi API returned invalid JSON");
});
it("preserves statusCode on API errors", async () => {
vi.mocked(globalThis.fetch).mockReturnValue(
mockFetchResponse({ error: { message: "Bad request" } }, 400)
);
try {
await generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" });
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(KimiClientError);
expect((error as KimiClientError).statusCode).toBe(400);
}
});
});
+244
View File
@@ -0,0 +1,244 @@
import { z } from "zod";
import {
npcParamsSchema,
npcResultSchema,
type NPCParams,
type NPCResult,
type Open5eMonster,
} from "./types";
const KIMI_API_URL = "https://api.moonshot.ai/v1/chat/completions";
const KIMI_MODEL = "moonshot-v1-8k";
const REQUEST_TIMEOUT_MS = 10_000;
const npcJsonSchema = {
type: "object" as const,
properties: {
name: { type: "string" },
race: { type: "string" },
role: { type: "string" },
level: { type: "integer" },
hp: { type: "integer" },
ac: { type: "integer" },
cr: { type: "string" },
speed: { type: "string" },
appearance: { type: "string" },
trait: { type: "string" },
motivation: { type: "string" },
secret: { type: "string" },
history: { type: "string" },
},
required: [
"name",
"race",
"role",
"level",
"hp",
"ac",
"cr",
"speed",
"appearance",
"trait",
"motivation",
"secret",
"history",
],
};
function buildSystemPrompt(): string {
return [
"You are a Dungeons & Dragons NPC generator.",
"You MUST respond with valid JSON only. Do NOT wrap the response in markdown code blocks.",
"Do NOT include any explanatory text outside the JSON object.",
"The JSON must exactly match the provided schema.",
].join(" ");
}
function formatMonsterReference(monster: Open5eMonster): string {
const parts: string[] = [`Open5e Reference Monster: ${monster.name}`];
if (monster.size) parts.push(`Size: ${monster.size}`);
if (monster.type) parts.push(`Type: ${monster.type}`);
if (monster.alignment) parts.push(`Alignment: ${monster.alignment}`);
if (monster.armor_class != null) parts.push(`AC: ${monster.armor_class}`);
if (monster.hit_points != null) parts.push(`HP: ${monster.hit_points}`);
if (monster.challenge_rating_decimal != null)
parts.push(`CR: ${monster.challenge_rating_decimal}`);
if (monster.speed) {
const speedText =
typeof monster.speed === "string"
? monster.speed
: Object.entries(monster.speed)
.map(([k, v]) => `${k} ${v}`)
.join(", ");
parts.push(`Speed: ${speedText}`);
}
if (monster.languages) parts.push(`Languages: ${monster.languages}`);
if (monster.senses) parts.push(`Senses: ${monster.senses}`);
if (monster.actions?.length) {
parts.push(
`Actions: ${monster.actions.map((a) => `${a.name} (${a.desc})`).join("; ")}`
);
}
if (monster.special_abilities?.length) {
parts.push(
`Special Abilities: ${monster.special_abilities.map((a) => `${a.name} (${a.desc})`).join("; ")}`
);
}
return parts.join("\n");
}
function buildUserPrompt(params: NPCParams, reference?: Open5eMonster): string {
const lines: string[] = [
`Generate a D&D NPC with the following parameters:`,
`- Race: ${params.race}`,
`- Role/Class: ${params.role}`,
`- Level: ${params.level}`,
`- Tone: ${params.tone}`,
];
if (reference) {
lines.push("");
lines.push("Use the following Open5e monster as a mechanical reference for balancing stats:");
lines.push(formatMonsterReference(reference));
}
lines.push("");
lines.push("Respond with a single JSON object matching this schema:");
lines.push(JSON.stringify(npcJsonSchema, null, 2));
return lines.join("\n");
}
export class KimiClientError extends Error {
constructor(
message: string,
public readonly statusCode?: number,
public readonly responseBody?: string
) {
super(message);
this.name = "KimiClientError";
}
}
export async function generateNPC(
params: NPCParams,
reference?: Open5eMonster
): Promise<NPCResult> {
npcParamsSchema.parse(params);
const apiKey = process.env.KIMI_API_KEY;
if (!apiKey) {
throw new KimiClientError("KIMI_API_KEY is not configured");
}
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(KIMI_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: KIMI_MODEL,
messages: [
{ role: "system", content: buildSystemPrompt() },
{ role: "user", content: buildUserPrompt(params, reference) },
],
response_format: {
type: "json_schema",
json_schema: {
name: "npc_result",
strict: true,
schema: npcJsonSchema,
},
},
temperature: 0.7,
}),
signal: abortController.signal,
});
if (!response.ok) {
const body = await response.text().catch(() => undefined);
if (response.status === 429) {
throw new KimiClientError(
"Kimi API rate limit exceeded",
response.status,
body
);
}
throw new KimiClientError(
`Kimi API error: ${response.status} ${response.statusText}`,
response.status,
body
);
}
const responseText = await response.text();
let data: {
choices?: Array<{
message?: {
content?: string | null;
};
}>;
error?: {
message?: string;
};
};
try {
data = JSON.parse(responseText);
} catch {
throw new KimiClientError(
"Kimi API returned invalid JSON",
undefined,
responseText
);
}
if (data.error?.message) {
throw new KimiClientError(`Kimi API error: ${data.error.message}`);
}
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new KimiClientError("Kimi API returned empty content");
}
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
throw new KimiClientError(
"Kimi API returned invalid JSON",
undefined,
content
);
}
const validation = npcResultSchema.safeParse(parsed);
if (!validation.success) {
throw new KimiClientError(
`Kimi API response validation failed: ${validation.error.message}`,
undefined,
content
);
}
return validation.data;
} catch (error) {
if (error instanceof KimiClientError) {
throw error;
}
if (error instanceof Error && error.name === "AbortError") {
throw new KimiClientError("Kimi API request timed out after 10s");
}
throw new KimiClientError(
error instanceof Error ? error.message : "Unknown Kimi API error"
);
} finally {
clearTimeout(timeoutId);
}
}
+252
View File
@@ -0,0 +1,252 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
generateNPC,
type NPCParams,
type NPCResult,
} from "./openrouter";
describe("generateNPC", () => {
const validNPCResponse: NPCResult = {
name: "Gorath the Grim",
race: "Half-Orc",
role: "Mercenary Captain",
hp: 45,
ac: 16,
cr: "2",
speed: "30 ft.",
appearance: "Scarred face, heavy armor, carries a battleaxe.",
trait: "Suspicious of everyone but fiercely loyal to allies.",
motivation: "Amassing gold to buy land and retire.",
secret: "He betrayed his previous company for a bag of gold.",
history: "Born in the slums, fought his way up through underground pits.",
};
beforeEach(() => {
vi.stubEnv("OPENROUTER_API_KEY", "test-api-key");
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("returns parsed NPC on success", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: JSON.stringify(validNPCResponse),
},
},
],
}),
})
) as unknown as typeof fetch;
const params: NPCParams = { theme: "dark fantasy", role: "villain" };
const result = await generateNPC(params);
expect(result).toEqual(validNPCResponse);
expect(global.fetch).toHaveBeenCalledTimes(1);
const callArgs = (global.fetch as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(callArgs[0]).toBe(
"https://openrouter.ai/api/v1/chat/completions"
);
expect(callArgs[1].method).toBe("POST");
expect(callArgs[1].headers.Authorization).toBe(
"Bearer test-api-key"
);
const body = JSON.parse(callArgs[1].body);
expect(body.model).toBe("llama-3.3-70b:free");
expect(body.messages[0].content).toContain("valid JSON only");
expect(body.messages[1].content).toContain("dark fantasy");
expect(body.messages[1].content).toContain("villain");
});
it("includes reference monster in prompt", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: JSON.stringify(validNPCResponse),
},
},
],
}),
})
) as unknown as typeof fetch;
const reference = {
name: "Goblin",
key: "goblin",
challenge_rating_decimal: "0.25",
type: "humanoid",
hit_points: 7,
armor_class: 15,
};
await generateNPC({ role: "minion" }, reference);
const body = JSON.parse(
(global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body
);
expect(body.messages[1].content).toContain("Goblin");
expect(body.messages[1].content).toContain("HP: 7");
expect(body.messages[1].content).toContain("AC: 15");
expect(body.messages[1].content).toContain("CR: 0.25");
});
it("throws on missing API key", async () => {
vi.unstubAllEnvs();
vi.stubEnv("OPENROUTER_API_KEY", "");
await expect(generateNPC({})).rejects.toThrow(
"OPENROUTER_API_KEY is not set"
);
});
it("throws on rate limit (429)", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: false,
status: 429,
statusText: "Too Many Requests",
text: () => Promise.resolve("Rate limited"),
})
) as unknown as typeof fetch;
await expect(generateNPC({})).rejects.toThrow(
"Rate limited by OpenRouter (429)"
);
});
it("throws on API error", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: false,
status: 500,
statusText: "Internal Server Error",
text: () => Promise.resolve("Server error"),
})
) as unknown as typeof fetch;
await expect(generateNPC({})).rejects.toThrow(
"OpenRouter API error 500"
);
});
it("throws on invalid JSON response", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: "This is not JSON",
},
},
],
}),
})
) as unknown as typeof fetch;
await expect(generateNPC({})).rejects.toThrow(
"Invalid JSON in OpenRouter response"
);
});
it("throws on schema validation failure", async () => {
const invalidNPC = { name: "Only Name", race: "Human" };
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: JSON.stringify(invalidNPC),
},
},
],
}),
})
) as unknown as typeof fetch;
await expect(generateNPC({})).rejects.toThrow(
"NPC schema validation failed"
);
});
it("throws on timeout", async () => {
vi.useFakeTimers();
global.fetch = vi.fn((_url, options) => {
return new Promise((_resolve, reject) => {
const onAbort = () => {
const err = new Error("The operation was aborted");
err.name = "AbortError";
reject(err);
};
if (options.signal) {
if (options.signal.aborted) {
onAbort();
} else {
options.signal.addEventListener("abort", onAbort, { once: true });
}
}
});
}) as unknown as typeof fetch;
const promise = generateNPC({});
vi.advanceTimersByTime(11_000);
await expect(promise).rejects.toThrow(
"OpenRouter request timed out after 10s"
);
vi.useRealTimers();
});
it("allows custom model override", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: JSON.stringify(validNPCResponse),
},
},
],
}),
})
) as unknown as typeof fetch;
await generateNPC({}, undefined, "custom-model:free");
const body = JSON.parse(
(global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body
);
expect(body.model).toBe("custom-model:free");
});
});
+162
View File
@@ -0,0 +1,162 @@
import { z } from "zod";
import type { Monster } from "@/lib/open5e/client";
export type Open5eMonster = Monster;
export interface NPCParams {
theme?: string;
setting?: string;
role?: string;
level?: number;
race?: string;
}
export interface NPCResult {
name: string;
race: string;
role: string;
hp: number;
ac: number;
cr: string;
speed: string;
appearance: string;
trait: string;
motivation: string;
secret: string;
history: string;
}
const npcResultSchema = z.object({
name: z.string(),
race: z.string(),
role: z.string(),
hp: z.number(),
ac: z.number(),
cr: z.string(),
speed: z.string(),
appearance: z.string(),
trait: z.string(),
motivation: z.string(),
secret: z.string(),
history: z.string(),
});
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
const DEFAULT_MODEL = "llama-3.3-70b:free";
const TIMEOUT_MS = 10_000;
function buildSystemPrompt(): string {
return "You are a creative D&D NPC generator. Respond with valid JSON only, no markdown, no code fences, no explanatory text.";
}
function buildUserPrompt(params: NPCParams, reference?: Monster): string {
const schema = JSON.stringify({
name: "string (unique name)",
race: "string (e.g. Human, Elf, Orc)",
role: "string (e.g. Merchant, Bandit, Wizard)",
hp: "number (hit points)",
ac: "number (armor class)",
cr: "string (challenge rating, e.g. '1/4', '5')",
speed: "string (e.g. '30 ft.')",
appearance: "string (2-3 sentences)",
trait: "string (personality trait)",
motivation: "string (what drives them)",
secret: "string (a hidden secret)",
history: "string (1-2 sentence backstory)",
});
let prompt = `Generate a D&D NPC with the following parameters:\n`;
if (params.theme) prompt += `- Theme: ${params.theme}\n`;
if (params.setting) prompt += `- Setting: ${params.setting}\n`;
if (params.role) prompt += `- Role: ${params.role}\n`;
if (params.level) prompt += `- Level/CR range: around ${params.level}\n`;
if (params.race) prompt += `- Race: ${params.race}\n`;
if (reference) {
prompt += `\nUse this reference monster stat block for balance:\n`;
prompt += `- Name: ${reference.name}\n`;
prompt += `- Type: ${reference.type}\n`;
prompt += `- HP: ${reference.hit_points}\n`;
prompt += `- AC: ${reference.armor_class}\n`;
prompt += `- CR: ${reference.challenge_rating_decimal}\n`;
}
prompt += `\nRespond with valid JSON matching this schema:\n${schema}`;
return prompt;
}
export async function generateNPC(
params: NPCParams,
reference?: Monster,
model: string = DEFAULT_MODEL
): Promise<NPCResult> {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is not set");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(OPENROUTER_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"HTTP-Referer": process.env.PUBLIC_APP_URL || "https://randify.pro",
"X-Title": "Randify.pro DM Dashboard",
},
body: JSON.stringify({
model,
messages: [
{ role: "system", content: buildSystemPrompt() },
{ role: "user", content: buildUserPrompt(params, reference) },
],
temperature: 0.8,
}),
signal: controller.signal,
});
if (response.status === 429) {
throw new Error("Rate limited by OpenRouter (429)");
}
if (!response.ok) {
const text = await response.text();
throw new Error(`OpenRouter API error ${response.status}: ${text}`);
}
const data = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error("Empty response from OpenRouter");
}
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
throw new Error(`Invalid JSON in OpenRouter response: ${content}`);
}
const validated = npcResultSchema.safeParse(parsed);
if (!validated.success) {
throw new Error(
`NPC schema validation failed: ${validated.error.message}`
);
}
return validated.data;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw new Error("OpenRouter request timed out after 10s");
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
+55
View File
@@ -0,0 +1,55 @@
import { z } from "zod";
export const npcParamsSchema = z.object({
race: z.string(),
role: z.string(),
level: z.number().int().min(1).max(20),
tone: z.string(),
});
export type NPCParams = z.infer<typeof npcParamsSchema>;
export const npcResultSchema = z.object({
name: z.string(),
race: z.string(),
role: z.string(),
level: z.number().int(),
hp: z.number().int(),
ac: z.number().int(),
cr: z.string(),
speed: z.string(),
appearance: z.string(),
trait: z.string(),
motivation: z.string(),
secret: z.string(),
history: z.string(),
});
export type NPCResult = z.infer<typeof npcResultSchema>;
export interface Open5eMonster {
name: string;
size?: string;
type?: string;
subtype?: string;
alignment?: string;
armor_class?: number;
hit_points?: number;
speed?: Record<string, string> | string;
strength?: number;
dexterity?: number;
constitution?: number;
intelligence?: number;
wisdom?: number;
charisma?: number;
challenge_rating_decimal?: number;
languages?: string;
senses?: string;
damage_immunities?: string;
damage_resistances?: string;
damage_vulnerabilities?: string;
condition_immunities?: string;
actions?: Array<{ name: string; desc: string }>;
special_abilities?: Array<{ name: string; desc: string }>;
legendary_actions?: Array<{ name: string; desc: string }>;
}
+225
View File
@@ -0,0 +1,225 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const baseEnv = {
JWT_SECRET: 'test-secret-that-is-long-enough-for-hs256-algorithm',
VK_CLIENT_ID: 'vk-test-id',
VK_CLIENT_SECRET: 'vk-test-secret',
YANDEX_CLIENT_ID: 'ya-test-id',
YANDEX_CLIENT_SECRET: 'ya-test-secret',
PUBLIC_APP_URL: 'https://test.example.com',
};
describe('Auth Environment Validation', () => {
beforeEach(() => {
vi.resetModules();
Object.assign(process.env, baseEnv);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should validate required env vars and expose authEnv', async () => {
const { authEnv, requireEnv } = await import('./env');
expect(authEnv.JWT_SECRET).toBe(baseEnv.JWT_SECRET);
expect(authEnv.VK_CLIENT_ID).toBe(baseEnv.VK_CLIENT_ID);
expect(authEnv.PUBLIC_APP_URL).toBe(baseEnv.PUBLIC_APP_URL);
expect(requireEnv('PUBLIC_APP_URL')).toBe(baseEnv.PUBLIC_APP_URL);
});
it('should throw at module load if JWT_SECRET is missing', async () => {
delete process.env.JWT_SECRET;
await expect(import('./env')).rejects.toThrow('JWT_SECRET');
});
it('should throw at module load if PUBLIC_APP_URL is invalid', async () => {
process.env.PUBLIC_APP_URL = 'not-a-url';
await expect(import('./env')).rejects.toThrow('PUBLIC_APP_URL');
});
it('should throw at module load if VK_CLIENT_ID is missing', async () => {
delete process.env.VK_CLIENT_ID;
await expect(import('./env')).rejects.toThrow('VK_CLIENT_ID');
});
});
describe('JWT Token Operations', () => {
beforeEach(() => {
vi.resetModules();
Object.assign(process.env, baseEnv);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should create and verify a token', async () => {
const { createToken, verifyToken } = await import('./jwt');
const token = await createToken('user123');
const payload = await verifyToken(token);
expect(payload.sub).toBe('user123');
});
it('should reject an invalid token', async () => {
const { verifyToken } = await import('./jwt');
await expect(verifyToken('invalid.token.here')).rejects.toThrow();
});
it('should set auth cookie with sameSite=lax', async () => {
const { setAuthCookie } = await import('./jwt');
const setMock = vi.fn();
const mockContext = { cookies: { set: setMock } } as unknown as import('astro').APIContext;
setAuthCookie('test-token', mockContext);
expect(setMock).toHaveBeenCalledWith('auth_token', 'test-token', expect.objectContaining({
sameSite: 'lax',
httpOnly: true,
secure: true,
}));
});
it('should clear auth cookie', async () => {
const { clearAuthCookie } = await import('./jwt');
const deleteMock = vi.fn();
const mockContext = { cookies: { delete: deleteMock } } as unknown as import('astro').APIContext;
clearAuthCookie(mockContext);
expect(deleteMock).toHaveBeenCalledWith('auth_token', { path: '/' });
});
});
describe('OAuth Helpers', () => {
beforeEach(() => {
vi.resetModules();
Object.assign(process.env, baseEnv);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should generate code verifier and challenge', async () => {
const { generateCodeVerifier, generateCodeChallenge } = await import('./oauth');
const verifier = generateCodeVerifier();
expect(verifier).toBeTruthy();
expect(verifier.length).toBeGreaterThan(0);
const challenge = await generateCodeChallenge(verifier);
expect(challenge).toBeTruthy();
expect(challenge.length).toBeGreaterThan(0);
});
it('should generate state', async () => {
const { generateState } = await import('./oauth');
const state = generateState();
expect(state).toBeTruthy();
expect(state.length).toBeGreaterThan(0);
});
it('should parse cookie value', async () => {
const { getCookieValue } = await import('./oauth');
expect(getCookieValue('auth_token=abc123; other=xyz', 'auth_token')).toBe('abc123');
expect(getCookieValue(null, 'auth_token')).toBeNull();
expect(getCookieValue('other=xyz', 'auth_token')).toBeNull();
});
it('should create and verify session token', async () => {
const { createSessionToken, verifySessionToken } = await import('./oauth');
const token = await createSessionToken(42);
const result = await verifySessionToken(token);
expect(result).not.toBeNull();
expect(result!.userId).toBe(42);
});
it('should return null for invalid session token', async () => {
const { verifySessionToken } = await import('./oauth');
const result = await verifySessionToken('totally.invalid.token');
expect(result).toBeNull();
});
it('should use validated env values without empty fallbacks', async () => {
const { vkOAuthConfig, yandexOAuthConfig } = await import('./oauth');
expect(vkOAuthConfig.clientId).toBe(baseEnv.VK_CLIENT_ID);
expect(vkOAuthConfig.clientSecret).toBe(baseEnv.VK_CLIENT_SECRET);
expect(yandexOAuthConfig.clientId).toBe(baseEnv.YANDEX_CLIENT_ID);
expect(yandexOAuthConfig.clientSecret).toBe(baseEnv.YANDEX_CLIENT_SECRET);
expect(vkOAuthConfig.clientId).not.toBe('');
expect(yandexOAuthConfig.clientId).not.toBe('');
});
});
describe('Logout Route', () => {
beforeEach(() => {
vi.resetModules();
Object.assign(process.env, baseEnv);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should redirect on logout with token present', async () => {
const { GET } = await import('@/pages/api/auth/logout');
const request = new Request('http://test', {
headers: { cookie: 'auth_token=abc123' },
});
const response = await GET({ request } as unknown as import('astro').APIContext);
expect(response.status).toBe(302);
expect(response.headers.get('Location')).toBe('/dm/');
});
it('should redirect on logout without token', async () => {
const { GET } = await import('@/pages/api/auth/logout');
const request = new Request('http://test');
const response = await GET({ request } as unknown as import('astro').APIContext);
expect(response.status).toBe(302);
expect(response.headers.get('Location')).toBe('/dm/');
});
});
describe('Middleware', () => {
beforeEach(() => {
vi.resetModules();
Object.assign(process.env, baseEnv);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should clear stale auth_token cookie when token is invalid', async () => {
const { onRequest } = await import('@/middleware/index');
const deleteMock = vi.fn();
const nextMock = vi.fn(() => Promise.resolve(new Response('ok')));
const mockContext = {
locals: { user: null },
request: { headers: { get: () => 'auth_token=badtoken' } },
cookies: {
get: (name: string) => name === 'auth_token' ? { value: 'badtoken' } : undefined,
delete: deleteMock,
},
} as unknown as Parameters<typeof onRequest>[0];
await onRequest(mockContext, nextMock);
expect(deleteMock).toHaveBeenCalledWith('auth_token', { path: '/' });
});
it('should clear auth_token cookie when session is missing in DB', async () => {
const { createToken } = await import('./jwt');
const { onRequest } = await import('@/middleware/index');
const token = await createToken('123');
const deleteMock = vi.fn();
const nextMock = vi.fn(() => Promise.resolve(new Response('ok')));
const mockContext = {
locals: { user: null },
request: { headers: { get: () => `auth_token=${token}` } },
cookies: {
get: (name: string) => name === 'auth_token' ? { value: token } : undefined,
delete: deleteMock,
},
} as unknown as Parameters<typeof onRequest>[0];
await onRequest(mockContext, nextMock);
expect(deleteMock).toHaveBeenCalledWith('auth_token', { path: '/' });
});
});
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod';
const envSchema = z.object({
JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
VK_CLIENT_ID: z.string().min(1, 'VK_CLIENT_ID is required'),
VK_CLIENT_SECRET: z.string().min(1, 'VK_CLIENT_SECRET is required'),
YANDEX_CLIENT_ID: z.string().min(1, 'YANDEX_CLIENT_ID is required'),
YANDEX_CLIENT_SECRET: z.string().min(1, 'YANDEX_CLIENT_SECRET is required'),
PUBLIC_APP_URL: z.string().url('PUBLIC_APP_URL must be a valid URL'),
});
function validateAuthEnv() {
const raw = {
JWT_SECRET: process.env.JWT_SECRET,
VK_CLIENT_ID: process.env.VK_CLIENT_ID,
VK_CLIENT_SECRET: process.env.VK_CLIENT_SECRET,
YANDEX_CLIENT_ID: process.env.YANDEX_CLIENT_ID,
YANDEX_CLIENT_SECRET: process.env.YANDEX_CLIENT_SECRET,
PUBLIC_APP_URL: process.env.PUBLIC_APP_URL,
};
return envSchema.parse(raw);
}
export const authEnv = validateAuthEnv();
export function requireEnv(name: keyof typeof envSchema.shape): string {
return authEnv[name];
}
+3 -2
View File
@@ -1,7 +1,8 @@
import { SignJWT, jwtVerify } from 'jose';
import type { APIContext } from 'astro';
import { authEnv } from './env';
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
const SECRET = new TextEncoder().encode(authEnv.JWT_SECRET);
export async function createToken(userId: string): Promise<string> {
return new SignJWT({ sub: userId })
@@ -25,7 +26,7 @@ export function setAuthCookie(token: string, context: APIContext): void {
context.cookies.set('auth_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
});
+6 -5
View File
@@ -1,6 +1,7 @@
import { SignJWT, jwtVerify } from 'jose';
import { authEnv } from './env';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || '');
const JWT_SECRET = new TextEncoder().encode(authEnv.JWT_SECRET);
export function generateCodeVerifier(): string {
const array = new Uint8Array(64);
@@ -30,16 +31,16 @@ export function generateState(): string {
}
export const vkOAuthConfig = {
clientId: process.env.VK_CLIENT_ID || '',
clientSecret: process.env.VK_CLIENT_SECRET || '',
clientId: authEnv.VK_CLIENT_ID,
clientSecret: authEnv.VK_CLIENT_SECRET,
authUrl: 'https://id.vk.ru/authorize',
tokenUrl: 'https://id.vk.ru/oauth2/auth',
scope: 'email phone',
};
export const yandexOAuthConfig = {
clientId: process.env.YANDEX_CLIENT_ID || '',
clientSecret: process.env.YANDEX_CLIENT_SECRET || '',
clientId: authEnv.YANDEX_CLIENT_ID,
clientSecret: authEnv.YANDEX_CLIENT_SECRET,
authUrl: 'https://oauth.yandex.com/authorize',
tokenUrl: 'https://oauth.yandex.com/token',
scope: 'login:email login:info login:avatar',
+42 -4
View File
@@ -5,6 +5,11 @@ import { getCached, setCached } from "./cache";
const API_BASE = "https://api.open5e.com/v2/";
export interface MonsterAction {
name: string;
desc?: string;
}
export interface Monster {
name: string;
key: string;
@@ -12,6 +17,30 @@ export interface Monster {
type: string;
hit_points: number;
armor_class: number;
speed?: Record<string, string | number | null>;
actions?: MonsterAction[];
special_abilities?: MonsterAction[];
legendary_actions?: MonsterAction[];
senses?: Record<string, string | number | null>;
languages?: string;
strength?: number;
dexterity?: number;
constitution?: number;
intelligence?: number;
wisdom?: number;
charisma?: number;
size?: string;
subtype?: string;
alignment?: string;
damage_immunities?: string[];
damage_resistances?: string[];
damage_vulnerabilities?: string[];
condition_immunities?: string[];
}
export interface SpellClass {
name: string;
slug?: string;
}
export interface Spell {
@@ -19,6 +48,15 @@ export interface Spell {
key: string;
level: number;
school: string;
casting_time?: string;
range?: string;
components?: string;
duration?: string;
desc?: string[];
higher_level?: string[];
ritual?: boolean;
concentration?: boolean;
classes?: SpellClass[];
}
interface SearchFilters {
@@ -52,7 +90,7 @@ export async function searchMonsters(
query: string,
filters?: SearchFilters
): Promise<Monster[]> {
const cacheKey = `open5e:monsters:${query}`;
const cacheKey = `open5e:v2:monsters:${query}`;
const cached = getCached<Monster[]>(cacheKey);
if (cached) return cached;
@@ -75,7 +113,7 @@ export async function searchMonsters(
}
export async function getMonster(key: string): Promise<Monster> {
const cacheKey = `open5e:monster:${key}`;
const cacheKey = `open5e:v2:monster:${key}`;
const cached = getCached<Monster>(cacheKey);
if (cached) return cached;
@@ -96,7 +134,7 @@ export async function searchSpells(
query: string,
filters?: SearchFilters
): Promise<Spell[]> {
const cacheKey = `open5e:spells:${query}`;
const cacheKey = `open5e:v2:spells:${query}`;
const cached = getCached<Spell[]>(cacheKey);
if (cached) return cached;
@@ -116,7 +154,7 @@ export async function searchSpells(
}
export async function getSpell(key: string): Promise<Spell> {
const cacheKey = `open5e:spell:${key}`;
const cacheKey = `open5e:v2:spell:${key}`;
const cached = getCached<Spell>(cacheKey);
if (cached) return cached;
+3 -1
View File
@@ -24,6 +24,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
const session = await getSession(token);
if (!session) {
console.log('[Middleware] Session validation failed: no session in DB');
context.cookies.delete('auth_token', { path: '/' });
return next();
}
@@ -41,10 +42,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
console.log('[Middleware] User attached to locals:', { userId: user.id, name: user.name });
} else {
console.log('[Middleware] No user found for session');
context.cookies.delete('auth_token', { path: '/' });
}
} catch {
console.log('[Middleware] Session validation failed: invalid token');
/* ignore invalid tokens */
context.cookies.delete('auth_token', { path: '/' });
}
return next();
+2 -1
View File
@@ -9,6 +9,7 @@ import {
createSessionToken,
COOKIE_NAME,
} from '@/lib/auth/oauth';
import { authEnv } from '@/lib/auth/env';
export const prerender = false;
@@ -47,7 +48,7 @@ export const GET: APIRoute = async ({ url, request }) => {
});
}
const redirectUri = `${process.env.PUBLIC_APP_URL || url.origin}/api/auth/callback/vk`;
const redirectUri = `${authEnv.PUBLIC_APP_URL}/api/auth/callback/vk`;
const tokenBody = new URLSearchParams({
client_id: vkOAuthConfig.clientId,
+2 -1
View File
@@ -9,6 +9,7 @@ import {
createSessionToken,
COOKIE_NAME,
} from '@/lib/auth/oauth';
import { authEnv } from '@/lib/auth/env';
export const prerender = false;
@@ -27,7 +28,7 @@ export const GET: APIRoute = async ({ url, request }) => {
});
}
const redirectUri = `${process.env.PUBLIC_APP_URL || url.origin}/api/auth/callback/yandex`;
const redirectUri = `${authEnv.PUBLIC_APP_URL}/api/auth/callback/yandex`;
const tokenRes = await fetch(yandexOAuthConfig.tokenUrl, {
method: 'POST',
+3 -2
View File
@@ -1,14 +1,15 @@
import type { APIRoute } from 'astro';
import { generateCodeVerifier, generateCodeChallenge, generateState, vkOAuthConfig, VERIFIER_COOKIE_NAME } from '@/lib/auth/oauth';
import { authEnv } from '@/lib/auth/env';
export const prerender = false;
export const GET: APIRoute = async ({ url }) => {
export const GET: APIRoute = async () => {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateState();
const redirectUri = `${process.env.PUBLIC_APP_URL || url.origin}/api/auth/callback/vk`;
const redirectUri = `${authEnv.PUBLIC_APP_URL}/api/auth/callback/vk`;
const params = new URLSearchParams({
client_id: vkOAuthConfig.clientId,
+3 -2
View File
@@ -1,14 +1,15 @@
import type { APIRoute } from 'astro';
import { generateCodeVerifier, generateCodeChallenge, generateState, yandexOAuthConfig, VERIFIER_COOKIE_NAME } from '@/lib/auth/oauth';
import { authEnv } from '@/lib/auth/env';
export const prerender = false;
export const GET: APIRoute = async ({ url }) => {
export const GET: APIRoute = async () => {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateState();
const redirectUri = `${process.env.PUBLIC_APP_URL || url.origin}/api/auth/callback/yandex`;
const redirectUri = `${authEnv.PUBLIC_APP_URL}/api/auth/callback/yandex`;
const params = new URLSearchParams({
client_id: yandexOAuthConfig.clientId,
+10 -2
View File
@@ -1,9 +1,17 @@
import type { APIRoute } from 'astro';
import { COOKIE_NAME } from '@/lib/auth/oauth';
import { COOKIE_NAME, getCookieValue } from '@/lib/auth/oauth';
import { deleteSession } from '@/lib/auth/session';
export const prerender = false;
export const GET: APIRoute = async () => {
export const GET: APIRoute = async ({ request }) => {
const cookieHeader = request.headers.get('cookie');
const token = getCookieValue(cookieHeader, COOKIE_NAME);
if (token) {
await deleteSession(token);
}
const headers = new Headers();
headers.set('Location', '/dm/');
headers.append('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/`);
+3
View File
@@ -0,0 +1,3 @@
export function defineMiddleware(fn: unknown) {
return fn;
}
+8 -8
View File
@@ -197,7 +197,7 @@ describe("Open5e Client", () => {
},
];
mockStorage.setItem(
"open5e:monsters:dragon",
"open5e:v2:monsters:dragon",
JSON.stringify({ data: cached, timestamp: Date.now() })
);
@@ -230,7 +230,7 @@ describe("Open5e Client", () => {
) as unknown as typeof fetch;
await searchMonsters("new");
const stored = mockStorage.getItem("open5e:monsters:new");
const stored = mockStorage.getItem("open5e:v2:monsters:new");
expect(stored).not.toBeNull();
const parsed = JSON.parse(stored!);
expect(parsed.data).toEqual(mockData.results);
@@ -249,7 +249,7 @@ describe("Open5e Client", () => {
},
];
mockStorage.setItem(
"open5e:monsters:dragon",
"open5e:v2:monsters:dragon",
JSON.stringify({ data: cached, timestamp: Date.now() })
);
@@ -277,7 +277,7 @@ describe("Open5e Client", () => {
},
];
mockStorage.setItem(
"open5e:monsters:dragon",
"open5e:v2:monsters:dragon",
JSON.stringify({ data: staleData, timestamp: Date.now() - 25 * 60 * 60 * 1000 })
);
@@ -318,10 +318,10 @@ describe("Open5e Client", () => {
await searchSpells("fire");
await getSpell("fireball");
expect(mockStorage.getItem("open5e:monsters:dragon")).not.toBeNull();
expect(mockStorage.getItem("open5e:monster:goblin")).not.toBeNull();
expect(mockStorage.getItem("open5e:spells:fire")).not.toBeNull();
expect(mockStorage.getItem("open5e:spell:fireball")).not.toBeNull();
expect(mockStorage.getItem("open5e:v2:monsters:dragon")).not.toBeNull();
expect(mockStorage.getItem("open5e:v2:monster:goblin")).not.toBeNull();
expect(mockStorage.getItem("open5e:v2:spells:fire")).not.toBeNull();
expect(mockStorage.getItem("open5e:v2:spell:fireball")).not.toBeNull();
});
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import type { Monster, Spell } from "../src/lib/open5e/client";
// Type-level assertions: verify interfaces contain all required fields.
// If a listed key is missing from the interface, TypeScript will error at compile time.
type AssertKeys<T, K extends keyof T> = K;
type _MonsterHasAllFields = AssertKeys<
Monster,
| "name"
| "key"
| "challenge_rating_decimal"
| "type"
| "hit_points"
| "armor_class"
| "speed"
| "actions"
| "special_abilities"
| "legendary_actions"
| "senses"
| "languages"
| "strength"
| "dexterity"
| "constitution"
| "intelligence"
| "wisdom"
| "charisma"
| "size"
| "subtype"
| "alignment"
| "damage_immunities"
| "damage_resistances"
| "damage_vulnerabilities"
| "condition_immunities"
>;
type _SpellHasAllFields = AssertKeys<
Spell,
| "name"
| "key"
| "level"
| "school"
| "casting_time"
| "range"
| "components"
| "duration"
| "desc"
| "higher_level"
| "ritual"
| "concentration"
| "classes"
>;
// Runtime dummy test so Vitest recognizes this file.
describe("Open5e type assertions", () => {
it("Monster interface contains all required keys at compile time", () => {
expect(true).toBe(true);
});
it("Spell interface contains all required keys at compile time", () => {
expect(true).toBe(true);
});
});
+193
View File
@@ -0,0 +1,193 @@
import { describe, it, expect } from 'vitest';
import { getTableConfig } from 'drizzle-orm/pg-core';
import {
users,
sessions,
npcs,
generationCounters,
translations,
notes,
initiativeSessions,
} from '../src/db/schema';
describe('Database Schema', () => {
describe('users table', () => {
it('has all required columns', () => {
const config = getTableConfig(users);
const columnNames = config.columns.map((c) => c.name);
expect(columnNames).toEqual(
expect.arrayContaining([
'id',
'vk_id',
'yandex_id',
'email',
'name',
'avatar',
'tier',
'boosty_verified_at',
'created_at',
])
);
});
it('has tier column with default free', () => {
const config = getTableConfig(users);
const tier = config.columns.find((c) => c.name === 'tier');
expect(tier).toBeDefined();
});
it('has boosty_verified_at column', () => {
const config = getTableConfig(users);
const col = config.columns.find((c) => c.name === 'boosty_verified_at');
expect(col).toBeDefined();
});
it('has tier check constraint', () => {
const config = getTableConfig(users);
const checkNames = config.checks.map((c) => c.name);
expect(checkNames).toContain('users_tier_check');
});
});
describe('sessions table', () => {
it('has required columns', () => {
const config = getTableConfig(sessions);
const columnNames = config.columns.map((c) => c.name);
expect(columnNames).toEqual(
expect.arrayContaining(['id', 'user_id', 'token', 'expires_at'])
);
});
});
describe('npcs table', () => {
it('has required columns', () => {
const config = getTableConfig(npcs);
const columnNames = config.columns.map((c) => c.name);
expect(columnNames).toEqual(
expect.arrayContaining([
'id',
'user_id',
'name',
'race',
'role',
'level',
'tone',
'content',
'created_at',
])
);
});
it('has cascade foreign key to users', () => {
const config = getTableConfig(npcs);
const fk = config.foreignKeys.find((fk) =>
fk.reference().columns.some((c) => c.name === 'user_id')
);
expect(fk).toBeDefined();
});
});
describe('generationCounters table', () => {
it('has required columns', () => {
const config = getTableConfig(generationCounters);
const columnNames = config.columns.map((c) => c.name);
expect(columnNames).toEqual(
expect.arrayContaining([
'id',
'user_id',
'hour_window',
'count',
'model',
])
);
});
it('has cascade foreign key to users', () => {
const config = getTableConfig(generationCounters);
const fk = config.foreignKeys.find((fk) =>
fk.reference().columns.some((c) => c.name === 'user_id')
);
expect(fk).toBeDefined();
});
it('has composite unique index on userId, hourWindow, model', () => {
const config = getTableConfig(generationCounters);
const idxNames = config.indexes.map((i) => i.config.name);
expect(idxNames).toContain('generation_counters_user_window_model_idx');
});
});
describe('translations table', () => {
it('has required columns', () => {
const config = getTableConfig(translations);
const columnNames = config.columns.map((c) => c.name);
expect(columnNames).toEqual(
expect.arrayContaining([
'id',
'slug',
'type',
'language',
'content',
'created_at',
'updated_at',
])
);
});
it('has index on slug, type, language', () => {
const config = getTableConfig(translations);
const idxNames = config.indexes.map((i) => i.config.name);
expect(idxNames).toContain('translations_slug_type_language_idx');
});
});
describe('notes table', () => {
it('has required columns', () => {
const config = getTableConfig(notes);
const columnNames = config.columns.map((c) => c.name);
expect(columnNames).toEqual(
expect.arrayContaining([
'id',
'user_id',
'title',
'content',
'created_at',
'updated_at',
])
);
});
it('has cascade foreign key to users', () => {
const config = getTableConfig(notes);
const fk = config.foreignKeys.find((fk) =>
fk.reference().columns.some((c) => c.name === 'user_id')
);
expect(fk).toBeDefined();
});
});
describe('initiativeSessions table', () => {
it('has required columns', () => {
const config = getTableConfig(initiativeSessions);
const columnNames = config.columns.map((c) => c.name);
expect(columnNames).toEqual(
expect.arrayContaining([
'id',
'user_id',
'name',
'participants',
'created_at',
'updated_at',
])
);
});
it('has cascade foreign key to users', () => {
const config = getTableConfig(initiativeSessions);
const fk = config.foreignKeys.find((fk) =>
fk.reference().columns.some((c) => c.name === 'user_id')
);
expect(fk).toBeDefined();
});
});
});
+3 -12
View File
@@ -1,14 +1,5 @@
import "vitest";
import { webcrypto } from 'node:crypto';
Object.defineProperty(globalThis, "crypto", {
value: {
getRandomValues: (arr: Uint8Array) => {
for (let i = 0; i < arr.length; i++) {
arr[i] = Math.floor(Math.random() * 256);
}
return arr;
},
subtle: {},
randomUUID: () => "00000000-0000-0000-0000-000000000000",
},
Object.defineProperty(globalThis, 'crypto', {
value: webcrypto,
});
+1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(import.meta.dirname, "./src"),
"astro:middleware": path.resolve(import.meta.dirname, "./tests/mocks/astro-middleware.ts"),
},
},
test: {