From bd8a8299bc63b5193123fbd397e281bab927d387 Mon Sep 17 00:00:00 2001 From: zk Date: Tue, 24 Mar 2026 23:57:21 +0000 Subject: [PATCH] feat: client stuff to postgres --- .../components/imports/KAIIntegrationPage.tsx | 19 +- .../src/actions/create-api-client.test.ts | 279 +++++++++ .../server/src/actions/create-api-client.ts | 89 +++ .../src/actions/delete-api-client.test.ts | 129 +++++ .../server/src/actions/delete-api-client.ts | 29 + .../actions/reset-api-client-secret.test.ts | 150 +++++ .../src/actions/reset-api-client-secret.ts | 38 ++ .../src/actions/update-api-client.test.ts | 214 +++++++ .../server/src/actions/update-api-client.ts | 71 +++ typescript/server/src/lib/actions/actions.ts | 64 +++ .../src/server/router/api/v1/auth/router.ts | 37 +- .../router/api/v1/clients/middleware.ts | 58 +- .../router/api/v1/clients/router.test.ts | 536 ++++++++++++++++++ .../server/router/api/v1/clients/router.ts | 275 +++------ typescript/server/src/server/server.ts | 11 + typescript/server/src/utils/response.ts | 18 - 16 files changed, 1720 insertions(+), 297 deletions(-) create mode 100644 typescript/server/src/actions/create-api-client.test.ts create mode 100644 typescript/server/src/actions/create-api-client.ts create mode 100644 typescript/server/src/actions/delete-api-client.test.ts create mode 100644 typescript/server/src/actions/delete-api-client.ts create mode 100644 typescript/server/src/actions/reset-api-client-secret.test.ts create mode 100644 typescript/server/src/actions/reset-api-client-secret.ts create mode 100644 typescript/server/src/actions/update-api-client.test.ts create mode 100644 typescript/server/src/actions/update-api-client.ts create mode 100644 typescript/server/src/server/router/api/v1/clients/router.test.ts diff --git a/typescript/client/src/components/imports/KAIIntegrationPage.tsx b/typescript/client/src/components/imports/KAIIntegrationPage.tsx index 579e954ca..9a1271e39 100644 --- a/typescript/client/src/components/imports/KAIIntegrationPage.tsx +++ b/typescript/client/src/components/imports/KAIIntegrationPage.tsx @@ -7,8 +7,7 @@ import useImport from "#components/util/import/useImport"; import Loading from "#components/util/Loading"; import useApiQuery from "#components/util/query/useApiQuery"; import { UserContext } from "#context/UserContext"; -import hashjs from "hash"; -import React, { useContext, useMemo, useState } from "react"; +import React, { useContext, useEffect, useState } from "react"; import { Button, Form, InputGroup } from "react-bootstrap"; import { type APIImportTypes, GetGameGroupConfig } from "tachi-common"; @@ -127,13 +126,23 @@ function KAINeedsIntegrate({ kaiType, hash, clientID, redirectUri }: Omit(""); + const [valid, setValid] = useState(null); - const valid = useMemo(() => { + useEffect(() => { if (!url) { - return null; + setValid(null); + return; } - return hashjs.sha256().update(url).digest("hex") === hash; + const data = new TextEncoder().encode(url); + + crypto.subtle.digest("SHA-256", data).then((hashBuffer) => { + const hashHex = Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + setValid(hashHex === hash); + }); }, [url]); return ( diff --git a/typescript/server/src/actions/create-api-client.test.ts b/typescript/server/src/actions/create-api-client.test.ts new file mode 100644 index 000000000..f09b2e0c9 --- /dev/null +++ b/typescript/server/src/actions/create-api-client.test.ts @@ -0,0 +1,279 @@ +import { ServerConfig } from "#lib/setup/config"; +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"; + +// ─── ACTION_CreateApiClient ─────────────────────────────────────────────────── + +describe("ACTION_CreateApiClient", () => { + let userId: number; + let username: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ username: "test_user" })); + }); + + // ── Input validation ────────────────────────────────────────────────────── + + it("throws 400 when permissions list is empty", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: [], + }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("throws 400 for an invalid permission name", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["not_a_real_permission"], + }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("throws 400 when apiKeyTemplate does not contain %%TACHI_KEY%%", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: "missing-the-placeholder", + apiKeyFilename: null, + permissions: ["submit_score"], + }), + ).rejects.toMatchObject({ code: 400 }); + }); + + // ── Client cap ──────────────────────────────────────────────────────────── + + it("throws 400 when user has reached OAUTH_CLIENT_CAP", async () => { + for (let i = 0; i < ServerConfig.OAUTH_CLIENT_CAP; i++) { + await seedApiClient({ clientId: `CI_test_${i}`, authorId: userId }); + } + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_CreateApiClient(taker, { + name: "One Too Many", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score"], + }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("does not apply the cap to admin users", async () => { + const { id: adminId, username: adminUsername } = await seedUser({ + username: "admin_user", + authLevel: "admin", + }); + + for (let i = 0; i < ServerConfig.OAUTH_CLIENT_CAP; i++) { + await seedApiClient({ clientId: `CI_admin_${i}`, authorId: adminId }); + } + + const taker = { ip: "127.0.0.1", acct: { id: adminId, username: adminUsername } }; + + await expect( + ACTION_CreateApiClient(taker, { + name: "Admin Extra Client", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score"], + }), + ).resolves.toMatchObject({ name: "Admin Extra Client" }); + }); + + // ── Happy path ──────────────────────────────────────────────────────────── + + it("inserts a row into priv_api_client", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score"], + }); + + const row = await DB.selectFrom("priv_api_client") + .select(["client_id", "name", "author"]) + .where("client_id", "=", result.clientID) + .executeTakeFirst(); + + expect(row).toBeDefined(); + expect(row?.name).toBe("My App"); + expect(row?.author).toBe(userId); + }); + + it("returns a clientID matching CI prefix and a clientSecret matching CS prefix", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score"], + }); + + expect(result.clientID).toMatch(/^CI[0-9a-f]{40}$/u); + expect(result.clientSecret).toMatch(/^CS[0-9a-f]{40}$/u); + }); + + it("deduplicates permissions", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score", "submit_score", "customise_profile"], + }); + + expect(result.requestedPermissions).toHaveLength(2); + expect(result.requestedPermissions).toContain("submit_score"); + expect(result.requestedPermissions).toContain("customise_profile"); + }); + + it("stores redirect_uri and webhook_uri when provided", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: "https://example.com/callback", + webhookUri: "https://example.com/webhook", + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score"], + }); + + const row = await DB.selectFrom("priv_api_client") + .select(["redirect_uri", "webhook_uri"]) + .where("client_id", "=", result.clientID) + .executeTakeFirstOrThrow(); + + expect(row.redirect_uri).toBe("https://example.com/callback"); + expect(row.webhook_uri).toBe("https://example.com/webhook"); + }); + + it("stores apiKeyTemplate and apiKeyFilename when provided", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: "key=%%TACHI_KEY%%", + apiKeyFilename: "tachi-key.txt", + permissions: ["submit_score"], + }); + + const row = await DB.selectFrom("priv_api_client") + .select(["api_key_template", "api_key_filename"]) + .where("client_id", "=", result.clientID) + .executeTakeFirstOrThrow(); + + expect(row.api_key_template).toBe("key=%%TACHI_KEY%%"); + expect(row.api_key_filename).toBe("tachi-key.txt"); + }); + + it("sets pm_submit_score when that permission is requested", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score"], + }); + + const row = await DB.selectFrom("priv_api_client") + .select(["pm_submit_score", "pm_customise_profile"]) + .where("client_id", "=", result.clientID) + .executeTakeFirstOrThrow(); + + expect(row.pm_submit_score).toBe(true); + expect(row.pm_customise_profile).toBeNull(); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score"], + }); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "CREATE_API_CLIENT") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "CREATE_API_CLIENT", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); + + it("writes a BAD action row when permissions are invalid", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_CreateApiClient(taker, { + name: "My App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: [], + }), + ).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "CREATE_API_CLIENT") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); +}); diff --git a/typescript/server/src/actions/create-api-client.ts b/typescript/server/src/actions/create-api-client.ts new file mode 100644 index 000000000..622cc58f6 --- /dev/null +++ b/typescript/server/src/actions/create-api-client.ts @@ -0,0 +1,89 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import { ServerConfig } from "#lib/setup/config.js"; +import DB from "#services/pg/db.js"; +import { DedupeArr, Random20Hex } from "#utils/misc.js"; +import { IsUserAdmin } from "#utils/user.js"; +import { ExpectedErr } from "bliss"; +import { ALL_PERMISSIONS, type APIPermissions } from "tachi-common"; + +const VALID_PERMISSIONS = new Set(Object.keys(ALL_PERMISSIONS)); + +function permissionsToColumns(perms: Array) { + return { + pm_customise_profile: perms.includes("customise_profile") ? true : null, + pm_customise_score: perms.includes("customise_score") ? true : null, + pm_customise_session: perms.includes("customise_session") ? true : null, + pm_delete_score: perms.includes("delete_score") ? true : null, + pm_manage_rivals: perms.includes("manage_rivals") ? true : null, + pm_manage_targets: perms.includes("manage_targets") ? true : null, + pm_submit_score: perms.includes("submit_score") ? true : null, + pm_manage_challenges: perms.includes("manage_challenges") ? true : null, + }; +} + +export const ACTION_CreateApiClient = MakeAction( + "CREATE_API_CLIENT", + 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)); + + if (invalid.length > 0) { + throw new ExpectedErr(400, `Invalid permissions: ${invalid.join(", ")}`); + } + + if (permissions_deduped.length === 0) { + throw new ExpectedErr(400, "Must require at least one permission."); + } + + if (apiKeyTemplate !== null && !apiKeyTemplate.includes("%%TACHI_KEY%%")) { + throw new ExpectedErr(400, "apiKeyTemplate must contain %%TACHI_KEY%%."); + } + + const isAdmin = await IsUserAdmin(taker.acct.id); + + if (!isAdmin) { + const existingCount = await DB.selectFrom("priv_api_client") + .select(DB.fn.countAll().as("count")) + .where("author", "=", taker.acct.id) + .executeTakeFirstOrThrow(); + + if (Number(existingCount.count) >= ServerConfig.OAUTH_CLIENT_CAP) { + throw new ExpectedErr( + 400, + `You have created too many API clients. The current cap is ${ServerConfig.OAUTH_CLIENT_CAP}.`, + ); + } + } + + const clientID = `CI${Random20Hex()}`; + const clientSecret = `CS${Random20Hex()}`; + + await DB.insertInto("priv_api_client") + .values({ + client_id: clientID, + client_secret: clientSecret, + name, + author: taker.acct.id, + redirect_uri: redirectUri, + webhook_uri: webhookUri, + api_key_template: apiKeyTemplate, + api_key_filename: apiKeyFilename, + is_builtin: false, + ...permissionsToColumns(permissions_deduped), + }) + .execute(); + + return { + clientID, + clientSecret, + name, + author: taker.acct.id, + requestedPermissions: permissions_deduped, + redirectUri, + webhookUri, + apiKeyTemplate, + apiKeyFilename, + }; + }, +); diff --git a/typescript/server/src/actions/delete-api-client.test.ts b/typescript/server/src/actions/delete-api-client.test.ts new file mode 100644 index 000000000..66597e501 --- /dev/null +++ b/typescript/server/src/actions/delete-api-client.test.ts @@ -0,0 +1,129 @@ +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"; + +// ─── ACTION_DeleteApiClient ─────────────────────────────────────────────────── + +describe("ACTION_DeleteApiClient", () => { + let userId: number; + let username: string; + let clientId: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ username: "test_user" })); + clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId }); + }); + + // ── Existence / ownership ───────────────────────────────────────────────── + + it("throws 404 when the client does not exist", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_DeleteApiClient(taker, { clientID: "CINonExistent" }), + ).rejects.toMatchObject({ code: 404 }); + }); + + it("throws 403 when the taker is not the client owner", async () => { + 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 }); + }); + + // ── Happy path ──────────────────────────────────────────────────────────── + + it("removes the client from priv_api_client", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteApiClient(taker, { clientID: clientId }); + + const row = await DB.selectFrom("priv_api_client") + .select("client_id") + .where("client_id", "=", clientId) + .executeTakeFirst(); + + expect(row).toBeUndefined(); + }); + + it("removes all api tokens associated with this client", async () => { + await seedApiToken({ token: "T_token_1", userId, fromClient: clientId }); + await seedApiToken({ token: "T_token_2", userId, fromClient: clientId }); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteApiClient(taker, { clientID: clientId }); + + const remaining = await DB.selectFrom("priv_api_token") + .select("token") + .where("from_oauth2_client", "=", clientId) + .execute(); + + expect(remaining).toHaveLength(0); + }); + + 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" }); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteApiClient(taker, { clientID: clientId }); + + const preserved = await DB.selectFrom("priv_api_token") + .select("token") + .where("token", "=", "T_other_token") + .executeTakeFirst(); + + expect(preserved).toBeDefined(); + }); + + it("succeeds and returns {} when the client has no tokens", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_DeleteApiClient(taker, { clientID: clientId }); + + expect(result).toEqual({}); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteApiClient(taker, { clientID: clientId }); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "DELETE_API_CLIENT") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "DELETE_API_CLIENT", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); + + it("writes a BAD action row when client does not exist", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_DeleteApiClient(taker, { clientID: "CINonExistent" }), + ).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "DELETE_API_CLIENT") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); +}); diff --git a/typescript/server/src/actions/delete-api-client.ts b/typescript/server/src/actions/delete-api-client.ts new file mode 100644 index 000000000..8eec082aa --- /dev/null +++ b/typescript/server/src/actions/delete-api-client.ts @@ -0,0 +1,29 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import DB from "#services/pg/db.js"; +import { ExpectedErr } from "bliss"; + +export const ACTION_DeleteApiClient = MakeAction( + "DELETE_API_CLIENT", + async (taker, { clientID }) => { + const existing = await DB.selectFrom("priv_api_client") + .select(["client_id", "author"]) + .where("client_id", "=", clientID) + .executeTakeFirst(); + + if (!existing) { + throw new ExpectedErr(404, "This client does not exist."); + } + + if (existing.author !== taker.acct.id) { + 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_client").where("client_id", "=", clientID).execute(); + + return {}; + }, +); diff --git a/typescript/server/src/actions/reset-api-client-secret.test.ts b/typescript/server/src/actions/reset-api-client-secret.test.ts new file mode 100644 index 000000000..817294ec2 --- /dev/null +++ b/typescript/server/src/actions/reset-api-client-secret.test.ts @@ -0,0 +1,150 @@ +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"; + +// ─── ACTION_ResetApiClientSecret ────────────────────────────────────────────── + +describe("ACTION_ResetApiClientSecret", () => { + let userId: number; + let username: string; + let clientId: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ username: "test_user" })); + clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId }); + }); + + // ── Existence / ownership ───────────────────────────────────────────────── + + it("throws 404 when the client does not exist", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ResetApiClientSecret(taker, { clientID: "CINonExistent" }), + ).rejects.toMatchObject({ code: 404 }); + }); + + it("throws 403 when the taker is not the client owner", async () => { + 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_ResetApiClientSecret(taker, { clientID: clientId }), + ).rejects.toMatchObject({ code: 403 }); + }); + + // ── Happy path ──────────────────────────────────────────────────────────── + + it("generates a new secret different from the original", async () => { + const originalRow = await DB.selectFrom("priv_api_client") + .select("client_secret") + .where("client_id", "=", clientId) + .executeTakeFirstOrThrow(); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + await ACTION_ResetApiClientSecret(taker, { clientID: clientId }); + + const updatedRow = await DB.selectFrom("priv_api_client") + .select("client_secret") + .where("client_id", "=", clientId) + .executeTakeFirstOrThrow(); + + expect(updatedRow.client_secret).not.toBe(originalRow.client_secret); + }); + + it("new secret matches CS prefix format", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const result = await ACTION_ResetApiClientSecret(taker, { clientID: clientId }); + + expect(result.clientSecret).toMatch(/^CS[0-9a-f]{40}$/u); + }); + + it("persists the new secret to the database", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const result = await ACTION_ResetApiClientSecret(taker, { clientID: clientId }); + + const row = await DB.selectFrom("priv_api_client") + .select("client_secret") + .where("client_id", "=", clientId) + .executeTakeFirstOrThrow(); + + expect(row.client_secret).toBe(result.clientSecret); + }); + + it("returns the full updated client document", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const result = await ACTION_ResetApiClientSecret(taker, { clientID: clientId }); + + expect(result.clientID).toBe(clientId); + expect(result.author).toBe(userId); + expect(result.clientSecret).toMatch(/^CS[0-9a-f]{40}$/u); + }); + + it("does not invalidate existing tokens for the client", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await DB.insertInto("priv_api_token") + .values({ + token: "T_existing_token", + user_id: userId, + identifier: "Test Token", + from_oauth2_client: clientId, + pm_submit_score: null, + pm_customise_profile: null, + pm_customise_score: null, + pm_customise_session: null, + pm_delete_score: null, + pm_manage_rivals: null, + pm_manage_targets: null, + pm_manage_challenges: null, + }) + .execute(); + + await ACTION_ResetApiClientSecret(taker, { clientID: clientId }); + + const tokenStillExists = await DB.selectFrom("priv_api_token") + .select("token") + .where("token", "=", "T_existing_token") + .executeTakeFirst(); + + expect(tokenStillExists).toBeDefined(); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_ResetApiClientSecret(taker, { clientID: clientId }); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "RESET_API_CLIENT_SECRET") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "RESET_API_CLIENT_SECRET", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); + + it("writes a BAD action row when client does not exist", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ResetApiClientSecret(taker, { clientID: "CINonExistent" }), + ).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "RESET_API_CLIENT_SECRET") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); +}); diff --git a/typescript/server/src/actions/reset-api-client-secret.ts b/typescript/server/src/actions/reset-api-client-secret.ts new file mode 100644 index 000000000..77ddd1fe8 --- /dev/null +++ b/typescript/server/src/actions/reset-api-client-secret.ts @@ -0,0 +1,38 @@ +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 { ExpectedErr } from "bliss"; + +export const ACTION_ResetApiClientSecret = MakeAction( + "RESET_API_CLIENT_SECRET", + async (taker, { clientID }) => { + const existing = await DB.selectFrom("priv_api_client") + .select(["client_id", "author"]) + .where("client_id", "=", clientID) + .executeTakeFirst(); + + if (!existing) { + throw new ExpectedErr(404, "This client does not exist."); + } + + if (existing.author !== taker.acct.id) { + throw new ExpectedErr(403, "You are not authorized to perform this action."); + } + + const newSecret = `CS${Random20Hex()}`; + + await DB.updateTable("priv_api_client") + .set({ client_secret: newSecret }) + .where("client_id", "=", clientID) + .execute(); + + const updated = await GetClientByID(clientID); + + if (!updated) { + throw new ExpectedErr(500, "Failed to retrieve updated client."); + } + + return updated; + }, +); diff --git a/typescript/server/src/actions/update-api-client.test.ts b/typescript/server/src/actions/update-api-client.test.ts new file mode 100644 index 000000000..93f95667e --- /dev/null +++ b/typescript/server/src/actions/update-api-client.test.ts @@ -0,0 +1,214 @@ +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_UpdateApiClient } from "./update-api-client"; + +// ─── ACTION_UpdateApiClient ─────────────────────────────────────────────────── + +describe("ACTION_UpdateApiClient", () => { + let userId: number; + let username: string; + let clientId: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ username: "test_user" })); + clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Old Name" }); + }); + + // ── Existence / ownership ───────────────────────────────────────────────── + + it("throws 404 when the client does not exist", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_UpdateApiClient(taker, { clientID: "CINonExistent", name: "New Name" }), + ).rejects.toMatchObject({ code: 404 }); + }); + + it("throws 403 when the taker is not the client owner", async () => { + 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_UpdateApiClient(taker, { clientID: clientId, name: "Hijacked" }), + ).rejects.toMatchObject({ code: 403 }); + }); + + // ── Input validation ────────────────────────────────────────────────────── + + 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 }); + }); + + it("throws 400 when apiKeyTemplate does not contain %%TACHI_KEY%%", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_UpdateApiClient(taker, { + clientID: clientId, + apiKeyTemplate: "missing-placeholder", + }), + ).rejects.toMatchObject({ code: 400 }); + }); + + // ── Happy path ──────────────────────────────────────────────────────────── + + it("updates the name field", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_UpdateApiClient(taker, { + clientID: clientId, + name: "New Name", + }); + + expect(result.name).toBe("New Name"); + + const row = await DB.selectFrom("priv_api_client") + .select("name") + .where("client_id", "=", clientId) + .executeTakeFirstOrThrow(); + + expect(row.name).toBe("New Name"); + }); + + it("updates redirectUri", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateApiClient(taker, { + clientID: clientId, + redirectUri: "https://example.com/callback", + }); + + const row = await DB.selectFrom("priv_api_client") + .select("redirect_uri") + .where("client_id", "=", clientId) + .executeTakeFirstOrThrow(); + + expect(row.redirect_uri).toBe("https://example.com/callback"); + }); + + it("sets redirectUri to null", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateApiClient(taker, { clientID: clientId, redirectUri: null }); + + const row = await DB.selectFrom("priv_api_client") + .select("redirect_uri") + .where("client_id", "=", clientId) + .executeTakeFirstOrThrow(); + + expect(row.redirect_uri).toBeNull(); + }); + + it("updates webhookUri", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateApiClient(taker, { + clientID: clientId, + webhookUri: "https://example.com/webhook", + }); + + const row = await DB.selectFrom("priv_api_client") + .select("webhook_uri") + .where("client_id", "=", clientId) + .executeTakeFirstOrThrow(); + + expect(row.webhook_uri).toBe("https://example.com/webhook"); + }); + + it("updates apiKeyTemplate when it contains %%TACHI_KEY%%", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateApiClient(taker, { + clientID: clientId, + apiKeyTemplate: "key=%%TACHI_KEY%%", + }); + + const row = await DB.selectFrom("priv_api_client") + .select("api_key_template") + .where("client_id", "=", clientId) + .executeTakeFirstOrThrow(); + + expect(row.api_key_template).toBe("key=%%TACHI_KEY%%"); + }); + + it("clears apiKeyTemplate when set to null", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateApiClient(taker, { clientID: clientId, apiKeyTemplate: null }); + + const row = await DB.selectFrom("priv_api_client") + .select("api_key_template") + .where("client_id", "=", clientId) + .executeTakeFirstOrThrow(); + + expect(row.api_key_template).toBeNull(); + }); + + it("updates apiKeyFilename", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateApiClient(taker, { clientID: clientId, apiKeyFilename: "keys.txt" }); + + const row = await DB.selectFrom("priv_api_client") + .select("api_key_filename") + .where("client_id", "=", clientId) + .executeTakeFirstOrThrow(); + + expect(row.api_key_filename).toBe("keys.txt"); + }); + + it("returns the updated client document", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_UpdateApiClient(taker, { + clientID: clientId, + name: "Updated Name", + }); + + expect(result.clientID).toBe(clientId); + expect(result.name).toBe("Updated Name"); + expect(result.author).toBe(userId); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateApiClient(taker, { clientID: clientId, name: "New Name" }); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "UPDATE_API_CLIENT") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "UPDATE_API_CLIENT", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); + + it("writes a BAD action row when client does not exist", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_UpdateApiClient(taker, { clientID: "CINonExistent", name: "New Name" }), + ).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "UPDATE_API_CLIENT") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); +}); diff --git a/typescript/server/src/actions/update-api-client.ts b/typescript/server/src/actions/update-api-client.ts new file mode 100644 index 000000000..8f202c47a --- /dev/null +++ b/typescript/server/src/actions/update-api-client.ts @@ -0,0 +1,71 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import DB from "#services/pg/db.js"; +import { GetClientByID } from "#utils/queries/api-clients.js"; +import { ExpectedErr } from "bliss"; + +export const ACTION_UpdateApiClient = MakeAction( + "UPDATE_API_CLIENT", + async (taker, { clientID, name, redirectUri, webhookUri, apiKeyTemplate, apiKeyFilename }) => { + const existing = await DB.selectFrom("priv_api_client") + .select(["client_id", "author"]) + .where("client_id", "=", clientID) + .executeTakeFirst(); + + if (!existing) { + throw new ExpectedErr(404, "This client does not exist."); + } + + if (existing.author !== taker.acct.id) { + throw new ExpectedErr(403, "You are not authorized to perform this action."); + } + + const updates: { + api_key_filename?: string | null; + api_key_template?: string | null; + name?: string; + redirect_uri?: string | null; + webhook_uri?: string | null; + } = {}; + + if (name !== undefined) { + updates.name = name; + } + + if (redirectUri !== undefined) { + updates.redirect_uri = redirectUri; + } + + if (webhookUri !== undefined) { + updates.webhook_uri = webhookUri; + } + + if (apiKeyTemplate !== undefined) { + if (apiKeyTemplate !== null && !apiKeyTemplate.includes("%%TACHI_KEY%%")) { + throw new ExpectedErr(400, "apiKeyTemplate must contain %%TACHI_KEY%%."); + } + + updates.api_key_template = apiKeyTemplate; + } + + if (apiKeyFilename !== undefined) { + updates.api_key_filename = apiKeyFilename; + } + + if (Object.keys(updates).length === 0) { + throw new ExpectedErr(400, "No changes to make."); + } + + await DB.updateTable("priv_api_client") + .set(updates) + .where("client_id", "=", clientID) + .execute(); + + const updated = await GetClientByID(clientID); + + if (!updated) { + throw new ExpectedErr(500, "Failed to retrieve updated client."); + } + + return updated; + }, +); diff --git a/typescript/server/src/lib/actions/actions.ts b/typescript/server/src/lib/actions/actions.ts index 59d3f1ce1..b2e70a044 100644 --- a/typescript/server/src/lib/actions/actions.ts +++ b/typescript/server/src/lib/actions/actions.ts @@ -128,6 +128,70 @@ export const ActionSignatures = { }), output: z.object({}), }, + CREATE_API_CLIENT: { + input: z.object({ + name: z.string().min(3).max(80), + redirectUri: z.url().nullable(), + webhookUri: z.url().nullable(), + apiKeyTemplate: z.string().nullable(), + apiKeyFilename: z.string().nullable(), + permissions: z.array(z.string()), + }), + output: z.object({ + clientID: z.string(), + clientSecret: z.string(), + name: z.string(), + author: z.number().int(), + requestedPermissions: z.array(z.string()), + redirectUri: z.string().nullable(), + webhookUri: z.string().nullable(), + apiKeyTemplate: z.string().nullable(), + apiKeyFilename: z.string().nullable(), + }), + }, + UPDATE_API_CLIENT: { + input: z.object({ + clientID: z.string(), + name: z.string().min(3).max(80).optional(), + redirectUri: z.url().nullable().optional(), + webhookUri: z.url().nullable().optional(), + apiKeyTemplate: z.string().nullable().optional(), + apiKeyFilename: z.string().min(3).max(80).nullable().optional(), + }), + output: z.object({ + clientID: z.string(), + clientSecret: z.string(), + name: z.string(), + author: z.number().int(), + requestedPermissions: z.array(z.string()), + redirectUri: z.string().nullable(), + webhookUri: z.string().nullable(), + apiKeyTemplate: z.string().nullable(), + apiKeyFilename: z.string().nullable(), + }), + }, + RESET_API_CLIENT_SECRET: { + input: z.object({ + clientID: z.string(), + }), + output: z.object({ + clientID: z.string(), + clientSecret: z.string(), + name: z.string(), + author: z.number().int(), + requestedPermissions: z.array(z.string()), + redirectUri: z.string().nullable(), + webhookUri: z.string().nullable(), + apiKeyTemplate: z.string().nullable(), + apiKeyFilename: z.string().nullable(), + }), + }, + DELETE_API_CLIENT: { + input: z.object({ + clientID: z.string(), + }), + output: z.object({}), + }, } satisfies Record; export const AnonActionSignatures = { diff --git a/typescript/server/src/server/router/api/v1/auth/router.ts b/typescript/server/src/server/router/api/v1/auth/router.ts index 2d448912a..cb63239f8 100644 --- a/typescript/server/src/server/router/api/v1/auth/router.ts +++ b/typescript/server/src/server/router/api/v1/auth/router.ts @@ -17,7 +17,7 @@ import { AggressiveRateLimitMiddleware, HyperAggressiveRateLimitMiddleware, } from "#server/middleware/rate-limiter"; -import { actionErrorToResponse, apiSuccess } from "#utils/response"; +import { apiSuccess } from "#utils/response"; import { FormatUserDoc, GetSettingsForUser, @@ -187,23 +187,18 @@ router.post( username: string; }; - let newUser: { userID: number }; - try { - newUser = await ANON_ACTION_Register( - { - ip: req.ip, - }, - { - email: body.email, - "!password": body["!password"], - inviteCode: body.inviteCode ?? null, - captcha: body.captcha, - username: body.username, - }, - ); - } catch (err) { - return actionErrorToResponse(res, err); - } + const newUser = await ANON_ACTION_Register( + { + ip: req.ip, + }, + { + email: body.email, + "!password": body["!password"], + inviteCode: body.inviteCode ?? null, + captcha: body.captcha, + username: body.username, + }, + ); const user = await GetUserWithID(newUser.userID); @@ -245,11 +240,7 @@ router.post( code: string; }; - try { - await ANON_ACTION_VerifyEmail({ ip: req.ip }, { code: body.code }); - } catch (err) { - return actionErrorToResponse(res, err); - } + await ANON_ACTION_VerifyEmail({ ip: req.ip }, { code: body.code }); return res.status(200).json({ success: true, diff --git a/typescript/server/src/server/router/api/v1/clients/middleware.ts b/typescript/server/src/server/router/api/v1/clients/middleware.ts index 182ab3a85..b8cc0bcea 100644 --- a/typescript/server/src/server/router/api/v1/clients/middleware.ts +++ b/typescript/server/src/server/router/api/v1/clients/middleware.ts @@ -1,21 +1,10 @@ import type { RequestHandler } from "express"; -import type { TachiAPIClientDocument } from "tachi-common"; -import { Env } from "#lib/setup/config"; -import MONGODB_KILL from "#services/mongo/db"; -import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; +import { GetClientByID } from "#utils/queries/api-clients"; +import { AssignToReqTachiData } from "#utils/req-tachi-data"; export const GetClientFromID: RequestHandler = async (req, res, next) => { - const client = await MONGODB_KILL["api-clients"].findOne( - { - clientID: req.params.clientID, - }, - { - projection: { - clientSecret: 0, - }, - }, - ); + const client = await GetClientByID(req.params.clientID); if (!client) { return res.status(404).json({ @@ -24,45 +13,10 @@ export const GetClientFromID: RequestHandler = async (req, res, next) => { }); } - AssignToReqTachiData(req, { apiClientDoc: client }); + // Strip the client secret — this middleware is used for public lookups. + const { clientSecret: _secret, ...publicClient } = client; - next(); -}; - -export const RequireOwnershipOfClient: RequestHandler = (req, res, next) => { - let client: Omit; - - // @hack - // Sadly, expMiddlewareMock doesn't support mounting symbol props on - // request. To hack around this for testing, we perform this hack. - // There's an open issue for this here: https://github.com/i-like-robots/express-request-mock/issues/19 - /* istanbul ignore next */ - if ( - Env.NODE_ENV === "test" && - (req.safeBody.__terribleHackOauth2ClientDoc as TachiAPIClientDocument | undefined) - ) { - // obviously a glaring hack and security flaw - this only applies - // in testing. - client = req.safeBody.__terribleHackOauth2ClientDoc as TachiAPIClientDocument; - } else { - client = GetTachiData(req, "apiClientDoc"); - } - - const user = req.session.tachi?.user; - - if (!user) { - return res.status(401).json({ - success: false, - description: `You are not authenticated (for a session-level request, atleast).`, - }); - } - - if (user.id !== client.author) { - return res.status(403).json({ - success: false, - description: `You are not authorized to perform this action.`, - }); - } + AssignToReqTachiData(req, { apiClientDoc: publicClient }); next(); }; 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 new file mode 100644 index 000000000..b7410dd57 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/clients/router.test.ts @@ -0,0 +1,536 @@ +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 ───────────────────────────────────────────────────────────────── + +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[]; +} + +// ─── GET /api/v1/clients ────────────────────────────────────────────────────── + +describe("GET /api/v1/clients", () => { + 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/clients"); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("returns 200 with empty array when user has no clients", async () => { + const res = await mockApi.get("/api/v1/clients").set("Cookie", cookie); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body).toEqual([]); + }); + + it("returns only the authenticated user's clients", async () => { + await seedApiClient({ clientId: "CIOwn", authorId: userId, name: "My Client" }); + + const { id: otherId } = await seedUser({ username: "other_user" }); + await seedApiClient({ clientId: "CIOther", authorId: otherId, name: "Other Client" }); + + const res = await mockApi.get("/api/v1/clients").set("Cookie", cookie); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(1); + expect(res.body.body[0].clientID).toBe("CIOwn"); + }); + + it("includes clientSecret in the response (owner view)", async () => { + await seedApiClient({ clientId: "CIOwn", authorId: userId }); + + const res = await mockApi.get("/api/v1/clients").set("Cookie", cookie); + + expect(res.status).toBe(200); + expect(res.body.body[0].clientSecret).toBeDefined(); + expect(res.body.body[0].clientSecret).not.toBe(""); + }); +}); + +// ─── POST /api/v1/clients/create ───────────────────────────────────────────── + +describe("POST /api/v1/clients/create", () => { + 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.post("/api/v1/clients/create").send({ + name: "My App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score"], + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("returns 400 when name is missing", async () => { + const res = await mockApi + .post("/api/v1/clients/create") + .set("Cookie", cookie) + .send({ permissions: ["submit_score"] }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + 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: [], + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("returns 400 for invalid permission names", 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: ["not_valid"], + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("returns 400 when apiKeyTemplate is missing %%TACHI_KEY%%", async () => { + const res = await mockApi + .post("/api/v1/clients/create") + .set("Cookie", cookie) + .send({ + name: "My App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: "no-placeholder-here", + apiKeyFilename: null, + permissions: ["submit_score"], + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("creates a client and returns 200 with its data", 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: ["submit_score"], + }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body.name).toBe("My App"); + expect(res.body.body.clientID).toMatch(/^CI[0-9a-f]{40}$/u); + expect(res.body.body.clientSecret).toMatch(/^CS[0-9a-f]{40}$/u); + expect(res.body.body.author).toBe(userId); + }); + + it("persists the new client to the database", async () => { + const res = await mockApi + .post("/api/v1/clients/create") + .set("Cookie", cookie) + .send({ + name: "Persistent App", + redirectUri: null, + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, + permissions: ["submit_score"], + }); + + expect(res.status).toBe(200); + + const row = await DB.selectFrom("priv_api_client") + .select(["client_id", "name"]) + .where("client_id", "=", res.body.body.clientID) + .executeTakeFirst(); + + expect(row?.name).toBe("Persistent App"); + }); +}); + +// ─── GET /api/v1/clients/:clientID ─────────────────────────────────────────── + +describe("GET /api/v1/clients/:clientID", () => { + let userId: number; + let clientId: string; + + beforeEach(async () => { + ({ id: userId } = await seedUser({ username: "test_user" })); + clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Test Client" }); + }); + + it("returns 404 for a non-existent client", async () => { + const res = await mockApi.get("/api/v1/clients/CINonExistent"); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); + + it("returns 200 with client data (no clientSecret)", async () => { + const res = await mockApi.get(`/api/v1/clients/${clientId}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body.clientID).toBe(clientId); + expect(res.body.body.name).toBe("Test Client"); + expect(res.body.body.clientSecret).toBeUndefined(); + }); + + it("is accessible without authentication", async () => { + const res = await mockApi.get(`/api/v1/clients/${clientId}`); + + expect(res.status).toBe(200); + }); +}); + +// ─── PATCH /api/v1/clients/:clientID ───────────────────────────────────────── + +describe("PATCH /api/v1/clients/:clientID", () => { + let cookie: string[]; + let userId: number; + let clientId: string; + + beforeEach(async () => { + ({ id: userId } = await seedUser({ + username: "test_user", + withCredential: true, + withSettings: true, + })); + cookie = await loginAs("test_user"); + 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" }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("returns 404 for a non-existent client", async () => { + const res = await mockApi + .patch("/api/v1/clients/CINonExistent") + .set("Cookie", cookie) + .send({ name: "New Name" }); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); + + it("returns 403 when authenticated user does not own the client", async () => { + const { id: otherId } = await seedUser({ + username: "other_user", + withCredential: true, + withSettings: true, + }); + await seedApiClient({ clientId: "CIOther", authorId: otherId, name: "Other" }); + const otherCookie = await loginAs("other_user"); + + const res = await mockApi + .patch(`/api/v1/clients/${clientId}`) + .set("Cookie", otherCookie) + .send({ name: "Hijacked" }); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + }); + + it("returns 400 when no fields are provided", async () => { + const res = await mockApi + .patch(`/api/v1/clients/${clientId}`) + .set("Cookie", cookie) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("returns 200 and updated client when name is changed", async () => { + const res = await mockApi + .patch(`/api/v1/clients/${clientId}`) + .set("Cookie", cookie) + .send({ name: "New Name" }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body.name).toBe("New Name"); + }); + + it("persists the name change to the database", async () => { + await mockApi + .patch(`/api/v1/clients/${clientId}`) + .set("Cookie", cookie) + .send({ name: "Persisted Name" }); + + const row = await DB.selectFrom("priv_api_client") + .select("name") + .where("client_id", "=", clientId) + .executeTakeFirstOrThrow(); + + expect(row.name).toBe("Persisted Name"); + }); + + it("returns 400 when apiKeyTemplate is missing %%TACHI_KEY%%", async () => { + const res = await mockApi + .patch(`/api/v1/clients/${clientId}`) + .set("Cookie", cookie) + .send({ apiKeyTemplate: "no-placeholder" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); +}); + +// ─── POST /api/v1/clients/:clientID/reset-secret ───────────────────────────── + +describe("POST /api/v1/clients/:clientID/reset-secret", () => { + let cookie: string[]; + let userId: number; + let clientId: string; + + beforeEach(async () => { + ({ id: userId } = await seedUser({ + username: "test_user", + withCredential: true, + withSettings: true, + })); + cookie = await loginAs("test_user"); + clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId }); + }); + + it("returns 401 when not authenticated", async () => { + const res = await mockApi.post(`/api/v1/clients/${clientId}/reset-secret`); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("returns 404 for a non-existent client", async () => { + const res = await mockApi + .post("/api/v1/clients/CINonExistent/reset-secret") + .set("Cookie", cookie); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); + + it("returns 403 when authenticated user does not own the client", async () => { + const { id: otherId } = await seedUser({ + username: "other_user", + withCredential: true, + withSettings: true, + }); + await seedApiClient({ clientId: "CIOther", authorId: otherId }); + const otherCookie = await loginAs("other_user"); + + const res = await mockApi + .post(`/api/v1/clients/${clientId}/reset-secret`) + .set("Cookie", otherCookie); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + }); + + it("returns 200 with the updated client including a new secret", async () => { + const res = await mockApi + .post(`/api/v1/clients/${clientId}/reset-secret`) + .set("Cookie", cookie); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body.clientSecret).toMatch(/^CS[0-9a-f]{40}$/u); + }); + + it("persists the new secret to the database", async () => { + const before = await DB.selectFrom("priv_api_client") + .select("client_secret") + .where("client_id", "=", clientId) + .executeTakeFirstOrThrow(); + + const res = await mockApi + .post(`/api/v1/clients/${clientId}/reset-secret`) + .set("Cookie", cookie); + + expect(res.status).toBe(200); + + const after = await DB.selectFrom("priv_api_client") + .select("client_secret") + .where("client_id", "=", clientId) + .executeTakeFirstOrThrow(); + + expect(after.client_secret).not.toBe(before.client_secret); + expect(after.client_secret).toBe(res.body.body.clientSecret); + }); + + 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); + + const token = await DB.selectFrom("priv_api_token") + .select("token") + .where("token", "=", "T_should_survive") + .executeTakeFirst(); + + expect(token).toBeDefined(); + }); +}); + +// ─── DELETE /api/v1/clients/:clientID ──────────────────────────────────────── + +describe("DELETE /api/v1/clients/:clientID", () => { + let cookie: string[]; + let userId: number; + let clientId: string; + + beforeEach(async () => { + ({ id: userId } = await seedUser({ + username: "test_user", + withCredential: true, + withSettings: true, + })); + cookie = await loginAs("test_user"); + clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId }); + }); + + it("returns 401 when not authenticated", async () => { + const res = await mockApi.delete(`/api/v1/clients/${clientId}`); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("returns 404 for a non-existent client", async () => { + const res = await mockApi + .delete("/api/v1/clients/CINonExistent") + .set("Cookie", cookie); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); + + it("returns 403 when authenticated user does not own the client", async () => { + const { id: otherId } = await seedUser({ + username: "other_user", + withCredential: true, + withSettings: true, + }); + await seedApiClient({ clientId: "CIOther", authorId: otherId }); + const otherCookie = await loginAs("other_user"); + + 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); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const row = await DB.selectFrom("priv_api_client") + .select("client_id") + .where("client_id", "=", clientId) + .executeTakeFirst(); + + expect(row).toBeUndefined(); + }); + + it("removes all api tokens associated with the deleted client", async () => { + await seedApiToken({ token: "T_linked_token", userId, fromClient: clientId }); + + await mockApi.delete(`/api/v1/clients/${clientId}`).set("Cookie", cookie); + + const token = await DB.selectFrom("priv_api_token") + .select("token") + .where("token", "=", "T_linked_token") + .executeTakeFirst(); + + expect(token).toBeUndefined(); + }); + + it("does not remove tokens belonging to other clients", async () => { + const { id: otherId } = await seedUser({ username: "other_user" }); + await seedApiClient({ clientId: "CIOther", authorId: otherId }); + await seedApiToken({ token: "T_other_token", userId: otherId, fromClient: "CIOther" }); + + await mockApi.delete(`/api/v1/clients/${clientId}`).set("Cookie", cookie); + + const preserved = await DB.selectFrom("priv_api_token") + .select("token") + .where("token", "=", "T_other_token") + .executeTakeFirst(); + + expect(preserved).toBeDefined(); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/clients/router.ts b/typescript/server/src/server/router/api/v1/clients/router.ts index eb1764c37..8bc4fbb23 100644 --- a/typescript/server/src/server/router/api/v1/clients/router.ts +++ b/typescript/server/src/server/router/api/v1/clients/router.ts @@ -1,21 +1,16 @@ -import { log } from "#lib/log/log"; -import { ServerConfig } from "#lib/setup/config"; +import { ACTION_CreateApiClient } from "#actions/create-api-client"; +import { ACTION_DeleteApiClient } from "#actions/delete-api-client"; +import { ACTION_ResetApiClientSecret } from "#actions/reset-api-client-secret"; +import { ACTION_UpdateApiClient } from "#actions/update-api-client"; +import { SELECT_API_CLIENT, ToAPIClientDocument } from "#lib/db-formats/api-client"; import prValidate from "#server/middleware/prudence-validate"; -import MONGODB_KILL from "#services/mongo/db"; -import { DedupeArr, DeleteUndefinedProps, IsValidURL, Random20Hex } from "#utils/misc"; -import { optNull } from "#utils/prudence"; +import DB from "#services/pg/db"; import { GetTachiData } from "#utils/req-tachi-data"; -import { FormatUserDoc } from "#utils/user"; import { Router } from "express"; import { p } from "prudence"; -import { - ALL_PERMISSIONS, - type APIPermissions, - type TachiAPIClientDocument, - UserAuthLevels, -} from "tachi-common"; +import { ALL_PERMISSIONS, type APIPermissions } from "tachi-common"; -import { GetClientFromID, RequireOwnershipOfClient } from "./middleware"; +import { GetClientFromID } from "./middleware"; const router: Router = Router({ mergeParams: true }); @@ -36,9 +31,12 @@ router.get("/", async (req, res) => { }); } - const clients = await MONGODB_KILL["api-clients"].find({ - author: user.id, - }); + const rows = await DB.selectFrom("priv_api_client") + .select(SELECT_API_CLIENT) + .where("author", "=", user.id) + .execute(); + + const clients = rows.map(ToAPIClientDocument); return res.status(200).json({ success: true, @@ -66,26 +64,14 @@ router.post( name: p.isBoundedString(3, 80), redirectUri: "?string", webhookUri: "?string", - apiKeyTemplate: (self) => { - if (self === null) { - return true; - } - - if (typeof self !== "string") { - return "Expected a string."; - } - - if (!self.includes("%%TACHI_KEY%%")) { - return "Must contain %%TACHI_KEY%% as part of the template."; - } - - return true; - }, + apiKeyTemplate: "?string", apiKeyFilename: "?string", permissions: [p.isIn(Object.keys(ALL_PERMISSIONS))], }), async (req, res) => { - if (!req.session.tachi?.user) { + const user = req.session.tachi?.user; + + if (!user) { return res.status(401).json({ success: false, description: `You are not authenticated.`, @@ -101,66 +87,16 @@ router.post( webhookUri: string | null; }; - const existingClients = await MONGODB_KILL["api-clients"].find({ - author: req.session.tachi.user.id, - }); + const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; - // Note: Admins are excluded from the API client cap. - if ( - req.session.tachi.user.authLevel !== UserAuthLevels.ADMIN && - existingClients.length >= ServerConfig.OAUTH_CLIENT_CAP - ) { - return res.status(400).json({ - success: false, - description: `You have created too many API clients. The current cap is ${ServerConfig.OAUTH_CLIENT_CAP}.`, - }); - } - - const permissions = DedupeArr(body.permissions); - - if (permissions.length === 0) { - return res.status(400).json({ - success: false, - description: `Invalid permissions -- Need to require atleast one.`, - }); - } - - if (body.redirectUri !== null && !IsValidURL(body.redirectUri)) { - return res.status(400).json({ - success: false, - description: `Invalid Redirect URL.`, - }); - } - - if (body.webhookUri !== null && !IsValidURL(body.webhookUri)) { - return res.status(400).json({ - success: false, - description: `Invalid Webhook URL.`, - }); - } - - const clientID = `CI${Random20Hex()}`; - const clientSecret = `CS${Random20Hex()}`; - - const clientDoc: TachiAPIClientDocument = { - clientID, - clientSecret, - requestedPermissions: permissions, + const clientDoc = await ACTION_CreateApiClient(taker, { name: body.name, - author: req.session.tachi.user.id, redirectUri: body.redirectUri, - webhookUri: body.webhookUri ?? null, - apiKeyFilename: body.apiKeyFilename ?? null, - apiKeyTemplate: body.apiKeyTemplate ?? null, - }; - - await MONGODB_KILL["api-clients"].insert(clientDoc); - - log.info( - `User ${FormatUserDoc(req.session.tachi.user)} created a new API Client ${ - body.name - } (${clientID}).`, - ); + webhookUri: body.webhookUri, + apiKeyTemplate: body.apiKeyTemplate, + apiKeyFilename: body.apiKeyFilename, + permissions: body.permissions, + }); return res.status(200).json({ success: true, @@ -192,94 +128,49 @@ router.get("/:clientID", GetClientFromID, (req, res) => { * @param name - Change the name of this client. * @param webhookUri - Change a bound webhookUri for this client. * @param redirectUri - Change a bound redirectUri for this client. - * @param apiKeyFormat - Change the APIKeyFormat for this client. + * @param apiKeyTemplate - Change the APIKeyTemplate for this client. * @param apiKeyFilename - Change the APIKeyFilename for this client. * * @name PATCH /api/v1/clients/:clientID */ router.patch( "/:clientID", - GetClientFromID, - RequireOwnershipOfClient, prValidate({ name: p.optional(p.isBoundedString(3, 80)), - apiKeyTemplate: optNull((self) => { - if (typeof self !== "string") { - return "Expected a string."; - } - - if (!self.includes("%%TACHI_KEY%%")) { - return "Must contain a %%TACHI_KEY%% placeholder."; - } - - return true; - }), - apiKeyFilename: optNull(p.isBoundedString(3, 80)), - webhookUri: optNull((self) => { - if (typeof self !== "string") { - return "Expected a string."; - } - - const res = IsValidURL(self); - - if (!res) { - return "Invalid URL."; - } - - return true; - }), - redirectUri: optNull((self) => { - if (typeof self !== "string") { - return "Expected a string."; - } - - const res = IsValidURL(self); - - if (!res) { - return "Invalid URL."; - } - - return true; - }), + apiKeyTemplate: "?string", + apiKeyFilename: p.optional(p.isBoundedString(3, 80)), + webhookUri: "?string", + redirectUri: "?string", }), async (req, res) => { + const user = req.session.tachi?.user; + + if (!user) { + return res.status(401).json({ + success: false, + description: `You are not authenticated.`, + }); + } + const body = req.safeBody as { apiKeyFilename?: string | null; apiKeyTemplate?: string | null; name?: string; - permissions?: Array; redirectUri?: string | null; webhookUri?: string | null; }; - const client = GetTachiData(req, "apiClientDoc"); + const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; - DeleteUndefinedProps(req.safeBody); - - if (Object.keys(req.safeBody).length === 0) { - return res.status(400).json({ - success: false, - description: `No changes to make.`, - }); - } - - const newClient = await MONGODB_KILL["api-clients"].findOneAndUpdate( - { - clientID: client.clientID, - }, - { - $set: req.safeBody, - }, - ); - - log.info( - `API Client ${client.name} (${client.clientID}) has been renamed to ${body.name}.`, - ); + const updatedClient = await ACTION_UpdateApiClient(taker, { + clientID: req.params.clientID, + ...body, + }); return res.status(200).json({ success: true, description: `Updated client.`, - body: newClient, + body: updatedClient, }); }, ); @@ -290,65 +181,51 @@ router.patch( * * @name POST /api/v1/clients/:clientID/reset-secret */ -router.post( - "/:clientID/reset-secret", - GetClientFromID, - RequireOwnershipOfClient, - async (req, res) => { - const client = GetTachiData(req, "apiClientDoc"); - const clientName = `${client.name} (${client.clientID})`; +router.post("/:clientID/reset-secret", async (req, res) => { + const user = req.session.tachi?.user; - log.info(`received request to reset client secret for ${clientName}`); - - const newSecret = Random20Hex(); - - const newClient = await MONGODB_KILL["api-clients"].findOneAndUpdate( - { - clientID: client.clientID, - }, - { - $set: { clientSecret: newSecret }, - }, - ); - - log.info(`Reset secret for ${clientName}.`); - - return res.status(200).json({ - success: true, - description: `Reset secret.`, - body: newClient, + if (!user) { + return res.status(401).json({ + success: false, + description: `You are not authenticated.`, }); - }, -); + } + + const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; + + const updatedClient = await ACTION_ResetApiClientSecret(taker, { + clientID: req.params.clientID, + }); + + return res.status(200).json({ + success: true, + description: `Reset secret.`, + body: updatedClient, + }); +}); /** * Delete this client. Must be authorized at a session-request level. * * @name DELETE /api/v1/clients/:clientID */ -router.delete("/:clientID", GetClientFromID, RequireOwnershipOfClient, async (req, res) => { - const client = GetTachiData(req, "apiClientDoc"); +router.delete("/:clientID", async (req, res) => { + const user = req.session.tachi?.user; - const clientName = `${client.name} (${client.clientID})`; + if (!user) { + return res.status(401).json({ + success: false, + description: `You are not authenticated.`, + }); + } - log.info(`received request to destroy API Client ${client.name} (${client.clientID})`); + const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; - log.debug(`Removing API Client ${clientName}.`); - await MONGODB_KILL["api-clients"].remove({ - clientID: client.clientID, - }); - log.info(`Removed API Client ${clientName}.`); - - log.debug(`Removing all associated api tokens.`); - const result = await MONGODB_KILL["api-tokens"].remove({ - fromOAuth2Client: client.clientID, - }); - - log.info(`Removed ${result.deletedCount} api tokens from ${clientName}.`); + await ACTION_DeleteApiClient(taker, { clientID: req.params.clientID }); return res.status(200).json({ success: true, - description: `Deleted ${clientName}.`, + description: `Deleted client ${req.params.clientID}.`, body: {}, }); }); diff --git a/typescript/server/src/server/server.ts b/typescript/server/src/server/server.ts index 866065039..96f233e25 100644 --- a/typescript/server/src/server/server.ts +++ b/typescript/server/src/server/server.ts @@ -9,6 +9,7 @@ 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 ExpressPromBundle from "express-prom-bundle"; @@ -205,6 +206,16 @@ const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, _next) => // else, this isn't a JSON parsing error } + // Action errors (ExpectedErr) carry an HTTP status code and a user-facing + // reason. They are intentional control-flow throws and should not be logged + // as fatal errors. + if (ExpectedErr.is(err)) { + return res.status(err.code).json({ + success: false, + description: err.reason, + }); + } + log.error({ url: req.originalUrl, body: req.body }, `MAIN_ERR_HANDLER hit by request.`); const unknownErr = err as unknown; diff --git a/typescript/server/src/utils/response.ts b/typescript/server/src/utils/response.ts index 0e4ad328a..f2bb475eb 100644 --- a/typescript/server/src/utils/response.ts +++ b/typescript/server/src/utils/response.ts @@ -1,5 +1,3 @@ -import { ExpectedErr } from "bliss"; - import { type TachiAPIFailResponse } from "./types"; export function apiSuccess( @@ -25,19 +23,3 @@ export function apiFail(description: string): { success: false, }; } - -import { type Response } from "express"; - -export function actionErrorToResponse(res: Response, err: unknown) { - if (ExpectedErr.is(err)) { - return res.status(err.code).json({ - success: false, - description: err.reason, - }); - } - - return res.status(500).json({ - success: false, - description: "An internal server error has occured.", - }); -}