feat: client stuff to postgres

This commit is contained in:
zk
2026-03-24 23:57:21 +00:00
parent 33ee32d474
commit bd8a8299bc
16 changed files with 1720 additions and 297 deletions
@@ -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<Props,
});
const [url, setUrl] = useState<string>("");
const [valid, setValid] = useState<boolean | null>(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 (
@@ -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");
});
});
@@ -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<APIPermissions>) {
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<APIPermissions>;
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,
};
},
);
@@ -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");
});
});
@@ -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 {};
},
);
@@ -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");
});
});
@@ -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;
},
);
@@ -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");
});
});
@@ -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;
},
);
@@ -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<string, ActionSignature>;
export const AnonActionSignatures = {
@@ -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,
@@ -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<TachiAPIClientDocument, "clientSecret">;
// @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();
};
@@ -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();
});
});
@@ -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<APIPermissions>(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<APIPermissions>;
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: {},
});
});
+11
View File
@@ -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;
-18
View File
@@ -1,5 +1,3 @@
import { ExpectedErr } from "bliss";
import { type TachiAPIFailResponse } from "./types";
export function apiSuccess<T = never>(
@@ -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.",
});
}