@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.
Files changed (94) hide show
  1. package/apps/agents-server/README.md +0 -1
  2. package/apps/agents-server/src/app/admin/chat-feedback/page.tsx +2 -4
  3. package/apps/agents-server/src/app/admin/chat-history/page.tsx +2 -4
  4. package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +246 -15
  5. package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +237 -4
  6. package/apps/agents-server/src/app/api/admin/update/log/route.ts +42 -0
  7. package/apps/agents-server/src/app/api/agents/[agentName]/route.ts +28 -9
  8. package/apps/agents-server/src/app/api/agents/export/route.ts +41 -0
  9. package/apps/agents-server/src/app/api/agents/import/route.ts +168 -0
  10. package/apps/agents-server/src/app/api/chat/route.ts +29 -7
  11. package/apps/agents-server/src/app/api/chat-feedback/export/route.ts +2 -2
  12. package/apps/agents-server/src/app/api/chat-feedback/route.ts +3 -3
  13. package/apps/agents-server/src/app/api/chat-history/[id]/route.ts +2 -2
  14. package/apps/agents-server/src/app/api/chat-history/export/route.ts +2 -2
  15. package/apps/agents-server/src/app/api/chat-history/route.ts +3 -3
  16. package/apps/agents-server/src/app/api/chat-streaming/route.ts +29 -0
  17. package/apps/agents-server/src/app/api/elevenlabs/tts/route.ts +60 -2
  18. package/apps/agents-server/src/app/api/federated-agents/route.ts +1 -1
  19. package/apps/agents-server/src/app/api/images/[filename]/route.ts +102 -0
  20. package/apps/agents-server/src/app/api/metadata/export/route.ts +34 -0
  21. package/apps/agents-server/src/app/api/metadata/import/route.ts +46 -0
  22. package/apps/agents-server/src/app/api/openai/v1/audio/transcriptions/route.ts +111 -2
  23. package/apps/agents-server/src/app/api/page-preview/check/route.ts +18 -0
  24. package/apps/agents-server/src/app/api/page-preview/screenshot/route.ts +15 -8
  25. package/apps/agents-server/src/app/api/team-agent-profile/route.ts +20 -0
  26. package/apps/agents-server/src/app/layout.tsx +1 -1
  27. package/apps/agents-server/src/components/AgentContextMenu/useAgentContextMenuItems.ts +27 -19
  28. package/apps/agents-server/src/components/Homepage/AgentsList.tsx +31 -1
  29. package/apps/agents-server/src/components/Homepage/AgentsListHeader.tsx +60 -1
  30. package/apps/agents-server/src/components/Homepage/useAgentsListImportExportState.ts +523 -0
  31. package/apps/agents-server/src/components/Homepage/useAgentsListState.ts +16 -0
  32. package/apps/agents-server/src/constants/defaultAgentAvatarVisual.ts +1 -1
  33. package/apps/agents-server/src/database/metadataDefaults.ts +0 -21
  34. package/apps/agents-server/src/database/migrations/2026-06-2700-default-agent-avatar-visual-octopus3d4.sql +16 -0
  35. package/apps/agents-server/src/database/seedCoreAgents.ts +235 -0
  36. package/apps/agents-server/src/database/seedDefaultAgents.ts +32 -1
  37. package/apps/agents-server/src/search/createDefaultServerSearchProviders/createFederatedAgentsSearchProvider.ts +1 -1
  38. package/apps/agents-server/src/tools/$provideAgentCollectionForServer.ts +2 -9
  39. package/apps/agents-server/src/utils/agentOwnership.ts +54 -5
  40. package/apps/agents-server/src/utils/agentsTransfer/createAgentsExportZipStream.ts +68 -0
  41. package/apps/agents-server/src/utils/agentsTransfer/importAgentsFromFiles.ts +852 -0
  42. package/apps/agents-server/src/utils/backup/createBooksBackupZipStream.ts +15 -2
  43. package/apps/agents-server/src/utils/defaultAgents/loadDefaultAgentBooks.ts +46 -4
  44. package/apps/agents-server/src/utils/findAgentForCallerWriteAccess.ts +36 -0
  45. package/apps/agents-server/src/utils/getFederatedServers.ts +3 -74
  46. package/apps/agents-server/src/utils/getWellKnownAgentUrl.ts +10 -4
  47. package/apps/agents-server/src/utils/metadataConfigurationTransfer.ts +426 -0
  48. package/apps/agents-server/src/utils/paidApiRequestGuard.ts +241 -0
  49. package/apps/agents-server/src/utils/serverManagement/createManagedServer/bootstrapManagedServer.ts +2 -5
  50. package/apps/agents-server/src/utils/serverManagement/createManagedServer/seedServerCoreAgents.ts +162 -0
  51. package/apps/agents-server/src/utils/userChat/createRunUserChatJobExecutionContext.ts +36 -8
  52. package/apps/agents-server/src/utils/userChat/persistUserChatJobProgressCard.ts +11 -2
  53. package/apps/agents-server/src/utils/userChat/resolveUserChatProgressToolHighlights.ts +100 -0
  54. package/apps/agents-server/src/utils/userChat/runUserChatJob.ts +4 -3
  55. package/apps/agents-server/src/utils/userChat/userChatProgressCard.ts +272 -27
  56. package/apps/agents-server/src/utils/validateApiKey.ts +7 -3
  57. package/apps/agents-server/src/utils/vpsSelfUpdate.ts +110 -0
  58. package/esm/index.es.js +795 -39
  59. package/esm/index.es.js.map +1 -1
  60. package/esm/src/_packages/core.index.d.ts +0 -2
  61. package/esm/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
  62. package/esm/src/avatars/visuals/octopus3d4AvatarVisual.d.ts +7 -0
  63. package/esm/src/utils/validators/url/isValidAgentUrl.d.ts +5 -0
  64. package/esm/src/version.d.ts +1 -1
  65. package/package.json +1 -1
  66. package/servers.ts +1 -16
  67. package/src/_packages/core.index.ts +0 -2
  68. package/src/avatars/types/AvatarVisualDefinition.ts +1 -0
  69. package/src/avatars/visuals/avatarVisualRegistry.ts +2 -0
  70. package/src/avatars/visuals/octopus3d4AvatarVisual.ts +1295 -0
  71. package/src/other/templates/getTemplatesPipelineCollection.ts +762 -883
  72. package/src/utils/agents/resolveAgentAvatarImageUrl.ts +1 -1
  73. package/src/utils/validators/url/isValidAgentUrl.ts +5 -7
  74. package/src/version.ts +2 -2
  75. package/src/versions.txt +2 -1
  76. package/umd/index.umd.js +795 -39
  77. package/umd/index.umd.js.map +1 -1
  78. package/umd/src/_packages/core.index.d.ts +0 -2
  79. package/umd/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
  80. package/umd/src/avatars/visuals/octopus3d4AvatarVisual.d.ts +7 -0
  81. package/umd/src/utils/validators/url/isValidAgentUrl.d.ts +5 -0
  82. package/umd/src/version.d.ts +1 -1
  83. package/apps/agents-server/src/utils/defaultFederatedAgents/DefaultFederatedAgentsSyncOptions.ts +0 -9
  84. package/apps/agents-server/src/utils/defaultFederatedAgents/ensureDefaultFederatedAgentExists.ts +0 -277
  85. package/apps/agents-server/src/utils/defaultFederatedAgents/fetchCoreOrganizationPayload.ts +0 -39
  86. package/apps/agents-server/src/utils/defaultFederatedAgents/fetchFederatedAgentBook.ts +0 -43
  87. package/apps/agents-server/src/utils/defaultFederatedAgents/fetchWithDefaultFederatedAgentTimeout.ts +0 -28
  88. package/apps/agents-server/src/utils/defaultFederatedAgents/getDefaultFederatedAgentSyncPool.ts +0 -38
  89. package/apps/agents-server/src/utils/defaultFederatedAgents/loadActiveLocalAgentIdsByNormalizedName.ts +0 -41
  90. package/apps/agents-server/src/utils/defaultFederatedAgents/loadDefaultFederatedAgentSyncMetadata.ts +0 -76
  91. package/apps/agents-server/src/utils/defaultFederatedAgents/quoteIdentifier.ts +0 -11
  92. package/apps/agents-server/src/utils/defaultFederatedAgents/scheduleDefaultFederatedAgentsSync.ts +0 -88
  93. package/apps/agents-server/src/utils/defaultFederatedAgents/selectDefaultFederatedAgentsFromOrganizationPayload.ts +0 -181
  94. 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 { 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
+ }
@@ -24,7 +24,7 @@ export async function GET() {
24
24
  });
25
25
  }
26
26
 
27
- const federatedServers = await getFederatedServers({ excludeHiddenCoreServer: true });
27
+ const federatedServers = await getFederatedServers();
28
28
 
29
29
  return NextResponse.json({
30
30
  federatedServers,
@@ -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 = normalizeFormDataStringValue(formData.get('language'));
40
- const prompt = normalizeFormDataStringValue(formData.get('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
  */