diff --git a/typescript/bliss/package.json b/typescript/bliss/package.json index 55d82fb03..1798d600e 100644 --- a/typescript/bliss/package.json +++ b/typescript/bliss/package.json @@ -43,11 +43,4 @@ "eslint-config-tachi": "workspace:*", "typescript": "catalog:" }, - "imports": { - "#*": [ - "./src/*", - "./src/*.ts", - "./src/*/index.ts" - ] - } } diff --git a/typescript/bliss/src/actions.ts b/typescript/bliss/src/actions.ts index b10b69932..2bb32615a 100644 --- a/typescript/bliss/src/actions.ts +++ b/typescript/bliss/src/actions.ts @@ -1,4 +1,4 @@ -import { AppendLogCtx, log as baseLogger } from "#log.js"; +import { AppendLogCtx, log as baseLogger } from "./log.js"; import { type ZodObject } from "zod"; export type ActionResult = "BAD" | "GOOD" | "THROW"; diff --git a/typescript/bliss/src/index.ts b/typescript/bliss/src/index.ts index d2b0acd7b..0d54c1bbc 100644 --- a/typescript/bliss/src/index.ts +++ b/typescript/bliss/src/index.ts @@ -1,9 +1,3 @@ -export { - AcctInfo, - ActionResult, - ActionTaker, - AnonActionTaker, - ExpectedErr, - MakeActionGuts, -} from "./actions"; +export type { AcctInfo, ActionResult, ActionTaker, AnonActionTaker } from "./actions"; +export { ExpectedErr, MakeActionGuts } from "./actions"; export * from "./log"; diff --git a/typescript/bliss/tsconfig.json b/typescript/bliss/tsconfig.json index e8eb41596..66cd56946 100644 --- a/typescript/bliss/tsconfig.json +++ b/typescript/bliss/tsconfig.json @@ -3,9 +3,6 @@ "compilerOptions": { "module": "Preserve", "moduleResolution": "bundler", - "paths": { - "#*": ["./src/*"] - } }, "include": ["src"], "exclude": ["node_modules"] diff --git a/typescript/common/package.json b/typescript/common/package.json index 3c95739af..02b2f5315 100644 --- a/typescript/common/package.json +++ b/typescript/common/package.json @@ -60,11 +60,4 @@ "src/external/mongo/schemas.ts" ] }, - "imports": { - "#*": [ - "./src/*", - "./src/*.ts", - "./src/*/index.ts" - ] - } } diff --git a/typescript/common/src/lib/zod-schemas.ts b/typescript/common/src/lib/zod-schemas.ts index e35468479..ae3ee6aed 100644 --- a/typescript/common/src/lib/zod-schemas.ts +++ b/typescript/common/src/lib/zod-schemas.ts @@ -1,4 +1,5 @@ -import { ALL_PERMISSIONS } from "#constants/permissions.js"; import z from "zod"; +import { ALL_PERMISSIONS } from "../constants/permissions"; + export const zodPermission = z.enum(Object.keys(ALL_PERMISSIONS)); diff --git a/typescript/common/tsconfig.json b/typescript/common/tsconfig.json index e8eb41596..66cd56946 100644 --- a/typescript/common/tsconfig.json +++ b/typescript/common/tsconfig.json @@ -3,9 +3,6 @@ "compilerOptions": { "module": "Preserve", "moduleResolution": "bundler", - "paths": { - "#*": ["./src/*"] - } }, "include": ["src"], "exclude": ["node_modules"] diff --git a/typescript/server/src/actions/install-builtin-client.test.ts b/typescript/server/src/actions/install-builtin-client.test.ts new file mode 100644 index 000000000..7528606b5 --- /dev/null +++ b/typescript/server/src/actions/install-builtin-client.test.ts @@ -0,0 +1,254 @@ +import DB from "#services/pg/db.js"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { ACTION_InstallBuiltinClient } from "./install-builtin-client.js"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function seedUser(username: string, authLevel: "admin" | "user" = "user") { + const { id } = await DB.insertInto("account") + .values({ username, about: "Test user.", auth_level: authLevel }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { id: Number(id), username }; +} + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const BASE_PERMISSIONS = { + customise_profile: false, + customise_score: false, + customise_session: false, + delete_score: false, + manage_rivals: false, + manage_targets: false, + submit_score: true, + manage_challenges: false, +}; + +const BASE_INPUT = { + clientID: "CXTestClient", + name: "Test Client", + permissions: BASE_PERMISSIONS, + apiKeyFilename: "test-client.json", + apiKeyTemplate: '{"token": "%%TACHI_KEY%%"}', + webhookUri: null, + redirectUri: null, +}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("ACTION_InstallBuiltinClient", () => { + let adminId: number; + let userId: number; + + beforeEach(async () => { + const admin = await seedUser("admin_user", "admin"); + adminId = admin.id; + + const user = await seedUser("regular_user", "user"); + userId = user.id; + }); + + // ── Authorization ────────────────────────────────────────────────────────── + + it("throws with code 403 when the taker is not an admin", async () => { + const taker = { ip: null, acct: { id: userId, username: "regular_user" } }; + + await expect(ACTION_InstallBuiltinClient(taker, BASE_INPUT)).rejects.toMatchObject({ + code: 403, + }); + }); + + it("writes a BAD action row when the taker is not an admin", async () => { + const taker = { ip: null, acct: { id: userId, username: "regular_user" } }; + + await expect(ACTION_InstallBuiltinClient(taker, BASE_INPUT)).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "INSTALL_BUILTIN_CLIENT") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); + + it("does not insert a client when the taker is not an admin", async () => { + const taker = { ip: null, acct: { id: userId, username: "regular_user" } }; + + await expect(ACTION_InstallBuiltinClient(taker, BASE_INPUT)).rejects.toThrow(); + + const clients = await DB.selectFrom("priv_api_client") + .select("client_id") + .where("client_id", "=", BASE_INPUT.clientID) + .execute(); + + expect(clients).toHaveLength(0); + }); + + // ── Success path ─────────────────────────────────────────────────────────── + + it("returns an empty object on success", async () => { + const taker = { ip: null, acct: { id: adminId, username: "admin_user" } }; + + const result = await ACTION_InstallBuiltinClient(taker, BASE_INPUT); + + expect(result).toEqual({}); + }); + + it("inserts the client with the correct fields", async () => { + const taker = { ip: null, acct: { id: adminId, username: "admin_user" } }; + + await ACTION_InstallBuiltinClient(taker, BASE_INPUT); + + const client = await DB.selectFrom("priv_api_client") + .selectAll() + .where("client_id", "=", BASE_INPUT.clientID) + .executeTakeFirst(); + + expect(client).toMatchObject({ + client_id: "CXTestClient", + name: "Test Client", + author: String(adminId), + pm_submit_score: true, + pm_customise_profile: false, + pm_customise_score: false, + pm_customise_session: false, + pm_delete_score: false, + pm_manage_rivals: false, + pm_manage_targets: false, + pm_manage_challenges: false, + api_key_filename: "test-client.json", + api_key_template: '{"token": "%%TACHI_KEY%%"}', + webhook_uri: null, + redirect_uri: null, + }); + }); + + it("always marks the client as is_builtin: true", async () => { + const taker = { ip: null, acct: { id: adminId, username: "admin_user" } }; + + await ACTION_InstallBuiltinClient(taker, BASE_INPUT); + + const client = await DB.selectFrom("priv_api_client") + .select("is_builtin") + .where("client_id", "=", BASE_INPUT.clientID) + .executeTakeFirstOrThrow(); + + expect(client.is_builtin).toBe(true); + }); + + it("generates a non-empty client secret", async () => { + const taker = { ip: null, acct: { id: adminId, username: "admin_user" } }; + + await ACTION_InstallBuiltinClient(taker, BASE_INPUT); + + const client = await DB.selectFrom("priv_api_client") + .select("client_secret") + .where("client_id", "=", BASE_INPUT.clientID) + .executeTakeFirstOrThrow(); + + expect(client.client_secret).toMatch(/^CS[0-9a-f]{40}$/u); + }); + + it("stores null for optional fields when they are not provided", async () => { + const taker = { ip: null, acct: { id: adminId, username: "admin_user" } }; + + await ACTION_InstallBuiltinClient(taker, { + ...BASE_INPUT, + apiKeyFilename: null, + apiKeyTemplate: null, + webhookUri: null, + redirectUri: null, + }); + + const client = await DB.selectFrom("priv_api_client") + .selectAll() + .where("client_id", "=", BASE_INPUT.clientID) + .executeTakeFirstOrThrow(); + + expect(client.api_key_filename).toBeNull(); + expect(client.api_key_template).toBeNull(); + expect(client.webhook_uri).toBeNull(); + expect(client.redirect_uri).toBeNull(); + }); + + // ── Upsert behaviour ─────────────────────────────────────────────────────── + + it("updates the existing row when called again with the same clientID", async () => { + const taker = { ip: null, acct: { id: adminId, username: "admin_user" } }; + + await ACTION_InstallBuiltinClient(taker, BASE_INPUT); + await ACTION_InstallBuiltinClient(taker, { ...BASE_INPUT, name: "Updated Name" }); + + const clients = await DB.selectFrom("priv_api_client") + .select(["client_id", "name"]) + .where("client_id", "=", BASE_INPUT.clientID) + .execute(); + + expect(clients).toHaveLength(1); + expect(clients[0]?.name).toBe("Updated Name"); + }); + + it("uses the new author on upsert", async () => { + const first = await seedUser("first_admin", "admin"); + const second = await seedUser("second_admin", "admin"); + + await ACTION_InstallBuiltinClient( + { ip: null, acct: { id: first.id, username: first.username } }, + BASE_INPUT, + ); + + await ACTION_InstallBuiltinClient( + { ip: null, acct: { id: second.id, username: second.username } }, + BASE_INPUT, + ); + + const client = await DB.selectFrom("priv_api_client") + .select("author") + .where("client_id", "=", BASE_INPUT.clientID) + .executeTakeFirstOrThrow(); + + expect(client.author).toBe(String(second.id)); + }); + + // ── Audit log ────────────────────────────────────────────────────────────── + + it("writes a GOOD action row to the audit log on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: adminId, username: "admin_user" } }; + + await ACTION_InstallBuiltinClient(taker, BASE_INPUT); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "INSTALL_BUILTIN_CLIENT") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + app: "TACHI_SERVER", + kind: "INSTALL_BUILTIN_CLIENT", + result: "GOOD", + user_id: String(adminId), + ip: "10.0.0.1", + }); + }); + + it("includes the client details in the audit log input", async () => { + const taker = { ip: null, acct: { id: adminId, username: "admin_user" } }; + + await ACTION_InstallBuiltinClient(taker, BASE_INPUT); + + const action = await DB.selectFrom("action") + .select("input") + .where("kind", "=", "INSTALL_BUILTIN_CLIENT") + .executeTakeFirstOrThrow(); + + const input = action.input as Record; + + expect(input).toMatchObject({ + clientID: BASE_INPUT.clientID, + name: BASE_INPUT.name, + }); + }); +}); diff --git a/typescript/server/src/actions/install-builtin-client.ts b/typescript/server/src/actions/install-builtin-client.ts index c5875fbbb..bc661595d 100644 --- a/typescript/server/src/actions/install-builtin-client.ts +++ b/typescript/server/src/actions/install-builtin-client.ts @@ -40,8 +40,8 @@ export const ACTION_InstallBuiltinClient = MakeAction( redirect_uri: redirectUri, is_builtin: true, }) - .onConflict((oc) => - oc.doUpdateSet({ + .onConflict((oc) => + oc.column("client_id").doUpdateSet({ client_secret: clientSecret, name: name, author: taker.acct.id, diff --git a/typescript/server/src/lib/builtin-clients/builtin-clients.ts b/typescript/server/src/lib/builtin-clients/builtin-clients.ts index 50b68e1fe..2a67151de 100644 --- a/typescript/server/src/lib/builtin-clients/builtin-clients.ts +++ b/typescript/server/src/lib/builtin-clients/builtin-clients.ts @@ -2,7 +2,7 @@ import { ACTION_InstallBuiltinClient } from "#actions/install-builtin-client.js" import { log } from "#lib/log/log.js"; import { ServerConfig, TachiConfig } from "#lib/setup/config"; import _ from "lodash"; -import { type TachiAPIClientDocument } from "tachi-common"; +import { type APIPermissions, type TachiAPIClientDocument } from "tachi-common"; /* eslint-disable no-await-in-loop */ import { GetClientByID } from "#utils/queries/api-clients.js"; import { GetFirstAdmin } from "#utils/user.js"; @@ -319,6 +319,11 @@ async function LoadClients(clients: DefaultClients) { } } + const permissionsObject: Partial> = {}; + for (const permission of client.requestedPermissions) { + permissionsObject[permission] = true; + } + await ACTION_InstallBuiltinClient( { ip: null, @@ -330,16 +335,7 @@ async function LoadClients(clients: DefaultClients) { { clientID: client.clientID, name: client.name, - permissions: { - customise_profile: false, - customise_score: false, - customise_session: false, - delete_score: false, - manage_rivals: false, - manage_targets: false, - submit_score: false, - manage_challenges: false, - }, + permissions: permissionsObject, apiKeyFilename: client.apiKeyFilename, apiKeyTemplate: client.apiKeyTemplate, redirectUri: client.redirectUri, diff --git a/typescript/server/vitest.config.ts b/typescript/server/vitest.config.ts index 02151b8c8..861d2f3b6 100644 --- a/typescript/server/vitest.config.ts +++ b/typescript/server/vitest.config.ts @@ -1,23 +1,52 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { defineConfig } from "vitest/config"; +import { defineConfig, type Plugin } from "vitest/config"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Read the test server config as a raw string — config.ts will JSON5-parse it at load time. const tachiConfig = fs.readFileSync(path.join(__dirname, "test.conf.json5"), "utf-8"); -export default defineConfig({ - resolve: { - // Map #* path aliases to src/* so vite-node resolves them correctly. - alias: [ - { - find: /^#(.+)/u, - replacement: `${path.resolve(__dirname, "src")}/$1`, - }, - ], +// Workspace root: /tachi/typescript/ +const TYPESCRIPT_ROOT = path.resolve(__dirname, ".."); + +/** + * Resolve '#*' package-level imports (Node.js 'imports' field) correctly for + * every workspace package. Without this, vitest's alias applies the server's + * src/ root to '#*' imports made inside other workspace packages (e.g. bliss, + * tachi-common), causing "Cannot find module" errors. + * + * Strategy: derive the package root from the importer's absolute path, then + * resolve the specifier relative to that package's src/ directory. + */ +const workspaceHashAliasPlugin: Plugin = { + name: "workspace-hash-alias", + resolveId(source, importer) { + if (!source.startsWith("#")) { + return null; + } + + const specifier = source.slice(1); + + // When the importer is inside /tachi/typescript//…, resolve + // '#specifier' relative to /tachi/typescript//src/. + if (importer) { + const rel = path.relative(TYPESCRIPT_ROOT, importer); + + if (!rel.startsWith("..")) { + const pkgName = rel.split(path.sep)[0]; + return path.join(TYPESCRIPT_ROOT, pkgName, "src", specifier); + } + } + + // Fallback: resolve relative to the server's own src/. + return path.join(__dirname, "src", specifier); }, +}; + +export default defineConfig({ + plugins: [workspaceHashAliasPlugin], test: { // Static env vars. POSTGRES_URL is set dynamically per-worker in vitest.setup.ts