Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
DB_FILE_NAME=file:local.db
ENCRYPTION_KEY="ABCD1234=" # openssl rand -base64 32
# COOKIE_SECURE=true # set when the dashboard is served over HTTPS
# COOKIE_SECURE=true # set when the dashboard is served over HTTPS
# PROVAL_USER_PROMPT_MAX_LENGTH=2000 # character limit for the custom instructions of a repository
89 changes: 89 additions & 0 deletions apps/api/src/api/auth/auth.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it, mock } from "bun:test";
import { USER_PROMPT_MAX_LENGTH } from "@proval/types";

let selectResultQueue: unknown[][] = [];

function createFromChain(result: unknown[]) {
return {
where: mock(() => ({
limit: mock(() => Promise.resolve(result)),
})),
then(resolve: (value: unknown) => void) {
resolve(result);
},
};
}

const insertMock = mock(() => ({
values: mock(() => ({
onConflictDoNothing: mock(() => ({
returning: mock(() => Promise.resolve([])),
})),
})),
}));

const selectMock = mock(() => ({
from: mock(() => createFromChain(selectResultQueue.shift() ?? [])),
}));

mock.module("../../db/index.js", () => ({
default: {
insert: insertMock,
select: selectMock,
},
}));

const { AuthService } = await import("./auth.service.js");
const authService = new AuthService();

const instanceSetting = {
id: 1,
authEnabled: false,
registrationEnabled: false,
};

function restoreUserPromptMaxLengthEnv(previous: string | undefined) {
if (previous === undefined) {
delete process.env.PROVAL_USER_PROMPT_MAX_LENGTH;
} else {
process.env.PROVAL_USER_PROMPT_MAX_LENGTH = previous;
}
}

describe("Get current user", () => {
it("announces the default userPromptMaxLength", async () => {
const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH;
delete process.env.PROVAL_USER_PROMPT_MAX_LENGTH;
try {
selectResultQueue = [[instanceSetting], [{ value: 0 }]];
const me = await authService.getMe(null);
expect(me.userPromptMaxLength).toBe(USER_PROMPT_MAX_LENGTH);
} finally {
restoreUserPromptMaxLengthEnv(previousMaxLength);
}
});

it("announces the userPromptMaxLength from PROVAL_USER_PROMPT_MAX_LENGTH", async () => {
const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH;
process.env.PROVAL_USER_PROMPT_MAX_LENGTH = "9000";
try {
selectResultQueue = [[instanceSetting], [{ value: 0 }]];
const me = await authService.getMe(null);
expect(me.userPromptMaxLength).toBe(9000);
} finally {
restoreUserPromptMaxLengthEnv(previousMaxLength);
}
});

it("announces the default userPromptMaxLength when the env value is invalid", async () => {
const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH;
process.env.PROVAL_USER_PROMPT_MAX_LENGTH = "abc";
try {
selectResultQueue = [[instanceSetting], [{ value: 0 }]];
const me = await authService.getMe(null);
expect(me.userPromptMaxLength).toBe(USER_PROMPT_MAX_LENGTH);
} finally {
restoreUserPromptMaxLengthEnv(previousMaxLength);
}
});
});
2 changes: 2 additions & 0 deletions apps/api/src/api/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
InstanceSettingUpdateInput,
UserResponse,
} from "@proval/types";
import { resolveUserPromptMaxLength } from "@proval/types";
import db from "../../db/index.js";

const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -72,6 +73,7 @@ export class AuthService {
isAuthEnabled: setting.authEnabled,
isRegistrationEnabled: setting.registrationEnabled,
isSetupRequired,
userPromptMaxLength: resolveUserPromptMaxLength(process.env.PROVAL_USER_PROMPT_MAX_LENGTH),
};
}

Expand Down
70 changes: 64 additions & 6 deletions apps/api/src/api/repository/repository.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";
import type { Repository } from "@proval/types";
import { USER_PROMPT_MAX_LENGTH } from "@proval/types";

process.env.ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");

function restoreUserPromptMaxLengthEnv(previous: string | undefined) {
if (previous === undefined) {
delete process.env.PROVAL_USER_PROMPT_MAX_LENGTH;
} else {
process.env.PROVAL_USER_PROMPT_MAX_LENGTH = previous;
}
}

const insertReturningMock = mock<() => Promise<Repository[]>>(() => Promise.resolve([]));
const insertValuesMock = mock((_value: unknown) => ({
returning: insertReturningMock,
Expand Down Expand Up @@ -306,9 +315,60 @@ describe("Update GitLab Repository", () => {
});

it("rejects userPrompt longer than the max length", async () => {
await expect(repositoryService.update(1, { userPrompt: "a".repeat(2001) })).rejects.toThrow(
"Custom instructions must be at most 2000 characters",
);
const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH;
delete process.env.PROVAL_USER_PROMPT_MAX_LENGTH;
try {
await expect(
repositoryService.update(1, { userPrompt: "a".repeat(USER_PROMPT_MAX_LENGTH + 1) }),
).rejects.toThrow(`Custom instructions must be at most ${USER_PROMPT_MAX_LENGTH} characters`);
} finally {
restoreUserPromptMaxLengthEnv(previousMaxLength);
}
});

it("accepts a userPrompt of exactly the effective limit", async () => {
const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH;
try {
delete process.env.PROVAL_USER_PROMPT_MAX_LENGTH;
const atDefaultLimit = "a".repeat(USER_PROMPT_MAX_LENGTH);
updateReturningMock.mockResolvedValueOnce([makeRepositoryRow({ userPrompt: atDefaultLimit })]);
const updatedAtDefault = await repositoryService.update(1, { userPrompt: atDefaultLimit });
expect(updatedAtDefault.userPrompt).toBe(atDefaultLimit);

process.env.PROVAL_USER_PROMPT_MAX_LENGTH = "50";
const atCustomLimit = "b".repeat(50);
updateReturningMock.mockResolvedValueOnce([makeRepositoryRow({ userPrompt: atCustomLimit })]);
const updatedAtCustom = await repositoryService.update(1, { userPrompt: atCustomLimit });
expect(updatedAtCustom.userPrompt).toBe(atCustomLimit);
} finally {
restoreUserPromptMaxLengthEnv(previousMaxLength);
}
});

it("uses PROVAL_USER_PROMPT_MAX_LENGTH when the deployment sets it", async () => {
const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH;
process.env.PROVAL_USER_PROMPT_MAX_LENGTH = "50";
try {
await expect(repositoryService.update(1, { userPrompt: "a".repeat(51) })).rejects.toThrow(
"Custom instructions must be at most 50 characters",
);
} finally {
restoreUserPromptMaxLengthEnv(previousMaxLength);
}
});

it("ignores a PROVAL_USER_PROMPT_MAX_LENGTH that is not a positive integer", async () => {
const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH;
try {
for (const invalid of ["abc", "0", "-5", "1.5", ""]) {
process.env.PROVAL_USER_PROMPT_MAX_LENGTH = invalid;
await expect(
repositoryService.update(1, { userPrompt: "a".repeat(USER_PROMPT_MAX_LENGTH + 1) }),
).rejects.toThrow(`Custom instructions must be at most ${USER_PROMPT_MAX_LENGTH} characters`);
}
} finally {
restoreUserPromptMaxLengthEnv(previousMaxLength);
}
});

it("rejects userPrompt when it is not a string", async () => {
Expand All @@ -318,9 +378,7 @@ describe("Update GitLab Repository", () => {
});

it("rejects invalid reasoning effort", async () => {
await expect(
repositoryService.update(1, { reasoningEffort: "bogus" as "medium" }),
).rejects.toThrow(
await expect(repositoryService.update(1, { reasoningEffort: "bogus" as "medium" })).rejects.toThrow(
"Invalid reasoning effort",
);
});
Expand Down
7 changes: 4 additions & 3 deletions apps/api/src/api/repository/repository.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { GitLabProvider } from "../../git-provider/gitlab.js";
import type { GitProvider } from "../../git-provider/types.js";
import { logError } from "../../util/log.js";
import { decrypt, encrypt } from "../../util/encrypt.js";
import { reasoningEffortValueList, USER_PROMPT_MAX_LENGTH } from "@proval/types";
import { reasoningEffortValueList, resolveUserPromptMaxLength } from "@proval/types";
import type {
ReasoningEffort,
Repository,
Expand Down Expand Up @@ -458,8 +458,9 @@ export class RepositoryService {
if (!trimmed) {
return null;
}
if (trimmed.length > USER_PROMPT_MAX_LENGTH) {
throw new Error(`Custom instructions must be at most ${USER_PROMPT_MAX_LENGTH} characters`);
const maxLength = resolveUserPromptMaxLength(process.env.PROVAL_USER_PROMPT_MAX_LENGTH);
if (trimmed.length > maxLength) {
throw new Error(`Custom instructions must be at most ${maxLength} characters`);
}
return trimmed;
}
Expand Down
7 changes: 4 additions & 3 deletions apps/client/src/lib/components/organism/RepositoryForm.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script lang="ts">
import InputText from "../atom/InputText.svelte";
import fetchApi from "$lib/utils";
import { reasoningEffortValueList, USER_PROMPT_MAX_LENGTH } from "@proval/types";
import { reasoningEffortValueList } from "@proval/types";
import { userPromptMaxLength } from "$lib/store/instance";
import type {
ModelProviderModelListResponse,
ModelProviderResponse,
Expand Down Expand Up @@ -504,13 +505,13 @@
<textarea
{id}
bind:value={userPrompt}
maxlength={USER_PROMPT_MAX_LENGTH}
maxlength={$userPromptMaxLength}
placeholder="Ignore the lack of test coverage."
rows={4}
class="w-full resize-y rounded-lg border border-input bg-input-background px-4 py-3 text-sm text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
></textarea>
<p class="mt-1 text-right text-xs text-muted-foreground">
{userPromptLength}/{USER_PROMPT_MAX_LENGTH}
{userPromptLength}/{$userPromptMaxLength}
</p>
{/snippet}
</FormField>
Expand Down
14 changes: 14 additions & 0 deletions apps/client/src/lib/store/instance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { writable } from "svelte/store";
import { USER_PROMPT_MAX_LENGTH } from "@proval/types";

/**
* Effective custom instruction limit announced by the instance. Falls back to
* the build-time constant when the API does not announce one.
*/
export const userPromptMaxLength = writable<number>(USER_PROMPT_MAX_LENGTH);

export function setUserPromptMaxLength(value: unknown): void {
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
userPromptMaxLength.set(value);
}
}
8 changes: 7 additions & 1 deletion apps/client/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
import { isDemoMode } from "$lib/demo/enabled";
import { isAuthPagePath } from "$lib/auth";
import { initializeTheme } from "$lib/store/theme";
import { setUserPromptMaxLength } from "$lib/store/instance";
import type { LayoutProps } from "./$types";

const GITHUB_URL = "https://github.com/seoes/proval";

const { children } = $props();
const { data, children }: LayoutProps = $props();
const demoMode = isDemoMode();
let sidebarOpen = $state(false);
let sidebarAnimate = $state(false);
Expand All @@ -22,6 +24,10 @@

onMount(initializeTheme);

$effect(() => {
setUserPromptMaxLength(data.auth?.userPromptMaxLength);
});

function toggleSidebar() {
sidebarAnimate = true;
sidebarOpen = !sidebarOpen;
Expand Down
2 changes: 2 additions & 0 deletions apps/client/src/routes/+layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { redirect } from "@sveltejs/kit";
import type { LayoutLoad } from "./$types";
import { fetchAuthMe, isAuthPagePath } from "$lib/auth";
import { isDemoMode } from "$lib/demo/enabled";
import { USER_PROMPT_MAX_LENGTH } from "@proval/types";

export const ssr = false;
export const prerender = false;
Expand All @@ -19,6 +20,7 @@ export const load: LayoutLoad = async ({ url }) => {
isAuthEnabled: false,
isRegistrationEnabled: false,
isSetupRequired: false,
userPromptMaxLength: USER_PROMPT_MAX_LENGTH,
},
};
}
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export type AuthMeResponse = {
isAuthEnabled: boolean;
isRegistrationEnabled: boolean;
isSetupRequired: boolean;
userPromptMaxLength: number;
};

export type InstanceSettingResponse = {
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export type {
InstanceSettingUpdateInput,
AuthCredentialInput,
} from "./database.js";
export { USER_PROMPT_MAX_LENGTH } from "./user-prompt.js";
export { USER_PROMPT_MAX_LENGTH, resolveUserPromptMaxLength } from "./user-prompt.js";
export { reasoningEffortValueList } from "./database.js";
export type { Pagination } from "./pagination.js";
export type {
Expand Down
13 changes: 13 additions & 0 deletions packages/types/src/user-prompt.ts
Original file line number Diff line number Diff line change
@@ -1 +1,14 @@
export const USER_PROMPT_MAX_LENGTH = 2000;

/**
* Effective limit for the custom instructions of a repository.
* A deployment can raise or lower it with PROVAL_USER_PROMPT_MAX_LENGTH; any
* value that is not a positive integer is ignored and the default applies.
*/
export function resolveUserPromptMaxLength(raw?: string | number | null): number {
const value = typeof raw === "string" ? Number(raw.trim()) : raw;
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
return USER_PROMPT_MAX_LENGTH;
}
return value;
}