@promptbook/cli 0.112.0-134 → 0.112.0-136
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/agents-server/README.md +0 -1
- package/apps/agents-server/src/app/admin/chat-feedback/page.tsx +2 -4
- package/apps/agents-server/src/app/admin/chat-history/page.tsx +2 -4
- package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +246 -15
- package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +237 -4
- package/apps/agents-server/src/app/api/admin/update/log/route.ts +42 -0
- package/apps/agents-server/src/app/api/agents/[agentName]/route.ts +28 -9
- package/apps/agents-server/src/app/api/agents/export/route.ts +41 -0
- package/apps/agents-server/src/app/api/agents/import/route.ts +168 -0
- package/apps/agents-server/src/app/api/chat/route.ts +29 -7
- package/apps/agents-server/src/app/api/chat-feedback/export/route.ts +2 -2
- package/apps/agents-server/src/app/api/chat-feedback/route.ts +3 -3
- package/apps/agents-server/src/app/api/chat-history/[id]/route.ts +2 -2
- package/apps/agents-server/src/app/api/chat-history/export/route.ts +2 -2
- package/apps/agents-server/src/app/api/chat-history/route.ts +3 -3
- package/apps/agents-server/src/app/api/chat-streaming/route.ts +29 -0
- package/apps/agents-server/src/app/api/elevenlabs/tts/route.ts +60 -2
- package/apps/agents-server/src/app/api/federated-agents/route.ts +1 -1
- package/apps/agents-server/src/app/api/images/[filename]/route.ts +102 -0
- package/apps/agents-server/src/app/api/metadata/export/route.ts +34 -0
- package/apps/agents-server/src/app/api/metadata/import/route.ts +46 -0
- package/apps/agents-server/src/app/api/openai/v1/audio/transcriptions/route.ts +111 -2
- package/apps/agents-server/src/app/api/page-preview/check/route.ts +18 -0
- package/apps/agents-server/src/app/api/page-preview/screenshot/route.ts +15 -8
- package/apps/agents-server/src/app/api/team-agent-profile/route.ts +20 -0
- package/apps/agents-server/src/app/layout.tsx +1 -1
- package/apps/agents-server/src/components/AgentContextMenu/useAgentContextMenuItems.ts +27 -19
- package/apps/agents-server/src/components/Homepage/AgentsList.tsx +31 -1
- package/apps/agents-server/src/components/Homepage/AgentsListHeader.tsx +60 -1
- package/apps/agents-server/src/components/Homepage/useAgentsListImportExportState.ts +523 -0
- package/apps/agents-server/src/components/Homepage/useAgentsListState.ts +16 -0
- package/apps/agents-server/src/constants/defaultAgentAvatarVisual.ts +1 -1
- package/apps/agents-server/src/database/metadataDefaults.ts +0 -21
- package/apps/agents-server/src/database/migrations/2026-06-2700-default-agent-avatar-visual-octopus3d4.sql +16 -0
- package/apps/agents-server/src/database/seedCoreAgents.ts +235 -0
- package/apps/agents-server/src/database/seedDefaultAgents.ts +32 -1
- package/apps/agents-server/src/search/createDefaultServerSearchProviders/createFederatedAgentsSearchProvider.ts +1 -1
- package/apps/agents-server/src/tools/$provideAgentCollectionForServer.ts +2 -9
- package/apps/agents-server/src/utils/agentOwnership.ts +54 -5
- package/apps/agents-server/src/utils/agentsTransfer/createAgentsExportZipStream.ts +68 -0
- package/apps/agents-server/src/utils/agentsTransfer/importAgentsFromFiles.ts +852 -0
- package/apps/agents-server/src/utils/backup/createBooksBackupZipStream.ts +15 -2
- package/apps/agents-server/src/utils/defaultAgents/loadDefaultAgentBooks.ts +46 -4
- package/apps/agents-server/src/utils/findAgentForCallerWriteAccess.ts +36 -0
- package/apps/agents-server/src/utils/getFederatedServers.ts +3 -74
- package/apps/agents-server/src/utils/getWellKnownAgentUrl.ts +10 -4
- package/apps/agents-server/src/utils/metadataConfigurationTransfer.ts +426 -0
- package/apps/agents-server/src/utils/paidApiRequestGuard.ts +241 -0
- package/apps/agents-server/src/utils/serverManagement/createManagedServer/bootstrapManagedServer.ts +2 -5
- package/apps/agents-server/src/utils/serverManagement/createManagedServer/seedServerCoreAgents.ts +162 -0
- package/apps/agents-server/src/utils/userChat/createRunUserChatJobExecutionContext.ts +36 -8
- package/apps/agents-server/src/utils/userChat/persistUserChatJobProgressCard.ts +11 -2
- package/apps/agents-server/src/utils/userChat/resolveUserChatProgressToolHighlights.ts +100 -0
- package/apps/agents-server/src/utils/userChat/runUserChatJob.ts +4 -3
- package/apps/agents-server/src/utils/userChat/userChatProgressCard.ts +272 -27
- package/apps/agents-server/src/utils/validateApiKey.ts +7 -3
- package/apps/agents-server/src/utils/vpsSelfUpdate.ts +110 -0
- package/esm/index.es.js +795 -39
- package/esm/index.es.js.map +1 -1
- package/esm/src/_packages/core.index.d.ts +0 -2
- package/esm/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
- package/esm/src/avatars/visuals/octopus3d4AvatarVisual.d.ts +7 -0
- package/esm/src/utils/validators/url/isValidAgentUrl.d.ts +5 -0
- package/esm/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/servers.ts +1 -16
- package/src/_packages/core.index.ts +0 -2
- package/src/avatars/types/AvatarVisualDefinition.ts +1 -0
- package/src/avatars/visuals/avatarVisualRegistry.ts +2 -0
- package/src/avatars/visuals/octopus3d4AvatarVisual.ts +1295 -0
- package/src/other/templates/getTemplatesPipelineCollection.ts +762 -883
- package/src/utils/agents/resolveAgentAvatarImageUrl.ts +1 -1
- package/src/utils/validators/url/isValidAgentUrl.ts +5 -7
- package/src/version.ts +2 -2
- package/src/versions.txt +2 -1
- package/umd/index.umd.js +795 -39
- package/umd/index.umd.js.map +1 -1
- package/umd/src/_packages/core.index.d.ts +0 -2
- package/umd/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
- package/umd/src/avatars/visuals/octopus3d4AvatarVisual.d.ts +7 -0
- package/umd/src/utils/validators/url/isValidAgentUrl.d.ts +5 -0
- package/umd/src/version.d.ts +1 -1
- package/apps/agents-server/src/utils/defaultFederatedAgents/DefaultFederatedAgentsSyncOptions.ts +0 -9
- package/apps/agents-server/src/utils/defaultFederatedAgents/ensureDefaultFederatedAgentExists.ts +0 -277
- package/apps/agents-server/src/utils/defaultFederatedAgents/fetchCoreOrganizationPayload.ts +0 -39
- package/apps/agents-server/src/utils/defaultFederatedAgents/fetchFederatedAgentBook.ts +0 -43
- package/apps/agents-server/src/utils/defaultFederatedAgents/fetchWithDefaultFederatedAgentTimeout.ts +0 -28
- package/apps/agents-server/src/utils/defaultFederatedAgents/getDefaultFederatedAgentSyncPool.ts +0 -38
- package/apps/agents-server/src/utils/defaultFederatedAgents/loadActiveLocalAgentIdsByNormalizedName.ts +0 -41
- package/apps/agents-server/src/utils/defaultFederatedAgents/loadDefaultFederatedAgentSyncMetadata.ts +0 -76
- package/apps/agents-server/src/utils/defaultFederatedAgents/quoteIdentifier.ts +0 -11
- package/apps/agents-server/src/utils/defaultFederatedAgents/scheduleDefaultFederatedAgentsSync.ts +0 -88
- package/apps/agents-server/src/utils/defaultFederatedAgents/selectDefaultFederatedAgentsFromOrganizationPayload.ts +0 -181
- package/apps/agents-server/src/utils/defaultFederatedAgents/synchronizeDefaultFederatedAgents.ts +0 -77
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
import { createOpenAiExecutionTools } from '@promptbook-local/openai';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { spaceTrim } from 'spacetrim';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether this development-only OpenAI smoke test endpoint is reachable.
|
|
7
|
+
*
|
|
8
|
+
* The route exists to verify that the configured `OPENAI_API_KEY` actually
|
|
9
|
+
* talks to OpenAI, so it must never be exposed in production where it would
|
|
10
|
+
* let any anonymous client drain the server's paid OpenAI credit by repeatedly
|
|
11
|
+
* calling this unauthenticated `GET` with attacker-controlled `message`.
|
|
12
|
+
*
|
|
13
|
+
* @private internal helper of `/api/chat`
|
|
14
|
+
*/
|
|
15
|
+
const IS_DEVELOPMENT_CHAT_TEST_ENDPOINT_ENABLED = process.env.NODE_ENV !== 'production';
|
|
2
16
|
|
|
3
17
|
/**
|
|
4
18
|
* Handles get.
|
|
5
19
|
*/
|
|
6
20
|
export async function GET(request: Request) {
|
|
21
|
+
if (!IS_DEVELOPMENT_CHAT_TEST_ENDPOINT_ENABLED) {
|
|
22
|
+
return NextResponse.json(
|
|
23
|
+
{
|
|
24
|
+
error: {
|
|
25
|
+
message: spaceTrim(`
|
|
26
|
+
Endpoint \`/api/chat\` is a development-only OpenAI smoke test
|
|
27
|
+
and is disabled in production.
|
|
28
|
+
`),
|
|
29
|
+
type: 'not_available_in_production',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{ status: 404 },
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
7
36
|
const { searchParams } = new URL(request.url);
|
|
8
37
|
const message = searchParams.get('message') || 'Hello, who are you?';
|
|
9
38
|
|
|
@@ -21,13 +50,6 @@ export async function GET(request: Request) {
|
|
|
21
50
|
content: message,
|
|
22
51
|
});
|
|
23
52
|
|
|
24
|
-
/*
|
|
25
|
-
return new Response(JSON.stringify(response, null, 4), {
|
|
26
|
-
status: 200,
|
|
27
|
-
headers: { 'Content-Type': 'application/json' },
|
|
28
|
-
});
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
53
|
return new Response(response.content, {
|
|
32
54
|
status: 200,
|
|
33
55
|
headers: { 'Content-Type': 'text/markdown' },
|
|
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|
|
2
2
|
import { $getTableName } from '../../../../database/$getTableName';
|
|
3
3
|
import { $provideSupabase } from '../../../../database/$provideSupabase';
|
|
4
4
|
import { convertToCsv } from '../../../../utils/convertToCsv';
|
|
5
|
-
import {
|
|
5
|
+
import { isUserAdmin } from '../../../../utils/isUserAdmin';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Export chat feedback as CSV.
|
|
@@ -11,7 +11,7 @@ import { getCurrentUser } from '../../../../utils/getCurrentUser';
|
|
|
11
11
|
* - agentName: filter by agent name (optional)
|
|
12
12
|
*/
|
|
13
13
|
export async function GET(request: NextRequest) {
|
|
14
|
-
if (!(await
|
|
14
|
+
if (!(await isUserAdmin())) {
|
|
15
15
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { $getTableName } from '../../../database/$getTableName';
|
|
3
3
|
import { $provideSupabase } from '../../../database/$provideSupabase';
|
|
4
|
-
import {
|
|
4
|
+
import { isUserAdmin } from '../../../utils/isUserAdmin';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Size of default page.
|
|
@@ -58,7 +58,7 @@ function parseSortOrder(value: string | null): SortOrder {
|
|
|
58
58
|
* - sortOrder: asc | desc (default: desc)
|
|
59
59
|
*/
|
|
60
60
|
export async function GET(request: NextRequest) {
|
|
61
|
-
if (!(await
|
|
61
|
+
if (!(await isUserAdmin())) {
|
|
62
62
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -145,7 +145,7 @@ export async function GET(request: NextRequest) {
|
|
|
145
145
|
* - agentName: name of the agent whose feedback should be removed
|
|
146
146
|
*/
|
|
147
147
|
export async function DELETE(request: NextRequest) {
|
|
148
|
-
if (!(await
|
|
148
|
+
if (!(await isUserAdmin())) {
|
|
149
149
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
150
150
|
}
|
|
151
151
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { $getTableName } from '../../../../database/$getTableName';
|
|
3
3
|
import { $provideSupabase } from '../../../../database/$provideSupabase';
|
|
4
|
-
import {
|
|
4
|
+
import { isUserAdmin } from '../../../../utils/isUserAdmin';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Delete a single chat history entry by ID.
|
|
8
8
|
*/
|
|
9
9
|
export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
|
10
|
-
if (!(await
|
|
10
|
+
if (!(await isUserAdmin())) {
|
|
11
11
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
12
12
|
}
|
|
13
13
|
|
|
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|
|
2
2
|
import { $getTableName } from '../../../../database/$getTableName';
|
|
3
3
|
import { $provideSupabase } from '../../../../database/$provideSupabase';
|
|
4
4
|
import { convertToCsv } from '../../../../utils/convertToCsv';
|
|
5
|
-
import {
|
|
5
|
+
import { isUserAdmin } from '../../../../utils/isUserAdmin';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Export chat history as CSV.
|
|
@@ -11,7 +11,7 @@ import { getCurrentUser } from '../../../../utils/getCurrentUser';
|
|
|
11
11
|
* - agentName: filter by agent name (optional)
|
|
12
12
|
*/
|
|
13
13
|
export async function GET(request: NextRequest) {
|
|
14
|
-
if (!(await
|
|
14
|
+
if (!(await isUserAdmin())) {
|
|
15
15
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { $getTableName } from '../../../database/$getTableName';
|
|
3
3
|
import { $provideSupabase } from '../../../database/$provideSupabase';
|
|
4
|
-
import {
|
|
4
|
+
import { isUserAdmin } from '../../../utils/isUserAdmin';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Size of default page.
|
|
@@ -58,7 +58,7 @@ function parseSortOrder(value: string | null): SortOrder {
|
|
|
58
58
|
* - sortOrder: asc | desc (default: desc)
|
|
59
59
|
*/
|
|
60
60
|
export async function GET(request: NextRequest) {
|
|
61
|
-
if (!(await
|
|
61
|
+
if (!(await isUserAdmin())) {
|
|
62
62
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -135,7 +135,7 @@ export async function GET(request: NextRequest) {
|
|
|
135
135
|
* - agentName: name of the agent whose history should be removed
|
|
136
136
|
*/
|
|
137
137
|
export async function DELETE(request: NextRequest) {
|
|
138
|
-
if (!(await
|
|
138
|
+
if (!(await isUserAdmin())) {
|
|
139
139
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
140
140
|
}
|
|
141
141
|
|
|
@@ -1,10 +1,39 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
1
2
|
import { OpenAI } from 'openai';
|
|
3
|
+
import { spaceTrim } from 'spacetrim';
|
|
2
4
|
import { forTime } from 'waitasecond';
|
|
3
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Whether this development-only OpenAI streaming smoke test endpoint is reachable.
|
|
8
|
+
*
|
|
9
|
+
* The route exists to verify that streaming chat completions work end-to-end,
|
|
10
|
+
* so it must never be exposed in production where it would let any anonymous
|
|
11
|
+
* client drain the server's paid OpenAI credit by repeatedly calling this
|
|
12
|
+
* unauthenticated `GET` with attacker-controlled `message`.
|
|
13
|
+
*
|
|
14
|
+
* @private internal helper of `/api/chat-streaming`
|
|
15
|
+
*/
|
|
16
|
+
const IS_DEVELOPMENT_CHAT_STREAMING_TEST_ENDPOINT_ENABLED = process.env.NODE_ENV !== 'production';
|
|
17
|
+
|
|
4
18
|
/**
|
|
5
19
|
* Handles get.
|
|
6
20
|
*/
|
|
7
21
|
export async function GET(request: Request) {
|
|
22
|
+
if (!IS_DEVELOPMENT_CHAT_STREAMING_TEST_ENDPOINT_ENABLED) {
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{
|
|
25
|
+
error: {
|
|
26
|
+
message: spaceTrim(`
|
|
27
|
+
Endpoint \`/api/chat-streaming\` is a development-only OpenAI smoke test
|
|
28
|
+
and is disabled in production.
|
|
29
|
+
`),
|
|
30
|
+
type: 'not_available_in_production',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{ status: 404 },
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
8
37
|
const { searchParams } = new URL(request.url);
|
|
9
38
|
const message = searchParams.get('message') || 'Hello, who are you?';
|
|
10
39
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server';
|
|
2
|
+
import { spaceTrim } from 'spacetrim';
|
|
1
3
|
import { respondIfClientVersionIsOutdated } from '../../../../utils/clientVersionGuard';
|
|
2
4
|
import { getMetadata } from '@/src/database/getMetadata';
|
|
5
|
+
import { guardPaidApiRequest } from '@/src/utils/paidApiRequestGuard';
|
|
3
6
|
import { textToSpeechText } from '../../../../utils/textToSpeechText';
|
|
4
7
|
|
|
5
8
|
/**
|
|
@@ -37,6 +40,19 @@ const ELEVEN_LABS_API_KEY = process.env.ELEVEN_LABS_API_KEY;
|
|
|
37
40
|
*/
|
|
38
41
|
const MAX_ELEVEN_LABS_TEXT_LENGTH = 4500;
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Pattern matching valid ElevenLabs voice identifiers.
|
|
45
|
+
*
|
|
46
|
+
* ElevenLabs voice IDs are 20-character alphanumeric strings (e.g.
|
|
47
|
+
* `21m00Tcm4TlvDq8ikWAM`). Validating with a strict allowlist prevents an
|
|
48
|
+
* attacker from injecting arbitrary path segments into the upstream URL
|
|
49
|
+
* (which is built by string interpolation below) and stops requests for
|
|
50
|
+
* unknown ids that would still bill our ElevenLabs account.
|
|
51
|
+
*
|
|
52
|
+
* @private
|
|
53
|
+
*/
|
|
54
|
+
const ELEVEN_LABS_VOICE_ID_PATTERN = /^[A-Za-z0-9]{15,40}$/u;
|
|
55
|
+
|
|
40
56
|
/**
|
|
41
57
|
* Handles the OPTIONS request from the chat for CORS / preflight.
|
|
42
58
|
*
|
|
@@ -59,7 +75,7 @@ export function OPTIONS() {
|
|
|
59
75
|
*
|
|
60
76
|
* @private
|
|
61
77
|
*/
|
|
62
|
-
export async function POST(request:
|
|
78
|
+
export async function POST(request: NextRequest) {
|
|
63
79
|
const versionMismatchResponse = respondIfClientVersionIsOutdated(request, 'json');
|
|
64
80
|
if (versionMismatchResponse) {
|
|
65
81
|
return versionMismatchResponse;
|
|
@@ -87,10 +103,31 @@ export async function POST(request: Request) {
|
|
|
87
103
|
);
|
|
88
104
|
}
|
|
89
105
|
|
|
106
|
+
const guard = await guardPaidApiRequest(request, 'TEXT_TO_SPEECH');
|
|
107
|
+
if (!guard.ok) {
|
|
108
|
+
return guard.response;
|
|
109
|
+
}
|
|
110
|
+
|
|
90
111
|
const payload = (await request.json().catch(() => null)) as { text?: string; voiceId?: string } | null;
|
|
91
112
|
const rawText = (payload?.text?.toString() ?? '').trim();
|
|
92
113
|
const requestedVoiceId = payload?.voiceId?.toString().trim();
|
|
93
|
-
const voiceIdToUse = requestedVoiceId
|
|
114
|
+
const voiceIdToUse = resolveValidatedVoiceId(requestedVoiceId);
|
|
115
|
+
|
|
116
|
+
if (voiceIdToUse === null) {
|
|
117
|
+
return new Response(
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
error: spaceTrim(`
|
|
120
|
+
Invalid \`voiceId\`.
|
|
121
|
+
|
|
122
|
+
ElevenLabs voice ids must be 15–40 alphanumeric characters.
|
|
123
|
+
`),
|
|
124
|
+
}),
|
|
125
|
+
{
|
|
126
|
+
status: 400,
|
|
127
|
+
headers: { 'Content-Type': 'application/json' },
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
}
|
|
94
131
|
|
|
95
132
|
if (!rawText) {
|
|
96
133
|
return new Response(
|
|
@@ -149,3 +186,24 @@ export async function POST(request: Request) {
|
|
|
149
186
|
},
|
|
150
187
|
});
|
|
151
188
|
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Validates the caller-supplied voice id and falls back to the configured default.
|
|
192
|
+
*
|
|
193
|
+
* Returns `null` if the caller provided a voice id that does not match the
|
|
194
|
+
* ElevenLabs format. The default value comes from a trusted environment
|
|
195
|
+
* variable, so it is forwarded as-is without re-validation.
|
|
196
|
+
*
|
|
197
|
+
* @private internal helper of `POST /api/elevenlabs/tts`
|
|
198
|
+
*/
|
|
199
|
+
function resolveValidatedVoiceId(requestedVoiceId: string | undefined): string | null {
|
|
200
|
+
if (!requestedVoiceId) {
|
|
201
|
+
return ELEVEN_LABS_VOICE_ID;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!ELEVEN_LABS_VOICE_ID_PATTERN.test(requestedVoiceId)) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return requestedVoiceId;
|
|
209
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { serializeError, computeHash } from '@promptbook-local/utils';
|
|
2
2
|
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import { spaceTrim } from 'spacetrim';
|
|
3
4
|
import { assertsError } from '../../../../../../../src/errors/assertsError';
|
|
4
5
|
import type { LlmExecutionTools } from '../../../../../../../src/execution/LlmExecutionTools';
|
|
5
6
|
import { getSingleLlmExecutionTools } from '../../../../../../../src/llm-providers/_multiple/getSingleLlmExecutionTools';
|
|
@@ -11,6 +12,50 @@ import { $provideServer } from '../../../../tools/$provideServer';
|
|
|
11
12
|
import { getGeneratedImageCdnKey } from '../../../../utils/cdn/utils/getGeneratedImageCdnKey';
|
|
12
13
|
import { ensureGeneratedImage } from '../../../../utils/imageGeneration/ensureGeneratedImage';
|
|
13
14
|
import { filenameToPrompt } from '../../../../utils/normalization/filenameToPrompt';
|
|
15
|
+
import { guardPaidApiRequest } from '../../../../utils/paidApiRequestGuard';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Allowlist of image generation models that may be requested by the caller.
|
|
19
|
+
*
|
|
20
|
+
* Image generation is one of the most expensive paid AI operations, so we
|
|
21
|
+
* reject any unknown model id before forwarding to the upstream provider.
|
|
22
|
+
*/
|
|
23
|
+
const ALLOWED_IMAGE_GENERATION_MODELS = new Set<string>([
|
|
24
|
+
'dall-e-3',
|
|
25
|
+
'dall-e-2',
|
|
26
|
+
'gpt-image-1',
|
|
27
|
+
'gemini-3-pro-image-preview',
|
|
28
|
+
'gemini-2.0-flash-preview-image-generation',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Allowlist of image sizes accepted from the caller.
|
|
33
|
+
*
|
|
34
|
+
* Larger sizes cost more on every supported provider, so callers must pick a
|
|
35
|
+
* value from this fixed list instead of supplying arbitrary `${number}x${number}`
|
|
36
|
+
* strings.
|
|
37
|
+
*/
|
|
38
|
+
const ALLOWED_IMAGE_GENERATION_SIZES = new Set<NonNullable<ImageGenerationModelRequirements['size']>>([
|
|
39
|
+
'1024x1024',
|
|
40
|
+
'1792x1024',
|
|
41
|
+
'1024x1792',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Allowlist of image quality values accepted from the caller.
|
|
46
|
+
*/
|
|
47
|
+
const ALLOWED_IMAGE_GENERATION_QUALITIES = new Set<NonNullable<ImageGenerationModelRequirements['quality']>>([
|
|
48
|
+
'standard',
|
|
49
|
+
'hd',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Allowlist of image style values accepted from the caller.
|
|
54
|
+
*/
|
|
55
|
+
const ALLOWED_IMAGE_GENERATION_STYLES = new Set<NonNullable<ImageGenerationModelRequirements['style']>>([
|
|
56
|
+
'vivid',
|
|
57
|
+
'natural',
|
|
58
|
+
]);
|
|
14
59
|
|
|
15
60
|
/**
|
|
16
61
|
* Chooses default image model from current provider metadata.
|
|
@@ -37,6 +82,63 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|
|
37
82
|
return NextResponse.json({ error: 'Filename is required' }, { status: 400 });
|
|
38
83
|
}
|
|
39
84
|
|
|
85
|
+
const guard = await guardPaidApiRequest(request, 'IMAGE_GENERATION');
|
|
86
|
+
if (!guard.ok) {
|
|
87
|
+
return guard.response;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (modelName !== null && !ALLOWED_IMAGE_GENERATION_MODELS.has(modelName)) {
|
|
91
|
+
return NextResponse.json(
|
|
92
|
+
{
|
|
93
|
+
error: spaceTrim(`
|
|
94
|
+
Requested image generation \`modelName\` is not allowed.
|
|
95
|
+
|
|
96
|
+
**Allowed models:** ${Array.from(ALLOWED_IMAGE_GENERATION_MODELS).join(', ')}
|
|
97
|
+
`),
|
|
98
|
+
},
|
|
99
|
+
{ status: 400 },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (size !== null && !ALLOWED_IMAGE_GENERATION_SIZES.has(size as never)) {
|
|
104
|
+
return NextResponse.json(
|
|
105
|
+
{
|
|
106
|
+
error: spaceTrim(`
|
|
107
|
+
Requested image \`size\` is not allowed.
|
|
108
|
+
|
|
109
|
+
**Allowed sizes:** ${Array.from(ALLOWED_IMAGE_GENERATION_SIZES).join(', ')}
|
|
110
|
+
`),
|
|
111
|
+
},
|
|
112
|
+
{ status: 400 },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (quality !== null && !ALLOWED_IMAGE_GENERATION_QUALITIES.has(quality as never)) {
|
|
117
|
+
return NextResponse.json(
|
|
118
|
+
{
|
|
119
|
+
error: spaceTrim(`
|
|
120
|
+
Requested image \`quality\` is not allowed.
|
|
121
|
+
|
|
122
|
+
**Allowed values:** ${Array.from(ALLOWED_IMAGE_GENERATION_QUALITIES).join(', ')}
|
|
123
|
+
`),
|
|
124
|
+
},
|
|
125
|
+
{ status: 400 },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (style !== null && !ALLOWED_IMAGE_GENERATION_STYLES.has(style as never)) {
|
|
130
|
+
return NextResponse.json(
|
|
131
|
+
{
|
|
132
|
+
error: spaceTrim(`
|
|
133
|
+
Requested image \`style\` is not allowed.
|
|
134
|
+
|
|
135
|
+
**Allowed values:** ${Array.from(ALLOWED_IMAGE_GENERATION_STYLES).join(', ')}
|
|
136
|
+
`),
|
|
137
|
+
},
|
|
138
|
+
{ status: 400 },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
40
142
|
let attachments: unknown[] | undefined;
|
|
41
143
|
if (attachmentsRaw) {
|
|
42
144
|
try {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { createMetadataConfigurationExport } from '../../../../utils/metadataConfigurationTransfer';
|
|
3
|
+
import { isUserAdmin } from '../../../../utils/isUserAdmin';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* This route is always dynamic because it depends on live DB state and auth.
|
|
7
|
+
*/
|
|
8
|
+
export const dynamic = 'force-dynamic';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Serves the standalone server metadata JSON export.
|
|
12
|
+
*
|
|
13
|
+
* Returns `401` for non-admin callers.
|
|
14
|
+
*/
|
|
15
|
+
export async function GET() {
|
|
16
|
+
if (!(await isUserAdmin())) {
|
|
17
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const { filename, payload } = await createMetadataConfigurationExport();
|
|
22
|
+
|
|
23
|
+
return new NextResponse(`${JSON.stringify(payload, null, 4)}\n`, {
|
|
24
|
+
headers: {
|
|
25
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
26
|
+
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
27
|
+
'Cache-Control': 'no-store',
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error('Metadata export error:', error);
|
|
32
|
+
return NextResponse.json({ error: 'Failed to export metadata.' }, { status: 500 });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { DatabaseError } from '../../../../../../../src/errors/DatabaseError';
|
|
3
|
+
import { ParseError } from '../../../../../../../src/errors/ParseError';
|
|
4
|
+
import { importMetadataConfigurationPayload } from '../../../../utils/metadataConfigurationTransfer';
|
|
5
|
+
import { isUserAdmin } from '../../../../utils/isUserAdmin';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* This route is always dynamic because it depends on live DB state and auth.
|
|
9
|
+
*/
|
|
10
|
+
export const dynamic = 'force-dynamic';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Imports standalone server metadata JSON.
|
|
14
|
+
*
|
|
15
|
+
* Returns `401` for non-admin callers.
|
|
16
|
+
*/
|
|
17
|
+
export async function POST(request: NextRequest) {
|
|
18
|
+
if (!(await isUserAdmin())) {
|
|
19
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let payload: unknown;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
payload = await request.json();
|
|
26
|
+
} catch {
|
|
27
|
+
return NextResponse.json({ error: 'Metadata import must be valid JSON.' }, { status: 400 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const importSummary = await importMetadataConfigurationPayload(payload);
|
|
32
|
+
return NextResponse.json(importSummary);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error instanceof ParseError) {
|
|
35
|
+
return NextResponse.json({ error: error.message }, { status: 400 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (error instanceof DatabaseError) {
|
|
39
|
+
console.error('Metadata import database error:', error);
|
|
40
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.error('Metadata import error:', error);
|
|
44
|
+
return NextResponse.json({ error: 'Failed to import metadata.' }, { status: 500 });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import OpenAI from 'openai';
|
|
3
|
+
import { spaceTrim } from 'spacetrim';
|
|
3
4
|
import { getMetadata } from '@/src/database/getMetadata';
|
|
5
|
+
import { guardPaidApiRequest } from '@/src/utils/paidApiRequestGuard';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Fallback file name used when the uploaded blob does not carry one.
|
|
@@ -12,6 +14,38 @@ const DEFAULT_AUDIO_TRANSCRIPTION_FILE_NAME = 'speech-recording.webm';
|
|
|
12
14
|
*/
|
|
13
15
|
const DEFAULT_AUDIO_TRANSCRIPTION_MODEL_PRIORITY = ['gpt-4o-transcribe', 'gpt-4o-mini-transcribe', 'whisper-1'] as const;
|
|
14
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Allowlist of model identifiers accepted from the caller.
|
|
19
|
+
*
|
|
20
|
+
* Caller-supplied model names are forwarded to the paid OpenAI API, so we
|
|
21
|
+
* validate them against a fixed allowlist to prevent attackers from forcing
|
|
22
|
+
* the server to call expensive or experimental models that are not part of
|
|
23
|
+
* the supported transcription stack.
|
|
24
|
+
*/
|
|
25
|
+
const ALLOWED_AUDIO_TRANSCRIPTION_MODELS = new Set<string>(DEFAULT_AUDIO_TRANSCRIPTION_MODEL_PRIORITY);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Maximum upload size that will be forwarded to OpenAI transcription.
|
|
29
|
+
*
|
|
30
|
+
* OpenAI's own limit is 25 MB; mirroring it here ensures we fail fast with a
|
|
31
|
+
* clear error before paying for an upload that the provider would reject.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_AUDIO_TRANSCRIPTION_FILE_SIZE_BYTES = 25 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pattern matching ISO-639-1 language codes that OpenAI transcription accepts.
|
|
37
|
+
*/
|
|
38
|
+
const ISO_639_1_LANGUAGE_CODE_PATTERN = /^[a-z]{2}$/u;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Maximum prompt length forwarded as transcription context.
|
|
42
|
+
*
|
|
43
|
+
* OpenAI rejects prompts longer than 224 tokens; we keep a conservative
|
|
44
|
+
* character cap that comfortably stays under that limit while preventing
|
|
45
|
+
* attackers from inflating request size.
|
|
46
|
+
*/
|
|
47
|
+
const MAX_AUDIO_TRANSCRIPTION_PROMPT_LENGTH = 800;
|
|
48
|
+
|
|
15
49
|
/**
|
|
16
50
|
* Proxy endpoint for OpenAI audio transcription
|
|
17
51
|
*
|
|
@@ -33,17 +67,55 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
33
67
|
);
|
|
34
68
|
}
|
|
35
69
|
|
|
70
|
+
const guard = await guardPaidApiRequest(request, 'AUDIO_TRANSCRIPTION');
|
|
71
|
+
if (!guard.ok) {
|
|
72
|
+
return guard.response;
|
|
73
|
+
}
|
|
74
|
+
|
|
36
75
|
const formData = await request.formData();
|
|
37
76
|
const file = formData.get('file') as File;
|
|
38
77
|
const requestedModel = normalizeFormDataStringValue(formData.get('model'));
|
|
39
|
-
const language =
|
|
40
|
-
const prompt =
|
|
78
|
+
const language = resolveAudioTranscriptionLanguage(formData.get('language'));
|
|
79
|
+
const prompt = resolveAudioTranscriptionPrompt(formData.get('prompt'));
|
|
41
80
|
const temperature = resolveAudioTranscriptionTemperature(formData.get('temperature'));
|
|
42
81
|
|
|
43
82
|
if (!file) {
|
|
44
83
|
return NextResponse.json({ error: { message: 'No file provided' } }, { status: 400 });
|
|
45
84
|
}
|
|
46
85
|
|
|
86
|
+
if (file.size > MAX_AUDIO_TRANSCRIPTION_FILE_SIZE_BYTES) {
|
|
87
|
+
return NextResponse.json(
|
|
88
|
+
{
|
|
89
|
+
error: {
|
|
90
|
+
message: spaceTrim(`
|
|
91
|
+
Uploaded audio file is too large.
|
|
92
|
+
|
|
93
|
+
**Maximum allowed size:** ${Math.round(
|
|
94
|
+
MAX_AUDIO_TRANSCRIPTION_FILE_SIZE_BYTES / (1024 * 1024),
|
|
95
|
+
)} MB
|
|
96
|
+
**Received:** ${(file.size / (1024 * 1024)).toFixed(2)} MB
|
|
97
|
+
`),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{ status: 413 },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (requestedModel !== undefined && !ALLOWED_AUDIO_TRANSCRIPTION_MODELS.has(requestedModel)) {
|
|
105
|
+
return NextResponse.json(
|
|
106
|
+
{
|
|
107
|
+
error: {
|
|
108
|
+
message: spaceTrim(`
|
|
109
|
+
Requested transcription model is not allowed.
|
|
110
|
+
|
|
111
|
+
**Allowed models:** ${Array.from(ALLOWED_AUDIO_TRANSCRIPTION_MODELS).join(', ')}
|
|
112
|
+
`),
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{ status: 400 },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
47
119
|
const openai = new OpenAI({
|
|
48
120
|
apiKey: openAiApiKey,
|
|
49
121
|
});
|
|
@@ -108,6 +180,43 @@ function normalizeFormDataStringValue(value: FormDataEntryValue | null): string
|
|
|
108
180
|
return normalizedValue;
|
|
109
181
|
}
|
|
110
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Validates the caller-supplied language hint against ISO-639-1.
|
|
185
|
+
*
|
|
186
|
+
* Forwarding arbitrary `language` strings to OpenAI is harmless cost-wise but
|
|
187
|
+
* widens the attack surface — invalid values can trigger model retries that
|
|
188
|
+
* silently inflate the bill. We mirror the documented constraint and drop
|
|
189
|
+
* anything that does not match.
|
|
190
|
+
*/
|
|
191
|
+
function resolveAudioTranscriptionLanguage(value: FormDataEntryValue | null): string | undefined {
|
|
192
|
+
const normalizedValue = normalizeFormDataStringValue(value);
|
|
193
|
+
if (!normalizedValue) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!ISO_639_1_LANGUAGE_CODE_PATTERN.test(normalizedValue)) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return normalizedValue.toLowerCase();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Validates and truncates the optional transcription prompt before sending it to OpenAI.
|
|
206
|
+
*/
|
|
207
|
+
function resolveAudioTranscriptionPrompt(value: FormDataEntryValue | null): string | undefined {
|
|
208
|
+
const normalizedValue = normalizeFormDataStringValue(value);
|
|
209
|
+
if (!normalizedValue) {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (normalizedValue.length <= MAX_AUDIO_TRANSCRIPTION_PROMPT_LENGTH) {
|
|
214
|
+
return normalizedValue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return normalizedValue.slice(0, MAX_AUDIO_TRANSCRIPTION_PROMPT_LENGTH);
|
|
218
|
+
}
|
|
219
|
+
|
|
111
220
|
/**
|
|
112
221
|
* Parses optional transcription temperature from multipart form data.
|
|
113
222
|
*/
|