Add read/write API token types (#2756)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kalle 2026-01-25 13:55:16 +02:00 committed by GitHub
parent a57d1a71d2
commit 57892fb571
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 290 additions and 89 deletions

View File

@ -42,5 +42,3 @@ VITE_SHOW_LUTI_NAV_ITEM=false
VITE_VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_EMAIL=
PUBLIC_API_TOKENS=secret,secret2

View File

@ -961,10 +961,13 @@ export interface UserFriendCode {
createdAt: GeneratedAlways<number>;
}
export type ApiTokenType = "read" | "write";
export interface ApiToken {
id: GeneratedAlways<number>;
userId: number;
token: string;
type: Generated<ApiTokenType>;
createdAt: GeneratedAlways<number>;
}

View File

@ -1,9 +1,16 @@
import type { ApiTokenType } from "~/db/tables";
import * as ApiRepository from "~/features/api/ApiRepository.server";
async function loadApiTokensCache() {
const envTokens = process.env.PUBLIC_API_TOKENS?.split(",") ?? [];
const dbTokens = await ApiRepository.allApiTokens();
return new Set([...envTokens, ...dbTokens]);
const tokenMap = new Map<string, ApiTokenType>();
for (const { token, type } of dbTokens) {
tokenMap.set(token, type);
}
return tokenMap;
}
let apiTokens = await loadApiTokensCache();
@ -12,12 +19,16 @@ export async function refreshApiTokensCache() {
apiTokens = await loadApiTokensCache();
}
export function requireBearerAuth(req: Request) {
function extractToken(req: Request) {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
throw new Response("Missing Authorization header", { status: 401 });
}
const token = authHeader.replace("Bearer ", "");
return authHeader.replace("Bearer ", "");
}
export function requireBearerAuth(req: Request) {
const token = extractToken(req);
if (!apiTokens.has(token)) {
throw new Response("Invalid token", { status: 401 });
}

View File

@ -12,15 +12,15 @@ describe("findTokenByUserId", () => {
});
test("returns undefined when user has no token", async () => {
const result = await ApiRepository.findTokenByUserId(1);
const result = await ApiRepository.findTokenByUserId(1, "read");
expect(result).toBeUndefined();
});
test("finds existing token for user", async () => {
await ApiRepository.generateToken(1);
await ApiRepository.generateToken(1, "read");
const result = await ApiRepository.findTokenByUserId(1);
const result = await ApiRepository.findTokenByUserId(1, "read");
expect(result).toBeDefined();
expect(result?.userId).toBe(1);
@ -28,16 +28,30 @@ describe("findTokenByUserId", () => {
});
test("returns correct token for specific user", async () => {
const token1 = await ApiRepository.generateToken(1);
const token2 = await ApiRepository.generateToken(2);
const token1 = await ApiRepository.generateToken(1, "read");
const token2 = await ApiRepository.generateToken(2, "read");
const result1 = await ApiRepository.findTokenByUserId(1);
const result2 = await ApiRepository.findTokenByUserId(2);
const result1 = await ApiRepository.findTokenByUserId(1, "read");
const result2 = await ApiRepository.findTokenByUserId(2, "read");
expect(result1?.token).toBe(token1.token);
expect(result2?.token).toBe(token2.token);
expect(result1?.token).not.toBe(result2?.token);
});
test("finds correct token by type", async () => {
await ApiRepository.generateToken(1, "read");
await ApiRepository.generateToken(1, "write");
const readResult = await ApiRepository.findTokenByUserId(1, "read");
const writeResult = await ApiRepository.findTokenByUserId(1, "write");
expect(readResult).toBeDefined();
expect(writeResult).toBeDefined();
expect(readResult?.token).not.toBe(writeResult?.token);
expect(readResult?.type).toBe("read");
expect(writeResult?.type).toBe("write");
});
});
describe("generateToken", () => {
@ -50,7 +64,7 @@ describe("generateToken", () => {
});
test("creates new token for user", async () => {
const result = await ApiRepository.generateToken(1);
const result = await ApiRepository.generateToken(1, "read");
expect(result.token).toBeDefined();
expect(typeof result.token).toBe("string");
@ -58,19 +72,19 @@ describe("generateToken", () => {
});
test("deletes existing token before creating new one", async () => {
const firstToken = await ApiRepository.generateToken(1);
const secondToken = await ApiRepository.generateToken(1);
const firstToken = await ApiRepository.generateToken(1, "read");
const secondToken = await ApiRepository.generateToken(1, "read");
expect(firstToken.token).not.toBe(secondToken.token);
const storedToken = await ApiRepository.findTokenByUserId(1);
const storedToken = await ApiRepository.findTokenByUserId(1, "read");
expect(storedToken?.token).toBe(secondToken.token);
});
test("generates unique tokens for different users", async () => {
const token1 = await ApiRepository.generateToken(1);
const token2 = await ApiRepository.generateToken(2);
const token3 = await ApiRepository.generateToken(3);
const token1 = await ApiRepository.generateToken(1, "read");
const token2 = await ApiRepository.generateToken(2, "read");
const token3 = await ApiRepository.generateToken(3, "read");
expect(token1.token).not.toBe(token2.token);
expect(token1.token).not.toBe(token3.token);
@ -78,17 +92,28 @@ describe("generateToken", () => {
});
test("replaces only the specific user's token", async () => {
const user1FirstToken = await ApiRepository.generateToken(1);
const user2Token = await ApiRepository.generateToken(2);
const user1SecondToken = await ApiRepository.generateToken(1);
const user1FirstToken = await ApiRepository.generateToken(1, "read");
const user2Token = await ApiRepository.generateToken(2, "read");
const user1SecondToken = await ApiRepository.generateToken(1, "read");
const result1 = await ApiRepository.findTokenByUserId(1);
const result2 = await ApiRepository.findTokenByUserId(2);
const result1 = await ApiRepository.findTokenByUserId(1, "read");
const result2 = await ApiRepository.findTokenByUserId(2, "read");
expect(result1?.token).toBe(user1SecondToken.token);
expect(result1?.token).not.toBe(user1FirstToken.token);
expect(result2?.token).toBe(user2Token.token);
});
test("allows same user to have both read and write tokens", async () => {
const readToken = await ApiRepository.generateToken(1, "read");
const writeToken = await ApiRepository.generateToken(1, "write");
const readResult = await ApiRepository.findTokenByUserId(1, "read");
const writeResult = await ApiRepository.findTokenByUserId(1, "write");
expect(readResult?.token).toBe(readToken.token);
expect(writeResult?.token).toBe(writeToken.token);
});
});
describe("allApiTokens", () => {
@ -106,12 +131,17 @@ describe("allApiTokens", () => {
expect(result).toEqual([]);
});
test("returns array of token strings", async () => {
await ApiRepository.generateToken(1);
test("returns array of token objects with type", async () => {
await ApiRepository.generateToken(1, "read");
const result = await ApiRepository.allApiTokens();
expect(Array.isArray(result)).toBe(true);
expect(result.every((token) => typeof token === "string")).toBe(true);
expect(
result.every(
(item) =>
typeof item.token === "string" && typeof item.type === "string",
),
).toBe(true);
});
});

View File

@ -1,48 +1,43 @@
import { nanoid } from "nanoid";
import { db } from "~/db/sql";
import type { ApiTokenType } from "~/db/tables";
const API_TOKEN_LENGTH = 20;
/**
* Finds an API token for the given user ID.
* @returns API token record if found, undefined otherwise
*/
export function findTokenByUserId(userId: number) {
/** Finds an API token for the given user ID and type. */
export function findTokenByUserId(userId: number, type: ApiTokenType) {
return db
.selectFrom("ApiToken")
.selectAll()
.where("userId", "=", userId)
.where("type", "=", type)
.executeTakeFirst();
}
/**
* Generates a new API token for the given user.
* Deletes any existing token for the user before creating a new one.
* @returns Object containing the newly generated token
*/
export function generateToken(userId: number) {
/** Generates a new API token for the given user. Deletes any existing token of the same type before creating a new one. */
export function generateToken(userId: number, type: ApiTokenType) {
const token = nanoid(API_TOKEN_LENGTH);
return db.transaction().execute(async (trx) => {
await trx.deleteFrom("ApiToken").where("userId", "=", userId).execute();
await trx
.deleteFrom("ApiToken")
.where("userId", "=", userId)
.where("type", "=", type)
.execute();
return trx
.insertInto("ApiToken")
.values({
userId,
token,
type,
})
.returning("token")
.executeTakeFirstOrThrow();
});
}
/**
* Retrieves all valid API tokens from users with API access.
* Includes tokens from users with the isApiAccesser flag enabled (includes supporters tier 2+),
* or users who are ADMIN, ORGANIZER, or STREAMER members of established tournament organizations.
* @returns Array of valid API token strings
*/
/** Retrieves all valid API tokens and their types from users with API access. */
export async function allApiTokens() {
const tokens = await db
.selectFrom("ApiToken")
@ -57,7 +52,7 @@ export async function allApiTokens() {
"TournamentOrganization.id",
"TournamentOrganizationMember.organizationId",
)
.select("ApiToken.token")
.select(["ApiToken.token", "ApiToken.type"])
// NOTE: permissions logic also exists in checkUserHasApiAccess function
.where((eb) =>
eb.or([
@ -77,5 +72,5 @@ export async function allApiTokens() {
.groupBy("ApiToken.token")
.execute();
return tokens.map((row) => row.token);
return tokens.map((row) => ({ token: row.token, type: row.type }));
}

View File

@ -3,12 +3,11 @@ import { z } from "zod";
import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.server";
import { requireUser } from "~/features/auth/core/user.server";
import { parseRequestPayload, successToast } from "~/utils/remix.server";
import { _action } from "~/utils/zod";
import * as ApiRepository from "../ApiRepository.server";
import { checkUserHasApiAccess } from "../core/perms";
const apiActionSchema = z.object({
_action: _action("GENERATE"),
_action: z.enum(["GENERATE_READ", "GENERATE_WRITE"]),
});
export const action = async ({ request }: ActionFunctionArgs) => {
@ -24,12 +23,16 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}
switch (data._action) {
case "GENERATE": {
await ApiRepository.generateToken(user.id);
case "GENERATE_READ": {
await ApiRepository.generateToken(user.id, "read");
await refreshApiTokensCache();
successToast("API token generated successfully");
successToast("Read token generated successfully");
break;
}
case "GENERATE_WRITE": {
await ApiRepository.generateToken(user.id, "write");
await refreshApiTokensCache();
successToast("Write token generated successfully");
break;
}
default: {

View File

@ -19,7 +19,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
test("both functions grant access for isApiAccesser flag", async () => {
await AdminRepository.makeApiAccesserByUserId(1);
await ApiRepository.generateToken(1);
await ApiRepository.generateToken(1, "read");
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(1);
@ -32,7 +32,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
test("both functions grant access for isTournamentOrganizer flag", async () => {
await AdminRepository.makeTournamentOrganizerByUserId(1);
await ApiRepository.generateToken(1);
await ApiRepository.generateToken(1, "read");
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(1);
@ -50,7 +50,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
patronTill: add(new Date(), { months: 3 }),
});
await ApiRepository.generateToken(1);
await ApiRepository.generateToken(1, "read");
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(1);
@ -68,7 +68,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
patronTill: add(new Date(), { months: 3 }),
});
await ApiRepository.generateToken(1);
await ApiRepository.generateToken(1, "read");
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(1);
@ -101,7 +101,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
badges: [],
});
await ApiRepository.generateToken(userId);
await ApiRepository.generateToken(userId, "read");
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(userId);
@ -131,7 +131,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
badges: [],
});
await ApiRepository.generateToken(2);
await ApiRepository.generateToken(2, "read");
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(2);
@ -158,7 +158,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
badges: [],
});
await ApiRepository.generateToken(2);
await ApiRepository.generateToken(2, "read");
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(2);

View File

@ -10,14 +10,19 @@ export const loader = async () => {
if (!hasApiAccess) {
return {
hasAccess: false,
apiToken: null,
readToken: null,
writeToken: null,
};
}
const apiToken = await ApiRepository.findTokenByUserId(user.id);
const [readToken, writeToken] = await Promise.all([
ApiRepository.findTokenByUserId(user.id, "read"),
ApiRepository.findTokenByUserId(user.id, "write"),
]);
return {
hasAccess: true,
apiToken: apiToken?.token ?? null,
readToken: readToken?.token ?? null,
writeToken: writeToken?.token ?? null,
};
};

View File

@ -45,12 +45,55 @@ export default function ApiPage() {
<div>
<FormMessage type="info">{t("common:api.noAccess")}</FormMessage>
</div>
) : data.apiToken ? (
) : (
<div className="stack lg">
<TokenSection
token={data.readToken}
tokenType="read"
generateAction="GENERATE_READ"
/>
<TokenSection
token={data.writeToken}
tokenType="write"
generateAction="GENERATE_WRITE"
/>
</div>
)}
</Main>
);
}
function TokenSection({
token,
tokenType,
generateAction,
}: {
token: string | null;
tokenType: "read" | "write";
generateAction: string;
}) {
const { t } = useTranslation(["common"]);
const isWriteToken = tokenType === "write";
const labelKey = isWriteToken
? "common:api.writeTokenLabel"
: "common:api.readTokenLabel";
const descriptionKey = isWriteToken
? "common:api.writeTokenDescription"
: "common:api.readTokenDescription";
return (
<div className="stack md">
<div>
<h2 className="text-md">{t(labelKey)}</h2>
<p className="text-xs text-lighter">{t(descriptionKey)}</p>
</div>
{token ? (
<div className="stack md">
<div>
<label>{t("common:api.tokenLabel")}</label>
<CopyToClipboardPopover
url={data.apiToken}
url={token}
trigger={
<SendouButton icon={<EyeIcon />}>
{t("common:api.revealButton")}
@ -62,12 +105,12 @@ export default function ApiPage() {
<FormWithConfirm
dialogHeading={t("common:api.regenerate.heading")}
submitButtonText={t("common:api.regenerate.confirm")}
fields={[["_action", "GENERATE"]]}
fields={[["_action", generateAction]]}
>
<SendouButton
className="mx-auto"
variant="outlined"
icon={<RefreshArrowsIcon />}
className="mr-auto"
>
{t("common:api.regenerate.button")}
</SendouButton>
@ -75,11 +118,11 @@ export default function ApiPage() {
</div>
) : (
<form method="post">
<SubmitButton _action="GENERATE">
<SubmitButton _action={generateAction}>
{t("common:api.generate")}
</SubmitButton>
</form>
)}
</Main>
</div>
);
}

Binary file not shown.

View File

@ -1,4 +1,5 @@
import { expect, seed, test } from "~/utils/playwright";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { expect, impersonate, navigate, seed, test } from "~/utils/playwright";
test.describe("Public API", () => {
test("OPTIONS preflight request returns 204 with CORS headers", async ({
@ -25,4 +26,34 @@ test.describe("Public API", () => {
expect(response.headers()["access-control-allow-origin"]).toBe("*");
});
test("creates read API token and calls public endpoint", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({ page, url: "/api" });
await page.locator("form").first().getByRole("button").click();
await page.waitForURL("/api");
await page
.getByRole("button", { name: /reveal/i })
.first()
.click();
const token = await page.locator("input[readonly]").inputValue();
expect(token).toBeTruthy();
expect(token.length).toBe(20);
const response = await page.request.fetch(`/api/user/${ADMIN_ID}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.id).toBe(ADMIN_ID);
expect(data.name).toBe("Sendou");
});
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -323,7 +323,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -323,7 +323,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -323,7 +323,10 @@
"api.title": "API Access",
"api.description": "Generate an API token to access the sendou.ink API. See the <1>API documentation</1> for available endpoints, usage examples and guidelines to follow.",
"api.noAccess": "You do not have access to the API. Access is granted to supporters (Supporter tier or higher) and admins, organizers, or streamers of established tournament organizations.",
"api.tokenLabel": "Your API Token",
"api.readTokenLabel": "Read Token",
"api.readTokenDescription": "Use this token to access read-only API endpoints.",
"api.writeTokenLabel": "Write Token",
"api.writeTokenDescription": "Use this token to access both read and write API endpoints.",
"api.revealButton": "Click to reveal",
"api.regenerate.heading": "Regenerating will invalidate your current token. Any applications using the old token will stop working.",
"api.regenerate.button": "Regenerate token",

View File

@ -325,7 +325,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -325,7 +325,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -325,7 +325,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -325,7 +325,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -324,7 +324,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -325,7 +325,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -319,7 +319,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -319,7 +319,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -323,7 +323,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -326,7 +326,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -325,7 +325,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -326,7 +326,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -319,7 +319,10 @@
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.readTokenLabel": "",
"api.readTokenDescription": "",
"api.writeTokenLabel": "",
"api.writeTokenDescription": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",

View File

@ -0,0 +1,34 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `
create table "ApiToken_new" (
"id" integer primary key,
"token" text not null unique,
"userId" integer not null,
"type" text not null default 'read',
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("userId") references "User"("id") on delete cascade
) strict
`,
).run();
db.prepare(
/* sql */ `
insert into "ApiToken_new" ("id", "token", "userId", "type", "createdAt")
select "id", "token", "userId", 'read', "createdAt"
from "ApiToken"
`,
).run();
db.prepare(/* sql */ "drop table ApiToken").run();
db.prepare(
/* sql */ `alter table "ApiToken_new" rename to "ApiToken"`,
).run();
db.prepare(
/* sql */ `create unique index api_token_user_id_type on "ApiToken"("userId", "type")`,
).run();
})();
}