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:
+48
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }>;
|
||||
}
|
||||
@@ -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: '/' });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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: '/',
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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=/`);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function defineMiddleware(fn: unknown) {
|
||||
return fn;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user