@promptbook/cli 0.112.0-101 → 0.112.0-102

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 (78) hide show
  1. package/apps/agents-server/README.md +6 -0
  2. package/apps/agents-server/package.json +1 -1
  3. package/apps/agents-server/scripts/prerender-homepage.js +76 -1
  4. package/apps/agents-server/src/app/actions.ts +0 -6
  5. package/apps/agents-server/src/app/admin/about/page.tsx +1 -1
  6. package/apps/agents-server/src/app/admin/login-methods/shibboleth/page.tsx +365 -0
  7. package/apps/agents-server/src/app/admin/servers/ServersRegistryTable.tsx +3 -3
  8. package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +12 -3
  9. package/apps/agents-server/src/app/admin/usage/UsageClientTimelineChart.tsx +1 -1
  10. package/apps/agents-server/src/app/admin/users/[userId]/UserDetailClient.tsx +21 -14
  11. package/apps/agents-server/src/app/agents/[agentName]/chat/AgentChatPageLayout.tsx +2 -2
  12. package/apps/agents-server/src/app/agents/[agentName]/chat/AgentChatSidebarDefault.tsx +11 -7
  13. package/apps/agents-server/src/app/api/admin/cli-access/route.ts +27 -123
  14. package/apps/agents-server/src/app/api/admin/code-runners/authentication/route.ts +33 -125
  15. package/apps/agents-server/src/app/api/auth/login/route.ts +0 -10
  16. package/apps/agents-server/src/app/api/auth/shibboleth/acs/route.ts +77 -57
  17. package/apps/agents-server/src/app/api/auth/shibboleth/login/route.ts +57 -33
  18. package/apps/agents-server/src/app/api/auth/shibboleth/metadata/route.ts +4 -29
  19. package/apps/agents-server/src/app/api/auth/shibboleth/status/route.ts +17 -0
  20. package/apps/agents-server/src/app/api/upload/route.ts +230 -18
  21. package/apps/agents-server/src/app/api/users/[username]/route.ts +1 -1
  22. package/apps/agents-server/src/app/api/users/route.ts +5 -5
  23. package/apps/agents-server/src/app/dashboard/page.tsx +1 -1
  24. package/apps/agents-server/src/app/docs/[docId]/page.tsx +1 -1
  25. package/apps/agents-server/src/app/docs/page.tsx +1 -1
  26. package/apps/agents-server/src/app/globals.css +100 -0
  27. package/apps/agents-server/src/app/layout.tsx +7 -0
  28. package/apps/agents-server/src/app/recycle-bin/page.tsx +1 -1
  29. package/apps/agents-server/src/app/system/settings/KeybindingsSettingsClient.tsx +13 -7
  30. package/apps/agents-server/src/components/AdminTerminal/useAdminTerminalSession.ts +29 -1
  31. package/apps/agents-server/src/components/AgentProfile/AgentProfile.tsx +3 -3
  32. package/apps/agents-server/src/components/AgentProfile/AgentProfileImage.tsx +8 -2
  33. package/apps/agents-server/src/components/DocsToolbar/DocsToolbar.tsx +4 -4
  34. package/apps/agents-server/src/components/DocumentationContent/DocumentationContent.tsx +9 -9
  35. package/apps/agents-server/src/components/Footer/Footer.tsx +7 -7
  36. package/apps/agents-server/src/components/Header/Header.tsx +24 -4
  37. package/apps/agents-server/src/components/Header/HeaderTypes.ts +6 -0
  38. package/apps/agents-server/src/components/Header/buildHeaderSystemMenuItems.ts +51 -1
  39. package/apps/agents-server/src/components/Homepage/Card.tsx +1 -1
  40. package/apps/agents-server/src/components/Homepage/Section.tsx +3 -1
  41. package/apps/agents-server/src/components/LayoutWrapper/LayoutWrapper.tsx +12 -1
  42. package/apps/agents-server/src/components/LoginForm/LoginForm.tsx +100 -149
  43. package/apps/agents-server/src/components/Skeleton/ConsolePageLoadingSkeleton.tsx +1 -1
  44. package/apps/agents-server/src/components/Skeleton/DocumentationRouteLoadingSkeleton.tsx +1 -1
  45. package/apps/agents-server/src/components/Skeleton/HomepageLoadingSkeleton.tsx +1 -1
  46. package/apps/agents-server/src/components/UsersList/UsersList.tsx +20 -4
  47. package/apps/agents-server/src/components/UsersList/useUsersAdmin.ts +3 -0
  48. package/apps/agents-server/src/constants/shibbolethAuth.ts +139 -0
  49. package/apps/agents-server/src/database/metadataDefaults.ts +54 -80
  50. package/apps/agents-server/src/database/migrate.ts +30 -1
  51. package/apps/agents-server/src/database/migrations/2026-06-0100-shibboleth-auth.sql +136 -0
  52. package/apps/agents-server/src/database/sqlite/$provideLocalSqliteSupabase.ts +88 -36
  53. package/apps/agents-server/src/languages/ServerTranslationKeys.ts +4 -2
  54. package/apps/agents-server/src/languages/translations/czech.yaml +4 -2
  55. package/apps/agents-server/src/languages/translations/english.yaml +5 -3
  56. package/apps/agents-server/src/tools/$provideCdnForServer.ts +69 -23
  57. package/apps/agents-server/src/utils/cdn/classes/DigitalOceanSpaces.ts +54 -6
  58. package/apps/agents-server/src/utils/cdn/classes/TrackedFilesStorage.ts +4 -6
  59. package/apps/agents-server/src/utils/cdn/resolveCdnStorageProvider.ts +40 -0
  60. package/apps/agents-server/src/utils/chatExport/renderHtmlToPdfOnServer.ts +11 -0
  61. package/apps/agents-server/src/utils/createAdminTerminalRouteHandlers.ts +264 -0
  62. package/apps/agents-server/src/utils/shareTargetPayloads.ts +11 -10
  63. package/apps/agents-server/src/utils/shibbolethAuthentication.ts +729 -621
  64. package/apps/agents-server/src/utils/upload/createBookEditorUploadHandler.ts +137 -19
  65. package/esm/index.es.js +1 -1
  66. package/esm/src/book-components/Chat/MarkdownContent/MarkdownContent.d.ts +1 -0
  67. package/esm/src/version.d.ts +1 -1
  68. package/package.json +2 -2
  69. package/src/book-components/Chat/MarkdownContent/MarkdownContent.tsx +65 -4
  70. package/src/other/templates/getTemplatesPipelineCollection.ts +788 -719
  71. package/src/version.ts +2 -2
  72. package/src/versions.txt +1 -0
  73. package/umd/index.umd.js +1 -1
  74. package/umd/src/book-components/Chat/MarkdownContent/MarkdownContent.d.ts +1 -0
  75. package/umd/src/version.d.ts +1 -1
  76. package/apps/agents-server/src/app/api/auth/methods/route.ts +0 -44
  77. package/apps/agents-server/src/constants/authenticationMethods.ts +0 -74
  78. 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,43 +1,89 @@
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';
6
+ import { resolveCdnStorageProvider } from '../utils/cdn/resolveCdnStorageProvider';
5
7
 
6
8
  /**
7
- * Cache of CDN instance
9
+ * Environment value that enables path-style S3 requests.
8
10
  *
9
11
  * @private internal cache for `$provideCdnForServer`
10
12
  */
11
- let cdn: IIFilesStorageWithCdn | null = null;
13
+ const TRUE_ENV_VALUE = 'true';
14
+
15
+ /**
16
+ * Cache of raw CDN instance.
17
+ *
18
+ * @private internal cache for `$provideCdnForServer`
19
+ */
20
+ let rawCdn: IIFilesStorageWithCdn | null = null;
21
+
22
+ /**
23
+ * Cache of tracked CDN instance.
24
+ *
25
+ * @private internal cache for `$provideCdnForServer`
26
+ */
27
+ let trackedCdn: IIFilesStorageWithCdn | null = null;
28
+
29
+ /**
30
+ * Creates a CDN storage instance from environment variables.
31
+ *
32
+ * @private internal factory for `$provideCdnForServer`
33
+ */
34
+ function createCdnForServer(): IIFilesStorageWithCdn {
35
+ const provider = resolveCdnStorageProvider();
36
+
37
+ switch (provider) {
38
+ case 's3':
39
+ return new DigitalOceanSpaces({
40
+ bucket: process.env.CDN_BUCKET!,
41
+ pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX || '',
42
+ endpoint: process.env.CDN_ENDPOINT!,
43
+ region: process.env.CDN_REGION || 'auto',
44
+ accessKeyId: process.env.CDN_ACCESS_KEY_ID!,
45
+ secretAccessKey: process.env.CDN_SECRET_ACCESS_KEY!,
46
+ cdnPublicUrl: new URL(process.env.NEXT_PUBLIC_CDN_PUBLIC_URL!),
47
+ gzip: process.env.CDN_GZIP !== 'false',
48
+ forcePathStyle: process.env.CDN_FORCE_PATH_STYLE === TRUE_ENV_VALUE,
49
+ isPublicReadAclEnabled: process.env.CDN_ENABLE_PUBLIC_READ_ACL !== 'false',
50
+ });
51
+
52
+ case 'vercel':
53
+ return new VercelBlobStorage({
54
+ token: process.env.VERCEL_BLOB_READ_WRITE_TOKEN!,
55
+ pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX!,
56
+ cdnPublicUrl: new URL(process.env.NEXT_PUBLIC_CDN_PUBLIC_URL!),
57
+ });
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Provides an untracked CDN storage interface for code paths that manage `File` rows themselves.
63
+ *
64
+ * @private internal cache for `$provideCdnForServer`
65
+ */
66
+ export function $provideUntrackedCdnForServer(): IIFilesStorageWithCdn {
67
+ if (!rawCdn) {
68
+ rawCdn = createCdnForServer();
69
+ }
70
+
71
+ return rawCdn;
72
+ }
12
73
 
13
74
  /**
14
75
  * Provides a CDN storage interface for server-side file operations, with caching to reuse instances.
76
+ *
77
+ * @private internal cache for `$provideCdnForServer`
15
78
  */
16
79
  export function $provideCdnForServer(): IIFilesStorageWithCdn {
17
- 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
-
80
+ if (!trackedCdn) {
81
+ const inner = $provideUntrackedCdnForServer();
24
82
  const supabase = $provideSupabaseForServer();
25
- cdn = new TrackedFilesStorage(inner, supabase);
26
-
27
- /*
28
- cdn = new DigitalOceanSpaces({
29
- bucket: process.env.CDN_BUCKET!,
30
- pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX!,
31
- endpoint: process.env.CDN_ENDPOINT!,
32
- accessKeyId: process.env.CDN_ACCESS_KEY_ID!,
33
- secretAccessKey: process.env.CDN_SECRET_ACCESS_KEY!,
34
- cdnPublicUrl: new URL(process.env.NEXT_PUBLIC_CDN_PUBLIC_URL!),
35
- gzip: true,
36
- });
37
- */
83
+ trackedCdn = new TrackedFilesStorage(inner, supabase);
38
84
  }
39
85
 
40
- return cdn;
86
+ return trackedCdn;
41
87
  }
42
88
 
43
89
  // TODO: [🏓] Unite `xxxForServer` and `xxxForNode` naming
@@ -12,14 +12,45 @@ type IDigitalOceanSpacesConfig = {
12
12
  readonly bucket: string;
13
13
  readonly pathPrefix: string;
14
14
  readonly endpoint: string;
15
+ readonly region?: string;
15
16
  readonly accessKeyId: string;
16
17
  readonly secretAccessKey: string;
17
18
  readonly cdnPublicUrl: URL;
18
19
  readonly gzip: boolean;
20
+ readonly forcePathStyle?: boolean;
21
+ readonly isPublicReadAclEnabled?: boolean;
19
22
 
20
23
  // TODO: [⛳️] Probbably prefix should be in this config not on the consumer side
21
24
  };
22
25
 
26
+ /**
27
+ * Resolves the S3 endpoint URL, preserving explicit `http` endpoints for local MinIO.
28
+ *
29
+ * @private internal helper for DigitalOceanSpaces.
30
+ */
31
+ function resolveS3Endpoint(endpoint: string): string {
32
+ if (/^https?:\/\//i.test(endpoint)) {
33
+ return endpoint;
34
+ }
35
+
36
+ return `https://${endpoint}`;
37
+ }
38
+
39
+ /**
40
+ * Returns a URL object whose pathname ends with `/`.
41
+ *
42
+ * @private internal helper for DigitalOceanSpaces.
43
+ */
44
+ function ensureTrailingSlashUrl(url: URL): URL {
45
+ const normalizedUrl = new URL(url.href);
46
+
47
+ if (!normalizedUrl.pathname.endsWith('/')) {
48
+ normalizedUrl.pathname = `${normalizedUrl.pathname}/`;
49
+ }
50
+
51
+ return normalizedUrl;
52
+ }
53
+
23
54
  /**
24
55
  * Class implementing digital ocean spaces.
25
56
  */
@@ -32,8 +63,9 @@ export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
32
63
 
33
64
  public constructor(private readonly config: IDigitalOceanSpacesConfig) {
34
65
  this.s3 = new S3Client({
35
- region: 'auto',
36
- endpoint: 'https://' + config.endpoint,
66
+ region: config.region || 'auto',
67
+ endpoint: resolveS3Endpoint(config.endpoint),
68
+ forcePathStyle: config.forcePathStyle,
37
69
  credentials: {
38
70
  accessKeyId: config.accessKeyId,
39
71
  secretAccessKey: config.secretAccessKey,
@@ -42,13 +74,13 @@ export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
42
74
  }
43
75
 
44
76
  public getItemUrl(key: string): URL {
45
- return new URL(this.config.pathPrefix + '/' + key, this.cdnPublicUrl);
77
+ return new URL(this.getStorageKey(key), ensureTrailingSlashUrl(this.cdnPublicUrl));
46
78
  }
47
79
 
48
80
  public async getItem(key: string): Promise<IFile | null> {
49
81
  const parameters = {
50
82
  Bucket: this.config.bucket,
51
- Key: this.config.pathPrefix + '/' + key,
83
+ Key: this.getStorageKey(key),
52
84
  };
53
85
 
54
86
  try {
@@ -104,12 +136,12 @@ export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
104
136
  const uploadResult = await this.s3.send(
105
137
  new PutObjectCommand({
106
138
  Bucket: this.config.bucket,
107
- Key: this.config.pathPrefix + '/' + key,
139
+ Key: this.getStorageKey(key),
108
140
  ContentType: processedFile.type,
109
141
  ...putObjectRequestAdditional,
110
142
  Body: processedFile.data,
111
143
  // TODO: Public read access / just private to extending class
112
- ACL: 'public-read',
144
+ ...(this.config.isPublicReadAclEnabled === false ? {} : { ACL: 'public-read' }),
113
145
  }),
114
146
  );
115
147
 
@@ -117,6 +149,22 @@ export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
117
149
  throw new Error(`Upload result does not contain ETag`);
118
150
  }
119
151
  }
152
+
153
+ /**
154
+ * Builds the final object key used in the S3 bucket.
155
+ *
156
+ * @private internal helper for DigitalOceanSpaces.
157
+ */
158
+ private getStorageKey(key: string): string {
159
+ const normalizedKey = key.replace(/^\/+/g, '');
160
+ const normalizedPathPrefix = this.config.pathPrefix.replace(/^\/+|\/+$/g, '');
161
+
162
+ if (!normalizedPathPrefix) {
163
+ return normalizedKey;
164
+ }
165
+
166
+ return `${normalizedPathPrefix}/${normalizedKey}`;
167
+ }
120
168
  }
121
169
 
122
170
  // TODO: Implement Read-only mode
@@ -33,24 +33,22 @@ 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
- const cdnUrl = this.getItemUrl(key).href;
40
+ const storageUrl = this.getItemUrl(key).href;
42
41
 
43
-
44
42
  await this.supabase.from(await $getTableName('File')).insert({
45
43
  userId: userId || null,
46
44
  fileName: key,
47
45
  fileSize: file.fileSize ?? file.data.length,
48
46
  fileType: file.type,
49
- cdnUrl,
47
+ storageUrl,
48
+ shortUrl: null,
50
49
  purpose: purpose || 'UNKNOWN',
50
+ status: 'COMPLETED',
51
51
  });
52
-
53
-
54
52
  } catch (error) {
55
53
  console.error('Failed to track upload:', error);
56
54
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Supported file storage backends for Agents Server CDN uploads.
3
+ *
4
+ * @private internal CDN configuration helper.
5
+ */
6
+ export type CdnStorageProvider = 's3' | 'vercel';
7
+
8
+ /**
9
+ * Resolves the configured CDN storage provider.
10
+ *
11
+ * @private internal CDN configuration helper.
12
+ */
13
+ export function resolveCdnStorageProvider(): CdnStorageProvider {
14
+ const rawProvider = (process.env.NEXT_PUBLIC_CDN_STORAGE_PROVIDER || '').trim().toLowerCase();
15
+
16
+ switch (rawProvider) {
17
+ case 's3':
18
+ case 'minio':
19
+ case 'external-s3':
20
+ case 'self-contained-s3':
21
+ return 's3';
22
+
23
+ case 'vercel':
24
+ case 'vercel-blob':
25
+ case '':
26
+ return 'vercel';
27
+
28
+ default:
29
+ throw new Error(`Unsupported CDN storage provider \`${rawProvider}\`.`);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Checks whether browser uploads should be routed through the Agents Server API.
35
+ *
36
+ * @private internal CDN configuration helper.
37
+ */
38
+ export function isServerRoutedCdnUploadProvider(provider: CdnStorageProvider = resolveCdnStorageProvider()): boolean {
39
+ return provider === 's3';
40
+ }
@@ -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 { $provideUntrackedCdnForServer } 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,12 @@ 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 = $provideUntrackedCdnForServer();
235
+ await cdn.setItem(blobPath, {
236
+ data: buffer,
237
+ type: mimeType,
238
+ fileSize: buffer.byteLength,
239
+ purpose: SHARE_TARGET_FILE_PURPOSE,
240
240
  }).catch((error) => {
241
241
  throw new DatabaseError(
242
242
  spaceTrim(`
@@ -246,18 +246,19 @@ async function createShareTargetAttachment(file: File, maxFileUploadBytes: numbe
246
246
  `),
247
247
  );
248
248
  });
249
+ const storageUrl = cdn.getItemUrl(blobPath).href;
249
250
  const fileRecordId = await insertShareTargetFileRecord({
250
251
  fileName: normalizedFilename,
251
252
  fileSize: buffer.byteLength,
252
253
  fileType: mimeType,
253
- storageUrl: uploadedBlob.url,
254
+ storageUrl,
254
255
  });
255
256
 
256
257
  if (fileRecordId !== null) {
257
258
  after(() =>
258
259
  populateShareTargetFileSecurityResult({
259
260
  fileId: fileRecordId,
260
- storageUrl: uploadedBlob.url,
261
+ storageUrl,
261
262
  }).catch((error) => {
262
263
  console.error('[share-target] Failed to finalize file security result', error);
263
264
  }),
@@ -267,7 +268,7 @@ async function createShareTargetAttachment(file: File, maxFileUploadBytes: numbe
267
268
  return {
268
269
  name: normalizedFilename,
269
270
  type: mimeType,
270
- url: uploadedBlob.url,
271
+ url: storageUrl,
271
272
  };
272
273
  }
273
274