@promptbook/cli 0.112.0-133 → 0.112.0-135

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.
Files changed (56) hide show
  1. package/apps/agents-server/src/app/admin/chat-feedback/page.tsx +2 -4
  2. package/apps/agents-server/src/app/admin/chat-history/page.tsx +2 -4
  3. package/apps/agents-server/src/app/agents/[agentName]/api/book/route.ts +2 -0
  4. package/apps/agents-server/src/app/api/agents/[agentName]/route.ts +33 -9
  5. package/apps/agents-server/src/app/api/chat/route.ts +29 -7
  6. package/apps/agents-server/src/app/api/chat-feedback/export/route.ts +2 -2
  7. package/apps/agents-server/src/app/api/chat-feedback/route.ts +3 -3
  8. package/apps/agents-server/src/app/api/chat-history/[id]/route.ts +2 -2
  9. package/apps/agents-server/src/app/api/chat-history/export/route.ts +2 -2
  10. package/apps/agents-server/src/app/api/chat-history/route.ts +3 -3
  11. package/apps/agents-server/src/app/api/chat-streaming/route.ts +29 -0
  12. package/apps/agents-server/src/app/api/elevenlabs/tts/route.ts +60 -2
  13. package/apps/agents-server/src/app/api/images/[filename]/route.ts +102 -0
  14. package/apps/agents-server/src/app/api/openai/v1/audio/transcriptions/route.ts +111 -2
  15. package/apps/agents-server/src/app/api/page-preview/check/route.ts +18 -0
  16. package/apps/agents-server/src/app/api/page-preview/screenshot/route.ts +15 -8
  17. package/apps/agents-server/src/app/api/team-agent-profile/route.ts +20 -0
  18. package/apps/agents-server/src/app/api/v1/agents/[agentId]/route.ts +4 -0
  19. package/apps/agents-server/src/app/api/v1/agents/route.ts +2 -0
  20. package/apps/agents-server/src/app/recycle-bin/actions.ts +3 -0
  21. package/apps/agents-server/src/components/AgentContextMenu/useAgentContextMenuItems.ts +27 -19
  22. package/apps/agents-server/src/constants/defaultAgentAvatarVisual.ts +1 -1
  23. package/apps/agents-server/src/database/migrations/2026-06-2600-agent-directory-performance-indexes.sql +14 -0
  24. package/apps/agents-server/src/database/migrations/2026-06-2700-default-agent-avatar-visual-octopus3d4.sql +16 -0
  25. package/apps/agents-server/src/database/sqlite/$provideLocalSqliteSupabase.ts +92 -1
  26. package/apps/agents-server/src/utils/agentOwnership.ts +54 -5
  27. package/apps/agents-server/src/utils/findAgentForCallerWriteAccess.ts +36 -0
  28. package/apps/agents-server/src/utils/paidApiRequestGuard.ts +241 -0
  29. package/apps/agents-server/src/utils/userChat/createRunUserChatJobExecutionContext.ts +36 -8
  30. package/apps/agents-server/src/utils/userChat/listUserChats.ts +5 -1
  31. package/apps/agents-server/src/utils/userChat/persistUserChatJobProgressCard.ts +11 -2
  32. package/apps/agents-server/src/utils/userChat/resolveUserChatProgressToolHighlights.ts +100 -0
  33. package/apps/agents-server/src/utils/userChat/runUserChatJob.ts +4 -3
  34. package/apps/agents-server/src/utils/userChat/userChatProgressCard.ts +272 -27
  35. package/apps/agents-server/src/utils/validateApiKey.ts +7 -3
  36. package/esm/index.es.js +795 -39
  37. package/esm/index.es.js.map +1 -1
  38. package/esm/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
  39. package/esm/src/avatars/visuals/octopus3d4AvatarVisual.d.ts +7 -0
  40. package/esm/src/utils/validators/url/isValidAgentUrl.d.ts +5 -0
  41. package/esm/src/version.d.ts +1 -1
  42. package/package.json +1 -1
  43. package/src/avatars/types/AvatarVisualDefinition.ts +1 -0
  44. package/src/avatars/visuals/avatarVisualRegistry.ts +2 -0
  45. package/src/avatars/visuals/octopus3d4AvatarVisual.ts +1295 -0
  46. package/src/other/templates/getTemplatesPipelineCollection.ts +759 -679
  47. package/src/utils/agents/resolveAgentAvatarImageUrl.ts +1 -1
  48. package/src/utils/validators/url/isValidAgentUrl.ts +5 -7
  49. package/src/version.ts +2 -2
  50. package/src/versions.txt +1 -0
  51. package/umd/index.umd.js +795 -39
  52. package/umd/index.umd.js.map +1 -1
  53. package/umd/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
  54. package/umd/src/avatars/visuals/octopus3d4AvatarVisual.d.ts +7 -0
  55. package/umd/src/utils/validators/url/isValidAgentUrl.d.ts +5 -0
  56. package/umd/src/version.d.ts +1 -1
@@ -1,5 +1,5 @@
1
1
  import { ForbiddenPage } from '../../../components/ForbiddenPage/ForbiddenPage';
2
- import { getCurrentUser } from '../../../utils/getCurrentUser';
2
+ import { isUserAdmin } from '../../../utils/isUserAdmin';
3
3
  import { ChatFeedbackClient } from './ChatFeedbackClient';
4
4
 
5
5
  /**
@@ -15,9 +15,7 @@ type AdminChatFeedbackPageProps = {
15
15
  * Handles admin chat feedback page.
16
16
  */
17
17
  export default async function AdminChatFeedbackPage({ searchParams }: AdminChatFeedbackPageProps) {
18
- const currentUser = await getCurrentUser();
19
-
20
- if (!currentUser) {
18
+ if (!(await isUserAdmin())) {
21
19
  return <ForbiddenPage />;
22
20
  }
23
21
 
@@ -1,5 +1,5 @@
1
1
  import { ForbiddenPage } from '../../../components/ForbiddenPage/ForbiddenPage';
2
- import { getCurrentUser } from '../../../utils/getCurrentUser';
2
+ import { isUserAdmin } from '../../../utils/isUserAdmin';
3
3
  import { ChatHistoryClient } from './ChatHistoryClient';
4
4
 
5
5
  /**
@@ -15,9 +15,7 @@ type AdminChatHistoryPageProps = {
15
15
  * Handles admin chat history page.
16
16
  */
17
17
  export default async function AdminChatHistoryPage({ searchParams }: AdminChatHistoryPageProps) {
18
- const currentUser = await getCurrentUser();
19
-
20
- if (!currentUser) {
18
+ if (!(await isUserAdmin())) {
21
19
  return <ForbiddenPage />;
22
20
  }
23
21
 
@@ -1,5 +1,6 @@
1
1
  import { $provideAgentCollectionForServer } from '@/src/tools/$provideAgentCollectionForServer';
2
2
  import { $provideAgentReferenceResolver } from '@/src/utils/agentReferenceResolver/$provideAgentReferenceResolver';
3
+ import { invalidateCachedActiveOrganizationSnapshots } from '@/src/utils/agentOrganization/loadAgentOrganizationState';
3
4
  import {
4
5
  parseBookScopedAgentIdentifier,
5
6
  resolveBookScopedAgentContext,
@@ -172,6 +173,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ agen
172
173
 
173
174
  const agentId = await collection.getAgentPermanentId(agentName);
174
175
  await collection.updateAgentSource(agentId, agentSource, { versionName });
176
+ invalidateCachedActiveOrganizationSnapshots();
175
177
  // <- TODO: [🐱‍🚀] Properly type as string_book
176
178
 
177
179
  return new Response(
@@ -7,11 +7,23 @@ import { $provideAgentCollectionForServer } from '@/src/tools/$provideAgentColle
7
7
  import { $provideAgentReferenceResolver } from '@/src/utils/agentReferenceResolver/$provideAgentReferenceResolver';
8
8
  import { renameAgentSource } from '@/src/utils/renameAgentSource';
9
9
  import { isAgentVisibility, type AgentVisibility } from '@/src/utils/agentVisibility';
10
- import { TODO_any } from '@promptbook-local/types';
10
+ import { TODO_any, type string_book } from '@promptbook-local/types';
11
11
  import { NextResponse } from 'next/server';
12
- import { buildAgentNameOrIdFilter } from '@/src/utils/agentIdentifier';
12
+ import { findAgentForCallerWriteAccess } from '@/src/utils/findAgentForCallerWriteAccess';
13
13
  import { getCurrentUser } from '@/src/utils/getCurrentUser';
14
14
  import { resolveServerAgentContext } from '@/src/utils/resolveServerAgentContext';
15
+ import { invalidateCachedActiveOrganizationSnapshots } from '@/src/utils/agentOrganization/loadAgentOrganizationState';
16
+ import type { OwnedAgentRow } from '@/src/utils/agentOwnership';
17
+
18
+ /**
19
+ * Resolves the stable identifier used when calling the agent collection.
20
+ *
21
+ * @param agent - Persisted agent row.
22
+ * @returns Permanent id when available, otherwise the legacy agent name.
23
+ */
24
+ function getAgentCollectionIdentifier(agent: OwnedAgentRow): string {
25
+ return agent.permanentId || agent.agentName;
26
+ }
15
27
 
16
28
  /**
17
29
  * Handles patch.
@@ -24,6 +36,11 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ ag
24
36
  return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 });
25
37
  }
26
38
 
39
+ const targetAgent = await findAgentForCallerWriteAccess(agentName);
40
+ if (!targetAgent || targetAgent.deletedAt) {
41
+ return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
42
+ }
43
+
27
44
  const body = (await request.json()) as { visibility?: AgentVisibility; name?: string };
28
45
 
29
46
  if (typeof body.name === 'string') {
@@ -33,9 +50,8 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ ag
33
50
  }
34
51
 
35
52
  const collection = await $provideAgentCollectionForServer();
36
- const agentId = await collection.getAgentPermanentId(agentName);
37
- const agentSource = await collection.getAgentSource(agentId);
38
- const nextAgentSource = renameAgentSource(agentSource, trimmedName);
53
+ const agentId = getAgentCollectionIdentifier(targetAgent);
54
+ const nextAgentSource = renameAgentSource(targetAgent.agentSource as string_book, trimmedName);
39
55
 
40
56
  await collection.updateAgentSource(agentId, nextAgentSource);
41
57
  const baseAgentReferenceResolver = await $provideAgentReferenceResolver();
@@ -45,6 +61,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ ag
45
61
  localServerUrl: new URL(request.url).origin,
46
62
  fallbackResolver: baseAgentReferenceResolver,
47
63
  });
64
+ invalidateCachedActiveOrganizationSnapshots();
48
65
 
49
66
  return NextResponse.json({
50
67
  success: true,
@@ -67,13 +84,15 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ ag
67
84
  .from(await $getTableName(`Agent`))
68
85
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
86
  .update({ visibility } as any)
70
- .or(buildAgentNameOrIdFilter(agentName))
87
+ .eq('id', targetAgent.id)
71
88
  .is('deletedAt', null);
72
89
 
73
90
  if (updateResult.error) {
74
91
  return NextResponse.json({ success: false, error: updateResult.error.message }, { status: 500 });
75
92
  }
76
93
 
94
+ invalidateCachedActiveOrganizationSnapshots();
95
+
77
96
  return NextResponse.json({ success: true });
78
97
  } catch (error) {
79
98
  return NextResponse.json(
@@ -88,15 +107,20 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ ag
88
107
  */
89
108
  export async function DELETE(request: Request, { params }: { params: Promise<{ agentName: string }> }) {
90
109
  const { agentName } = await params;
91
- const collection = await $provideAgentCollectionForServer();
92
110
 
93
111
  try {
94
112
  if (!(await getCurrentUser())) {
95
113
  return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 });
96
114
  }
97
115
 
98
- const agentId = await collection.getAgentPermanentId(agentName);
99
- await collection.deleteAgent(agentId);
116
+ const targetAgent = await findAgentForCallerWriteAccess(agentName);
117
+ if (!targetAgent || targetAgent.deletedAt) {
118
+ return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
119
+ }
120
+
121
+ const collection = await $provideAgentCollectionForServer();
122
+ await collection.deleteAgent(getAgentCollectionIdentifier(targetAgent));
123
+ invalidateCachedActiveOrganizationSnapshots();
100
124
  return NextResponse.json({ success: true });
101
125
  } catch (error) {
102
126
  return NextResponse.json(
@@ -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 { getCurrentUser } from '../../../../utils/getCurrentUser';
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 getCurrentUser())) {
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 { getCurrentUser } from '../../../utils/getCurrentUser';
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 getCurrentUser())) {
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 getCurrentUser())) {
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 { getCurrentUser } from '../../../../utils/getCurrentUser';
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 getCurrentUser())) {
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 { getCurrentUser } from '../../../../utils/getCurrentUser';
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 getCurrentUser())) {
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 { getCurrentUser } from '../../../utils/getCurrentUser';
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 getCurrentUser())) {
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 getCurrentUser())) {
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: 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 || ELEVEN_LABS_VOICE_ID;
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 {