@promptbook/cli 0.112.0-134 → 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 (47) 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/api/agents/[agentName]/route.ts +28 -9
  4. package/apps/agents-server/src/app/api/chat/route.ts +29 -7
  5. package/apps/agents-server/src/app/api/chat-feedback/export/route.ts +2 -2
  6. package/apps/agents-server/src/app/api/chat-feedback/route.ts +3 -3
  7. package/apps/agents-server/src/app/api/chat-history/[id]/route.ts +2 -2
  8. package/apps/agents-server/src/app/api/chat-history/export/route.ts +2 -2
  9. package/apps/agents-server/src/app/api/chat-history/route.ts +3 -3
  10. package/apps/agents-server/src/app/api/chat-streaming/route.ts +29 -0
  11. package/apps/agents-server/src/app/api/elevenlabs/tts/route.ts +60 -2
  12. package/apps/agents-server/src/app/api/images/[filename]/route.ts +102 -0
  13. package/apps/agents-server/src/app/api/openai/v1/audio/transcriptions/route.ts +111 -2
  14. package/apps/agents-server/src/app/api/page-preview/check/route.ts +18 -0
  15. package/apps/agents-server/src/app/api/page-preview/screenshot/route.ts +15 -8
  16. package/apps/agents-server/src/app/api/team-agent-profile/route.ts +20 -0
  17. package/apps/agents-server/src/components/AgentContextMenu/useAgentContextMenuItems.ts +27 -19
  18. package/apps/agents-server/src/constants/defaultAgentAvatarVisual.ts +1 -1
  19. package/apps/agents-server/src/database/migrations/2026-06-2700-default-agent-avatar-visual-octopus3d4.sql +16 -0
  20. package/apps/agents-server/src/utils/agentOwnership.ts +54 -5
  21. package/apps/agents-server/src/utils/findAgentForCallerWriteAccess.ts +36 -0
  22. package/apps/agents-server/src/utils/paidApiRequestGuard.ts +241 -0
  23. package/apps/agents-server/src/utils/userChat/createRunUserChatJobExecutionContext.ts +36 -8
  24. package/apps/agents-server/src/utils/userChat/persistUserChatJobProgressCard.ts +11 -2
  25. package/apps/agents-server/src/utils/userChat/resolveUserChatProgressToolHighlights.ts +100 -0
  26. package/apps/agents-server/src/utils/userChat/runUserChatJob.ts +4 -3
  27. package/apps/agents-server/src/utils/userChat/userChatProgressCard.ts +272 -27
  28. package/apps/agents-server/src/utils/validateApiKey.ts +7 -3
  29. package/esm/index.es.js +795 -39
  30. package/esm/index.es.js.map +1 -1
  31. package/esm/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
  32. package/esm/src/avatars/visuals/octopus3d4AvatarVisual.d.ts +7 -0
  33. package/esm/src/utils/validators/url/isValidAgentUrl.d.ts +5 -0
  34. package/package.json +1 -1
  35. package/src/avatars/types/AvatarVisualDefinition.ts +1 -0
  36. package/src/avatars/visuals/avatarVisualRegistry.ts +2 -0
  37. package/src/avatars/visuals/octopus3d4AvatarVisual.ts +1295 -0
  38. package/src/other/templates/getTemplatesPipelineCollection.ts +772 -942
  39. package/src/utils/agents/resolveAgentAvatarImageUrl.ts +1 -1
  40. package/src/utils/validators/url/isValidAgentUrl.ts +5 -7
  41. package/src/version.ts +1 -1
  42. package/src/versions.txt +1 -1
  43. package/umd/index.umd.js +795 -39
  44. package/umd/index.umd.js.map +1 -1
  45. package/umd/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
  46. package/umd/src/avatars/visuals/octopus3d4AvatarVisual.d.ts +7 -0
  47. package/umd/src/utils/validators/url/isValidAgentUrl.d.ts +5 -0
@@ -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
  */
@@ -1,6 +1,8 @@
1
1
  import type { NextRequest } from 'next/server';
2
2
  import { NextResponse } from 'next/server';
3
3
  import { assertsError } from '../../../../../../../src/errors/assertsError';
4
+ import { assertSafeUrl } from '../../../../utils/assertSafeUrl';
5
+ import { getCurrentUser } from '../../../../utils/getCurrentUser';
4
6
  import { checkIfUrlCanBeEmbedded } from '../../../../utils/iframe/checkIfUrlCanBeEmbedded';
5
7
 
6
8
  /**
@@ -11,14 +13,30 @@ import { checkIfUrlCanBeEmbedded } from '../../../../utils/iframe/checkIfUrlCanB
11
13
  * - `url` — the fully-qualified HTTP(S) URL to check
12
14
  *
13
15
  * Returns `{ canEmbed: boolean }`.
16
+ *
17
+ * Requires authentication to prevent unauthenticated SSRF abuse.
18
+ * The destination URL is validated against private/internal IP ranges before fetching.
14
19
  */
15
20
  export async function GET(request: NextRequest): Promise<NextResponse> {
21
+ const currentUser = await getCurrentUser();
22
+ if (!currentUser) {
23
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
24
+ }
25
+
16
26
  const url = request.nextUrl.searchParams.get('url');
17
27
 
18
28
  if (!url) {
19
29
  return NextResponse.json({ error: 'Missing required query parameter: url' }, { status: 400 });
20
30
  }
21
31
 
32
+ // Guard against SSRF: reject private/internal IPs and non-HTTP(S) schemes
33
+ try {
34
+ assertSafeUrl(url);
35
+ } catch (error) {
36
+ assertsError(error);
37
+ return NextResponse.json({ error: error.message }, { status: 400 });
38
+ }
39
+
22
40
  try {
23
41
  const canEmbed = await checkIfUrlCanBeEmbedded(url);
24
42
  return NextResponse.json({ canEmbed });
@@ -3,6 +3,8 @@ import { serializeError } from '@promptbook-local/utils';
3
3
  import type { NextRequest } from 'next/server';
4
4
  import { NextResponse } from 'next/server';
5
5
  import { assertsError } from '../../../../../../../src/errors/assertsError';
6
+ import { assertSafeUrl } from '../../../../utils/assertSafeUrl';
7
+ import { getCurrentUser } from '../../../../utils/getCurrentUser';
6
8
 
7
9
  /**
8
10
  * Takes a screenshot of the given URL using a headless browser.
@@ -11,23 +13,28 @@ import { assertsError } from '../../../../../../../src/errors/assertsError';
11
13
  * - `url` — the fully-qualified HTTP(S) URL to screenshot
12
14
  *
13
15
  * Returns a PNG image.
16
+ *
17
+ * Requires authentication to prevent unauthenticated SSRF abuse.
18
+ * The destination URL is validated against private/internal IP ranges before fetching.
14
19
  */
15
20
  export async function GET(request: NextRequest): Promise<NextResponse> {
21
+ const currentUser = await getCurrentUser();
22
+ if (!currentUser) {
23
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
24
+ }
25
+
16
26
  const url = request.nextUrl.searchParams.get('url');
17
27
 
18
28
  if (!url) {
19
29
  return NextResponse.json({ error: 'Missing required query parameter: url' }, { status: 400 });
20
30
  }
21
31
 
22
- let parsedUrl: URL;
32
+ // Guard against SSRF: reject private/internal IPs and non-HTTP(S) schemes
23
33
  try {
24
- parsedUrl = new URL(url);
25
- } catch {
26
- return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
27
- }
28
-
29
- if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
30
- return NextResponse.json({ error: 'Only http and https URLs are supported' }, { status: 400 });
34
+ assertSafeUrl(url);
35
+ } catch (error) {
36
+ assertsError(error);
37
+ return NextResponse.json({ error: error.message }, { status: 400 });
31
38
  }
32
39
 
33
40
  try {
@@ -8,7 +8,10 @@ import {
8
8
  resolvePlaceholderImageUrl,
9
9
  resolveProfileImageUrl,
10
10
  } from '../../../../../../src/book-components/Chat/utils/loadAgentProfile';
11
+ import { assertsError } from '../../../../../../src/errors/assertsError';
11
12
  import { isValidAgentUrl } from '../../../../../../src/utils/validators/url/isValidAgentUrl';
13
+ import { assertSafeUrl } from '../../../utils/assertSafeUrl';
14
+ import { getCurrentUser } from '../../../utils/getCurrentUser';
12
15
 
13
16
  /**
14
17
  * Response for team agent profile.
@@ -88,8 +91,17 @@ async function fetchAgentProfile(agentUrl: string, localServerUrl: string): Prom
88
91
  *
89
92
  * The client uses this to render tool call chips without relying on direct cross-origin requests
90
93
  * to the teammate agent.
94
+ *
95
+ * Requires authentication to prevent unauthenticated SSRF abuse and exfiltration of the
96
+ * internal team access token. The destination URL is validated against private/internal
97
+ * IP ranges before any outbound request is made.
91
98
  */
92
99
  export async function GET(request: Request) {
100
+ const currentUser = await getCurrentUser();
101
+ if (!currentUser) {
102
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
103
+ }
104
+
93
105
  const requestUrl = new URL(request.url);
94
106
  const agentUrl = requestUrl.searchParams.get('url');
95
107
 
@@ -97,6 +109,14 @@ export async function GET(request: Request) {
97
109
  return NextResponse.json({ error: 'Invalid agent URL' }, { status: 400 });
98
110
  }
99
111
 
112
+ // Guard against SSRF: reject private/internal IPs and non-HTTP(S) schemes
113
+ try {
114
+ assertSafeUrl(agentUrl);
115
+ } catch (error) {
116
+ assertsError(error);
117
+ return NextResponse.json({ error: error.message }, { status: 400 });
118
+ }
119
+
100
120
  const normalizedUrl = agentUrl.replace(/\/$/, '');
101
121
  const profile = await fetchAgentProfile(normalizedUrl, requestUrl.origin);
102
122
 
@@ -300,6 +300,7 @@ function createManagementMenuItems(
300
300
  * @param formatText - Agent-aware text formatter.
301
301
  * @param handleRequestVisibilityUpdate - Visibility dialog action.
302
302
  * @param integrationLink - Generated integration link metadata.
303
+ * @param isAdmin - Whether the current user is an administrator (gates admin-only links).
303
304
  * @param isAuthenticated - Whether the current user is logged in.
304
305
  * @param shouldShowVisibilityAction - Whether visibility editing is available.
305
306
  * @param usageAnalyticsHref - Usage analytics URL for the current agent.
@@ -311,6 +312,7 @@ function createAdminMenuItems(
311
312
  formatText: FormatAgentContextMenuText,
312
313
  handleRequestVisibilityUpdate: () => Promise<void>,
313
314
  integrationLink: AgentContextMenuNavigationLink,
315
+ isAdmin: boolean,
314
316
  isAuthenticated: boolean,
315
317
  shouldShowVisibilityAction: boolean,
316
318
  usageAnalyticsHref: string,
@@ -333,26 +335,31 @@ function createAdminMenuItems(
333
335
  );
334
336
  }
335
337
 
338
+ if (isAdmin) {
339
+ menuItems.push(
340
+ {
341
+ type: 'link',
342
+ href: `/admin/chat-history?agentName=${encodeURIComponent(agentName)}`,
343
+ icon: MessageSquareIcon,
344
+ label: formatText('Chat History'),
345
+ },
346
+ {
347
+ type: 'link',
348
+ href: usageAnalyticsHref,
349
+ icon: BarChart3Icon,
350
+ label: formatText('Usage Analytics'),
351
+ },
352
+ {
353
+ type: 'link',
354
+ href: `/admin/chat-feedback?agentName=${encodeURIComponent(agentName)}`,
355
+ icon: MessageCircleQuestionIcon,
356
+ label: formatText('Chat Feedback'),
357
+ },
358
+ createDividerItem(),
359
+ );
360
+ }
361
+
336
362
  menuItems.push(
337
- {
338
- type: 'link',
339
- href: `/admin/chat-history?agentName=${encodeURIComponent(agentName)}`,
340
- icon: MessageSquareIcon,
341
- label: formatText('Chat History'),
342
- },
343
- {
344
- type: 'link',
345
- href: usageAnalyticsHref,
346
- icon: BarChart3Icon,
347
- label: formatText('Usage Analytics'),
348
- },
349
- {
350
- type: 'link',
351
- href: `/admin/chat-feedback?agentName=${encodeURIComponent(agentName)}`,
352
- icon: MessageCircleQuestionIcon,
353
- label: formatText('Chat Feedback'),
354
- },
355
- createDividerItem(),
356
363
  {
357
364
  type: 'link',
358
365
  href: integrationLink.href,
@@ -437,6 +444,7 @@ export function useAgentContextMenuItems(props: AgentContextMenuBaseProps): Cont
437
444
  formatText,
438
445
  handleRequestVisibilityUpdate,
439
446
  integrationLink,
447
+ isAdmin,
440
448
  isAuthenticated,
441
449
  shouldShowVisibilityAction,
442
450
  usageAnalyticsHref,
@@ -53,7 +53,7 @@ export const DEFAULT_AGENT_AVATAR_VISUAL_METADATA_VALUES = DEFAULT_AGENT_AVATAR_
53
53
  export const DEFAULT_AGENT_AVATAR_VISUAL_METADATA_VALUE =
54
54
  DEFAULT_AGENT_AVATAR_VISUAL_METADATA_OPTIONS.find(
55
55
  ({ visualId }) => visualId === SHARED_DEFAULT_AGENT_AVATAR_VISUAL_ID,
56
- )?.metadataValue || 'OCTOPUS3D3';
56
+ )?.metadataValue || 'OCTOPUS3D4';
57
57
 
58
58
  /**
59
59
  * Resolves one raw metadata value to a supported built-in avatar visual id.
@@ -0,0 +1,16 @@
1
+ INSERT INTO "prefix_Metadata" ("key", "value", "note", "createdAt", "updatedAt")
2
+ SELECT 'DEFAULT_AGENT_AVATAR_VISUAL',
3
+ 'OCTOPUS3D4',
4
+ 'Default built-in avatar visual used for agents without `META IMAGE` or `META AVATAR`. Allowed values: PIXEL_ART, OCTOPUS, OCTOPUS2, OCTOPUS3, OCTOPUS3D, OCTOPUS3D2, OCTOPUS3D3, OCTOPUS3D4, ASCII_OCTOPUS, MINECRAFT, MINECRAFT2, FRACTAL, ORB.',
5
+ NOW(),
6
+ NOW()
7
+ WHERE NOT EXISTS (SELECT 1 FROM "prefix_Metadata" WHERE "key" = 'DEFAULT_AGENT_AVATAR_VISUAL');
8
+
9
+ UPDATE "prefix_Metadata"
10
+ SET "value" = CASE
11
+ WHEN UPPER(COALESCE("value", '')) = 'OCTOPUS3D3' THEN 'OCTOPUS3D4'
12
+ ELSE "value"
13
+ END,
14
+ "note" = 'Default built-in avatar visual used for agents without `META IMAGE` or `META AVATAR`. Allowed values: PIXEL_ART, OCTOPUS, OCTOPUS2, OCTOPUS3, OCTOPUS3D, OCTOPUS3D2, OCTOPUS3D3, OCTOPUS3D4, ASCII_OCTOPUS, MINECRAFT, MINECRAFT2, FRACTAL, ORB.',
15
+ "updatedAt" = NOW()
16
+ WHERE "key" = 'DEFAULT_AGENT_AVATAR_VISUAL';
@@ -69,9 +69,7 @@ export async function findOwnedAgentByIdentifier(userId: number, identifier: str
69
69
  const tableName = await $getTableName('Agent');
70
70
  const { data, error } = await supabase
71
71
  .from(tableName)
72
- .select(
73
- 'id, agentName, createdAt, updatedAt, permanentId, agentHash, agentSource, agentProfile, promptbookEngineVersion, folderId, sortOrder, deletedAt, visibility, userId',
74
- )
72
+ .select(OWNED_AGENT_ROW_COLUMNS)
75
73
  .eq('userId', userId as never)
76
74
  .or(buildAgentNameOrIdFilter(identifier))
77
75
  .limit(10);
@@ -80,7 +78,58 @@ export async function findOwnedAgentByIdentifier(userId: number, identifier: str
80
78
  throw new Error(`Failed to load agent "${identifier}": ${error.message}`);
81
79
  }
82
80
 
83
- const rows = (data || []) as unknown as OwnedAgentRow[];
81
+ return pickAgentRowByIdentifier((data || []) as unknown as OwnedAgentRow[], identifier, 'for this owner');
82
+ }
83
+
84
+ /**
85
+ * Loads one agent by permanent id or agent name without an owner restriction.
86
+ *
87
+ * Owner-aware callers should use {@link findOwnedAgentByIdentifier}; this helper is reserved for administrative
88
+ * overrides where the caller has been independently authorized to read or mutate any agent.
89
+ *
90
+ * @param identifier - Permanent id or agent name.
91
+ * @returns Matching agent row or `null` when nothing matches.
92
+ */
93
+ export async function findAgentByIdentifier(identifier: string): Promise<OwnedAgentRow | null> {
94
+ const supabase = $provideSupabaseForServer();
95
+ const tableName = await $getTableName('Agent');
96
+ const { data, error } = await supabase
97
+ .from(tableName)
98
+ .select(OWNED_AGENT_ROW_COLUMNS)
99
+ .or(buildAgentNameOrIdFilter(identifier))
100
+ .limit(10);
101
+
102
+ if (error) {
103
+ throw new Error(`Failed to load agent "${identifier}": ${error.message}`);
104
+ }
105
+
106
+ return pickAgentRowByIdentifier((data || []) as unknown as OwnedAgentRow[], identifier, 'across agents');
107
+ }
108
+
109
+ /**
110
+ * Database columns selected for every agent row exposed by ownership-aware helpers.
111
+ */
112
+ const OWNED_AGENT_ROW_COLUMNS =
113
+ 'id, agentName, createdAt, updatedAt, permanentId, agentHash, agentSource, agentProfile, promptbookEngineVersion, folderId, sortOrder, deletedAt, visibility, userId';
114
+
115
+ /**
116
+ * Disambiguates a list of agent rows that all matched the same identifier.
117
+ *
118
+ * Resolution order:
119
+ * 1. Exact `permanentId` match — always preferred because permanent ids are unique.
120
+ * 2. Single `agentName` match — preserves the legacy lookup by human-readable name.
121
+ * 3. Single remaining row — accepts the only candidate found.
122
+ *
123
+ * @param rows - Candidate agent rows returned by Supabase.
124
+ * @param identifier - Identifier that was searched.
125
+ * @param ambiguityScope - Human-readable scope used in the ambiguity error message.
126
+ * @returns Best-matching row or `null` when no candidate matches.
127
+ */
128
+ function pickAgentRowByIdentifier(
129
+ rows: OwnedAgentRow[],
130
+ identifier: string,
131
+ ambiguityScope: string,
132
+ ): OwnedAgentRow | null {
84
133
  if (rows.length === 0) {
85
134
  return null;
86
135
  }
@@ -100,7 +149,7 @@ export async function findOwnedAgentByIdentifier(userId: number, identifier: str
100
149
  }
101
150
 
102
151
  throw new Error(
103
- `Agent identifier "${identifier}" is ambiguous for this owner. Use the stable \`permanentId\` returned by the API.`,
152
+ `Agent identifier "${identifier}" is ambiguous ${ambiguityScope}. Use the stable \`permanentId\` returned by the API.`,
104
153
  );
105
154
  }
106
155
 
@@ -0,0 +1,36 @@
1
+ import { findAgentByIdentifier, findOwnedAgentByIdentifier, type OwnedAgentRow } from './agentOwnership';
2
+ import { getCurrentUser } from './getCurrentUser';
3
+ import { isUserAdmin } from './isUserAdmin';
4
+
5
+ /**
6
+ * Loads one agent for the current caller, enforcing ownership-or-admin write access.
7
+ *
8
+ * Resolution order:
9
+ * 1. Anonymous callers receive `null` so routes can return `403`.
10
+ * 2. Owners are matched via {@link findOwnedAgentByIdentifier} using the authenticated user id.
11
+ * 3. Administrators (resolved via {@link isUserAdmin}) fall through to {@link findAgentByIdentifier} so they can manage any agent — including legacy `ADMIN_PASSWORD` admins that have no database user id.
12
+ *
13
+ * @param identifier - Permanent id or agent name supplied by the route.
14
+ * @returns Matching agent row when the caller owns it or is an administrator, otherwise `null`.
15
+ *
16
+ * @private internal helper of Agents Server route handlers
17
+ */
18
+ export async function findAgentForCallerWriteAccess(identifier: string): Promise<OwnedAgentRow | null> {
19
+ const [currentUser, isAdmin] = await Promise.all([getCurrentUser(), isUserAdmin()]);
20
+ if (!currentUser) {
21
+ return null;
22
+ }
23
+
24
+ if (typeof currentUser.id === 'number') {
25
+ const ownedAgent = await findOwnedAgentByIdentifier(currentUser.id, identifier);
26
+ if (ownedAgent) {
27
+ return ownedAgent;
28
+ }
29
+ }
30
+
31
+ if (!isAdmin) {
32
+ return null;
33
+ }
34
+
35
+ return findAgentByIdentifier(identifier);
36
+ }