Skip to content

Commit ca9a2fc

Browse files
committed
Harden API key handling and stop exposing secrets to clients
1 parent 2ef4526 commit ca9a2fc

15 files changed

Lines changed: 375 additions & 109 deletions

File tree

api-docs.md

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,8 @@ curl -X POST "http://localhost:3432/api/stt" \
326326
Proxies requests to NanoGPT Video Generation API.
327327

328328
**Authentication**: Session
329+
330+
**Note**: You may also supply a direct NanoGPT key via `x-api-key` or `Authorization: Bearer <nanogpt_key>`. The server key is not used for unauthenticated requests.
329331

330332
**Request Body**:
331333
```json
@@ -355,6 +357,8 @@ curl -X POST "http://localhost:3432/api/video/generate" \
355357
Check the status of a video generation task.
356358

357359
**Authentication**: Session
360+
361+
**Note**: You may also supply a direct NanoGPT key via `x-api-key` or `Authorization: Bearer <nanogpt_key>`. The server key is not used for unauthenticated requests.
358362

359363
**Query Parameters**:
360364
- `runId`: (Required) The run ID returned by the generate endpoint.
@@ -1873,9 +1877,12 @@ Get user settings.
18731877
"theme": "string | null",
18741878
"themePrimaryColor": "string | null",
18751879
"themeAccentColor": "string | null",
1880+
"karakeepUrl": "string | null",
1881+
"hasKarakeepApiKey": "boolean",
18761882
...
18771883
}
18781884
```
1885+
**Note**: Secret values such as `karakeepApiKey` are never returned by this endpoint.
18791886

18801887
**CURL Example**:
18811888
```bash
@@ -1895,13 +1902,16 @@ Update user settings.
18951902
"timezone": "string (optional, IANA timezone)",
18961903
"privacyMode": "boolean (optional)",
18971904
"contextMemoryEnabled": "boolean (optional)",
1905+
"karakeepUrl": "string | null (optional)",
1906+
"karakeepApiKey": "string | null (optional, write-only)",
18981907
"theme": "string (optional, theme id or null)",
18991908
"suggestedPromptsEnabled": "boolean (optional)",
19001909
"themePrimaryColor": "string (optional, #RRGGBB)",
19011910
"themeAccentColor": "string (optional, #RRGGBB)",
19021911
...
19031912
}
19041913
```
1914+
**Response**: Same as `GET /api/db/user-settings`. Secret values are omitted from responses.
19051915

19061916
**CURL Example**:
19071917
```bash
@@ -1916,24 +1926,34 @@ curl -X POST "http://localhost:3432/api/db/user-settings" \
19161926
### User Provider Keys
19171927

19181928
#### GET `/api/db/user-keys`
1919-
Get API keys configured by the user for different providers.
1929+
Get provider key status for the current user without returning the underlying secret.
19201930

19211931
**Authentication**: Session or API Key
19221932

19231933
**Query Parameters**:
1924-
- `provider`: (Optional) Get key for a specific provider (e.g., "nanogpt", "openai").
1934+
- `provider`: (Optional) Get status for a specific provider (e.g., "nanogpt", "openai").
19251935

19261936
**Response**:
19271937
```json
19281938
// Single provider
1929-
"sk-..."
1939+
{
1940+
"hasKey": true,
1941+
"source": "user"
1942+
}
19301943

19311944
// All providers
19321945
{
1933-
"nanogpt": "sk-...",
1934-
"openai": "sk-..."
1946+
"nanogpt": {
1947+
"hasKey": true,
1948+
"source": "server"
1949+
},
1950+
"openai": {
1951+
"hasKey": false,
1952+
"source": null
1953+
}
19351954
}
19361955
```
1956+
**Note**: This endpoint never returns raw or encrypted provider keys.
19371957

19381958
**CURL Example**:
19391959
```bash
@@ -1961,9 +1981,10 @@ Set an API key for a provider.
19611981
**Response**:
19621982
```json
19631983
{
1964-
"userId": "string",
1984+
"ok": true,
19651985
"provider": "string",
1966-
"createdAt": "date"
1986+
"createdAt": "date",
1987+
"updatedAt": "date"
19671988
}
19681989
```
19691990

src/lib/api.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
* using local SQLite + Drizzle backend via SvelteKit API routes
55
*/
66

7+
import type { Provider } from '$lib/types';
8+
79
// Re-export types from database schema
810
export type {
9-
UserSettings,
1011
UserKey,
1112
UserEnabledModel,
1213
UserRule,
@@ -23,13 +24,26 @@ export type {
2324
ProjectMember,
2425
} from '$lib/db/schema';
2526

27+
type DbUserSettings = import('$lib/db/schema').UserSettings;
28+
29+
export type UserSettings = Omit<DbUserSettings, 'karakeepApiKey'> & {
30+
hasKarakeepApiKey: boolean;
31+
};
32+
33+
export type UserKeyStatus = {
34+
hasKey: boolean;
35+
source: 'user' | 'server' | null;
36+
};
37+
38+
export type UserKeysByProvider = Record<Provider, UserKeyStatus>;
39+
2640
// Type aliases for backwards compatibility with Convex patterns
2741
export type Doc<T extends keyof DocTypes> = DocTypes[T];
2842
export type Id<T extends keyof DocTypes> = string;
2943

3044
interface DocTypes {
31-
user_settings: import('$lib/db/schema').UserSettings;
32-
user_keys: import('$lib/db/schema').UserKey;
45+
user_settings: UserSettings;
46+
user_keys: UserKeyStatus;
3347
user_enabled_models: import('$lib/db/schema').UserEnabledModel;
3448
user_rules: import('$lib/db/schema').UserRule;
3549
conversations: import('$lib/db/schema').Conversation;

src/lib/components/ui/share-button/share-button.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
let karakeepMessage = $state('');
4040
4141
const hasKarakeepConfig = $derived(
42-
Boolean(settingsQuery.data?.karakeepUrl && settingsQuery.data?.karakeepApiKey)
42+
Boolean(settingsQuery.data?.karakeepUrl && settingsQuery.data?.hasKarakeepApiKey)
4343
);
4444
4545
const popover = new Popover({

src/lib/db/queries/user-keys.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,32 @@ import { eq, and } from 'drizzle-orm';
44
import type { Provider } from '$lib/types';
55
import { encryptApiKey, decryptApiKey, isEncrypted } from '$lib/encryption';
66

7+
export type UserKeyStatus = {
8+
hasKey: boolean;
9+
source: 'user' | 'server' | null;
10+
};
11+
12+
const providers = ['nanogpt', 'huggingface', 'openai', 'anthropic'] as const;
13+
14+
function resolveUserKeyStatus(provider: string, hasUserKey: boolean): UserKeyStatus {
15+
if (hasUserKey) {
16+
return { hasKey: true, source: 'user' };
17+
}
18+
19+
if (provider === 'nanogpt' && process.env.NANOGPT_API_KEY) {
20+
return { hasKey: true, source: 'server' };
21+
}
22+
23+
return { hasKey: false, source: null };
24+
}
25+
726
export async function getAllUserKeys(
827
userId: string
928
): Promise<Record<Provider, string | undefined>> {
1029
const allKeys = await db.query.userKeys.findMany({
1130
where: eq(userKeys.userId, userId),
1231
});
1332

14-
const providers = ['nanogpt', 'huggingface', 'openai', 'anthropic'] as const;
1533
return providers.reduce(
1634
(acc, key) => {
1735
acc[key] = allKeys.find((item) => item.provider === key)?.key;
@@ -21,6 +39,36 @@ export async function getAllUserKeys(
2139
);
2240
}
2341

42+
export async function getAllUserKeyStatuses(userId: string): Promise<Record<Provider, UserKeyStatus>> {
43+
const allKeys = await db.query.userKeys.findMany({
44+
where: eq(userKeys.userId, userId),
45+
columns: {
46+
provider: true,
47+
key: true,
48+
},
49+
});
50+
51+
return providers.reduce(
52+
(acc, provider) => {
53+
const hasUserKey = Boolean(allKeys.find((item) => item.provider === provider)?.key);
54+
acc[provider] = resolveUserKeyStatus(provider, hasUserKey);
55+
return acc;
56+
},
57+
{} as Record<Provider, UserKeyStatus>
58+
);
59+
}
60+
61+
export async function getUserKeyStatus(userId: string, provider: string): Promise<UserKeyStatus> {
62+
const result = await db.query.userKeys.findFirst({
63+
where: and(eq(userKeys.userId, userId), eq(userKeys.provider, provider)),
64+
columns: {
65+
key: true,
66+
},
67+
});
68+
69+
return resolveUserKeyStatus(provider, Boolean(result?.key));
70+
}
71+
2472
export async function getUserKey(userId: string, provider: string): Promise<string | null> {
2573
const result = await db.query.userKeys.findFirst({
2674
where: and(eq(userKeys.userId, userId), eq(userKeys.provider, provider)),

src/lib/db/queries/user-settings.ts

Lines changed: 73 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,110 @@
11
import { db, generateId } from '../index';
22
import { userSettings, type UserSettings, type NewUserSettings } from '../schema';
33
import { eq } from 'drizzle-orm';
4+
import { decryptApiKey, encryptApiKey, isEncrypted } from '$lib/encryption';
5+
6+
type UserSettingsUpdate = Partial<
7+
Omit<NewUserSettings, 'id' | 'userId' | 'createdAt' | 'updatedAt'>
8+
>;
9+
10+
export type PublicUserSettings = Omit<UserSettings, 'karakeepApiKey'> & {
11+
hasKarakeepApiKey: boolean;
12+
};
13+
14+
function decryptUserSettingsSecrets(settings: UserSettings): UserSettings {
15+
if (!settings.karakeepApiKey) {
16+
return settings;
17+
}
18+
19+
return {
20+
...settings,
21+
karakeepApiKey: isEncrypted(settings.karakeepApiKey)
22+
? decryptApiKey(settings.karakeepApiKey)
23+
: settings.karakeepApiKey,
24+
};
25+
}
26+
27+
function prepareUserSettingsWrite(data: UserSettingsUpdate): UserSettingsUpdate {
28+
if (!('karakeepApiKey' in data)) {
29+
return data;
30+
}
31+
32+
const { karakeepApiKey, ...rest } = data;
33+
34+
return {
35+
...rest,
36+
karakeepApiKey:
37+
typeof karakeepApiKey === 'string' && karakeepApiKey.length > 0
38+
? encryptApiKey(karakeepApiKey)
39+
: karakeepApiKey,
40+
};
41+
}
42+
43+
export function toPublicUserSettings(settings: UserSettings): PublicUserSettings {
44+
const { karakeepApiKey, ...rest } = settings;
45+
46+
return {
47+
...rest,
48+
hasKarakeepApiKey: Boolean(karakeepApiKey),
49+
};
50+
}
451

552
export async function getUserSettings(userId: string): Promise<UserSettings | null> {
653
const result = await db.query.userSettings.findFirst({
754
where: eq(userSettings.userId, userId),
855
});
9-
return result ?? null;
56+
return result ? decryptUserSettingsSecrets(result) : null;
1057
}
1158

1259
export async function createUserSettings(
1360
userId: string,
14-
data?: Partial<Omit<NewUserSettings, 'id' | 'userId' | 'createdAt' | 'updatedAt'>>
61+
data?: UserSettingsUpdate
1562
): Promise<UserSettings> {
1663
const now = new Date();
64+
const preparedData = prepareUserSettingsWrite(data ?? {});
1765
const [result] = await db
1866
.insert(userSettings)
1967
.values({
2068
id: generateId(),
2169
userId,
22-
timezone: data?.timezone ?? 'UTC',
23-
privacyMode: data?.privacyMode ?? false,
24-
contextMemoryEnabled: data?.contextMemoryEnabled ?? false,
25-
persistentMemoryEnabled: data?.persistentMemoryEnabled ?? false,
26-
youtubeTranscriptsEnabled: data?.youtubeTranscriptsEnabled ?? false,
27-
webScrapingEnabled: data?.webScrapingEnabled ?? false,
28-
mcpEnabled: data?.mcpEnabled ?? false,
29-
followUpQuestionsEnabled: data?.followUpQuestionsEnabled ?? true,
30-
suggestedPromptsEnabled: data?.suggestedPromptsEnabled ?? true,
31-
freeMessagesUsed: data?.freeMessagesUsed ?? 0,
32-
karakeepUrl: data?.karakeepUrl ?? null,
33-
karakeepApiKey: data?.karakeepApiKey ?? null,
34-
theme: data?.theme ?? null,
35-
themePrimaryColor: data?.themePrimaryColor ?? null,
36-
themeAccentColor: data?.themeAccentColor ?? null,
37-
titleModelId: data?.titleModelId ?? null,
38-
followUpModelId: data?.followUpModelId ?? null,
39-
createdAt: now,
70+
timezone: preparedData.timezone ?? 'UTC',
71+
privacyMode: preparedData.privacyMode ?? false,
72+
contextMemoryEnabled: preparedData.contextMemoryEnabled ?? false,
73+
persistentMemoryEnabled: preparedData.persistentMemoryEnabled ?? false,
74+
youtubeTranscriptsEnabled: preparedData.youtubeTranscriptsEnabled ?? false,
75+
webScrapingEnabled: preparedData.webScrapingEnabled ?? false,
76+
mcpEnabled: preparedData.mcpEnabled ?? false,
77+
followUpQuestionsEnabled: preparedData.followUpQuestionsEnabled ?? true,
78+
suggestedPromptsEnabled: preparedData.suggestedPromptsEnabled ?? true,
79+
freeMessagesUsed: preparedData.freeMessagesUsed ?? 0,
80+
karakeepUrl: preparedData.karakeepUrl ?? null,
81+
karakeepApiKey: preparedData.karakeepApiKey ?? null,
82+
theme: preparedData.theme ?? null,
83+
themePrimaryColor: preparedData.themePrimaryColor ?? null,
84+
themeAccentColor: preparedData.themeAccentColor ?? null,
85+
titleModelId: preparedData.titleModelId ?? null,
86+
followUpModelId: preparedData.followUpModelId ?? null,
87+
createdAt: now,
4088
updatedAt: now,
4189
})
4290
.returning();
43-
return result!;
91+
return decryptUserSettingsSecrets(result!);
4492
}
4593

4694
export async function updateUserSettings(
4795
userId: string,
48-
data: Partial<Omit<NewUserSettings, 'id' | 'userId' | 'createdAt' | 'updatedAt'>>
96+
data: UserSettingsUpdate
4997
): Promise<UserSettings | null> {
98+
const preparedData = prepareUserSettingsWrite(data);
5099
const [result] = await db
51100
.update(userSettings)
52101
.set({
53-
...data,
102+
...preparedData,
54103
updatedAt: new Date(),
55104
})
56105
.where(eq(userSettings.userId, userId))
57106
.returning();
58-
return result ?? null;
107+
return result ? decryptUserSettingsSecrets(result) : null;
59108
}
60109

61110
export async function incrementFreeMessageCount(userId: string): Promise<void> {

src/lib/utils/providers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export type NanoGPTConnectionData = {
2828
};
2929

3030
export const NanoGPT = {
31-
getApiKey: async (_key: string): Promise<Result<NanoGPTConnectionData, string>> => {
31+
getApiKey: async (): Promise<Result<NanoGPTConnectionData, string>> => {
3232
return await ResultAsync.fromPromise(
3333
(async () => {
3434
const [balanceRes, usageRes] = await Promise.all([

0 commit comments

Comments
 (0)