diff --git a/.cursor/skills/actions-and-pg-migration/SKILL.md b/.cursor/skills/actions-and-pg-migration/SKILL.md new file mode 100644 index 000000000..e3cc6fdb0 --- /dev/null +++ b/.cursor/skills/actions-and-pg-migration/SKILL.md @@ -0,0 +1,257 @@ +--- +name: actions-and-pg-migration +description: Patterns for writing actions (MakeAction/MakeAnonAction), using the Postgres DB (Kysely), and migrating Express routes from MongoDB to Postgres in the Tachi server. Use when adding a new mutation, migrating a Mongo-backed router to Postgres, writing action files, or writing tests for actions or routers. +--- + +# Actions & Postgres Migration — Tachi Server + +## Actions + +### Signatures + +All action input/output schemas live in `src/lib/actions/actions.ts`. Add a new entry to `ActionSignatures` (for authenticated actions) or `AnonActionSignatures` (for unauthenticated ones): + +```typescript +// src/lib/actions/actions.ts +MY_ACTION: { + input: z.object({ ... }), + output: z.object({ ... }), +}, +``` + +### Action files + +Place the implementation in `src/actions/my-action.ts`. Always use `.js` extensions on imports (the package uses ESM): + +```typescript +import { MakeAction } from "#lib/actions/actions.js"; +import DB from "#services/pg/db.js"; +import { ExpectedErr } from "bliss"; + +export const ACTION_MyAction = MakeAction( + "MY_ACTION", + async (taker, { fieldA, fieldB }) => { + // taker.acct.id — authenticated user's numeric ID + // taker.acct.username — their username + // taker.ip — request IP (for audit log) + + if (somethingWrong) { + throw new ExpectedErr(400, "Human-readable reason."); + } + + await DB.insertInto("priv_some_table").values({ ... }).execute(); + + return { result: "value" }; // must match output schema + }, +); +``` + +`MakeAction` automatically writes an `action` row (`kind`, `result: "GOOD"|"BAD"`, `ip`, `user_id`) to the `action` audit table on every call. `MakeAnonAction` is the same but `taker` only has `ip` (no `acct`). + +### ExpectedErr + +`ExpectedErr(code, reason)` is an intentional control-flow error. Throw it for 400/403/404/409 etc. The global Express error handler in `server.ts` (`MAIN_ERR_HANDLER`) catches it and returns `{ success: false, description: reason }` with the right HTTP status. **Never wrap action calls in `try/catch`** — let it propagate. + +### Calling actions from a router + +```typescript +// taker construction — same pattern everywhere: +const user = req.session.tachi?.user; +if (!user) return res.status(401).json({ success: false, description: "..." }); + +const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; + +// No try/catch — errors reach MAIN_ERR_HANDLER automatically +const result = await ACTION_MyAction(taker, { fieldA: body.fieldA }); + +return res.status(200).json({ success: true, description: "...", body: result }); +``` + +--- + +## Postgres / Kysely + +### DB import + +```typescript +import DB from "#services/pg/db"; // routers / utils +import DB from "#services/pg/db.js"; // action files (ESM) +``` + +`DB` is a typed `Kysely` instance. Types come from the generated `tachi-db` workspace package (`src/generated/public/Priv*.ts`). + +### Table naming + +Postgres tables use `snake_case`. Private/sensitive tables are prefixed `priv_`. Permissions are sparse boolean columns named `pm_`. + +| Concept | Mongo | Postgres | +|---|---|---| +| API clients | `api-clients` | `priv_api_client` | +| API tokens | `api-tokens` | `priv_api_token` | +| Users | `users` | `account` | + +### Column lists & document mappers + +Reusable `SELECT_*` arrays and `To*Document` mappers live in `src/lib/db-formats/`. Use them to avoid repetition and keep types consistent: + +```typescript +import { SELECT_API_CLIENT, ToAPIClientDocument } from "#lib/db-formats/api-client"; + +const rows = await DB.selectFrom("priv_api_client") + .select(SELECT_API_CLIENT) + .where("author", "=", userId) + .execute(); + +const docs = rows.map(ToAPIClientDocument); // → TachiAPIClientDocument[] +``` + +`SELECT_API_CLIENT` includes `client_secret`. For public-facing lookups, strip it after fetching: + +```typescript +const { clientSecret: _secret, ...publicDoc } = doc; +``` + +### Common query patterns + +```typescript +// Select one +await DB.selectFrom("priv_api_client") + .select(["client_id", "author"]) + .where("client_id", "=", id) + .executeTakeFirst(); // returns undefined if not found + +// Insert +await DB.insertInto("priv_api_client").values({ ... }).execute(); + +// Update (fetch after separately — don't use .returning() with prefixed column lists) +await DB.updateTable("priv_api_client").set({ name: "New" }).where("client_id", "=", id).execute(); +const updated = await GetClientByID(id); // re-fetch via existing query helper + +// Delete +await DB.deleteFrom("priv_api_token").where("from_oauth2_client", "=", id).execute(); + +// Count +const { count } = await DB.selectFrom("priv_api_client") + .select(DB.fn.countAll().as("count")) + .where("author", "=", userId) + .executeTakeFirstOrThrow(); +``` + +Existing query helpers (e.g. `GetClientByID`) live in `src/utils/queries/`. Use them in routers; actions can query directly. + +--- + +## Migrating a Mongo-backed router to Postgres + +### Checklist + +1. **Identify Mongo collections** → find the equivalent `priv_*` Postgres table. +2. **Reads** — replace `MONGODB_KILL["collection"].find/findOne()` with Kysely selects. Use the existing `SELECT_*` + `To*Document` helpers from `src/lib/db-formats/`. +3. **Mutations** — extract each write operation into a `MakeAction`-wrapped file in `src/actions/`. Add its signature to `ActionSignatures` in `actions.ts`. +4. **Middleware** — update any middleware that does Mongo lookups (e.g. `GetClientFromID`) to use a query helper or direct Kysely query. +5. **Remove Mongo import** — `MONGODB_KILL` should be gone from the file. +6. **Ownership checks** — move them inside the action (`taker.acct.id === row.author`) rather than in Express middleware, so the action is self-contained. +7. **Error handler** — throw `ExpectedErr` instead of returning early; no `try/catch` in routes needed. + +### Permission columns + +Mongo stored permissions as an array. Postgres uses individual nullable boolean columns (`pm_submit_score`, `pm_customise_profile`, etc.). Convert with: + +```typescript +// array → columns +perms.includes("submit_score") ? true : null // null = permission not granted + +// columns → array (see ToAPIClientDocument for full example) +if (row.pm_submit_score) result.push("submit_score"); +``` + +--- + +## Testing + +### Framework & setup + +- **Vitest** + colocated `*.test.ts` files (not `__tests__/` folders) +- Each test worker gets its own DB, truncated between every test via `vitest.setup.ts` +- Run with `bun run test` + +### Action tests (unit-style) + +```typescript +import { seedUser } from "#test-utils/pg-fixtures"; +import { seedApiClient } from "./test-utils/api-tokens"; + +describe("ACTION_MyAction", () => { + let userId: number; + let username: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ username: "test_user" })); + }); + + it("throws 404 when resource does not exist", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + await expect(ACTION_MyAction(taker, { id: "missing" })) + .rejects.toMatchObject({ code: 404 }); + }); + + it("writes a GOOD action row on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + await ACTION_MyAction(taker, { ... }); + + const row = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "MY_ACTION") + .executeTakeFirstOrThrow(); + + expect(row).toMatchObject({ result: "GOOD", ip: "10.0.0.1", user_id: userId }); + }); +}); +``` + +### Router integration tests + +```typescript +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; + +afterAll(() => CloseServerConnection()); + +async function loginAs(username: string, password = "password123") { + const res = await mockApi.post("/api/v1/auth/login").send({ + username, "!password": password, captcha: "test", + }); + return res.headers["set-cookie"] as unknown as string[]; +} + +describe("POST /api/v1/some/route", () => { + let cookie: string[]; + + beforeEach(async () => { + await seedUser({ username: "test_user", withCredential: true, withSettings: true }); + cookie = await loginAs("test_user"); + }); + + it("returns 401 when not authenticated", async () => { + const res = await mockApi.post("/api/v1/some/route").send({ ... }); + expect(res.status).toBe(401); + }); + + it("returns 200 on success", async () => { + const res = await mockApi.post("/api/v1/some/route") + .set("Cookie", cookie) + .send({ ... }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); +}); +``` + +### Key test fixtures + +| Helper | Source | Purpose | +|---|---|---| +| `seedUser(opts?)` | `#test-utils/pg-fixtures` | Insert `account` row; `withCredential` + `withSettings` needed for login | +| `seedApiClient(opts)` | `src/actions/test-utils/api-tokens` | Insert `priv_api_client` row | +| `seedApiToken(opts)` | `src/actions/test-utils/api-tokens` | Insert `priv_api_token` row | +| `getApiToken(token)` | `src/actions/test-utils/api-tokens` | Select a token row by value | diff --git a/.cursor/skills/db-formats/SKILL.md b/.cursor/skills/db-formats/SKILL.md new file mode 100644 index 000000000..9c2173d85 --- /dev/null +++ b/.cursor/skills/db-formats/SKILL.md @@ -0,0 +1,132 @@ +--- +name: db-formats +description: Guidance on using db-formats/ column lists (SELECT_*) and document mappers (To*Document) instead of selectAll() when writing Postgres-backed endpoints in the Tachi server. Use when writing a new endpoint that reads from a Postgres table, adding a new db-formats file, or when working with Kysely selects that need to return API-compatible (v1-mongodb-like) response shapes. +--- + +# db-formats: Column Lists & Document Mappers + +## Why not `.selectAll()` + +`.selectAll()` is banned in endpoint code because it: + +- Returns raw snake_case Postgres column names directly to API consumers (breaks the v1 MongoDB-compatible contract) +- Over-fetches columns (e.g. `client_secret` on a public endpoint, internal credential columns) +- Gives no compile-time guarantee that the row shape matches the `tachi-common` document type + +**Exceptions** where `.selectAll()` is fine: +- Internal/private lookups that never reach API responses (e.g. `priv_account_credential` for password verification) +- Assertions on audit rows in tests (`DB.selectFrom("action").selectAll()...`) + +--- + +## The pattern + +Every Postgres table that is returned through the API needs two things in `src/lib/db-formats/.ts`: + +1. **`SELECT_*`** — a `const` array of `"table.column"` strings listing exactly the columns needed +2. **`To*Document`** — a mapper function that converts a typed Kysely row to the matching `tachi-common` document type + +```typescript +// src/lib/db-formats/my-table.ts +import { type Selection } from "kysely"; +import { type MyDocument } from "tachi-common"; +import { type Database } from "tachi-db"; + +export const SELECT_MY_TABLE = [ + "my_table.user_id", + "my_table.some_column", +] as const; + +export function ToMyDocument( + row: Selection, +): MyDocument { + return { + userID: row.user_id, // snake_case → camelCase + someColumn: row.some_column, + }; +} +``` + +The `Selection` type ensures the mapper only receives columns that were actually selected — Kysely will error at compile time if the select array and mapper fall out of sync. + +--- + +## Using it in an endpoint + +```typescript +import { SELECT_MY_TABLE, ToMyDocument } from "#lib/db-formats/my-table"; + +// Many rows +const rows = await DB.selectFrom("my_table") + .select(SELECT_MY_TABLE) + .where("user_id", "=", userId) + .execute(); + +return res.status(200).json({ + success: true, + description: "...", + body: rows.map(ToMyDocument), +}); + +// Single row +const row = await DB.selectFrom("my_table") + .select(SELECT_MY_TABLE) + .where("user_id", "=", userId) + .executeTakeFirst(); + +return res.status(200).json({ + success: true, + description: "...", + body: row ? ToMyDocument(row) : null, +}); +``` + +--- + +## v1-MongoDB-compatible mapping conventions + +The `To*Document` function is responsible for all shape translation. Common patterns: + +| Postgres column type | API document shape | Example | +|---|---|---| +| `snake_case` columns | `camelCase` fields | `user_id` → `userID`, `client_id` → `clientID` | +| `ISO 8601` timestamp string | Unix milliseconds (`number`) | `ISO8601ToUnixMilliseconds(row.joined)` → `joinDate` | +| `pm_*` nullable boolean columns | Array or permissions object | `if (row.pm_submit_score) perms.push("submit_score")` | +| `bd_*` boolean badge columns | `badges: UserBadges[]` array | `if (row.bd_alpha) badges.push("alpha")` | +| `sm_*` social media columns | `socialMedia: { ... }` object | `{ discord: row.sm_discord, ... }` | +| V3 combined game column | `{ game, playtype }` split | `V3ToGamePT(row.game)` | +| `auth_level` enum string | `authLevel: number` | `AuthLevelToInt(row.auth_level)` | + +--- + +## Stripping sensitive columns on public endpoints + +`SELECT_API_CLIENT` includes `client_secret`. If the endpoint is public-facing (not the client owner), strip it after mapping: + +```typescript +const { clientSecret: _secret, ...publicDoc } = ToAPIClientDocument(row); +``` + +--- + +## Adding a new db-formats file: checklist + +1. Create `src/lib/db-formats/
.ts` +2. Export `SELECT_
` as a `const` array of `"table.column"` strings (use the qualified `table.column` form, not bare column names, to avoid ambiguity in joins) +3. Export `ToDocument` typed with `Selection)[number]>` +4. Return the matching `tachi-common` document type (import from `"tachi-common"`) +5. Apply all naming/shape conventions from the table above +6. Import and use `SELECT_*` + `To*Document` in the router/util — never `.selectAll()` + +--- + +## Existing db-formats files + +| File | Table | Document type | +|---|---|---| +| `api-client.ts` | `priv_api_client` | `TachiAPIClientDocument` | +| `api-token.ts` | `priv_api_token` | `APITokenDocument` | +| `user.ts` | `account` | `UserDocument` | +| `user-settings.ts` | `account_settings` | `UserSettingsDocument` | +| `game-stats.ts` | `game_stats` | `UserGameStats` | +| `kshook-sv6c-settings.ts` | `svc_kshook_sv6c_settings` | `KsHookSettingsDocument` | diff --git a/typescript/bliss/src/actions.ts b/typescript/bliss/src/actions.ts index 9501d7ab8..e208181d5 100644 --- a/typescript/bliss/src/actions.ts +++ b/typescript/bliss/src/actions.ts @@ -67,11 +67,15 @@ export function MakeActionGuts({ appName, kind, fn: actionBodyFn, + inputSchema, + outputSchema, }: { appName: string; db: any; fn: (taker: ActionTaker | AnonActionTaker, input: object) => Promise; + inputSchema: ZodObject; kind: string; + outputSchema: ZodObject; }): unknown { return async (taker: ActionTaker | AnonActionTaker, input: Record) => { const ts_start = new Date(); @@ -88,8 +92,25 @@ export function MakeActionGuts({ log.debug({ input: OmitPrivate(input) }, "Action started"); try { + const inputParsed = inputSchema.safeParse(input); + + if (!inputParsed.success) { + throw new Error( + `Action ${kind} received invalid input: ${inputParsed.error.message}`, + ); + } + retval = await actionBodyFn(taker, input); + const outputParsed = outputSchema.safeParse(retval); + + if (!outputParsed.success) { + log.error( + { errors: outputParsed.error.issues, output: OmitPrivate(retval ?? {}) }, + `Action ${kind} returned invalid output`, + ); + } + outputJSON = JSON.stringify(OmitPrivate(retval ?? {})); log.debug( { input: OmitPrivate(input), output: OmitPrivate(retval ?? {}) }, diff --git a/typescript/eslint-config/index.js b/typescript/eslint-config/index.js index d8e09967d..b0c2c9a36 100644 --- a/typescript/eslint-config/index.js +++ b/typescript/eslint-config/index.js @@ -1,4 +1,4 @@ -import eslint from "@eslint"; +import eslint from "@eslint/js"; import configPrettier from "eslint-config-prettier"; import pluginImport from "eslint-plugin-import"; import pluginJsxA11y from "eslint-plugin-jsx-a11y"; diff --git a/typescript/server/src/actions/change-email.test.ts b/typescript/server/src/actions/change-email.test.ts index 5d226ff3f..e881e82f6 100644 --- a/typescript/server/src/actions/change-email.test.ts +++ b/typescript/server/src/actions/change-email.test.ts @@ -193,7 +193,9 @@ describe("ACTION_ChangeEmail", () => { }); beforeEach(() => { - (ServerConfig as Record).EMAIL_CONFIG = { FROM: "noreply@example.com" }; + (ServerConfig as Record).EMAIL_CONFIG = { + FROM: "noreply@example.com", + }; }); it("inserts a verify-email token for the new address", async () => { diff --git a/typescript/server/src/actions/change-pfp.test.ts b/typescript/server/src/actions/change-pfp.test.ts index 8d8c38f52..0275fbd84 100644 --- a/typescript/server/src/actions/change-pfp.test.ts +++ b/typescript/server/src/actions/change-pfp.test.ts @@ -164,7 +164,12 @@ describe("ACTION_ChangePfp", () => { .where("kind", "=", "CHANGE_PFP") .executeTakeFirstOrThrow(); - expect(action).toMatchObject({ kind: "CHANGE_PFP", result: "GOOD", ip: "10.0.0.1", user_id: userId }); + expect(action).toMatchObject({ + kind: "CHANGE_PFP", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); }); it("writes a BAD action row on invalid mimetype", async () => { @@ -285,7 +290,12 @@ describe("ACTION_DeletePfp", () => { .where("kind", "=", "DELETE_PFP") .executeTakeFirstOrThrow(); - expect(action).toMatchObject({ kind: "DELETE_PFP", result: "GOOD", ip: "10.0.0.1", user_id: userId }); + expect(action).toMatchObject({ + kind: "DELETE_PFP", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); }); }); @@ -348,7 +358,10 @@ describe("ACTION_ChangeBanner", () => { it("persists the content hash to custom_banner_location", async () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + await ACTION_ChangeBanner(taker, { + "!fileBuffer": JPEG_BUFFER, + fileMimetype: "image/jpeg", + }); expect(await getBannerLocation(userId)).toBe(HashSHA256(JPEG_BUFFER)); }); @@ -357,7 +370,10 @@ describe("ACTION_ChangeBanner", () => { const other = await seedUser({ username: "other_user" }); const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + await ACTION_ChangeBanner(taker, { + "!fileBuffer": JPEG_BUFFER, + fileMimetype: "image/jpeg", + }); expect(await getBannerLocation(other.id)).toBeNull(); }); @@ -377,7 +393,10 @@ describe("ACTION_ChangeBanner", () => { it("calls CDNStoreOrOverwrite with the correct URL and buffer", async () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + await ACTION_ChangeBanner(taker, { + "!fileBuffer": JPEG_BUFFER, + fileMimetype: "image/jpeg", + }); expect(CDNStoreOrOverwrite).toHaveBeenCalledOnce(); expect(CDNStoreOrOverwrite).toHaveBeenCalledWith( @@ -391,14 +410,22 @@ describe("ACTION_ChangeBanner", () => { it("writes a GOOD action row on success", async () => { const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; - await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + await ACTION_ChangeBanner(taker, { + "!fileBuffer": JPEG_BUFFER, + fileMimetype: "image/jpeg", + }); const action = await DB.selectFrom("action") .selectAll() .where("kind", "=", "CHANGE_BANNER") .executeTakeFirstOrThrow(); - expect(action).toMatchObject({ kind: "CHANGE_BANNER", result: "GOOD", ip: "10.0.0.1", user_id: userId }); + expect(action).toMatchObject({ + kind: "CHANGE_BANNER", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); }); it("writes a BAD action row on invalid mimetype", async () => { @@ -419,7 +446,10 @@ describe("ACTION_ChangeBanner", () => { it("does not store the file buffer content in the audit log input", async () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + await ACTION_ChangeBanner(taker, { + "!fileBuffer": JPEG_BUFFER, + fileMimetype: "image/jpeg", + }); const action = await DB.selectFrom("action") .select("input") @@ -519,6 +549,11 @@ describe("ACTION_DeleteBanner", () => { .where("kind", "=", "DELETE_BANNER") .executeTakeFirstOrThrow(); - expect(action).toMatchObject({ kind: "DELETE_BANNER", result: "GOOD", ip: "10.0.0.1", user_id: userId }); + expect(action).toMatchObject({ + kind: "DELETE_BANNER", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); }); }); diff --git a/typescript/server/src/actions/change-username.test.ts b/typescript/server/src/actions/change-username.test.ts index 889bdc994..fd99d3ef9 100644 --- a/typescript/server/src/actions/change-username.test.ts +++ b/typescript/server/src/actions/change-username.test.ts @@ -12,7 +12,7 @@ import { ACTION_ChangeUsername } from "./change-username"; */ async function seedUsernameChange( userId: number, - opts: { ageDays?: number; username?: string; previousUsername?: string } = {}, + opts: { ageDays?: number; previousUsername?: string; username?: string } = {}, ) { const ageDays = opts.ageDays ?? 0; const timestamp = new Date(Date.now() - ageDays * 24 * 60 * 60 * 1000).toISOString(); @@ -73,7 +73,10 @@ describe("ACTION_ChangeUsername", () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; await expect( - ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }), + ACTION_ChangeUsername(taker, { + newUsername: "new_username", + "!password": "wrongpassword", + }), ).rejects.toMatchObject({ code: 401 }); }); @@ -81,7 +84,10 @@ describe("ACTION_ChangeUsername", () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; await expect( - ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }), + ACTION_ChangeUsername(taker, { + newUsername: "new_username", + "!password": "wrongpassword", + }), ).rejects.toThrow(); const action = await DB.selectFrom("action") @@ -96,7 +102,10 @@ describe("ACTION_ChangeUsername", () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; await expect( - ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }), + ACTION_ChangeUsername(taker, { + newUsername: "new_username", + "!password": "wrongpassword", + }), ).rejects.toThrow(); const row = await DB.selectFrom("account") diff --git a/typescript/server/src/actions/create-api-client.test.ts b/typescript/server/src/actions/create-api-client.test.ts index f09b2e0c9..80df1aa8d 100644 --- a/typescript/server/src/actions/create-api-client.test.ts +++ b/typescript/server/src/actions/create-api-client.test.ts @@ -3,8 +3,8 @@ import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; -import { seedApiClient } from "./test-utils/api-tokens"; import { ACTION_CreateApiClient } from "./create-api-client"; +import { seedApiClient } from "./test-utils/api-tokens"; // ─── ACTION_CreateApiClient ─────────────────────────────────────────────────── diff --git a/typescript/server/src/actions/create-api-client.ts b/typescript/server/src/actions/create-api-client.ts index 622cc58f6..8e82d828a 100644 --- a/typescript/server/src/actions/create-api-client.ts +++ b/typescript/server/src/actions/create-api-client.ts @@ -23,7 +23,10 @@ function permissionsToColumns(perms: Array) { export const ACTION_CreateApiClient = MakeAction( "CREATE_API_CLIENT", - async (taker, { name, redirectUri, webhookUri, apiKeyTemplate, apiKeyFilename, permissions }) => { + async ( + taker, + { name, redirectUri, webhookUri, apiKeyTemplate, apiKeyFilename, permissions }, + ) => { const permissions_deduped = DedupeArr(permissions) as Array; const invalid = permissions_deduped.filter((p) => !VALID_PERMISSIONS.has(p)); diff --git a/typescript/server/src/actions/create-api-token.test.ts b/typescript/server/src/actions/create-api-token.test.ts index 71b442400..ddbc80e0d 100644 --- a/typescript/server/src/actions/create-api-token.test.ts +++ b/typescript/server/src/actions/create-api-token.test.ts @@ -2,8 +2,8 @@ import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; -import { getApiToken, seedApiClient, seedApiToken } from "./test-utils/api-tokens"; import { ACTION_CreateApiToken } from "./create-api-token"; +import { getApiToken, seedApiClient, seedApiToken } from "./test-utils/api-tokens"; // ─── ACTION_CreateApiToken ──────────────────────────────────────────────────── @@ -68,9 +68,7 @@ describe("ACTION_CreateApiToken", () => { it("writes a BAD action row when the clientID does not exist", async () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - await expect( - ACTION_CreateApiToken(taker, { clientID: "CXNonExistent" }), - ).rejects.toThrow(); + await expect(ACTION_CreateApiToken(taker, { clientID: "CXNonExistent" })).rejects.toThrow(); const action = await DB.selectFrom("action") .select("result") diff --git a/typescript/server/src/actions/create-api-token.ts b/typescript/server/src/actions/create-api-token.ts index 7b4525f9e..090cae478 100644 --- a/typescript/server/src/actions/create-api-token.ts +++ b/typescript/server/src/actions/create-api-token.ts @@ -23,10 +23,7 @@ export const ACTION_CreateApiToken = MakeAction( "CREATE_API_TOKEN", async (taker, { clientID, permissions, identifier }) => { if (clientID !== undefined && permissions !== undefined) { - throw new ExpectedErr( - 400, - "Cannot use clientID and permissions at the same time.", - ); + throw new ExpectedErr(400, "Cannot use clientID and permissions at the same time."); } let tokenIdentifier: string; diff --git a/typescript/server/src/actions/delete-all-notifications.test.ts b/typescript/server/src/actions/delete-all-notifications.test.ts index de42cea4a..3632b906c 100644 --- a/typescript/server/src/actions/delete-all-notifications.test.ts +++ b/typescript/server/src/actions/delete-all-notifications.test.ts @@ -3,12 +3,12 @@ import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; +import { ACTION_DeleteAllNotifications } from "./delete-all-notifications"; import { countNotificationsForUser, getNotification, seedNotification, } from "./test-utils/notifications"; -import { ACTION_DeleteAllNotifications } from "./delete-all-notifications"; // ─── ACTION_DeleteAllNotifications ─────────────────────────────────────────── diff --git a/typescript/server/src/actions/delete-api-client.test.ts b/typescript/server/src/actions/delete-api-client.test.ts index 66597e501..fc1e4bfbf 100644 --- a/typescript/server/src/actions/delete-api-client.test.ts +++ b/typescript/server/src/actions/delete-api-client.test.ts @@ -2,8 +2,8 @@ import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; -import { seedApiClient, seedApiToken } from "./test-utils/api-tokens"; import { ACTION_DeleteApiClient } from "./delete-api-client"; +import { seedApiClient, seedApiToken } from "./test-utils/api-tokens"; // ─── ACTION_DeleteApiClient ─────────────────────────────────────────────────── @@ -31,9 +31,9 @@ describe("ACTION_DeleteApiClient", () => { const { id: otherId, username: otherUsername } = await seedUser({ username: "other_user" }); const taker = { ip: "127.0.0.1", acct: { id: otherId, username: otherUsername } }; - await expect( - ACTION_DeleteApiClient(taker, { clientID: clientId }), - ).rejects.toMatchObject({ code: 403 }); + await expect(ACTION_DeleteApiClient(taker, { clientID: clientId })).rejects.toMatchObject({ + code: 403, + }); }); // ── Happy path ──────────────────────────────────────────────────────────── @@ -70,7 +70,11 @@ describe("ACTION_DeleteApiClient", () => { it("does not remove tokens belonging to other clients", async () => { const { id: otherId } = await seedUser({ username: "other_user" }); await seedApiClient({ clientId: "CIOtherClient", authorId: otherId }); - await seedApiToken({ token: "T_other_token", userId: otherId, fromClient: "CIOtherClient" }); + await seedApiToken({ + token: "T_other_token", + userId: otherId, + fromClient: "CIOtherClient", + }); const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; diff --git a/typescript/server/src/actions/delete-api-client.ts b/typescript/server/src/actions/delete-api-client.ts index 8eec082aa..cf5b3a35b 100644 --- a/typescript/server/src/actions/delete-api-client.ts +++ b/typescript/server/src/actions/delete-api-client.ts @@ -18,9 +18,7 @@ export const ACTION_DeleteApiClient = MakeAction( throw new ExpectedErr(403, "You are not authorized to perform this action."); } - await DB.deleteFrom("priv_api_token") - .where("from_oauth2_client", "=", clientID) - .execute(); + await DB.deleteFrom("priv_api_token").where("from_oauth2_client", "=", clientID).execute(); await DB.deleteFrom("priv_api_client").where("client_id", "=", clientID).execute(); diff --git a/typescript/server/src/actions/delete-api-token.test.ts b/typescript/server/src/actions/delete-api-token.test.ts index 520ed584d..f7b73f3c8 100644 --- a/typescript/server/src/actions/delete-api-token.test.ts +++ b/typescript/server/src/actions/delete-api-token.test.ts @@ -2,8 +2,8 @@ import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; -import { getApiToken, seedApiToken } from "./test-utils/api-tokens"; import { ACTION_DeleteApiToken } from "./delete-api-token"; +import { getApiToken, seedApiToken } from "./test-utils/api-tokens"; // ─── ACTION_DeleteApiToken ──────────────────────────────────────────────────── @@ -30,9 +30,9 @@ describe("ACTION_DeleteApiToken", () => { await seedApiToken({ token: "OTHER_TOKEN", userId: other.id }); const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - await expect( - ACTION_DeleteApiToken(taker, { token: "OTHER_TOKEN" }), - ).rejects.toMatchObject({ code: 404 }); + await expect(ACTION_DeleteApiToken(taker, { token: "OTHER_TOKEN" })).rejects.toMatchObject({ + code: 404, + }); }); it("writes a BAD action row when the token does not exist", async () => { diff --git a/typescript/server/src/actions/delete-api-token.ts b/typescript/server/src/actions/delete-api-token.ts index ec11150b3..a283ccdc5 100644 --- a/typescript/server/src/actions/delete-api-token.ts +++ b/typescript/server/src/actions/delete-api-token.ts @@ -2,21 +2,18 @@ import { MakeAction } from "#lib/actions/actions.js"; import DB from "#services/pg/db.js"; import { ExpectedErr } from "bliss"; -export const ACTION_DeleteApiToken = MakeAction( - "DELETE_API_TOKEN", - async (taker, { token }) => { - const existing = await DB.selectFrom("priv_api_token") - .select("token") - .where("token", "=", token) - .where("user_id", "=", taker.acct.id) - .executeTakeFirst(); +export const ACTION_DeleteApiToken = MakeAction("DELETE_API_TOKEN", async (taker, { token }) => { + const existing = await DB.selectFrom("priv_api_token") + .select("token") + .where("token", "=", token) + .where("user_id", "=", taker.acct.id) + .executeTakeFirst(); - if (!existing) { - throw new ExpectedErr(404, "This key does not exist."); - } + if (!existing) { + throw new ExpectedErr(404, "This key does not exist."); + } - await DB.deleteFrom("priv_api_token").where("token", "=", token).execute(); + await DB.deleteFrom("priv_api_token").where("token", "=", token).execute(); - return {}; - }, -); + return {}; +}); diff --git a/typescript/server/src/actions/mark-all-notifications-read.test.ts b/typescript/server/src/actions/mark-all-notifications-read.test.ts index 19cdcd709..345794e55 100644 --- a/typescript/server/src/actions/mark-all-notifications-read.test.ts +++ b/typescript/server/src/actions/mark-all-notifications-read.test.ts @@ -3,8 +3,8 @@ import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; -import { getNotification, seedNotification } from "./test-utils/notifications"; import { ACTION_MarkAllNotificationsRead } from "./mark-all-notifications-read"; +import { getNotification, seedNotification } from "./test-utils/notifications"; // ─── ACTION_MarkAllNotificationsRead ───────────────────────────────────────── diff --git a/typescript/server/src/actions/reset-api-client-secret.test.ts b/typescript/server/src/actions/reset-api-client-secret.test.ts index 817294ec2..8f82447a2 100644 --- a/typescript/server/src/actions/reset-api-client-secret.test.ts +++ b/typescript/server/src/actions/reset-api-client-secret.test.ts @@ -2,8 +2,8 @@ import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; -import { seedApiClient } from "./test-utils/api-tokens"; import { ACTION_ResetApiClientSecret } from "./reset-api-client-secret"; +import { seedApiClient } from "./test-utils/api-tokens"; // ─── ACTION_ResetApiClientSecret ────────────────────────────────────────────── diff --git a/typescript/server/src/actions/reset-api-client-secret.ts b/typescript/server/src/actions/reset-api-client-secret.ts index 77ddd1fe8..23a297d98 100644 --- a/typescript/server/src/actions/reset-api-client-secret.ts +++ b/typescript/server/src/actions/reset-api-client-secret.ts @@ -1,7 +1,7 @@ import { MakeAction } from "#lib/actions/actions.js"; import DB from "#services/pg/db.js"; -import { GetClientByID } from "#utils/queries/api-clients.js"; import { Random20Hex } from "#utils/misc.js"; +import { GetClientByID } from "#utils/queries/api-clients.js"; import { ExpectedErr } from "bliss"; export const ACTION_ResetApiClientSecret = MakeAction( diff --git a/typescript/server/src/actions/update-api-client.test.ts b/typescript/server/src/actions/update-api-client.test.ts index 93f95667e..97d0f053f 100644 --- a/typescript/server/src/actions/update-api-client.test.ts +++ b/typescript/server/src/actions/update-api-client.test.ts @@ -14,7 +14,11 @@ describe("ACTION_UpdateApiClient", () => { beforeEach(async () => { ({ id: userId, username } = await seedUser({ username: "test_user" })); - clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Old Name" }); + clientId = await seedApiClient({ + clientId: "CITestClient", + authorId: userId, + name: "Old Name", + }); }); // ── Existence / ownership ───────────────────────────────────────────────── @@ -41,9 +45,9 @@ describe("ACTION_UpdateApiClient", () => { it("throws 400 when no fields are provided", async () => { const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - await expect( - ACTION_UpdateApiClient(taker, { clientID: clientId }), - ).rejects.toMatchObject({ code: 400 }); + await expect(ACTION_UpdateApiClient(taker, { clientID: clientId })).rejects.toMatchObject({ + code: 400, + }); }); it("throws 400 when apiKeyTemplate does not contain %%TACHI_KEY%%", async () => { diff --git a/typescript/server/src/actions/update-kshook-sv6c-settings.test.ts b/typescript/server/src/actions/update-kshook-sv6c-settings.test.ts new file mode 100644 index 000000000..8d27b0fab --- /dev/null +++ b/typescript/server/src/actions/update-kshook-sv6c-settings.test.ts @@ -0,0 +1,105 @@ +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { ACTION_UpdateKshookSv6cSettings } from "./update-kshook-sv6c-settings"; + +async function getKshookSettings(userId: number) { + return DB.selectFrom("svc_kshook_sv6c_settings") + .selectAll() + .where("user_id", "=", userId) + .executeTakeFirst(); +} + +describe("ACTION_UpdateKshookSv6cSettings", () => { + let userId: number; + let username: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ username: "test_user" })); + }); + + // ── Insert (no existing row) ─────────────────────────────────────────────── + + it("inserts a new row when none exists", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: true }); + + const row = await getKshookSettings(userId); + expect(row).toBeDefined(); + expect(row!.force_static_import).toBe(true); + }); + + it("returns the updated forceStaticImport value", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_UpdateKshookSv6cSettings(taker, { + forceStaticImport: false, + }); + + expect(result).toEqual({ forceStaticImport: false }); + }); + + // ── Update (existing row) ────────────────────────────────────────────────── + + it("updates the existing row when one already exists", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: false }); + + await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: true }); + + const row = await getKshookSettings(userId); + expect(row!.force_static_import).toBe(true); + }); + + it("does not create a second row on repeated calls", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: false }); + await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: true }); + + const count = await DB.selectFrom("svc_kshook_sv6c_settings") + .select(DB.fn.countAll().as("count")) + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(Number(count.count)).toBe(1); + }); + + // ── Isolation ───────────────────────────────────────────────────────────── + + it("does not affect other users' settings", async () => { + const other = await seedUser({ username: "other_user" }); + const otherTaker = { + ip: "127.0.0.1", + acct: { id: other.id, username: other.username }, + }; + await ACTION_UpdateKshookSv6cSettings(otherTaker, { forceStaticImport: true }); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: false }); + + const otherRow = await getKshookSettings(other.id); + expect(otherRow!.force_static_import).toBe(true); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: true }); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "UPDATE_KSHOOK_SV6C_SETTINGS") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "UPDATE_KSHOOK_SV6C_SETTINGS", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); +}); diff --git a/typescript/server/src/actions/update-kshook-sv6c-settings.ts b/typescript/server/src/actions/update-kshook-sv6c-settings.ts new file mode 100644 index 000000000..61eae58d3 --- /dev/null +++ b/typescript/server/src/actions/update-kshook-sv6c-settings.ts @@ -0,0 +1,16 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import DB from "#services/pg/db.js"; + +export const ACTION_UpdateKshookSv6cSettings = MakeAction( + "UPDATE_KSHOOK_SV6C_SETTINGS", + async (taker, { forceStaticImport }) => { + await DB.insertInto("svc_kshook_sv6c_settings") + .values({ user_id: taker.acct.id, force_static_import: forceStaticImport }) + .onConflict((oc) => + oc.column("user_id").doUpdateSet({ force_static_import: forceStaticImport }), + ) + .execute(); + + return { forceStaticImport }; + }, +); diff --git a/typescript/server/src/anon-actions/register.ts b/typescript/server/src/anon-actions/register.ts index e16cabe06..ba9311d54 100644 --- a/typescript/server/src/anon-actions/register.ts +++ b/typescript/server/src/anon-actions/register.ts @@ -1,8 +1,8 @@ import { MakeAnonAction } from "#lib/actions/actions"; +import { AddNewUser, ValidateCaptcha } from "#lib/auth/auth"; import { SendEmail } from "#lib/email/client"; import { EmailFormatVerifyEmail } from "#lib/email/formats"; import { Env, ServerConfig } from "#lib/setup/config"; -import { AddNewUser, ValidateCaptcha } from "#lib/auth/auth"; import DB from "#services/pg/db"; import { Random20Hex } from "#utils/misc"; import { CheckIfEmailInUse, GetUserCaseInsensitive } from "#utils/user"; diff --git a/typescript/server/src/lib/actions/actions.ts b/typescript/server/src/lib/actions/actions.ts index b2e70a044..2f3b9374e 100644 --- a/typescript/server/src/lib/actions/actions.ts +++ b/typescript/server/src/lib/actions/actions.ts @@ -192,6 +192,10 @@ export const ActionSignatures = { }), output: z.object({}), }, + UPDATE_KSHOOK_SV6C_SETTINGS: { + input: z.object({ forceStaticImport: z.boolean() }), + output: z.object({ forceStaticImport: z.boolean() }), + }, } satisfies Record; export const AnonActionSignatures = { @@ -243,6 +247,8 @@ export function MakeAnonAction( db: DB, appName: APP_NAME, kind, + inputSchema: AnonActionSignatures[kind].input, + outputSchema: AnonActionSignatures[kind].output, // @ts-expect-error we're being creative with the types here fn, }) as AnonActionFn; @@ -257,6 +263,8 @@ export function MakeAction(kind: A, fn: ActionFn): Acti db: DB, appName: APP_NAME, kind, + inputSchema: ActionSignatures[kind].input, + outputSchema: ActionSignatures[kind].output, // @ts-expect-error we're being creative with the types here fn, }) as ActionFn; diff --git a/typescript/server/src/lib/db-formats/kshook-sv6c-settings.ts b/typescript/server/src/lib/db-formats/kshook-sv6c-settings.ts new file mode 100644 index 000000000..4ebd02ed6 --- /dev/null +++ b/typescript/server/src/lib/db-formats/kshook-sv6c-settings.ts @@ -0,0 +1,21 @@ +import { type Selection } from "kysely"; +import { type KsHookSettingsDocument } from "tachi-common"; +import { type Database } from "tachi-db"; + +export const SELECT_KSHOOK_SV6C_SETTINGS = [ + "svc_kshook_sv6c_settings.user_id", + "svc_kshook_sv6c_settings.force_static_import", +] as const; + +export function ToKshookSv6cSettings( + row: Selection< + Database, + "svc_kshook_sv6c_settings", + (typeof SELECT_KSHOOK_SV6C_SETTINGS)[number] + >, +): KsHookSettingsDocument { + return { + userID: row.user_id, + forceStaticImport: row.force_static_import, + }; +} diff --git a/typescript/server/src/lib/jobs/bms-table-sync.ts b/typescript/server/src/lib/jobs/bms-table-sync.ts index bf55bb5db..d39b2e6cd 100644 --- a/typescript/server/src/lib/jobs/bms-table-sync.ts +++ b/typescript/server/src/lib/jobs/bms-table-sync.ts @@ -4,7 +4,6 @@ import type { FilterQuery } from "mongodb"; import { log } from "#lib/log/log"; import { DeorphanIfInQueue } from "#lib/orphan-queue/orphan-queue"; import MONGODB_KILL from "#services/mongo/db"; -import { InitaliseFolderChartLookup } from "#utils/folder"; import { FormatBMSTables, WrapScriptPromise } from "#utils/misc"; import { type BMSTableEntry, LoadBMSTable } from "bms-table-loader"; import { BMS_TABLES, type BMSTableInfo, type ChartDocument, type Playtypes } from "tachi-common"; @@ -243,7 +242,7 @@ export async function SyncBMSTables() { } log.info(`Re-initialising folder-chart-lookup, since changes may have been made.`); - await InitaliseFolderChartLookup(); + // await InitaliseFolderChartLookup(); log.info(`Done.`); } diff --git a/typescript/server/src/proto/generated/cards/cards_connect.ts b/typescript/server/src/proto/generated/cards/cards_connect.ts index ecd470126..59148a119 100644 --- a/typescript/server/src/proto/generated/cards/cards_connect.ts +++ b/typescript/server/src/proto/generated/cards/cards_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { LookupRequest, LookupResponse } from "./cards_pb"; +import { LookupRequest, LookupResponse } from "./cards_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** diff --git a/typescript/server/src/proto/generated/chunithm/user_connect.ts b/typescript/server/src/proto/generated/chunithm/user_connect.ts index d7e494a9d..39f8154e9 100644 --- a/typescript/server/src/proto/generated/chunithm/user_connect.ts +++ b/typescript/server/src/proto/generated/chunithm/user_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb"; +import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** diff --git a/typescript/server/src/proto/generated/maimai/user_connect.ts b/typescript/server/src/proto/generated/maimai/user_connect.ts index b70e37ce6..f00425376 100644 --- a/typescript/server/src/proto/generated/maimai/user_connect.ts +++ b/typescript/server/src/proto/generated/maimai/user_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb"; +import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** diff --git a/typescript/server/src/proto/generated/ongeki/user_connect.ts b/typescript/server/src/proto/generated/ongeki/user_connect.ts index 027425c96..08015fa86 100644 --- a/typescript/server/src/proto/generated/ongeki/user_connect.ts +++ b/typescript/server/src/proto/generated/ongeki/user_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb"; +import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** diff --git a/typescript/server/src/proto/generated/wacca/user_connect.ts b/typescript/server/src/proto/generated/wacca/user_connect.ts index 4b476cde4..e6f4f0e36 100644 --- a/typescript/server/src/proto/generated/wacca/user_connect.ts +++ b/typescript/server/src/proto/generated/wacca/user_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { DataRequest, DataResponse, PlaylogRequest, PlaylogStreamItem } from "./user_pb"; +import { DataRequest, DataResponse, PlaylogRequest, PlaylogStreamItem } from "./user_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** diff --git a/typescript/server/src/scripts/load-seeds-mongo.ts b/typescript/server/src/scripts/load-seeds-mongo.ts index 48a3a8dc6..4c414c18a 100644 --- a/typescript/server/src/scripts/load-seeds-mongo.ts +++ b/typescript/server/src/scripts/load-seeds-mongo.ts @@ -23,7 +23,6 @@ import { UpdateQuestSubscriptions } from "#lib/targets/quests"; import MONGODB_KILL, { monkDB } from "#services/mongo/db"; import { RecalcAllScores } from "#utils/calculations/recalc-scores"; import { UpdateGameSongIDCounter } from "#utils/db"; -import { InitaliseFolderChartLookup } from "#utils/folder"; import { ArrayDiff, IsSupported, WrapScriptPromise } from "#utils/misc"; import fjsh from "fast-json-stable-hash"; @@ -187,7 +186,7 @@ const syncInstructions: Array = [ const r = await GenericUpsert(charts, collection, "chartID", log, false); if (r.thingsChanged) { - await InitaliseFolderChartLookup(); + // await InitaliseFolderChartLookup(); await UpdateIsPrimaryStatus(); await UpdateGameSongIDCounter(collectionName.includes("bms") ? "bms" : "pms"); @@ -210,7 +209,7 @@ const syncInstructions: Array = [ const r = await GenericUpsert(charts, collection, "chartID", log, true); if (r.thingsChanged) { - await InitaliseFolderChartLookup(); + // await InitaliseFolderChartLookup(); await UpdateIsPrimaryStatus(); await RecalcAllScores({ @@ -272,7 +271,7 @@ const syncInstructions: Array = [ ); if (r.thingsChanged) { - await InitaliseFolderChartLookup(); + // await InitaliseFolderChartLookup(); const allModifiedFolderIDs = r.changedFields as Array; diff --git a/typescript/server/src/server/router/api/v1/clients/router.test.ts b/typescript/server/src/server/router/api/v1/clients/router.test.ts index b7410dd57..83d090818 100644 --- a/typescript/server/src/server/router/api/v1/clients/router.test.ts +++ b/typescript/server/src/server/router/api/v1/clients/router.test.ts @@ -1,10 +1,9 @@ +import { seedApiClient, seedApiToken } from "#actions/test-utils/api-tokens"; import DB from "#services/pg/db"; import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; import { seedUser } from "#test-utils/pg-fixtures"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; -import { seedApiClient, seedApiToken } from "#actions/test-utils/api-tokens"; - afterAll(() => CloseServerConnection()); // ─── helpers ───────────────────────────────────────────────────────────────── @@ -113,17 +112,14 @@ describe("POST /api/v1/clients/create", () => { }); it("returns 400 when permissions array is empty", async () => { - const res = await mockApi - .post("/api/v1/clients/create") - .set("Cookie", cookie) - .send({ - name: "My App", - redirectUri: null, - webhookUri: null, - apiKeyTemplate: null, - apiKeyFilename: null, - permissions: [], - }); + const res = await mockApi.post("/api/v1/clients/create").set("Cookie", cookie).send({ + name: "My App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: [], + }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); @@ -216,7 +212,11 @@ describe("GET /api/v1/clients/:clientID", () => { beforeEach(async () => { ({ id: userId } = await seedUser({ username: "test_user" })); - clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Test Client" }); + clientId = await seedApiClient({ + clientId: "CITestClient", + authorId: userId, + name: "Test Client", + }); }); it("returns 404 for a non-existent client", async () => { @@ -257,13 +257,15 @@ describe("PATCH /api/v1/clients/:clientID", () => { withSettings: true, })); cookie = await loginAs("test_user"); - clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Old Name" }); + clientId = await seedApiClient({ + clientId: "CITestClient", + authorId: userId, + name: "Old Name", + }); }); it("returns 401 when not authenticated", async () => { - const res = await mockApi - .patch(`/api/v1/clients/${clientId}`) - .send({ name: "New Name" }); + const res = await mockApi.patch(`/api/v1/clients/${clientId}`).send({ name: "New Name" }); expect(res.status).toBe(401); expect(res.body.success).toBe(false); @@ -427,9 +429,7 @@ describe("POST /api/v1/clients/:clientID/reset-secret", () => { it("does not remove existing tokens for the client", async () => { await seedApiToken({ token: "T_should_survive", userId, fromClient: clientId }); - await mockApi - .post(`/api/v1/clients/${clientId}/reset-secret`) - .set("Cookie", cookie); + await mockApi.post(`/api/v1/clients/${clientId}/reset-secret`).set("Cookie", cookie); const token = await DB.selectFrom("priv_api_token") .select("token") @@ -465,9 +465,7 @@ describe("DELETE /api/v1/clients/:clientID", () => { }); it("returns 404 for a non-existent client", async () => { - const res = await mockApi - .delete("/api/v1/clients/CINonExistent") - .set("Cookie", cookie); + const res = await mockApi.delete("/api/v1/clients/CINonExistent").set("Cookie", cookie); expect(res.status).toBe(404); expect(res.body.success).toBe(false); @@ -482,18 +480,14 @@ describe("DELETE /api/v1/clients/:clientID", () => { await seedApiClient({ clientId: "CIOther", authorId: otherId }); const otherCookie = await loginAs("other_user"); - const res = await mockApi - .delete(`/api/v1/clients/${clientId}`) - .set("Cookie", otherCookie); + const res = await mockApi.delete(`/api/v1/clients/${clientId}`).set("Cookie", otherCookie); expect(res.status).toBe(403); expect(res.body.success).toBe(false); }); it("returns 200 and removes the client", async () => { - const res = await mockApi - .delete(`/api/v1/clients/${clientId}`) - .set("Cookie", cookie); + const res = await mockApi.delete(`/api/v1/clients/${clientId}`).set("Cookie", cookie); expect(res.status).toBe(200); expect(res.body.success).toBe(true); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts index 5e589c6b1..7100c7c84 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts @@ -1,8 +1,8 @@ import { CreateActivityRouteHandler } from "#lib/activity/activity"; +import { PasswordCompare, ValidatePassword } from "#lib/auth/auth"; import { ONE_MONTH, ONE_WEEK, ONE_YEAR } from "#lib/constants/time"; import { log } from "#lib/log/log"; import prValidate from "#server/middleware/prudence-validate"; -import { PasswordCompare, ValidatePassword } from "#lib/auth/auth"; import MONGODB_KILL from "#services/mongo/db"; import { IsString } from "#utils/misc"; import { GetTachiData, GetUGPT } from "#utils/req-tachi-data"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.test.ts new file mode 100644 index 000000000..a51f4bf13 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.test.ts @@ -0,0 +1,177 @@ +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function loginAs(username: string, password = "password123") { + const res = await mockApi.post("/api/v1/auth/login").send({ + username, + "!password": password, + captcha: "test", + }); + + return res.headers["set-cookie"] as unknown as string[]; +} + +async function seedKshookSettings(userId: number, forceStaticImport: boolean) { + await DB.insertInto("svc_kshook_sv6c_settings") + .values({ user_id: userId, force_static_import: forceStaticImport }) + .execute(); +} + +// ─── GET /api/v1/users/:userID/integrations/kshook-sv6c/settings ────────────── + +describe("GET /api/v1/users/:userID/integrations/kshook-sv6c/settings", () => { + let cookie: string[]; + let userId: number; + + beforeEach(async () => { + ({ id: userId } = await seedUser({ + username: "test_user", + withCredential: true, + withSettings: true, + })); + cookie = await loginAs("test_user"); + }); + + it("returns 401 when not authenticated", async () => { + const res = await mockApi.get(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("returns null body when the user has no settings", async () => { + const res = await mockApi + .get(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`) + .set("Cookie", cookie); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body).toBeNull(); + }); + + it("returns the user's settings when present", async () => { + await seedKshookSettings(userId, true); + + const res = await mockApi + .get(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`) + .set("Cookie", cookie); + + expect(res.status).toBe(200); + expect(res.body.body).toEqual({ userID: userId, forceStaticImport: true }); + }); + + it("does not return another user's settings", async () => { + const other = await seedUser({ username: "other_user" }); + await seedKshookSettings(other.id, true); + + const res = await mockApi + .get(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`) + .set("Cookie", cookie); + + expect(res.status).toBe(200); + expect(res.body.body).toBeNull(); + }); +}); + +// ─── PATCH /api/v1/users/:userID/integrations/kshook-sv6c/settings ──────────── + +describe("PATCH /api/v1/users/:userID/integrations/kshook-sv6c/settings", () => { + let cookie: string[]; + let userId: number; + + beforeEach(async () => { + ({ id: userId } = await seedUser({ + username: "test_user", + withCredential: true, + withSettings: true, + })); + cookie = await loginAs("test_user"); + }); + + it("returns 401 when not authenticated", async () => { + const res = await mockApi + .patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`) + .send({ forceStaticImport: true }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("returns 403 when authenticated as a different user", async () => { + const other = await seedUser({ + username: "other_user", + email: "other@example.com", + withCredential: true, + withSettings: true, + }); + const otherCookie = await loginAs("other_user"); + + const res = await mockApi + .patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`) + .set("Cookie", otherCookie) + .send({ forceStaticImport: true }); + + expect(res.status).toBe(403); + // suppress unused-variable warning + void other; + }); + + it("creates a settings row and returns 200 on first call", async () => { + const res = await mockApi + .patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`) + .set("Cookie", cookie) + .send({ forceStaticImport: true }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body).toEqual({ userID: userId, forceStaticImport: true }); + }); + + it("updates an existing settings row", async () => { + await seedKshookSettings(userId, false); + + const res = await mockApi + .patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`) + .set("Cookie", cookie) + .send({ forceStaticImport: true }); + + expect(res.status).toBe(200); + expect(res.body.body.forceStaticImport).toBe(true); + }); + + it("persists the change to the database", async () => { + await mockApi + .patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`) + .set("Cookie", cookie) + .send({ forceStaticImport: true }); + + const row = await DB.selectFrom("svc_kshook_sv6c_settings") + .selectAll() + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(row.force_static_import).toBe(true); + }); + + it("returns 400 when forceStaticImport is missing", async () => { + const res = await mockApi + .patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`) + .set("Cookie", cookie) + .send({}); + + expect(res.status).toBe(400); + }); + + it("returns 400 when forceStaticImport is not a boolean", async () => { + const res = await mockApi + .patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`) + .set("Cookie", cookie) + .send({ forceStaticImport: "yes" }); + + expect(res.status).toBe(400); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.ts index dbe0bdeca..b7b8ba138 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.ts @@ -1,6 +1,11 @@ +import { ACTION_UpdateKshookSv6cSettings } from "#actions/update-kshook-sv6c-settings.js"; +import { + SELECT_KSHOOK_SV6C_SETTINGS, + ToKshookSv6cSettings, +} from "#lib/db-formats/kshook-sv6c-settings"; import prValidate from "#server/middleware/prudence-validate"; import { RequireKamaitachi } from "#server/middleware/type-require"; -import MONGODB_KILL from "#services/mongo/db"; +import DB from "#services/pg/db"; import { GetTachiData } from "#utils/req-tachi-data"; import { Router } from "express"; @@ -19,14 +24,15 @@ router.use(RequireSelfRequestFromUser); router.get("/settings", async (req, res) => { const user = GetTachiData(req, "requestedUser"); - const settingsDoc = await MONGODB_KILL["kshook-sv6c-settings"].findOne({ - userID: user.id, - }); + const row = await DB.selectFrom("svc_kshook_sv6c_settings") + .select(SELECT_KSHOOK_SV6C_SETTINGS) + .where("user_id", "=", user.id) + .executeTakeFirst(); return res.status(200).json({ success: true, description: `Retrieved KsHook (S6VC) settings.`, - body: settingsDoc ?? null, + body: row ? ToKshookSv6cSettings(row) : null, }); }); @@ -35,33 +41,21 @@ router.get("/settings", async (req, res) => { * * @param forceStaticImport - Whether or whether not to statically import data. * - * @name PUT /api/v1/users/:userID/integrations/kshook-sv6c/settings + * @name PATCH /api/v1/users/:userID/integrations/kshook-sv6c/settings */ router.patch("/settings", prValidate({ forceStaticImport: "boolean" }), async (req, res) => { - const body = req.safeBody as { - forceStaticImport: boolean; - }; - const user = GetTachiData(req, "requestedUser"); - await MONGODB_KILL["kshook-sv6c-settings"].update( - { userID: user.id }, - { - $set: { - forceStaticImport: body.forceStaticImport, - }, - }, - { - upsert: true, - }, - ); + const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; - const settings = await MONGODB_KILL["kshook-sv6c-settings"].findOne({ userID: user.id }); + const result = await ACTION_UpdateKshookSv6cSettings(taker, { + forceStaticImport: req.body.forceStaticImport, + }); return res.status(200).json({ success: true, description: `Successfully updated settings.`, - body: settings, + body: { userID: user.id, ...result }, }); }); diff --git a/typescript/server/src/server/server.ts b/typescript/server/src/server/server.ts index 96f233e25..59297e329 100644 --- a/typescript/server/src/server/server.ts +++ b/typescript/server/src/server/server.ts @@ -9,9 +9,9 @@ import express, { type Express } from "express"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { log } from "#lib/log/log"; import { Env, ServerConfig, TachiConfig } from "#lib/setup/config"; -import { ExpectedErr } from "bliss"; import { RedisClient } from "#services/redis/redis"; import { IsNonEmptyString, IsRecord } from "#utils/misc"; +import { ExpectedErr } from "bliss"; import ExpressPromBundle from "express-prom-bundle"; import expressSession from "express-session"; import helmet from "helmet"; diff --git a/typescript/server/src/utils/user.ts b/typescript/server/src/utils/user.ts index 31e01a728..bd83af9aa 100644 --- a/typescript/server/src/utils/user.ts +++ b/typescript/server/src/utils/user.ts @@ -201,7 +201,7 @@ export function GetUGPTPlaycount(userID: integer, game: GameGroup, playtype: Pla .where("user_id", "=", userID) .where("game", "=", v3Game) .executeTakeFirst() - .then((res) => res?.playcount ?? 0); + .then((res) => Number(res?.playcount ?? 0)); } export async function GetAllRankings(stats: UserGameStats) {