@promptbook/cli 0.112.0-101 → 0.112.0-103

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 (91) hide show
  1. package/apps/agents-server/package.json +1 -1
  2. package/apps/agents-server/scripts/prerender-homepage.js +76 -1
  3. package/apps/agents-server/src/app/actions.ts +0 -6
  4. package/apps/agents-server/src/app/admin/about/page.tsx +1 -1
  5. package/apps/agents-server/src/app/admin/image-generator-test/ImageAttachmentsEditor.tsx +11 -6
  6. package/apps/agents-server/src/app/admin/login-methods/shibboleth/page.tsx +365 -0
  7. package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +13 -15
  8. package/apps/agents-server/src/app/admin/servers/ServersRegistryTable.tsx +3 -3
  9. package/apps/agents-server/src/app/admin/servers/useCreateServerWizard.ts +13 -14
  10. package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +12 -3
  11. package/apps/agents-server/src/app/admin/usage/UsageClientTimelineChart.tsx +1 -1
  12. package/apps/agents-server/src/app/admin/users/[userId]/UserDetailClient.tsx +21 -14
  13. package/apps/agents-server/src/app/agents/[agentName]/chat/AgentChatPageLayout.tsx +2 -2
  14. package/apps/agents-server/src/app/agents/[agentName]/chat/AgentChatSidebarDefault.tsx +11 -7
  15. package/apps/agents-server/src/app/api/admin/cli-access/route.ts +27 -123
  16. package/apps/agents-server/src/app/api/admin/code-runners/authentication/route.ts +33 -125
  17. package/apps/agents-server/src/app/api/auth/login/route.ts +0 -10
  18. package/apps/agents-server/src/app/api/auth/shibboleth/acs/route.ts +77 -57
  19. package/apps/agents-server/src/app/api/auth/shibboleth/login/route.ts +57 -33
  20. package/apps/agents-server/src/app/api/auth/shibboleth/metadata/route.ts +4 -29
  21. package/apps/agents-server/src/app/api/auth/shibboleth/status/route.ts +17 -0
  22. package/apps/agents-server/src/app/api/upload/route.ts +148 -209
  23. package/apps/agents-server/src/app/api/users/[username]/route.ts +1 -1
  24. package/apps/agents-server/src/app/api/users/route.ts +5 -5
  25. package/apps/agents-server/src/app/dashboard/page.tsx +1 -1
  26. package/apps/agents-server/src/app/docs/[docId]/page.tsx +1 -1
  27. package/apps/agents-server/src/app/docs/page.tsx +1 -1
  28. package/apps/agents-server/src/app/globals.css +100 -0
  29. package/apps/agents-server/src/app/layout.tsx +7 -0
  30. package/apps/agents-server/src/app/recycle-bin/page.tsx +1 -1
  31. package/apps/agents-server/src/app/system/settings/KeybindingsSettingsClient.tsx +13 -7
  32. package/apps/agents-server/src/components/AdminTerminal/useAdminTerminalSession.ts +29 -1
  33. package/apps/agents-server/src/components/AgentProfile/AgentProfile.tsx +3 -3
  34. package/apps/agents-server/src/components/AgentProfile/AgentProfileImage.tsx +8 -2
  35. package/apps/agents-server/src/components/DocsToolbar/DocsToolbar.tsx +4 -4
  36. package/apps/agents-server/src/components/DocumentationContent/DocumentationContent.tsx +9 -9
  37. package/apps/agents-server/src/components/Footer/Footer.tsx +7 -7
  38. package/apps/agents-server/src/components/Header/Header.tsx +24 -4
  39. package/apps/agents-server/src/components/Header/HeaderTypes.ts +6 -0
  40. package/apps/agents-server/src/components/Header/buildHeaderSystemMenuItems.ts +51 -1
  41. package/apps/agents-server/src/components/Homepage/Card.tsx +1 -1
  42. package/apps/agents-server/src/components/Homepage/Section.tsx +3 -1
  43. package/apps/agents-server/src/components/LayoutWrapper/LayoutWrapper.tsx +12 -1
  44. package/apps/agents-server/src/components/LoginForm/LoginForm.tsx +100 -149
  45. package/apps/agents-server/src/components/Skeleton/ConsolePageLoadingSkeleton.tsx +1 -1
  46. package/apps/agents-server/src/components/Skeleton/DocumentationRouteLoadingSkeleton.tsx +1 -1
  47. package/apps/agents-server/src/components/Skeleton/HomepageLoadingSkeleton.tsx +1 -1
  48. package/apps/agents-server/src/components/UsersList/UsersList.tsx +20 -4
  49. package/apps/agents-server/src/components/UsersList/useUsersAdmin.ts +3 -0
  50. package/apps/agents-server/src/constants/shibbolethAuth.ts +139 -0
  51. package/apps/agents-server/src/database/metadataDefaults.ts +54 -80
  52. package/apps/agents-server/src/database/migrate.ts +30 -1
  53. package/apps/agents-server/src/database/migrations/2026-06-0100-shibboleth-auth.sql +136 -0
  54. package/apps/agents-server/src/database/sqlite/$provideLocalSqliteSupabase.ts +88 -36
  55. package/apps/agents-server/src/languages/ServerTranslationKeys.ts +4 -2
  56. package/apps/agents-server/src/languages/translations/czech.yaml +4 -2
  57. package/apps/agents-server/src/languages/translations/english.yaml +5 -3
  58. package/apps/agents-server/src/tools/$provideCdnForServer.ts +54 -11
  59. package/apps/agents-server/src/utils/cdn/classes/DigitalOceanSpaces.ts +18 -2
  60. package/apps/agents-server/src/utils/cdn/classes/TrackedFilesStorage.ts +6 -5
  61. package/apps/agents-server/src/utils/cdn/interfaces/IFilesStorage.ts +5 -0
  62. package/apps/agents-server/src/utils/chatExport/renderHtmlToPdfOnServer.ts +11 -0
  63. package/apps/agents-server/src/utils/createAdminTerminalRouteHandlers.ts +264 -0
  64. package/apps/agents-server/src/utils/shareTargetPayloads.ts +19 -66
  65. package/apps/agents-server/src/utils/shibbolethAuthentication.ts +729 -621
  66. package/apps/agents-server/src/utils/upload/createBookEditorUploadHandler.ts +19 -28
  67. package/apps/agents-server/src/utils/upload/uploadFileToServer.ts +113 -0
  68. package/esm/index.es.js +194 -35
  69. package/esm/index.es.js.map +1 -1
  70. package/esm/scripts/run-codex-prompts/common/waitForPause.d.ts +12 -0
  71. package/esm/scripts/run-codex-prompts/main/runPromptRound.d.ts +2 -1
  72. package/esm/scripts/run-codex-prompts/ui/buildCoderRunUiFrame.d.ts +1 -0
  73. package/esm/scripts/run-codex-prompts/ui/buildRunUiFrameShared.d.ts +1 -1
  74. package/esm/src/book-components/Chat/MarkdownContent/MarkdownContent.d.ts +1 -0
  75. package/esm/src/version.d.ts +1 -1
  76. package/package.json +2 -2
  77. package/src/book-components/Chat/MarkdownContent/MarkdownContent.tsx +63 -4
  78. package/src/other/templates/getTemplatesPipelineCollection.ts +730 -739
  79. package/src/version.ts +2 -2
  80. package/src/versions.txt +2 -0
  81. package/umd/index.umd.js +194 -35
  82. package/umd/index.umd.js.map +1 -1
  83. package/umd/scripts/run-codex-prompts/common/waitForPause.d.ts +12 -0
  84. package/umd/scripts/run-codex-prompts/main/runPromptRound.d.ts +2 -1
  85. package/umd/scripts/run-codex-prompts/ui/buildCoderRunUiFrame.d.ts +1 -0
  86. package/umd/scripts/run-codex-prompts/ui/buildRunUiFrameShared.d.ts +1 -1
  87. package/umd/src/book-components/Chat/MarkdownContent/MarkdownContent.d.ts +1 -0
  88. package/umd/src/version.d.ts +1 -1
  89. package/apps/agents-server/src/app/api/auth/methods/route.ts +0 -44
  90. package/apps/agents-server/src/constants/authenticationMethods.ts +0 -74
  91. package/apps/agents-server/src/constants/shibbolethAuthentication.ts +0 -107
@@ -124,6 +124,7 @@ header.menuLabel: Menu
124
124
  header.myAccount: My Account
125
125
  header.superAdmin: Super Admin
126
126
  header.administration: Administration
127
+ header.loginMethods: Login Methods
127
128
  header.monitoringAndUsage: Monitoring & Usage
128
129
  header.integrationsAndKeys: Integrations & Keys
129
130
  header.developerDebug: Developer / Debug
@@ -164,6 +165,7 @@ header.errorSimulation: Error simulation
164
165
  header.imagesGallery: Images gallery
165
166
  header.files: Files
166
167
  header.users: Users
168
+ header.shibboleth: Shibboleth
167
169
  header.versionInfo: Version info
168
170
  header.experiments: Experiments
169
171
  header.story: Story
@@ -194,8 +196,8 @@ login.passwordLabel: Password
194
196
  login.passwordPlaceholder: Enter your password
195
197
  login.loggingIn: Logging in...
196
198
  login.loginAction: Log in
197
- login.shibbolethAction: Continue with {provider}
198
- login.methodDivider: or
199
+ login.shibbolethLoginAction: Continue with Shibboleth
200
+ login.shibbolethNotConfigured: Shibboleth login is active but needs administrator setup.
199
201
  login.forgottenPassword: Forgotten password?
200
202
  login.registerNewUser: Register new user
201
203
  login.errorOccurred: An error occurred
@@ -382,7 +384,7 @@ footer.logosAndBranding: Logos & Branding
382
384
  footer.connectSectionTitle: Connect
383
385
  footer.linksSectionTitle: Links
384
386
  footer.allRightsReserved: All rights reserved.
385
- footer.madeInCzechRepublic: Made with ❤️ in the Czech Republic.
387
+ footer.madeInCzechRepublic: Made with ❤️ in the Czech Republic
386
388
  footer.engineVersion: Promptbook engine version
387
389
  forbidden.title: 403 Forbidden
388
390
  forbidden.message: You do not have permission to access this page.
@@ -1,4 +1,5 @@
1
1
  import { $provideSupabaseForServer } from '../database/$provideSupabaseForServer';
2
+ import { DigitalOceanSpaces } from '../utils/cdn/classes/DigitalOceanSpaces';
2
3
  import { TrackedFilesStorage } from '../utils/cdn/classes/TrackedFilesStorage';
3
4
  import { VercelBlobStorage } from '../utils/cdn/classes/VercelBlobStorage';
4
5
  import { IIFilesStorageWithCdn } from '../utils/cdn/interfaces/IFilesStorage';
@@ -15,29 +16,71 @@ let cdn: IIFilesStorageWithCdn | null = null;
15
16
  */
16
17
  export function $provideCdnForServer(): IIFilesStorageWithCdn {
17
18
  if (!cdn) {
18
- const inner = new VercelBlobStorage({
19
- token: process.env.VERCEL_BLOB_READ_WRITE_TOKEN!,
20
- pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX!,
21
- cdnPublicUrl: new URL(process.env.NEXT_PUBLIC_CDN_PUBLIC_URL!),
22
- });
23
-
19
+ const inner = createCdnStorageForServer();
24
20
  const supabase = $provideSupabaseForServer();
25
21
  cdn = new TrackedFilesStorage(inner, supabase);
22
+ }
23
+
24
+ return cdn;
25
+ }
26
26
 
27
- /*
28
- cdn = new DigitalOceanSpaces({
27
+ /**
28
+ * Creates the configured CDN storage implementation for server-side file operations.
29
+ *
30
+ * @private helper of `$provideCdnForServer`
31
+ */
32
+ function createCdnStorageForServer(): IIFilesStorageWithCdn {
33
+ if (isS3CompatibleStorageSelected()) {
34
+ return new DigitalOceanSpaces({
29
35
  bucket: process.env.CDN_BUCKET!,
30
- pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX!,
36
+ pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX || '',
31
37
  endpoint: process.env.CDN_ENDPOINT!,
32
38
  accessKeyId: process.env.CDN_ACCESS_KEY_ID!,
33
39
  secretAccessKey: process.env.CDN_SECRET_ACCESS_KEY!,
34
40
  cdnPublicUrl: new URL(process.env.NEXT_PUBLIC_CDN_PUBLIC_URL!),
35
41
  gzip: true,
42
+ forcePathStyle: process.env.CDN_FORCE_PATH_STYLE === 'true',
43
+ region: process.env.CDN_REGION || 'auto',
36
44
  });
37
- */
38
45
  }
39
46
 
40
- return cdn;
47
+ return new VercelBlobStorage({
48
+ token: process.env.VERCEL_BLOB_READ_WRITE_TOKEN!,
49
+ pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX || '',
50
+ cdnPublicUrl: new URL(process.env.NEXT_PUBLIC_CDN_PUBLIC_URL!),
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Checks whether the current environment should use the S3-compatible storage implementation.
56
+ *
57
+ * @private helper of `$provideCdnForServer`
58
+ */
59
+ function isS3CompatibleStorageSelected(): boolean {
60
+ const storageMode = (process.env.PTBK_FILE_STORAGE_MODE || process.env.CDN_PROVIDER || '').toLowerCase();
61
+ const isS3StorageMode =
62
+ storageMode === 's3' || storageMode === 'external-s3' || storageMode === 'self-contained-s3';
63
+
64
+ if (isS3StorageMode) {
65
+ return hasS3CompatibleStorageConfiguration();
66
+ }
67
+
68
+ return !process.env.VERCEL_BLOB_READ_WRITE_TOKEN && hasS3CompatibleStorageConfiguration();
69
+ }
70
+
71
+ /**
72
+ * Checks whether all S3-compatible storage environment variables are present.
73
+ *
74
+ * @private helper of `$provideCdnForServer`
75
+ */
76
+ function hasS3CompatibleStorageConfiguration(): boolean {
77
+ return Boolean(
78
+ process.env.CDN_BUCKET &&
79
+ process.env.CDN_ENDPOINT &&
80
+ process.env.CDN_ACCESS_KEY_ID &&
81
+ process.env.CDN_SECRET_ACCESS_KEY &&
82
+ process.env.NEXT_PUBLIC_CDN_PUBLIC_URL,
83
+ );
41
84
  }
42
85
 
43
86
  // TODO: [🏓] Unite `xxxForServer` and `xxxForNode` naming
@@ -16,6 +16,8 @@ type IDigitalOceanSpacesConfig = {
16
16
  readonly secretAccessKey: string;
17
17
  readonly cdnPublicUrl: URL;
18
18
  readonly gzip: boolean;
19
+ readonly forcePathStyle?: boolean;
20
+ readonly region?: string;
19
21
 
20
22
  // TODO: [⛳️] Probbably prefix should be in this config not on the consumer side
21
23
  };
@@ -32,8 +34,9 @@ export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
32
34
 
33
35
  public constructor(private readonly config: IDigitalOceanSpacesConfig) {
34
36
  this.s3 = new S3Client({
35
- region: 'auto',
36
- endpoint: 'https://' + config.endpoint,
37
+ region: config.region || 'auto',
38
+ endpoint: normalizeS3Endpoint(config.endpoint),
39
+ forcePathStyle: config.forcePathStyle,
37
40
  credentials: {
38
41
  accessKeyId: config.accessKeyId,
39
42
  secretAccessKey: config.secretAccessKey,
@@ -119,5 +122,18 @@ export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
119
122
  }
120
123
  }
121
124
 
125
+ /**
126
+ * Normalizes endpoint values from legacy host-only configuration and full S3 URLs.
127
+ *
128
+ * @private helper of `DigitalOceanSpaces`
129
+ */
130
+ function normalizeS3Endpoint(endpoint: string): string {
131
+ if (/^https?:\/\//i.test(endpoint)) {
132
+ return endpoint;
133
+ }
134
+
135
+ return `https://${endpoint}`;
136
+ }
137
+
122
138
  // TODO: Implement Read-only mode
123
139
  // TODO: [☹️] Unite with `PromptbookStorage` and move to `/src/...`
@@ -33,24 +33,25 @@ export class TrackedFilesStorage implements IIFilesStorageWithCdn {
33
33
  }
34
34
 
35
35
  public async setItem(key: string, file: IFile): Promise<void> {
36
-
37
36
  await this.inner.setItem(key, file);
38
37
 
39
38
  try {
40
39
  const { userId, purpose } = file;
41
40
  const cdnUrl = this.getItemUrl(key).href;
41
+ const securityResult =
42
+ file.securityResult as AgentsServerDatabase['public']['Tables']['File']['Insert']['securityResult'];
42
43
 
43
-
44
44
  await this.supabase.from(await $getTableName('File')).insert({
45
45
  userId: userId || null,
46
46
  fileName: key,
47
47
  fileSize: file.fileSize ?? file.data.length,
48
48
  fileType: file.type,
49
- cdnUrl,
49
+ storageUrl: cdnUrl,
50
+ shortUrl: null,
50
51
  purpose: purpose || 'UNKNOWN',
52
+ status: 'COMPLETED',
53
+ securityResult: securityResult || null,
51
54
  });
52
-
53
-
54
55
  } catch (error) {
55
56
  console.error('Failed to track upload:', error);
56
57
  }
@@ -25,6 +25,11 @@ export type IFile = {
25
25
  * Note: This is optional, if not provided, the size of the buffer is used
26
26
  */
27
27
  fileSize?: number;
28
+
29
+ /**
30
+ * Optional file security result collected before the file is tracked.
31
+ */
32
+ securityResult?: Record<string, unknown>;
28
33
  };
29
34
 
30
35
  /**
@@ -9,6 +9,16 @@ const CHAT_EXPORT_PDF_VIEWPORT = {
9
9
  height: 1600,
10
10
  } as const;
11
11
 
12
+ /**
13
+ * Page margins applied to every exported PDF page.
14
+ */
15
+ const CHAT_EXPORT_PDF_MARGIN = {
16
+ top: '0.5in',
17
+ right: '0.5in',
18
+ bottom: '0.5in',
19
+ left: '0.5in',
20
+ } as const;
21
+
12
22
  /**
13
23
  * Prints standalone HTML into a PDF using a short-lived headless Chromium instance.
14
24
  *
@@ -31,6 +41,7 @@ export async function renderHtmlToPdfOnServer(html: string): Promise<Buffer> {
31
41
 
32
42
  return await page.pdf({
33
43
  format: 'Letter',
44
+ margin: CHAT_EXPORT_PDF_MARGIN,
34
45
  printBackground: true,
35
46
  preferCSSPageSize: true,
36
47
  });
@@ -0,0 +1,264 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { createInteractiveTerminalEventStream } from './createInteractiveTerminalEventStream';
3
+ import { isUserGlobalAdmin } from './isUserGlobalAdmin';
4
+ import type { InteractiveTerminalSessionSubscriber } from './interactiveTerminalSession';
5
+
6
+ /**
7
+ * Minimal browser-safe terminal session shape handled by the shared admin API routes.
8
+ */
9
+ type AdminTerminalRouteSession = {
10
+ /**
11
+ * Whether the terminal process is still active.
12
+ */
13
+ readonly isRunning: boolean;
14
+ };
15
+
16
+ /**
17
+ * Browser stream callbacks accepted by one admin terminal route.
18
+ */
19
+ type AdminTerminalRouteSubscriber<TSession extends AdminTerminalRouteSession> = {
20
+ /**
21
+ * Called whenever the terminal backend emits output.
22
+ */
23
+ readonly onOutput: InteractiveTerminalSessionSubscriber['onOutput'];
24
+
25
+ /**
26
+ * Called once the terminal backend exits.
27
+ */
28
+ readonly onExit: (event: { readonly type: 'exit'; readonly snapshot: TSession }) => void;
29
+ };
30
+
31
+ /**
32
+ * Terminal backend operations provided by each specific admin terminal.
33
+ */
34
+ type AdminTerminalRouteBackend<TSession extends AdminTerminalRouteSession> = {
35
+ /**
36
+ * Returns the latest session for the terminal purpose.
37
+ */
38
+ readonly getLatestSession: (request: Request) => Promise<TSession | null> | TSession | null;
39
+
40
+ /**
41
+ * Looks up one session by id.
42
+ */
43
+ readonly getSession: (sessionId: string) => Promise<TSession | null> | TSession | null;
44
+
45
+ /**
46
+ * Starts or reconnects to the terminal session.
47
+ */
48
+ readonly startSession: (request: Request) => Promise<TSession> | TSession;
49
+
50
+ /**
51
+ * Sends raw input to the terminal session.
52
+ */
53
+ readonly writeSessionInput: (sessionId: string, input: string) => Promise<TSession> | TSession;
54
+
55
+ /**
56
+ * Stops one terminal session.
57
+ */
58
+ readonly stopSession: (sessionId: string) => Promise<TSession> | TSession;
59
+
60
+ /**
61
+ * Subscribes one browser stream to live terminal events.
62
+ */
63
+ readonly subscribeToSession: (
64
+ sessionId: string,
65
+ subscriber: AdminTerminalRouteSubscriber<TSession>,
66
+ ) => (() => void) | null;
67
+ };
68
+
69
+ /**
70
+ * User-facing error messages used by the generated terminal route handlers.
71
+ */
72
+ type AdminTerminalRouteMessages = {
73
+ /**
74
+ * Error used when loading the latest session fails.
75
+ */
76
+ readonly loadErrorMessage: string;
77
+
78
+ /**
79
+ * Error used when the stream request omits a session id.
80
+ */
81
+ readonly missingStreamSessionIdMessage: string;
82
+
83
+ /**
84
+ * Error used when the requested stream session does not exist.
85
+ */
86
+ readonly sessionNotFoundMessage: string;
87
+
88
+ /**
89
+ * Error used when starting a session fails.
90
+ */
91
+ readonly startErrorMessage: string;
92
+
93
+ /**
94
+ * Error used when the input request is malformed.
95
+ */
96
+ readonly missingInputMessage: string;
97
+
98
+ /**
99
+ * Error used when writing input fails.
100
+ */
101
+ readonly sendErrorMessage: string;
102
+
103
+ /**
104
+ * Error used when the stop request omits a session id.
105
+ */
106
+ readonly missingStopSessionIdMessage: string;
107
+
108
+ /**
109
+ * Error used when stopping a session fails.
110
+ */
111
+ readonly stopErrorMessage: string;
112
+ };
113
+
114
+ /**
115
+ * Generated Next.js route handlers for one admin terminal endpoint.
116
+ */
117
+ type AdminTerminalRouteHandlers = {
118
+ /**
119
+ * Loads the latest terminal session or streams a specific session.
120
+ */
121
+ readonly GET: (request: Request) => Promise<Response>;
122
+
123
+ /**
124
+ * Starts or reconnects to the terminal session.
125
+ */
126
+ readonly POST: (request: Request) => Promise<Response>;
127
+
128
+ /**
129
+ * Sends raw input to a running terminal session.
130
+ */
131
+ readonly PATCH: (request: Request) => Promise<Response>;
132
+
133
+ /**
134
+ * Stops one terminal session.
135
+ */
136
+ readonly DELETE: (request: Request) => Promise<Response>;
137
+ };
138
+
139
+ /**
140
+ * Creates consistent admin terminal API handlers for GET/stream, POST, PATCH, and DELETE.
141
+ *
142
+ * @param backend - Terminal-specific backend operations.
143
+ * @param messages - Terminal-specific error messages.
144
+ * @returns Next.js route handlers ready to export from an API route.
145
+ */
146
+ export function createAdminTerminalRouteHandlers<TSession extends AdminTerminalRouteSession>(
147
+ backend: AdminTerminalRouteBackend<TSession>,
148
+ messages: AdminTerminalRouteMessages,
149
+ ): AdminTerminalRouteHandlers {
150
+ return {
151
+ async GET(request: Request): Promise<Response> {
152
+ if (!(await isUserGlobalAdmin())) {
153
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
154
+ }
155
+
156
+ try {
157
+ const { searchParams } = new URL(request.url);
158
+ const sessionId = searchParams.get('sessionId')?.trim() || '';
159
+ const isStreamRequested = searchParams.get('stream') === '1';
160
+
161
+ if (isStreamRequested) {
162
+ if (!sessionId) {
163
+ return NextResponse.json({ error: messages.missingStreamSessionIdMessage }, { status: 400 });
164
+ }
165
+
166
+ const session = await backend.getSession(sessionId);
167
+ if (!session) {
168
+ return NextResponse.json({ error: messages.sessionNotFoundMessage }, { status: 404 });
169
+ }
170
+
171
+ return createInteractiveTerminalEventStream(
172
+ request,
173
+ sessionId,
174
+ session,
175
+ backend.subscribeToSession,
176
+ );
177
+ }
178
+
179
+ return NextResponse.json({
180
+ session: await backend.getLatestSession(request),
181
+ });
182
+ } catch (error) {
183
+ return createAdminTerminalErrorResponse(error, messages.loadErrorMessage);
184
+ }
185
+ },
186
+
187
+ async POST(request: Request): Promise<Response> {
188
+ if (!(await isUserGlobalAdmin())) {
189
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
190
+ }
191
+
192
+ try {
193
+ return NextResponse.json({
194
+ session: await backend.startSession(request),
195
+ });
196
+ } catch (error) {
197
+ return createAdminTerminalErrorResponse(error, messages.startErrorMessage);
198
+ }
199
+ },
200
+
201
+ async PATCH(request: Request): Promise<Response> {
202
+ if (!(await isUserGlobalAdmin())) {
203
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
204
+ }
205
+
206
+ try {
207
+ const body = (await request.json().catch(() => null)) as
208
+ | {
209
+ readonly sessionId?: string;
210
+ readonly input?: string;
211
+ }
212
+ | null;
213
+
214
+ if (!body?.sessionId || typeof body.input !== 'string') {
215
+ return NextResponse.json({ error: messages.missingInputMessage }, { status: 400 });
216
+ }
217
+
218
+ return NextResponse.json({
219
+ session: await backend.writeSessionInput(body.sessionId, body.input),
220
+ });
221
+ } catch (error) {
222
+ return createAdminTerminalErrorResponse(error, messages.sendErrorMessage);
223
+ }
224
+ },
225
+
226
+ async DELETE(request: Request): Promise<Response> {
227
+ if (!(await isUserGlobalAdmin())) {
228
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
229
+ }
230
+
231
+ try {
232
+ const body = (await request.json().catch(() => null)) as
233
+ | {
234
+ readonly sessionId?: string;
235
+ }
236
+ | null;
237
+
238
+ if (!body?.sessionId) {
239
+ return NextResponse.json({ error: messages.missingStopSessionIdMessage }, { status: 400 });
240
+ }
241
+
242
+ return NextResponse.json({
243
+ session: await backend.stopSession(body.sessionId),
244
+ });
245
+ } catch (error) {
246
+ return createAdminTerminalErrorResponse(error, messages.stopErrorMessage);
247
+ }
248
+ },
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Converts one thrown backend failure into a consistent JSON error response.
254
+ *
255
+ * @param error - Thrown backend error.
256
+ * @param fallbackMessage - Message used when the thrown value is not an `Error`.
257
+ * @returns JSON error response.
258
+ */
259
+ function createAdminTerminalErrorResponse(error: unknown, fallbackMessage: string): Response {
260
+ return NextResponse.json(
261
+ { error: error instanceof Error ? error.message : fallbackMessage },
262
+ { status: 500 },
263
+ );
264
+ }
@@ -2,12 +2,12 @@ import { $getTableName } from '@/src/database/$getTableName';
2
2
  import { $provideSupabaseForServer } from '@/src/database/$provideSupabaseForServer';
3
3
  import type { Json } from '@/src/database/schema';
4
4
  import { FILE_SECURITY_CHECKERS } from '@/src/file-security-checkers';
5
+ import { $provideCdnForServer } from '@/src/tools/$provideCdnForServer';
5
6
  import { $provideServer } from '@/src/tools/$provideServer';
6
7
  import { getUserFileCdnKey } from '@/src/utils/cdn/utils/getUserFileCdnKey';
7
8
  import { validateMimeType } from '@/src/utils/validators/validateMimeType';
8
9
  import { normalizeChatAttachments } from '@promptbook-local/core';
9
10
  import type { TODO_any } from '@promptbook-local/types';
10
- import { put } from '@vercel/blob';
11
11
  import { after } from 'next/server';
12
12
  import { spaceTrim } from 'spacetrim';
13
13
  import { DatabaseError } from '../../../../src/errors/DatabaseError';
@@ -231,12 +231,14 @@ async function createShareTargetAttachment(file: File, maxFileUploadBytes: numbe
231
231
 
232
232
  const mimeType = resolveShareTargetMimeType(file.type);
233
233
  const blobPath = getUserFileCdnKey(buffer, normalizedFilename);
234
- const uploadedBlob = await put(blobPath, buffer, {
235
- access: 'public',
236
- addRandomSuffix: false,
237
- allowOverwrite: true,
238
- contentType: mimeType,
239
- token: process.env.VERCEL_BLOB_READ_WRITE_TOKEN!,
234
+ const cdn = $provideCdnForServer();
235
+ const storageUrl = cdn.getItemUrl(blobPath).href;
236
+
237
+ await cdn.setItem(blobPath, {
238
+ type: mimeType,
239
+ data: buffer,
240
+ purpose: SHARE_TARGET_FILE_PURPOSE,
241
+ fileSize: buffer.byteLength,
240
242
  }).catch((error) => {
241
243
  throw new DatabaseError(
242
244
  spaceTrim(`
@@ -246,75 +248,26 @@ async function createShareTargetAttachment(file: File, maxFileUploadBytes: numbe
246
248
  `),
247
249
  );
248
250
  });
249
- const fileRecordId = await insertShareTargetFileRecord({
250
- fileName: normalizedFilename,
251
- fileSize: buffer.byteLength,
252
- fileType: mimeType,
253
- storageUrl: uploadedBlob.url,
254
- });
255
251
 
256
- if (fileRecordId !== null) {
257
- after(() =>
258
- populateShareTargetFileSecurityResult({
259
- fileId: fileRecordId,
260
- storageUrl: uploadedBlob.url,
261
- }).catch((error) => {
262
- console.error('[share-target] Failed to finalize file security result', error);
263
- }),
264
- );
265
- }
252
+ after(() =>
253
+ populateShareTargetFileSecurityResult({
254
+ storageUrl,
255
+ }).catch((error) => {
256
+ console.error('[share-target] Failed to finalize file security result', error);
257
+ }),
258
+ );
266
259
 
267
260
  return {
268
261
  name: normalizedFilename,
269
262
  type: mimeType,
270
- url: uploadedBlob.url,
263
+ url: storageUrl,
271
264
  };
272
265
  }
273
266
 
274
- /**
275
- * Inserts one admin-visible file row for a shared attachment.
276
- */
277
- async function insertShareTargetFileRecord(options: {
278
- fileName: string;
279
- fileSize: number;
280
- fileType: string;
281
- storageUrl: string;
282
- }): Promise<number | null> {
283
- try {
284
- const supabase = $provideSupabaseForServer() as TODO_any;
285
- const fileTableName = await $getTableName('File');
286
- const { data, error } = await supabase
287
- .from(fileTableName)
288
- .insert({
289
- fileName: options.fileName,
290
- fileSize: options.fileSize,
291
- fileType: options.fileType,
292
- storageUrl: options.storageUrl,
293
- shortUrl: null,
294
- purpose: SHARE_TARGET_FILE_PURPOSE,
295
- status: 'COMPLETED',
296
- agentId: null,
297
- securityResult: null,
298
- })
299
- .select('id')
300
- .maybeSingle();
301
-
302
- if (error) {
303
- console.error('[share-target] Failed to store uploaded file metadata', error);
304
- return null;
305
- }
306
-
307
- return typeof data?.id === 'number' ? data.id : null;
308
- } catch (error) {
309
- console.error('[share-target] Failed to insert shared file metadata', error);
310
- return null;
311
- }
312
- }
313
-
314
267
  /**
315
268
  * Populates best-effort file-security results after the share redirect has already returned.
316
269
  */
317
- async function populateShareTargetFileSecurityResult(options: { fileId: number; storageUrl: string }): Promise<void> {
270
+ async function populateShareTargetFileSecurityResult(options: { storageUrl: string }): Promise<void> {
318
271
  const securityResult: Record<string, unknown> = {};
319
272
 
320
273
  for (const checkerId of Object.keys(FILE_SECURITY_CHECKERS)) {
@@ -342,7 +295,7 @@ async function populateShareTargetFileSecurityResult(options: { fileId: number;
342
295
  .update({
343
296
  securityResult,
344
297
  })
345
- .eq('id', options.fileId);
298
+ .eq('storageUrl', options.storageUrl);
346
299
 
347
300
  if (error) {
348
301
  throw new DatabaseError(