@valbuild/server 0.94.0 → 0.96.0

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.
@@ -2374,8 +2374,8 @@ class ValOps {
2374
2374
  }
2375
2375
 
2376
2376
  // #region createPatch
2377
- async createPatch(path, patch, patchId, parentRef, authorId) {
2378
- const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId);
2377
+ async createPatch(path, patch, patchId, parentRef, sessionId, authorId) {
2378
+ const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId);
2379
2379
  if (result.isErr(saveRes)) {
2380
2380
  console.error(`Could not save source patch at path: '${path}'. Error: ${saveRes.error.errorType === "other" ? saveRes.error.message : saveRes.error.errorType}`);
2381
2381
  if (saveRes.error.errorType === "patch-head-conflict") {
@@ -2579,6 +2579,76 @@ class ValOpsFS extends ValOps {
2579
2579
  async onInit() {
2580
2580
  // do nothing
2581
2581
  }
2582
+ async getPresignedAuthNonce(project, corsOrigin, auth) {
2583
+ const authHeader = "pat" in auth ? {
2584
+ "x-val-pat": auth.pat
2585
+ } : {
2586
+ Authorization: `Bearer ${auth.apiKey}`
2587
+ };
2588
+ try {
2589
+ const res = await fetch(`${this.contentUrl}/v1/${project}/presigned-auth-nonce`, {
2590
+ method: "POST",
2591
+ headers: {
2592
+ ...authHeader,
2593
+ "Content-Type": "application/json"
2594
+ },
2595
+ body: JSON.stringify({
2596
+ corsOrigin
2597
+ })
2598
+ });
2599
+ if (res.ok) {
2600
+ const json = await res.json();
2601
+ const parsed = z.object({
2602
+ nonce: z.string(),
2603
+ expiresAt: z.string()
2604
+ }).safeParse(json);
2605
+ if (parsed.success) {
2606
+ return {
2607
+ status: "success",
2608
+ data: {
2609
+ nonce: parsed.data.nonce,
2610
+ baseUrl: `${this.contentUrl}/v1/${project}`
2611
+ }
2612
+ };
2613
+ }
2614
+ console.error("Could not parse presigned auth nonce response. Error: " + fromError(parsed.error));
2615
+ return {
2616
+ status: "error",
2617
+ statusCode: 500,
2618
+ error: {
2619
+ message: "Could not get presigned auth nonce. The response from the content host was not in the expected format."
2620
+ }
2621
+ };
2622
+ }
2623
+ if (res.status === 401) {
2624
+ return {
2625
+ status: "error",
2626
+ statusCode: 401,
2627
+ error: {
2628
+ message: "Could not get presigned auth nonce. The local PAT was rejected by the content host. Try re-running `val login`."
2629
+ }
2630
+ };
2631
+ }
2632
+ const unknownErrorMessage = `Could not get presigned auth nonce. HTTP error: ${res.status} ${res.statusText}`;
2633
+ console.error(unknownErrorMessage);
2634
+ return {
2635
+ status: "error",
2636
+ statusCode: 500,
2637
+ error: {
2638
+ message: unknownErrorMessage
2639
+ }
2640
+ };
2641
+ } catch (e) {
2642
+ console.error("Could not get presigned auth nonce (connection error?):", e);
2643
+ return {
2644
+ status: "error",
2645
+ statusCode: 500,
2646
+ error: {
2647
+ message: `Could not get presigned auth nonce. Error: ${e instanceof Error ? e.message : JSON.stringify(e)}`
2648
+ }
2649
+ };
2650
+ }
2651
+ }
2582
2652
  async getCommitSummary() {
2583
2653
  return {
2584
2654
  error: {
@@ -2767,11 +2837,17 @@ class ValOpsFS extends ValOps {
2767
2837
  parentPatchId: dir
2768
2838
  });
2769
2839
  } else {
2770
- if (includes && includes.length > 0 && !includes.includes(parsedPatch.data.patchId)) {
2840
+ const patchId = parsedPatch.data.patchId;
2841
+ if (includes && includes.length > 0 && !includes.includes(patchId)) {
2771
2842
  return;
2772
2843
  }
2773
- patches[parsedPatch.data.patchId] = {
2774
- ...parsedPatch.data,
2844
+ patches[patchId] = {
2845
+ path: parsedPatch.data.path,
2846
+ patch: parsedPatch.data.patch,
2847
+ parentRef: parsedPatch.data.parentRef,
2848
+ baseSha: parsedPatch.data.baseSha,
2849
+ createdAt: parsedPatch.data.createdAt,
2850
+ authorId: parsedPatch.data.authorId,
2775
2851
  appliedAt: null
2776
2852
  };
2777
2853
  }
@@ -2942,7 +3018,7 @@ class ValOpsFS extends ValOps {
2942
3018
  };
2943
3019
  }
2944
3020
  }
2945
- async saveSourceFilePatch(path, patch, patchId, parentRef, authorId) {
3021
+ async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
2946
3022
  const patchDir = this.getParentPatchIdFromParentRef(parentRef);
2947
3023
  try {
2948
3024
  const baseSha = await this.getBaseSha();
@@ -2952,6 +3028,7 @@ class ValOpsFS extends ValOps {
2952
3028
  parentRef,
2953
3029
  path,
2954
3030
  authorId,
3031
+ sessionId,
2955
3032
  baseSha,
2956
3033
  coreVersion: Internal.VERSION.core,
2957
3034
  createdAt: new Date().toISOString()
@@ -3390,12 +3467,6 @@ class ValOpsFS extends ValOps {
3390
3467
  return result.ok(Object.fromEntries(Object.entries(patches.patches).map(([patchId, value]) => [patchId, this.getParentPatchIdFromParentRef(value.parentRef)])));
3391
3468
  }
3392
3469
 
3393
- // #region profiles
3394
- async getProfiles() {
3395
- // We do not have profiles in FS mode
3396
- return [];
3397
- }
3398
-
3399
3470
  // #region fs file path helpers
3400
3471
  getPatchesDir() {
3401
3472
  return path__default.join(this.rootDir, ValOpsFS.VAL_DIR, "patches");
@@ -3504,7 +3575,9 @@ const FSPatch = z.object({
3504
3575
  parentRef: ParentRef,
3505
3576
  authorId: z.string().refine(p => true).nullable(),
3506
3577
  createdAt: z.string().datetime(),
3507
- coreVersion: z.string().nullable() // TODO: use this to check if patch is compatible with current core version?
3578
+ coreVersion: z.string().nullable(),
3579
+ // TODO: use this to check if patch is compatible with current core version?
3580
+ sessionId: z.string().nullable()
3508
3581
  });
3509
3582
  const FSPatchBase = z.object({
3510
3583
  baseSha: z.string().refine(p => true),
@@ -3606,17 +3679,6 @@ const CommitResponse = z.object({
3606
3679
  commit: CommitSha,
3607
3680
  branch: z.string()
3608
3681
  });
3609
- const ProfilesResponse = z.object({
3610
- profiles: z.array(z.object({
3611
- profileId: z.string(),
3612
- fullName: z.string(),
3613
- email: z.string().optional(),
3614
- // TODO: make this required once this can be guaranteed
3615
- avatar: z.object({
3616
- url: z.string()
3617
- }).nullable()
3618
- }))
3619
- });
3620
3682
  const NonceResponse = z.object({
3621
3683
  nonce: z.string(),
3622
3684
  url: z.string()
@@ -3908,7 +3970,7 @@ class ValOpsHttp extends ValOps {
3908
3970
  return {
3909
3971
  status: "error",
3910
3972
  error: {
3911
- message: "Could not get nonce." + message
3973
+ message: "Could not get nonce. " + message
3912
3974
  }
3913
3975
  };
3914
3976
  }
@@ -4067,7 +4129,7 @@ class ValOpsHttp extends ValOps {
4067
4129
  };
4068
4130
  }
4069
4131
  }
4070
- async saveSourceFilePatch(path, patch, patchId, parentRef, authorId) {
4132
+ async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
4071
4133
  const baseSha = await this.getBaseSha();
4072
4134
  return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
4073
4135
  method: "POST",
@@ -4079,6 +4141,7 @@ class ValOpsHttp extends ValOps {
4079
4141
  path,
4080
4142
  patch,
4081
4143
  authorId,
4144
+ sessionId,
4082
4145
  patchId,
4083
4146
  parentPatchId: parentRef.type === "patch" ? parentRef.patchId : null,
4084
4147
  baseSha,
@@ -4502,31 +4565,6 @@ class ValOpsHttp extends ValOps {
4502
4565
  };
4503
4566
  }
4504
4567
  }
4505
-
4506
- // #region profiles
4507
- async getProfiles() {
4508
- var _res$headers$get6;
4509
- const res = await fetch(`${this.contentUrl}/v1/${this.project}/profiles`, {
4510
- headers: {
4511
- ...this.authHeaders,
4512
- "Content-Type": "application/json"
4513
- }
4514
- });
4515
- if (res.ok) {
4516
- const parsed = ProfilesResponse.safeParse(await res.json());
4517
- if (parsed.error) {
4518
- console.error("Could not parse profiles response", parsed.error);
4519
- throw Error(`Could not get profiles from remote server: wrong format. You might need to upgrade Val.`);
4520
- }
4521
- return parsed.data.profiles;
4522
- }
4523
- if ((_res$headers$get6 = res.headers.get("Content-Type")) !== null && _res$headers$get6 !== void 0 && _res$headers$get6.includes("application/json")) {
4524
- const json = await res.json();
4525
- const message = getErrorMessageFromUnknownJson(json, "Unknown error");
4526
- throw Error(`Could not get profiles (status: ${res.status}): ${message}`);
4527
- }
4528
- throw Error(`Could not get profiles. Got status: ${res.status}`);
4529
- }
4530
4568
  }
4531
4569
 
4532
4570
  const host = process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
@@ -4679,6 +4717,24 @@ function hasRemoteFileSchema(schema) {
4679
4717
 
4680
4718
  /* eslint-disable @typescript-eslint/no-unused-vars */
4681
4719
  const ValServer = (valModules, options, callbacks) => {
4720
+ const AIContentBlock = z.union([z.object({
4721
+ type: z.literal("text"),
4722
+ text: z.string()
4723
+ }), z.object({
4724
+ type: z.literal("image_url"),
4725
+ url: z.string()
4726
+ })]);
4727
+ const AIMessageContent = z.union([z.string(), z.array(AIContentBlock)]);
4728
+ const ProfilesResponse = z.object({
4729
+ profiles: z.array(z.object({
4730
+ profileId: z.string(),
4731
+ fullName: z.string(),
4732
+ email: z.string().optional(),
4733
+ avatar: z.object({
4734
+ url: z.string()
4735
+ }).nullable()
4736
+ }))
4737
+ });
4682
4738
  let serverOps;
4683
4739
  if (options.mode === "fs") {
4684
4740
  serverOps = new ValOpsFS(options.valContentUrl, options.cwd, valModules, {
@@ -5466,15 +5522,38 @@ const ValServer = (valModules, options, callbacks) => {
5466
5522
  };
5467
5523
  }
5468
5524
  if (serverOps instanceof ValOpsFS) {
5469
- // In FS mode we do not use the remote server at all and just return an url that points to this server
5470
- // which has an endpoint that handles this
5471
- // A bit hacky perhaps, but we want to have as similar semantics as possible in client code when it comes to FS / HTTP
5525
+ // In FS mode patch-file uploads are buffered through this server (no remote round-trip),
5526
+ // so baseUrl points at /api/val/upload. AI image uploads, however, go straight to the
5527
+ // content host we resolve a contentBaseUrl + a PAT-issued nonce here so the browser
5528
+ // can POST directly without exposing the PAT.
5472
5529
  const host = `/api/val`;
5530
+ let contentBaseUrl = null;
5531
+ let contentAuthNonce = null;
5532
+ if (!options.project) {
5533
+ console.warn("Direct content-host uploads (AI images) disabled: no `project` set in val.config (and VAL_PROJECT env var is not set).");
5534
+ } else {
5535
+ const authDataRes = await getRemoteFileAuth();
5536
+ if (authDataRes.status !== 200) {
5537
+ console.warn("Direct content-host uploads (AI images) disabled: " + authDataRes.json.message);
5538
+ } else {
5539
+ const corsOrigin = "*"; // TODO: add cors origin
5540
+ const presignedAuthNonce = await serverOps.getPresignedAuthNonce(options.project, corsOrigin, authDataRes.json.remoteFileAuth);
5541
+ if (presignedAuthNonce.status === "success") {
5542
+ contentBaseUrl = presignedAuthNonce.data.baseUrl;
5543
+ contentAuthNonce = presignedAuthNonce.data.nonce;
5544
+ } else {
5545
+ console.warn("Direct content-host uploads (AI images) disabled: " + presignedAuthNonce.error.message);
5546
+ }
5547
+ }
5548
+ }
5473
5549
  return {
5474
5550
  status: 200,
5475
5551
  json: {
5476
5552
  nonce: null,
5477
- baseUrl: `${host}/upload` // NOTE: this is the /upload/patches endpoint - the client will add /patches/:patchId/files to this and post to it
5553
+ baseUrl: `${host}/upload`,
5554
+ // NOTE: this is the /upload/patches endpoint - the client will add /patches/:patchId/files to this and post to it
5555
+ contentBaseUrl,
5556
+ contentAuthNonce
5478
5557
  }
5479
5558
  };
5480
5559
  }
@@ -5500,7 +5579,9 @@ const ValServer = (valModules, options, callbacks) => {
5500
5579
  status: 200,
5501
5580
  json: {
5502
5581
  nonce: presignedAuthNonce.data.nonce,
5503
- baseUrl: presignedAuthNonce.data.baseUrl
5582
+ baseUrl: presignedAuthNonce.data.baseUrl,
5583
+ contentBaseUrl: presignedAuthNonce.data.baseUrl,
5584
+ contentAuthNonce: presignedAuthNonce.data.nonce
5504
5585
  }
5505
5586
  };
5506
5587
  }
@@ -5528,10 +5609,11 @@ const ValServer = (valModules, options, callbacks) => {
5528
5609
  }
5529
5610
  const patches = req.body.patches;
5530
5611
  let parentRef = req.body.parentRef;
5612
+ const sessionId = req.body.sessionId ?? null;
5531
5613
  const authorId = "id" in auth ? auth.id : null;
5532
5614
  const newPatchIds = [];
5533
5615
  for (const patch of patches) {
5534
- const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, authorId);
5616
+ const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId);
5535
5617
  if (result.isErr(createPatchRes)) {
5536
5618
  if (createPatchRes.error.errorType === "patch-head-conflict") {
5537
5619
  return {
@@ -5892,13 +5974,87 @@ const ValServer = (valModules, options, callbacks) => {
5892
5974
  }
5893
5975
  };
5894
5976
  }
5895
- const profiles = await serverOps.getProfiles();
5896
- return {
5897
- status: 200,
5898
- json: {
5899
- profiles
5977
+ if (!options.project) {
5978
+ return {
5979
+ status: 500,
5980
+ json: {
5981
+ message: "Project is not configured"
5982
+ }
5983
+ };
5984
+ }
5985
+ const authDataRes = await getRemoteFileAuth();
5986
+ if (authDataRes.status !== 200) {
5987
+ if (serverOps instanceof ValOpsFS && authDataRes.json.errorCode === "pat-error") {
5988
+ return {
5989
+ status: 200,
5990
+ json: {
5991
+ profiles: []
5992
+ }
5993
+ };
5994
+ }
5995
+ return {
5996
+ status: 500,
5997
+ json: {
5998
+ message: authDataRes.json.message
5999
+ }
6000
+ };
6001
+ }
6002
+ const authData = authDataRes.json.remoteFileAuth;
6003
+ const execFetch = async headers => {
6004
+ try {
6005
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/profiles`;
6006
+ const upstreamRes = await fetch(upstreamUrl, {
6007
+ method: "GET",
6008
+ headers
6009
+ });
6010
+ if (!upstreamRes.ok) {
6011
+ const text = await upstreamRes.text();
6012
+ const isAuthError = upstreamRes.status === 401 || upstreamRes.status === 403;
6013
+ return {
6014
+ status: isAuthError ? 401 : 500,
6015
+ json: {
6016
+ message: isAuthError ? `Profile authentication failed: ${upstreamRes.status} ${text}` : `Profiles failed: ${upstreamRes.status} ${text}`
6017
+ }
6018
+ };
6019
+ }
6020
+ const parseRes = ProfilesResponse.safeParse(await upstreamRes.json());
6021
+ if (!parseRes.success) {
6022
+ return {
6023
+ status: 500,
6024
+ json: {
6025
+ message: "Could not parse profiles response: " + fromError(parseRes.error).toString()
6026
+ }
6027
+ };
6028
+ }
6029
+ return {
6030
+ status: 200,
6031
+ json: {
6032
+ profiles: parseRes.data.profiles
6033
+ }
6034
+ };
6035
+ } catch (err) {
6036
+ return {
6037
+ status: 500,
6038
+ json: {
6039
+ message: err instanceof Error ? err.message : "Profiles request failed"
6040
+ }
6041
+ };
5900
6042
  }
5901
6043
  };
6044
+ if (serverOps instanceof ValOpsFS) {
6045
+ return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
6046
+ }
6047
+ if (!options.valSecret) {
6048
+ return {
6049
+ status: 500,
6050
+ json: {
6051
+ message: "Secret is not configured"
6052
+ }
6053
+ };
6054
+ }
6055
+ return withAuth(options.valSecret, cookies, "profiles", data => {
6056
+ return execFetch(getProfileAuthHeaders(authData, data, "application/json"));
6057
+ });
5902
6058
  }
5903
6059
  },
5904
6060
  "/commit-summary": {
@@ -6080,84 +6236,750 @@ const ValServer = (valModules, options, callbacks) => {
6080
6236
  }
6081
6237
  }
6082
6238
  },
6083
- //#region files
6084
- "/files": {
6085
- GET: async req => {
6086
- const query = req.query;
6087
- const filePath = req.path;
6088
- // NOTE: no auth here since you would need the patch_id to get something that is not published.
6089
- // For everything that is published, well they are already public so no auth required there...
6090
- // We could imagine adding auth just to be a 200% certain,
6091
- // However that won't work since images are requested by the nextjs backend as a part of image optimization (again: as an example) which is a backend-to-backend op (no cookies, ...).
6092
- // So: 1) patch ids are not possible to guess (but possible to brute force)
6093
- // 2) the process of shimming a patch into the frontend would be quite challenging (so just trying out this attack would require a lot of effort)
6094
- // 3) the benefit an attacker would get is an image that is not yet published (i.e. most cases: not very interesting)
6095
- // Thus: attack surface + ease of attack + benefit = low probability of attack
6096
- // If we couldn't argue that patch ids are secret enough, then this would be a problem.
6097
- let cacheControl;
6098
- let fileBuffer;
6099
- let mimeType;
6100
- const remote = query.remote === "true";
6101
- if (query.patch_id) {
6102
- fileBuffer = await serverOps.getBase64EncodedBinaryFileFromPatch(filePath, query.patch_id, remote);
6103
- mimeType = Internal.filenameToMimeType(filePath);
6104
- // TODO: reenable this:
6105
- // cacheControl = "public, max-age=20000, immutable";
6106
- } else {
6107
- if (serverOps instanceof ValOpsHttp && remote) {
6108
- console.error(`Remote file: ${filePath} requested without patch id. This is most likely a bug in Val.`);
6109
- }
6110
- fileBuffer = await serverOps.getBinaryFile(filePath);
6239
+ //#region ai proxy
6240
+ "/ai/initialize": {
6241
+ POST: async req => {
6242
+ const cookies = req.cookies;
6243
+ const auth = getAuth(cookies);
6244
+ if (auth.error) {
6245
+ return {
6246
+ status: 401,
6247
+ json: {
6248
+ message: auth.error
6249
+ }
6250
+ };
6111
6251
  }
6112
- if (fileBuffer) {
6252
+ if (!options.project) {
6113
6253
  return {
6114
- status: 200,
6115
- headers: {
6116
- // TODO: we could use ETag and return 304 instead
6117
- "Content-Type": mimeType || "application/octet-stream",
6118
- "Cache-Control": cacheControl || "public, max-age=0, must-revalidate"
6119
- },
6120
- body: bufferToReadableStream(fileBuffer)
6254
+ status: 401,
6255
+ json: {
6256
+ message: "Project is not configured"
6257
+ }
6121
6258
  };
6122
- } else {
6259
+ }
6260
+ const authDataRes = await getRemoteFileAuth();
6261
+ if (authDataRes.status !== 200) {
6123
6262
  return {
6124
- status: 404,
6263
+ status: 401,
6125
6264
  json: {
6126
- message: "File not found"
6265
+ message: authDataRes.json.message
6266
+ }
6267
+ };
6268
+ }
6269
+ const authData = authDataRes.json.remoteFileAuth;
6270
+ const execFetch = async headers => {
6271
+ try {
6272
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/initialize`;
6273
+ const upstreamRes = await fetch(upstreamUrl, {
6274
+ method: "POST",
6275
+ headers,
6276
+ body: JSON.stringify({})
6277
+ });
6278
+ if (!upstreamRes.ok) {
6279
+ const text = await upstreamRes.text();
6280
+ return {
6281
+ status: 500,
6282
+ json: {
6283
+ message: `AI initialize failed: ${upstreamRes.status} ${text}`
6284
+ }
6285
+ };
6286
+ }
6287
+ const json = await upstreamRes.json();
6288
+ const wsUrl = options.valContentUrl.replace(/^https:/, "wss:").replace(/^http:/, "ws:") + `/v1/${options.project}/ai/connect`;
6289
+ return {
6290
+ status: 200,
6291
+ json: {
6292
+ nonce: json.nonce,
6293
+ wsUrl
6294
+ }
6295
+ };
6296
+ } catch (err) {
6297
+ return {
6298
+ status: 500,
6299
+ json: {
6300
+ message: err instanceof Error ? err.message : "AI initialize error"
6301
+ }
6302
+ };
6303
+ }
6304
+ };
6305
+ if (serverOps instanceof ValOpsFS) {
6306
+ return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
6307
+ }
6308
+ if (!options.valSecret) {
6309
+ return {
6310
+ status: 500,
6311
+ json: {
6312
+ message: "Secret is not configured"
6127
6313
  }
6128
6314
  };
6129
6315
  }
6316
+ return withAuth(options.valSecret, cookies, "ai/initialize", data => {
6317
+ return execFetch(getProfileAuthHeaders(authData, data, "application/json"));
6318
+ });
6130
6319
  }
6131
- }
6132
- };
6133
- };
6134
- function formatPatchSourceError(error) {
6135
- if ("message" in error) {
6136
- return error.message;
6137
- } else if (Array.isArray(error)) {
6138
- return error.map(formatPatchSourceError).join("\n");
6139
- } else {
6140
- const _exhaustiveCheck = error;
6141
- return "Unknown patch source error: " + JSON.stringify(_exhaustiveCheck);
6142
- }
6143
- }
6144
- function verifyCallbackReq(stateCookie, queryParams) {
6145
- if (typeof stateCookie !== "string") {
6146
- return {
6147
- success: false,
6148
- error: "No state cookie"
6149
- };
6150
- }
6151
- const {
6152
- code,
6153
- state: tokenFromQuery
6154
- } = queryParams;
6155
- if (typeof code !== "string") {
6156
- return {
6157
- success: false,
6158
- error: "No code query param"
6159
- };
6160
- }
6320
+ },
6321
+ "/ai/sessions": {
6322
+ GET: async req => {
6323
+ const cookies = req.cookies;
6324
+ const auth = getAuth(cookies);
6325
+ if (auth.error) {
6326
+ return {
6327
+ status: 401,
6328
+ json: {
6329
+ message: auth.error
6330
+ }
6331
+ };
6332
+ }
6333
+ if (!options.project) {
6334
+ return {
6335
+ status: 500,
6336
+ json: {
6337
+ message: "Project is not configured"
6338
+ }
6339
+ };
6340
+ }
6341
+ const authDataRes = await getRemoteFileAuth();
6342
+ if (authDataRes.status !== 200) {
6343
+ return {
6344
+ status: 500,
6345
+ json: {
6346
+ message: authDataRes.json.message
6347
+ }
6348
+ };
6349
+ }
6350
+ const authData = authDataRes.json.remoteFileAuth;
6351
+ const execFetch = async headers => {
6352
+ try {
6353
+ const SessionsResponse = z.object({
6354
+ sessions: z.array(z.object({
6355
+ id: z.string(),
6356
+ name: z.string().nullable(),
6357
+ createdAt: z.string(),
6358
+ updatedAt: z.string()
6359
+ })),
6360
+ nextCursor: z.object({
6361
+ updatedAt: z.string(),
6362
+ id: z.string()
6363
+ }).nullable().optional()
6364
+ });
6365
+ const params = new URLSearchParams();
6366
+ if (req.query.limit) params.set("limit", req.query.limit);
6367
+ if (req.query.cursor_updatedAt) {
6368
+ params.set("cursor_updatedAt", req.query.cursor_updatedAt);
6369
+ }
6370
+ if (req.query.cursor_id) {
6371
+ params.set("cursor_id", req.query.cursor_id);
6372
+ }
6373
+ const qs = params.toString();
6374
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/sessions${qs ? `?${qs}` : ""}`;
6375
+ const upstreamRes = await fetch(upstreamUrl, {
6376
+ headers
6377
+ });
6378
+ if (!upstreamRes.ok) {
6379
+ const text = await upstreamRes.text();
6380
+ return {
6381
+ status: 500,
6382
+ json: {
6383
+ message: `AI sessions failed: ${upstreamRes.status} ${text}`
6384
+ }
6385
+ };
6386
+ }
6387
+ const json = SessionsResponse.safeParse(await upstreamRes.json());
6388
+ if (!json.success) {
6389
+ return {
6390
+ status: 500,
6391
+ json: {
6392
+ message: "Could not parse AI sessions response: " + fromError(json.error).toString()
6393
+ }
6394
+ };
6395
+ }
6396
+ return {
6397
+ status: 200,
6398
+ json: json.data
6399
+ };
6400
+ } catch (err) {
6401
+ return {
6402
+ status: 500,
6403
+ json: {
6404
+ message: err instanceof Error ? err.message : "AI sessions error"
6405
+ }
6406
+ };
6407
+ }
6408
+ };
6409
+ if (serverOps instanceof ValOpsFS) {
6410
+ return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
6411
+ }
6412
+ if (!options.valSecret) {
6413
+ return {
6414
+ status: 500,
6415
+ json: {
6416
+ message: "Secret is not configured"
6417
+ }
6418
+ };
6419
+ }
6420
+ return withAuth(options.valSecret, cookies, "ai/sessions", data => {
6421
+ return execFetch(getProfileAuthHeaders(authData, data, "application/json"));
6422
+ });
6423
+ },
6424
+ PATCH: async req => {
6425
+ const cookies = req.cookies;
6426
+ const auth = getAuth(cookies);
6427
+ if (auth.error) {
6428
+ return {
6429
+ status: 401,
6430
+ json: {
6431
+ message: auth.error
6432
+ }
6433
+ };
6434
+ }
6435
+ if (!options.project) {
6436
+ return {
6437
+ status: 500,
6438
+ json: {
6439
+ message: "Project is not configured"
6440
+ }
6441
+ };
6442
+ }
6443
+ const pathParts = (req.path || "").split("/").filter(Boolean);
6444
+ const sessionId = pathParts[0];
6445
+ if (!sessionId) {
6446
+ return {
6447
+ status: 500,
6448
+ json: {
6449
+ message: "Missing sessionId in path"
6450
+ }
6451
+ };
6452
+ }
6453
+ const authDataRes = await getRemoteFileAuth();
6454
+ if (authDataRes.status !== 200) {
6455
+ return {
6456
+ status: 500,
6457
+ json: {
6458
+ message: authDataRes.json.message
6459
+ }
6460
+ };
6461
+ }
6462
+ const authData = authDataRes.json.remoteFileAuth;
6463
+ const execFetch = async headers => {
6464
+ try {
6465
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/sessions/${encodeURIComponent(sessionId)}`;
6466
+ const upstreamRes = await fetch(upstreamUrl, {
6467
+ method: "PATCH",
6468
+ headers,
6469
+ body: JSON.stringify({
6470
+ name: req.body.name
6471
+ })
6472
+ });
6473
+ if (!upstreamRes.ok) {
6474
+ const text = await upstreamRes.text();
6475
+ return {
6476
+ status: 500,
6477
+ json: {
6478
+ message: `AI session rename failed: ${upstreamRes.status} ${text}`
6479
+ }
6480
+ };
6481
+ }
6482
+ return {
6483
+ status: 200,
6484
+ json: {}
6485
+ };
6486
+ } catch (err) {
6487
+ return {
6488
+ status: 500,
6489
+ json: {
6490
+ message: err instanceof Error ? err.message : "AI session rename error"
6491
+ }
6492
+ };
6493
+ }
6494
+ };
6495
+ if (serverOps instanceof ValOpsFS) {
6496
+ return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
6497
+ }
6498
+ if (!options.valSecret) {
6499
+ return {
6500
+ status: 500,
6501
+ json: {
6502
+ message: "Secret is not configured"
6503
+ }
6504
+ };
6505
+ }
6506
+ return withAuth(options.valSecret, cookies, "ai/sessions/rename", data => {
6507
+ return execFetch(getProfileAuthHeaders(authData, data, "application/json"));
6508
+ });
6509
+ }
6510
+ },
6511
+ "/ai/messages": {
6512
+ GET: async req => {
6513
+ const cookies = req.cookies;
6514
+ const auth = getAuth(cookies);
6515
+ if (auth.error) {
6516
+ return {
6517
+ status: 401,
6518
+ json: {
6519
+ message: auth.error
6520
+ }
6521
+ };
6522
+ }
6523
+ if (!options.project) {
6524
+ return {
6525
+ status: 500,
6526
+ json: {
6527
+ message: "Project is not configured"
6528
+ }
6529
+ };
6530
+ }
6531
+ const pathParts = (req.path || "").split("/").filter(Boolean);
6532
+ const sessionId = pathParts[0];
6533
+ if (!sessionId) {
6534
+ return {
6535
+ status: 500,
6536
+ json: {
6537
+ message: "Missing sessionId in path"
6538
+ }
6539
+ };
6540
+ }
6541
+ const authDataRes = await getRemoteFileAuth();
6542
+ if (authDataRes.status !== 200) {
6543
+ return {
6544
+ status: 500,
6545
+ json: {
6546
+ message: authDataRes.json.message
6547
+ }
6548
+ };
6549
+ }
6550
+ const authData = authDataRes.json.remoteFileAuth;
6551
+ const execFetch = async headers => {
6552
+ try {
6553
+ const SessionMessagesResponse = z.object({
6554
+ messages: z.array(z.object({
6555
+ role: z.string(),
6556
+ content: AIMessageContent
6557
+ })),
6558
+ nextCursor: z.object({
6559
+ updatedAt: z.string(),
6560
+ id: z.string()
6561
+ }).nullable().optional()
6562
+ });
6563
+ const params = new URLSearchParams();
6564
+ if (req.query.limit) params.set("limit", req.query.limit);
6565
+ if (req.query.cursor_updatedAt) params.set("cursor_updatedAt", req.query.cursor_updatedAt);
6566
+ if (req.query.cursor_id) params.set("cursor_id", req.query.cursor_id);
6567
+ const qs = params.toString();
6568
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/sessions/${encodeURIComponent(sessionId)}/messages` + (qs ? `?${qs}` : "");
6569
+ const upstreamRes = await fetch(upstreamUrl, {
6570
+ headers
6571
+ });
6572
+ if (!upstreamRes.ok) {
6573
+ const text = await upstreamRes.text();
6574
+ return {
6575
+ status: 500,
6576
+ json: {
6577
+ message: `AI session messages failed: ${upstreamRes.status} ${text}`
6578
+ }
6579
+ };
6580
+ }
6581
+ const json = SessionMessagesResponse.safeParse(await upstreamRes.json());
6582
+ if (!json.success) {
6583
+ return {
6584
+ status: 500,
6585
+ json: {
6586
+ message: "Could not parse AI session messages response: " + fromError(json.error).toString()
6587
+ }
6588
+ };
6589
+ }
6590
+ return {
6591
+ status: 200,
6592
+ json: json.data
6593
+ };
6594
+ } catch (err) {
6595
+ return {
6596
+ status: 500,
6597
+ json: {
6598
+ message: err instanceof Error ? err.message : "AI session messages error"
6599
+ }
6600
+ };
6601
+ }
6602
+ };
6603
+ if (serverOps instanceof ValOpsFS) {
6604
+ return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
6605
+ }
6606
+ if (!options.valSecret) {
6607
+ return {
6608
+ status: 500,
6609
+ json: {
6610
+ message: "Secret is not configured"
6611
+ }
6612
+ };
6613
+ }
6614
+ return withAuth(options.valSecret, cookies, "ai/sessions/messages", data => {
6615
+ return execFetch(getProfileAuthHeaders(authData, data, "application/json"));
6616
+ });
6617
+ }
6618
+ },
6619
+ "/ai/session-image-to-patch-file": {
6620
+ POST: async req => {
6621
+ const cookies = req.cookies;
6622
+ const auth = getAuth(cookies);
6623
+ if (auth.error) {
6624
+ return {
6625
+ status: 401,
6626
+ json: {
6627
+ message: auth.error
6628
+ }
6629
+ };
6630
+ }
6631
+ if (!options.project) {
6632
+ return {
6633
+ status: 500,
6634
+ json: {
6635
+ message: "Project is not configured"
6636
+ }
6637
+ };
6638
+ }
6639
+ const authDataRes = await getRemoteFileAuth();
6640
+ if (authDataRes.status !== 200) {
6641
+ return {
6642
+ status: 500,
6643
+ json: {
6644
+ message: authDataRes.json.message
6645
+ }
6646
+ };
6647
+ }
6648
+ const authData = authDataRes.json.remoteFileAuth;
6649
+ let headers;
6650
+ if (serverOps instanceof ValOpsFS) {
6651
+ headers = getProfileAuthHeaders(authData, null, "application/json");
6652
+ } else {
6653
+ if (!("id" in auth) || !auth.id) {
6654
+ return {
6655
+ status: 401,
6656
+ json: {
6657
+ message: "Unauthorized"
6658
+ }
6659
+ };
6660
+ }
6661
+ headers = getProfileAuthHeaders(authData, {
6662
+ sub: auth.id
6663
+ }, "application/json");
6664
+ }
6665
+ const execFetch = async () => {
6666
+ try {
6667
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/patches/${encodeURIComponent(req.body.patchId)}/files/from-session-file`;
6668
+ const upstreamRes = await fetch(upstreamUrl, {
6669
+ method: "POST",
6670
+ headers,
6671
+ body: JSON.stringify({
6672
+ files: req.body.files.map(f => ({
6673
+ filePath: f.filePath,
6674
+ key: f.key,
6675
+ ...(f.isRemote !== undefined ? {
6676
+ isRemote: f.isRemote
6677
+ } : {})
6678
+ }))
6679
+ })
6680
+ });
6681
+ if (!upstreamRes.ok) {
6682
+ var _upstreamJson;
6683
+ const text = await upstreamRes.text();
6684
+ let upstreamJson = null;
6685
+ try {
6686
+ upstreamJson = JSON.parse(text);
6687
+ } catch {
6688
+ upstreamJson = null;
6689
+ }
6690
+ if (upstreamRes.status === 400 && upstreamJson) {
6691
+ return {
6692
+ status: 400,
6693
+ json: {
6694
+ message: upstreamJson.message ?? `AI session image to patch failed: ${text}`,
6695
+ ...(upstreamJson.details ? {
6696
+ details: upstreamJson.details
6697
+ } : {})
6698
+ }
6699
+ };
6700
+ }
6701
+ return {
6702
+ status: 500,
6703
+ json: {
6704
+ message: `AI session image to patch failed: ${upstreamRes.status} ${((_upstreamJson = upstreamJson) === null || _upstreamJson === void 0 ? void 0 : _upstreamJson.message) ?? text}`
6705
+ }
6706
+ };
6707
+ }
6708
+ const rawUpstreamJson = await upstreamRes.json();
6709
+ const UpstreamResponse = z.object({
6710
+ patchId: z.string(),
6711
+ files: z.array(z.object({
6712
+ filePath: z.string(),
6713
+ metadata: z.object({
6714
+ width: z.number(),
6715
+ height: z.number(),
6716
+ mimeType: z.string()
6717
+ })
6718
+ }))
6719
+ });
6720
+ const json = UpstreamResponse.safeParse(rawUpstreamJson);
6721
+ if (!json.success) {
6722
+ return {
6723
+ status: 500,
6724
+ json: {
6725
+ message: "Could not parse AI session image patch response: " + fromError(json.error).toString()
6726
+ }
6727
+ };
6728
+ }
6729
+ if (json.data.patchId !== req.body.patchId) {
6730
+ return {
6731
+ status: 500,
6732
+ json: {
6733
+ message: `AI session image to patch upstream returned mismatched patchId: expected '${req.body.patchId}', got '${json.data.patchId}'`
6734
+ }
6735
+ };
6736
+ }
6737
+ if (serverOps instanceof ValOpsFS) {
6738
+ // Mirror the binaries from the content host into local patch
6739
+ // storage so /files?patch_id=... can serve them. Match upstream
6740
+ // entries to client-provided keys by filePath.
6741
+ const keysByFilePath = new Map(req.body.files.map(f => [f.filePath, f.key]));
6742
+ for (const file of json.data.files) {
6743
+ const sessionKey = keysByFilePath.get(file.filePath);
6744
+ if (!sessionKey) {
6745
+ return {
6746
+ status: 500,
6747
+ json: {
6748
+ message: `AI session image to patch: upstream returned file '${file.filePath}' which was not in the request`
6749
+ }
6750
+ };
6751
+ }
6752
+ const downloadUrl = `${options.valContentUrl}/v1/${options.project}/ai/images?key=${encodeURIComponent(sessionKey)}`;
6753
+ let binaryRes;
6754
+ try {
6755
+ binaryRes = await fetch(downloadUrl, {
6756
+ headers
6757
+ });
6758
+ } catch (err) {
6759
+ return {
6760
+ status: 500,
6761
+ json: {
6762
+ message: `Could not download AI session image '${file.filePath}' from content host: ${err instanceof Error ? err.message : String(err)}`
6763
+ }
6764
+ };
6765
+ }
6766
+ if (!binaryRes.ok) {
6767
+ return {
6768
+ status: 500,
6769
+ json: {
6770
+ message: `Could not download AI session image '${file.filePath}' from content host: ${binaryRes.status} ${binaryRes.statusText}`
6771
+ }
6772
+ };
6773
+ }
6774
+ const arrayBuffer = await binaryRes.arrayBuffer();
6775
+ const base64 = Buffer.from(arrayBuffer).toString("base64");
6776
+ const dataUrl = `data:${file.metadata.mimeType};base64,${base64}`;
6777
+ const type = file.metadata.mimeType.startsWith("image/") ? "image" : "file";
6778
+ const saveRes = await serverOps.saveBase64EncodedBinaryFileFromPatch(file.filePath, req.body.parentRef, req.body.patchId, dataUrl, type, file.metadata);
6779
+ if (saveRes.error) {
6780
+ return {
6781
+ status: 500,
6782
+ json: {
6783
+ message: `Could not save AI session image '${file.filePath}' to local patch storage: ${saveRes.error.message}`
6784
+ }
6785
+ };
6786
+ }
6787
+ }
6788
+ }
6789
+ return {
6790
+ status: 200,
6791
+ json: {
6792
+ patchId: req.body.patchId,
6793
+ files: json.data.files
6794
+ }
6795
+ };
6796
+ } catch (err) {
6797
+ return {
6798
+ status: 500,
6799
+ json: {
6800
+ message: err instanceof Error ? err.message : "AI session image to patch error"
6801
+ }
6802
+ };
6803
+ }
6804
+ };
6805
+ return execFetch();
6806
+ }
6807
+ },
6808
+ "/ai/images": {
6809
+ PATCH: async req => {
6810
+ const cookies = req.cookies;
6811
+ const auth = getAuth(cookies);
6812
+ if (auth.error) {
6813
+ return {
6814
+ status: 401,
6815
+ json: {
6816
+ message: auth.error
6817
+ }
6818
+ };
6819
+ }
6820
+ if (!options.project) {
6821
+ return {
6822
+ status: 500,
6823
+ json: {
6824
+ message: "Project is not configured"
6825
+ }
6826
+ };
6827
+ }
6828
+ const authDataRes = await getRemoteFileAuth();
6829
+ if (authDataRes.status !== 200) {
6830
+ return {
6831
+ status: 500,
6832
+ json: {
6833
+ message: authDataRes.json.message
6834
+ }
6835
+ };
6836
+ }
6837
+ const authData = authDataRes.json.remoteFileAuth;
6838
+ let headers;
6839
+ if (serverOps instanceof ValOpsFS) {
6840
+ headers = getProfileAuthHeaders(authData, null, "application/json");
6841
+ } else {
6842
+ if (!("id" in auth) || !auth.id) {
6843
+ return {
6844
+ status: 401,
6845
+ json: {
6846
+ message: "Unauthorized"
6847
+ }
6848
+ };
6849
+ }
6850
+ headers = getProfileAuthHeaders(authData, {
6851
+ sub: auth.id
6852
+ }, "application/json");
6853
+ }
6854
+ const execFetch = async () => {
6855
+ try {
6856
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/images`;
6857
+ const upstreamRes = await fetch(upstreamUrl, {
6858
+ method: "PATCH",
6859
+ headers,
6860
+ body: JSON.stringify({
6861
+ key: req.body.key,
6862
+ metadata: req.body.metadata,
6863
+ contentType: req.body.contentType
6864
+ })
6865
+ });
6866
+ if (!upstreamRes.ok) {
6867
+ const text = await upstreamRes.text();
6868
+ return {
6869
+ status: 500,
6870
+ json: {
6871
+ message: `AI images patch failed: ${upstreamRes.status} ${text}`
6872
+ }
6873
+ };
6874
+ }
6875
+ const UpstreamResponse = z.object({
6876
+ key: z.string()
6877
+ });
6878
+ const json = UpstreamResponse.safeParse(await upstreamRes.json());
6879
+ if (!json.success) {
6880
+ return {
6881
+ status: 500,
6882
+ json: {
6883
+ message: "Could not parse AI images patch response: " + fromError(json.error).toString()
6884
+ }
6885
+ };
6886
+ }
6887
+ return {
6888
+ status: 200,
6889
+ json: {
6890
+ key: json.data.key
6891
+ }
6892
+ };
6893
+ } catch (err) {
6894
+ return {
6895
+ status: 500,
6896
+ json: {
6897
+ message: err instanceof Error ? err.message : "AI images patch error"
6898
+ }
6899
+ };
6900
+ }
6901
+ };
6902
+ return execFetch();
6903
+ }
6904
+ },
6905
+ //#region files
6906
+ "/files": {
6907
+ GET: async req => {
6908
+ const query = req.query;
6909
+ const filePath = req.path;
6910
+ // NOTE: no auth here since you would need the patch_id to get something that is not published.
6911
+ // For everything that is published, well they are already public so no auth required there...
6912
+ // We could imagine adding auth just to be a 200% certain,
6913
+ // However that won't work since images are requested by the nextjs backend as a part of image optimization (again: as an example) which is a backend-to-backend op (no cookies, ...).
6914
+ // So: 1) patch ids are not possible to guess (but possible to brute force)
6915
+ // 2) the process of shimming a patch into the frontend would be quite challenging (so just trying out this attack would require a lot of effort)
6916
+ // 3) the benefit an attacker would get is an image that is not yet published (i.e. most cases: not very interesting)
6917
+ // Thus: attack surface + ease of attack + benefit = low probability of attack
6918
+ // If we couldn't argue that patch ids are secret enough, then this would be a problem.
6919
+ let cacheControl;
6920
+ let fileBuffer;
6921
+ let mimeType;
6922
+ const remote = query.remote === "true";
6923
+ if (query.patch_id) {
6924
+ fileBuffer = await serverOps.getBase64EncodedBinaryFileFromPatch(filePath, query.patch_id, remote);
6925
+ mimeType = Internal.filenameToMimeType(filePath);
6926
+ // TODO: reenable this:
6927
+ // cacheControl = "public, max-age=20000, immutable";
6928
+ } else {
6929
+ if (serverOps instanceof ValOpsHttp && remote) {
6930
+ console.error(`Remote file: ${filePath} requested without patch id. This is most likely a bug in Val.`);
6931
+ }
6932
+ fileBuffer = await serverOps.getBinaryFile(filePath);
6933
+ }
6934
+ if (fileBuffer) {
6935
+ return {
6936
+ status: 200,
6937
+ headers: {
6938
+ // TODO: we could use ETag and return 304 instead
6939
+ "Content-Type": mimeType || "application/octet-stream",
6940
+ "Cache-Control": cacheControl || "public, max-age=0, must-revalidate"
6941
+ },
6942
+ body: bufferToReadableStream(fileBuffer)
6943
+ };
6944
+ } else {
6945
+ return {
6946
+ status: 404,
6947
+ json: {
6948
+ message: "File not found"
6949
+ }
6950
+ };
6951
+ }
6952
+ }
6953
+ }
6954
+ };
6955
+ };
6956
+ function formatPatchSourceError(error) {
6957
+ if ("message" in error) {
6958
+ return error.message;
6959
+ } else if (Array.isArray(error)) {
6960
+ return error.map(formatPatchSourceError).join("\n");
6961
+ } else {
6962
+ const _exhaustiveCheck = error;
6963
+ return "Unknown patch source error: " + JSON.stringify(_exhaustiveCheck);
6964
+ }
6965
+ }
6966
+ function verifyCallbackReq(stateCookie, queryParams) {
6967
+ if (typeof stateCookie !== "string") {
6968
+ return {
6969
+ success: false,
6970
+ error: "No state cookie"
6971
+ };
6972
+ }
6973
+ const {
6974
+ code,
6975
+ state: tokenFromQuery
6976
+ } = queryParams;
6977
+ if (typeof code !== "string") {
6978
+ return {
6979
+ success: false,
6980
+ error: "No code query param"
6981
+ };
6982
+ }
6161
6983
  if (typeof tokenFromQuery !== "string") {
6162
6984
  return {
6163
6985
  success: false,
@@ -6301,6 +7123,21 @@ async function withAuth(secret, cookies, errorMessageType, handler) {
6301
7123
  };
6302
7124
  }
6303
7125
  }
7126
+ function getProfileAuthHeaders(auth, data, type) {
7127
+ if ("pat" in auth) {
7128
+ return {
7129
+ "x-val-pat": auth.pat,
7130
+ "Content-Type": type
7131
+ };
7132
+ }
7133
+ if ("apiKey" in auth && data) {
7134
+ return {
7135
+ ...getAuthHeaders(auth.apiKey, type),
7136
+ "x-val-profile-id": data.sub
7137
+ };
7138
+ }
7139
+ throw new Error("Invalid auth");
7140
+ }
6304
7141
  function getAuthHeaders(token, type) {
6305
7142
  if (!type) {
6306
7143
  return {