@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.
@@ -2405,8 +2405,8 @@ class ValOps {
2405
2405
  }
2406
2406
 
2407
2407
  // #region createPatch
2408
- async createPatch(path, patch, patchId, parentRef, authorId) {
2409
- const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId);
2408
+ async createPatch(path, patch, patchId, parentRef, sessionId, authorId) {
2409
+ const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId);
2410
2410
  if (fp.result.isErr(saveRes)) {
2411
2411
  console.error(`Could not save source patch at path: '${path}'. Error: ${saveRes.error.errorType === "other" ? saveRes.error.message : saveRes.error.errorType}`);
2412
2412
  if (saveRes.error.errorType === "patch-head-conflict") {
@@ -2610,6 +2610,76 @@ class ValOpsFS extends ValOps {
2610
2610
  async onInit() {
2611
2611
  // do nothing
2612
2612
  }
2613
+ async getPresignedAuthNonce(project, corsOrigin, auth) {
2614
+ const authHeader = "pat" in auth ? {
2615
+ "x-val-pat": auth.pat
2616
+ } : {
2617
+ Authorization: `Bearer ${auth.apiKey}`
2618
+ };
2619
+ try {
2620
+ const res = await fetch(`${this.contentUrl}/v1/${project}/presigned-auth-nonce`, {
2621
+ method: "POST",
2622
+ headers: {
2623
+ ...authHeader,
2624
+ "Content-Type": "application/json"
2625
+ },
2626
+ body: JSON.stringify({
2627
+ corsOrigin
2628
+ })
2629
+ });
2630
+ if (res.ok) {
2631
+ const json = await res.json();
2632
+ const parsed = zod.z.object({
2633
+ nonce: zod.z.string(),
2634
+ expiresAt: zod.z.string()
2635
+ }).safeParse(json);
2636
+ if (parsed.success) {
2637
+ return {
2638
+ status: "success",
2639
+ data: {
2640
+ nonce: parsed.data.nonce,
2641
+ baseUrl: `${this.contentUrl}/v1/${project}`
2642
+ }
2643
+ };
2644
+ }
2645
+ console.error("Could not parse presigned auth nonce response. Error: " + zodValidationError.fromError(parsed.error));
2646
+ return {
2647
+ status: "error",
2648
+ statusCode: 500,
2649
+ error: {
2650
+ message: "Could not get presigned auth nonce. The response from the content host was not in the expected format."
2651
+ }
2652
+ };
2653
+ }
2654
+ if (res.status === 401) {
2655
+ return {
2656
+ status: "error",
2657
+ statusCode: 401,
2658
+ error: {
2659
+ message: "Could not get presigned auth nonce. The local PAT was rejected by the content host. Try re-running `val login`."
2660
+ }
2661
+ };
2662
+ }
2663
+ const unknownErrorMessage = `Could not get presigned auth nonce. HTTP error: ${res.status} ${res.statusText}`;
2664
+ console.error(unknownErrorMessage);
2665
+ return {
2666
+ status: "error",
2667
+ statusCode: 500,
2668
+ error: {
2669
+ message: unknownErrorMessage
2670
+ }
2671
+ };
2672
+ } catch (e) {
2673
+ console.error("Could not get presigned auth nonce (connection error?):", e);
2674
+ return {
2675
+ status: "error",
2676
+ statusCode: 500,
2677
+ error: {
2678
+ message: `Could not get presigned auth nonce. Error: ${e instanceof Error ? e.message : JSON.stringify(e)}`
2679
+ }
2680
+ };
2681
+ }
2682
+ }
2613
2683
  async getCommitSummary() {
2614
2684
  return {
2615
2685
  error: {
@@ -2798,11 +2868,17 @@ class ValOpsFS extends ValOps {
2798
2868
  parentPatchId: dir
2799
2869
  });
2800
2870
  } else {
2801
- if (includes && includes.length > 0 && !includes.includes(parsedPatch.data.patchId)) {
2871
+ const patchId = parsedPatch.data.patchId;
2872
+ if (includes && includes.length > 0 && !includes.includes(patchId)) {
2802
2873
  return;
2803
2874
  }
2804
- patches[parsedPatch.data.patchId] = {
2805
- ...parsedPatch.data,
2875
+ patches[patchId] = {
2876
+ path: parsedPatch.data.path,
2877
+ patch: parsedPatch.data.patch,
2878
+ parentRef: parsedPatch.data.parentRef,
2879
+ baseSha: parsedPatch.data.baseSha,
2880
+ createdAt: parsedPatch.data.createdAt,
2881
+ authorId: parsedPatch.data.authorId,
2806
2882
  appliedAt: null
2807
2883
  };
2808
2884
  }
@@ -2973,7 +3049,7 @@ class ValOpsFS extends ValOps {
2973
3049
  };
2974
3050
  }
2975
3051
  }
2976
- async saveSourceFilePatch(path, patch, patchId, parentRef, authorId) {
3052
+ async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
2977
3053
  const patchDir = this.getParentPatchIdFromParentRef(parentRef);
2978
3054
  try {
2979
3055
  const baseSha = await this.getBaseSha();
@@ -2983,6 +3059,7 @@ class ValOpsFS extends ValOps {
2983
3059
  parentRef,
2984
3060
  path,
2985
3061
  authorId,
3062
+ sessionId,
2986
3063
  baseSha,
2987
3064
  coreVersion: core.Internal.VERSION.core,
2988
3065
  createdAt: new Date().toISOString()
@@ -3421,12 +3498,6 @@ class ValOpsFS extends ValOps {
3421
3498
  return fp.result.ok(Object.fromEntries(Object.entries(patches.patches).map(([patchId, value]) => [patchId, this.getParentPatchIdFromParentRef(value.parentRef)])));
3422
3499
  }
3423
3500
 
3424
- // #region profiles
3425
- async getProfiles() {
3426
- // We do not have profiles in FS mode
3427
- return [];
3428
- }
3429
-
3430
3501
  // #region fs file path helpers
3431
3502
  getPatchesDir() {
3432
3503
  return path__namespace["default"].join(this.rootDir, ValOpsFS.VAL_DIR, "patches");
@@ -3535,7 +3606,9 @@ const FSPatch = zod.z.object({
3535
3606
  parentRef: internal.ParentRef,
3536
3607
  authorId: zod.z.string().refine(p => true).nullable(),
3537
3608
  createdAt: zod.z.string().datetime(),
3538
- coreVersion: zod.z.string().nullable() // TODO: use this to check if patch is compatible with current core version?
3609
+ coreVersion: zod.z.string().nullable(),
3610
+ // TODO: use this to check if patch is compatible with current core version?
3611
+ sessionId: zod.z.string().nullable()
3539
3612
  });
3540
3613
  const FSPatchBase = zod.z.object({
3541
3614
  baseSha: zod.z.string().refine(p => true),
@@ -3637,17 +3710,6 @@ const CommitResponse = zod.z.object({
3637
3710
  commit: CommitSha,
3638
3711
  branch: zod.z.string()
3639
3712
  });
3640
- const ProfilesResponse = zod.z.object({
3641
- profiles: zod.z.array(zod.z.object({
3642
- profileId: zod.z.string(),
3643
- fullName: zod.z.string(),
3644
- email: zod.z.string().optional(),
3645
- // TODO: make this required once this can be guaranteed
3646
- avatar: zod.z.object({
3647
- url: zod.z.string()
3648
- }).nullable()
3649
- }))
3650
- });
3651
3713
  const NonceResponse = zod.z.object({
3652
3714
  nonce: zod.z.string(),
3653
3715
  url: zod.z.string()
@@ -3939,7 +4001,7 @@ class ValOpsHttp extends ValOps {
3939
4001
  return {
3940
4002
  status: "error",
3941
4003
  error: {
3942
- message: "Could not get nonce." + message
4004
+ message: "Could not get nonce. " + message
3943
4005
  }
3944
4006
  };
3945
4007
  }
@@ -4098,7 +4160,7 @@ class ValOpsHttp extends ValOps {
4098
4160
  };
4099
4161
  }
4100
4162
  }
4101
- async saveSourceFilePatch(path, patch, patchId, parentRef, authorId) {
4163
+ async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
4102
4164
  const baseSha = await this.getBaseSha();
4103
4165
  return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
4104
4166
  method: "POST",
@@ -4110,6 +4172,7 @@ class ValOpsHttp extends ValOps {
4110
4172
  path,
4111
4173
  patch,
4112
4174
  authorId,
4175
+ sessionId,
4113
4176
  patchId,
4114
4177
  parentPatchId: parentRef.type === "patch" ? parentRef.patchId : null,
4115
4178
  baseSha,
@@ -4533,31 +4596,6 @@ class ValOpsHttp extends ValOps {
4533
4596
  };
4534
4597
  }
4535
4598
  }
4536
-
4537
- // #region profiles
4538
- async getProfiles() {
4539
- var _res$headers$get6;
4540
- const res = await fetch(`${this.contentUrl}/v1/${this.project}/profiles`, {
4541
- headers: {
4542
- ...this.authHeaders,
4543
- "Content-Type": "application/json"
4544
- }
4545
- });
4546
- if (res.ok) {
4547
- const parsed = ProfilesResponse.safeParse(await res.json());
4548
- if (parsed.error) {
4549
- console.error("Could not parse profiles response", parsed.error);
4550
- throw Error(`Could not get profiles from remote server: wrong format. You might need to upgrade Val.`);
4551
- }
4552
- return parsed.data.profiles;
4553
- }
4554
- if ((_res$headers$get6 = res.headers.get("Content-Type")) !== null && _res$headers$get6 !== void 0 && _res$headers$get6.includes("application/json")) {
4555
- const json = await res.json();
4556
- const message = internal.getErrorMessageFromUnknownJson(json, "Unknown error");
4557
- throw Error(`Could not get profiles (status: ${res.status}): ${message}`);
4558
- }
4559
- throw Error(`Could not get profiles. Got status: ${res.status}`);
4560
- }
4561
4599
  }
4562
4600
 
4563
4601
  const host = process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST;
@@ -4710,6 +4748,24 @@ function hasRemoteFileSchema(schema) {
4710
4748
 
4711
4749
  /* eslint-disable @typescript-eslint/no-unused-vars */
4712
4750
  const ValServer = (valModules, options, callbacks) => {
4751
+ const AIContentBlock = zod.z.union([zod.z.object({
4752
+ type: zod.z.literal("text"),
4753
+ text: zod.z.string()
4754
+ }), zod.z.object({
4755
+ type: zod.z.literal("image_url"),
4756
+ url: zod.z.string()
4757
+ })]);
4758
+ const AIMessageContent = zod.z.union([zod.z.string(), zod.z.array(AIContentBlock)]);
4759
+ const ProfilesResponse = zod.z.object({
4760
+ profiles: zod.z.array(zod.z.object({
4761
+ profileId: zod.z.string(),
4762
+ fullName: zod.z.string(),
4763
+ email: zod.z.string().optional(),
4764
+ avatar: zod.z.object({
4765
+ url: zod.z.string()
4766
+ }).nullable()
4767
+ }))
4768
+ });
4713
4769
  let serverOps;
4714
4770
  if (options.mode === "fs") {
4715
4771
  serverOps = new ValOpsFS(options.valContentUrl, options.cwd, valModules, {
@@ -5497,15 +5553,38 @@ const ValServer = (valModules, options, callbacks) => {
5497
5553
  };
5498
5554
  }
5499
5555
  if (serverOps instanceof ValOpsFS) {
5500
- // In FS mode we do not use the remote server at all and just return an url that points to this server
5501
- // which has an endpoint that handles this
5502
- // A bit hacky perhaps, but we want to have as similar semantics as possible in client code when it comes to FS / HTTP
5556
+ // In FS mode patch-file uploads are buffered through this server (no remote round-trip),
5557
+ // so baseUrl points at /api/val/upload. AI image uploads, however, go straight to the
5558
+ // content host we resolve a contentBaseUrl + a PAT-issued nonce here so the browser
5559
+ // can POST directly without exposing the PAT.
5503
5560
  const host = `/api/val`;
5561
+ let contentBaseUrl = null;
5562
+ let contentAuthNonce = null;
5563
+ if (!options.project) {
5564
+ console.warn("Direct content-host uploads (AI images) disabled: no `project` set in val.config (and VAL_PROJECT env var is not set).");
5565
+ } else {
5566
+ const authDataRes = await getRemoteFileAuth();
5567
+ if (authDataRes.status !== 200) {
5568
+ console.warn("Direct content-host uploads (AI images) disabled: " + authDataRes.json.message);
5569
+ } else {
5570
+ const corsOrigin = "*"; // TODO: add cors origin
5571
+ const presignedAuthNonce = await serverOps.getPresignedAuthNonce(options.project, corsOrigin, authDataRes.json.remoteFileAuth);
5572
+ if (presignedAuthNonce.status === "success") {
5573
+ contentBaseUrl = presignedAuthNonce.data.baseUrl;
5574
+ contentAuthNonce = presignedAuthNonce.data.nonce;
5575
+ } else {
5576
+ console.warn("Direct content-host uploads (AI images) disabled: " + presignedAuthNonce.error.message);
5577
+ }
5578
+ }
5579
+ }
5504
5580
  return {
5505
5581
  status: 200,
5506
5582
  json: {
5507
5583
  nonce: null,
5508
- baseUrl: `${host}/upload` // NOTE: this is the /upload/patches endpoint - the client will add /patches/:patchId/files to this and post to it
5584
+ baseUrl: `${host}/upload`,
5585
+ // NOTE: this is the /upload/patches endpoint - the client will add /patches/:patchId/files to this and post to it
5586
+ contentBaseUrl,
5587
+ contentAuthNonce
5509
5588
  }
5510
5589
  };
5511
5590
  }
@@ -5531,7 +5610,9 @@ const ValServer = (valModules, options, callbacks) => {
5531
5610
  status: 200,
5532
5611
  json: {
5533
5612
  nonce: presignedAuthNonce.data.nonce,
5534
- baseUrl: presignedAuthNonce.data.baseUrl
5613
+ baseUrl: presignedAuthNonce.data.baseUrl,
5614
+ contentBaseUrl: presignedAuthNonce.data.baseUrl,
5615
+ contentAuthNonce: presignedAuthNonce.data.nonce
5535
5616
  }
5536
5617
  };
5537
5618
  }
@@ -5559,10 +5640,11 @@ const ValServer = (valModules, options, callbacks) => {
5559
5640
  }
5560
5641
  const patches = req.body.patches;
5561
5642
  let parentRef = req.body.parentRef;
5643
+ const sessionId = req.body.sessionId ?? null;
5562
5644
  const authorId = "id" in auth ? auth.id : null;
5563
5645
  const newPatchIds = [];
5564
5646
  for (const patch of patches) {
5565
- const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, authorId);
5647
+ const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId);
5566
5648
  if (fp.result.isErr(createPatchRes)) {
5567
5649
  if (createPatchRes.error.errorType === "patch-head-conflict") {
5568
5650
  return {
@@ -5923,13 +6005,87 @@ const ValServer = (valModules, options, callbacks) => {
5923
6005
  }
5924
6006
  };
5925
6007
  }
5926
- const profiles = await serverOps.getProfiles();
5927
- return {
5928
- status: 200,
5929
- json: {
5930
- profiles
6008
+ if (!options.project) {
6009
+ return {
6010
+ status: 500,
6011
+ json: {
6012
+ message: "Project is not configured"
6013
+ }
6014
+ };
6015
+ }
6016
+ const authDataRes = await getRemoteFileAuth();
6017
+ if (authDataRes.status !== 200) {
6018
+ if (serverOps instanceof ValOpsFS && authDataRes.json.errorCode === "pat-error") {
6019
+ return {
6020
+ status: 200,
6021
+ json: {
6022
+ profiles: []
6023
+ }
6024
+ };
6025
+ }
6026
+ return {
6027
+ status: 500,
6028
+ json: {
6029
+ message: authDataRes.json.message
6030
+ }
6031
+ };
6032
+ }
6033
+ const authData = authDataRes.json.remoteFileAuth;
6034
+ const execFetch = async headers => {
6035
+ try {
6036
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/profiles`;
6037
+ const upstreamRes = await fetch(upstreamUrl, {
6038
+ method: "GET",
6039
+ headers
6040
+ });
6041
+ if (!upstreamRes.ok) {
6042
+ const text = await upstreamRes.text();
6043
+ const isAuthError = upstreamRes.status === 401 || upstreamRes.status === 403;
6044
+ return {
6045
+ status: isAuthError ? 401 : 500,
6046
+ json: {
6047
+ message: isAuthError ? `Profile authentication failed: ${upstreamRes.status} ${text}` : `Profiles failed: ${upstreamRes.status} ${text}`
6048
+ }
6049
+ };
6050
+ }
6051
+ const parseRes = ProfilesResponse.safeParse(await upstreamRes.json());
6052
+ if (!parseRes.success) {
6053
+ return {
6054
+ status: 500,
6055
+ json: {
6056
+ message: "Could not parse profiles response: " + zodValidationError.fromError(parseRes.error).toString()
6057
+ }
6058
+ };
6059
+ }
6060
+ return {
6061
+ status: 200,
6062
+ json: {
6063
+ profiles: parseRes.data.profiles
6064
+ }
6065
+ };
6066
+ } catch (err) {
6067
+ return {
6068
+ status: 500,
6069
+ json: {
6070
+ message: err instanceof Error ? err.message : "Profiles request failed"
6071
+ }
6072
+ };
5931
6073
  }
5932
6074
  };
6075
+ if (serverOps instanceof ValOpsFS) {
6076
+ return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
6077
+ }
6078
+ if (!options.valSecret) {
6079
+ return {
6080
+ status: 500,
6081
+ json: {
6082
+ message: "Secret is not configured"
6083
+ }
6084
+ };
6085
+ }
6086
+ return withAuth(options.valSecret, cookies, "profiles", data => {
6087
+ return execFetch(getProfileAuthHeaders(authData, data, "application/json"));
6088
+ });
5933
6089
  }
5934
6090
  },
5935
6091
  "/commit-summary": {
@@ -6111,84 +6267,750 @@ const ValServer = (valModules, options, callbacks) => {
6111
6267
  }
6112
6268
  }
6113
6269
  },
6114
- //#region files
6115
- "/files": {
6116
- GET: async req => {
6117
- const query = req.query;
6118
- const filePath = req.path;
6119
- // NOTE: no auth here since you would need the patch_id to get something that is not published.
6120
- // For everything that is published, well they are already public so no auth required there...
6121
- // We could imagine adding auth just to be a 200% certain,
6122
- // 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, ...).
6123
- // So: 1) patch ids are not possible to guess (but possible to brute force)
6124
- // 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)
6125
- // 3) the benefit an attacker would get is an image that is not yet published (i.e. most cases: not very interesting)
6126
- // Thus: attack surface + ease of attack + benefit = low probability of attack
6127
- // If we couldn't argue that patch ids are secret enough, then this would be a problem.
6128
- let cacheControl;
6129
- let fileBuffer;
6130
- let mimeType;
6131
- const remote = query.remote === "true";
6132
- if (query.patch_id) {
6133
- fileBuffer = await serverOps.getBase64EncodedBinaryFileFromPatch(filePath, query.patch_id, remote);
6134
- mimeType = core.Internal.filenameToMimeType(filePath);
6135
- // TODO: reenable this:
6136
- // cacheControl = "public, max-age=20000, immutable";
6137
- } else {
6138
- if (serverOps instanceof ValOpsHttp && remote) {
6139
- console.error(`Remote file: ${filePath} requested without patch id. This is most likely a bug in Val.`);
6140
- }
6141
- fileBuffer = await serverOps.getBinaryFile(filePath);
6270
+ //#region ai proxy
6271
+ "/ai/initialize": {
6272
+ POST: async req => {
6273
+ const cookies = req.cookies;
6274
+ const auth = getAuth(cookies);
6275
+ if (auth.error) {
6276
+ return {
6277
+ status: 401,
6278
+ json: {
6279
+ message: auth.error
6280
+ }
6281
+ };
6142
6282
  }
6143
- if (fileBuffer) {
6283
+ if (!options.project) {
6144
6284
  return {
6145
- status: 200,
6146
- headers: {
6147
- // TODO: we could use ETag and return 304 instead
6148
- "Content-Type": mimeType || "application/octet-stream",
6149
- "Cache-Control": cacheControl || "public, max-age=0, must-revalidate"
6150
- },
6151
- body: bufferToReadableStream(fileBuffer)
6285
+ status: 401,
6286
+ json: {
6287
+ message: "Project is not configured"
6288
+ }
6152
6289
  };
6153
- } else {
6290
+ }
6291
+ const authDataRes = await getRemoteFileAuth();
6292
+ if (authDataRes.status !== 200) {
6154
6293
  return {
6155
- status: 404,
6294
+ status: 401,
6156
6295
  json: {
6157
- message: "File not found"
6296
+ message: authDataRes.json.message
6297
+ }
6298
+ };
6299
+ }
6300
+ const authData = authDataRes.json.remoteFileAuth;
6301
+ const execFetch = async headers => {
6302
+ try {
6303
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/initialize`;
6304
+ const upstreamRes = await fetch(upstreamUrl, {
6305
+ method: "POST",
6306
+ headers,
6307
+ body: JSON.stringify({})
6308
+ });
6309
+ if (!upstreamRes.ok) {
6310
+ const text = await upstreamRes.text();
6311
+ return {
6312
+ status: 500,
6313
+ json: {
6314
+ message: `AI initialize failed: ${upstreamRes.status} ${text}`
6315
+ }
6316
+ };
6317
+ }
6318
+ const json = await upstreamRes.json();
6319
+ const wsUrl = options.valContentUrl.replace(/^https:/, "wss:").replace(/^http:/, "ws:") + `/v1/${options.project}/ai/connect`;
6320
+ return {
6321
+ status: 200,
6322
+ json: {
6323
+ nonce: json.nonce,
6324
+ wsUrl
6325
+ }
6326
+ };
6327
+ } catch (err) {
6328
+ return {
6329
+ status: 500,
6330
+ json: {
6331
+ message: err instanceof Error ? err.message : "AI initialize error"
6332
+ }
6333
+ };
6334
+ }
6335
+ };
6336
+ if (serverOps instanceof ValOpsFS) {
6337
+ return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
6338
+ }
6339
+ if (!options.valSecret) {
6340
+ return {
6341
+ status: 500,
6342
+ json: {
6343
+ message: "Secret is not configured"
6158
6344
  }
6159
6345
  };
6160
6346
  }
6347
+ return withAuth(options.valSecret, cookies, "ai/initialize", data => {
6348
+ return execFetch(getProfileAuthHeaders(authData, data, "application/json"));
6349
+ });
6161
6350
  }
6162
- }
6163
- };
6164
- };
6165
- function formatPatchSourceError(error) {
6166
- if ("message" in error) {
6167
- return error.message;
6168
- } else if (Array.isArray(error)) {
6169
- return error.map(formatPatchSourceError).join("\n");
6170
- } else {
6171
- const _exhaustiveCheck = error;
6172
- return "Unknown patch source error: " + JSON.stringify(_exhaustiveCheck);
6173
- }
6174
- }
6175
- function verifyCallbackReq(stateCookie, queryParams) {
6176
- if (typeof stateCookie !== "string") {
6177
- return {
6178
- success: false,
6179
- error: "No state cookie"
6180
- };
6181
- }
6182
- const {
6183
- code,
6184
- state: tokenFromQuery
6185
- } = queryParams;
6186
- if (typeof code !== "string") {
6187
- return {
6188
- success: false,
6189
- error: "No code query param"
6190
- };
6191
- }
6351
+ },
6352
+ "/ai/sessions": {
6353
+ GET: async req => {
6354
+ const cookies = req.cookies;
6355
+ const auth = getAuth(cookies);
6356
+ if (auth.error) {
6357
+ return {
6358
+ status: 401,
6359
+ json: {
6360
+ message: auth.error
6361
+ }
6362
+ };
6363
+ }
6364
+ if (!options.project) {
6365
+ return {
6366
+ status: 500,
6367
+ json: {
6368
+ message: "Project is not configured"
6369
+ }
6370
+ };
6371
+ }
6372
+ const authDataRes = await getRemoteFileAuth();
6373
+ if (authDataRes.status !== 200) {
6374
+ return {
6375
+ status: 500,
6376
+ json: {
6377
+ message: authDataRes.json.message
6378
+ }
6379
+ };
6380
+ }
6381
+ const authData = authDataRes.json.remoteFileAuth;
6382
+ const execFetch = async headers => {
6383
+ try {
6384
+ const SessionsResponse = zod.z.object({
6385
+ sessions: zod.z.array(zod.z.object({
6386
+ id: zod.z.string(),
6387
+ name: zod.z.string().nullable(),
6388
+ createdAt: zod.z.string(),
6389
+ updatedAt: zod.z.string()
6390
+ })),
6391
+ nextCursor: zod.z.object({
6392
+ updatedAt: zod.z.string(),
6393
+ id: zod.z.string()
6394
+ }).nullable().optional()
6395
+ });
6396
+ const params = new URLSearchParams();
6397
+ if (req.query.limit) params.set("limit", req.query.limit);
6398
+ if (req.query.cursor_updatedAt) {
6399
+ params.set("cursor_updatedAt", req.query.cursor_updatedAt);
6400
+ }
6401
+ if (req.query.cursor_id) {
6402
+ params.set("cursor_id", req.query.cursor_id);
6403
+ }
6404
+ const qs = params.toString();
6405
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/sessions${qs ? `?${qs}` : ""}`;
6406
+ const upstreamRes = await fetch(upstreamUrl, {
6407
+ headers
6408
+ });
6409
+ if (!upstreamRes.ok) {
6410
+ const text = await upstreamRes.text();
6411
+ return {
6412
+ status: 500,
6413
+ json: {
6414
+ message: `AI sessions failed: ${upstreamRes.status} ${text}`
6415
+ }
6416
+ };
6417
+ }
6418
+ const json = SessionsResponse.safeParse(await upstreamRes.json());
6419
+ if (!json.success) {
6420
+ return {
6421
+ status: 500,
6422
+ json: {
6423
+ message: "Could not parse AI sessions response: " + zodValidationError.fromError(json.error).toString()
6424
+ }
6425
+ };
6426
+ }
6427
+ return {
6428
+ status: 200,
6429
+ json: json.data
6430
+ };
6431
+ } catch (err) {
6432
+ return {
6433
+ status: 500,
6434
+ json: {
6435
+ message: err instanceof Error ? err.message : "AI sessions error"
6436
+ }
6437
+ };
6438
+ }
6439
+ };
6440
+ if (serverOps instanceof ValOpsFS) {
6441
+ return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
6442
+ }
6443
+ if (!options.valSecret) {
6444
+ return {
6445
+ status: 500,
6446
+ json: {
6447
+ message: "Secret is not configured"
6448
+ }
6449
+ };
6450
+ }
6451
+ return withAuth(options.valSecret, cookies, "ai/sessions", data => {
6452
+ return execFetch(getProfileAuthHeaders(authData, data, "application/json"));
6453
+ });
6454
+ },
6455
+ PATCH: async req => {
6456
+ const cookies = req.cookies;
6457
+ const auth = getAuth(cookies);
6458
+ if (auth.error) {
6459
+ return {
6460
+ status: 401,
6461
+ json: {
6462
+ message: auth.error
6463
+ }
6464
+ };
6465
+ }
6466
+ if (!options.project) {
6467
+ return {
6468
+ status: 500,
6469
+ json: {
6470
+ message: "Project is not configured"
6471
+ }
6472
+ };
6473
+ }
6474
+ const pathParts = (req.path || "").split("/").filter(Boolean);
6475
+ const sessionId = pathParts[0];
6476
+ if (!sessionId) {
6477
+ return {
6478
+ status: 500,
6479
+ json: {
6480
+ message: "Missing sessionId in path"
6481
+ }
6482
+ };
6483
+ }
6484
+ const authDataRes = await getRemoteFileAuth();
6485
+ if (authDataRes.status !== 200) {
6486
+ return {
6487
+ status: 500,
6488
+ json: {
6489
+ message: authDataRes.json.message
6490
+ }
6491
+ };
6492
+ }
6493
+ const authData = authDataRes.json.remoteFileAuth;
6494
+ const execFetch = async headers => {
6495
+ try {
6496
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/sessions/${encodeURIComponent(sessionId)}`;
6497
+ const upstreamRes = await fetch(upstreamUrl, {
6498
+ method: "PATCH",
6499
+ headers,
6500
+ body: JSON.stringify({
6501
+ name: req.body.name
6502
+ })
6503
+ });
6504
+ if (!upstreamRes.ok) {
6505
+ const text = await upstreamRes.text();
6506
+ return {
6507
+ status: 500,
6508
+ json: {
6509
+ message: `AI session rename failed: ${upstreamRes.status} ${text}`
6510
+ }
6511
+ };
6512
+ }
6513
+ return {
6514
+ status: 200,
6515
+ json: {}
6516
+ };
6517
+ } catch (err) {
6518
+ return {
6519
+ status: 500,
6520
+ json: {
6521
+ message: err instanceof Error ? err.message : "AI session rename error"
6522
+ }
6523
+ };
6524
+ }
6525
+ };
6526
+ if (serverOps instanceof ValOpsFS) {
6527
+ return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
6528
+ }
6529
+ if (!options.valSecret) {
6530
+ return {
6531
+ status: 500,
6532
+ json: {
6533
+ message: "Secret is not configured"
6534
+ }
6535
+ };
6536
+ }
6537
+ return withAuth(options.valSecret, cookies, "ai/sessions/rename", data => {
6538
+ return execFetch(getProfileAuthHeaders(authData, data, "application/json"));
6539
+ });
6540
+ }
6541
+ },
6542
+ "/ai/messages": {
6543
+ GET: async req => {
6544
+ const cookies = req.cookies;
6545
+ const auth = getAuth(cookies);
6546
+ if (auth.error) {
6547
+ return {
6548
+ status: 401,
6549
+ json: {
6550
+ message: auth.error
6551
+ }
6552
+ };
6553
+ }
6554
+ if (!options.project) {
6555
+ return {
6556
+ status: 500,
6557
+ json: {
6558
+ message: "Project is not configured"
6559
+ }
6560
+ };
6561
+ }
6562
+ const pathParts = (req.path || "").split("/").filter(Boolean);
6563
+ const sessionId = pathParts[0];
6564
+ if (!sessionId) {
6565
+ return {
6566
+ status: 500,
6567
+ json: {
6568
+ message: "Missing sessionId in path"
6569
+ }
6570
+ };
6571
+ }
6572
+ const authDataRes = await getRemoteFileAuth();
6573
+ if (authDataRes.status !== 200) {
6574
+ return {
6575
+ status: 500,
6576
+ json: {
6577
+ message: authDataRes.json.message
6578
+ }
6579
+ };
6580
+ }
6581
+ const authData = authDataRes.json.remoteFileAuth;
6582
+ const execFetch = async headers => {
6583
+ try {
6584
+ const SessionMessagesResponse = zod.z.object({
6585
+ messages: zod.z.array(zod.z.object({
6586
+ role: zod.z.string(),
6587
+ content: AIMessageContent
6588
+ })),
6589
+ nextCursor: zod.z.object({
6590
+ updatedAt: zod.z.string(),
6591
+ id: zod.z.string()
6592
+ }).nullable().optional()
6593
+ });
6594
+ const params = new URLSearchParams();
6595
+ if (req.query.limit) params.set("limit", req.query.limit);
6596
+ if (req.query.cursor_updatedAt) params.set("cursor_updatedAt", req.query.cursor_updatedAt);
6597
+ if (req.query.cursor_id) params.set("cursor_id", req.query.cursor_id);
6598
+ const qs = params.toString();
6599
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/sessions/${encodeURIComponent(sessionId)}/messages` + (qs ? `?${qs}` : "");
6600
+ const upstreamRes = await fetch(upstreamUrl, {
6601
+ headers
6602
+ });
6603
+ if (!upstreamRes.ok) {
6604
+ const text = await upstreamRes.text();
6605
+ return {
6606
+ status: 500,
6607
+ json: {
6608
+ message: `AI session messages failed: ${upstreamRes.status} ${text}`
6609
+ }
6610
+ };
6611
+ }
6612
+ const json = SessionMessagesResponse.safeParse(await upstreamRes.json());
6613
+ if (!json.success) {
6614
+ return {
6615
+ status: 500,
6616
+ json: {
6617
+ message: "Could not parse AI session messages response: " + zodValidationError.fromError(json.error).toString()
6618
+ }
6619
+ };
6620
+ }
6621
+ return {
6622
+ status: 200,
6623
+ json: json.data
6624
+ };
6625
+ } catch (err) {
6626
+ return {
6627
+ status: 500,
6628
+ json: {
6629
+ message: err instanceof Error ? err.message : "AI session messages error"
6630
+ }
6631
+ };
6632
+ }
6633
+ };
6634
+ if (serverOps instanceof ValOpsFS) {
6635
+ return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
6636
+ }
6637
+ if (!options.valSecret) {
6638
+ return {
6639
+ status: 500,
6640
+ json: {
6641
+ message: "Secret is not configured"
6642
+ }
6643
+ };
6644
+ }
6645
+ return withAuth(options.valSecret, cookies, "ai/sessions/messages", data => {
6646
+ return execFetch(getProfileAuthHeaders(authData, data, "application/json"));
6647
+ });
6648
+ }
6649
+ },
6650
+ "/ai/session-image-to-patch-file": {
6651
+ POST: async req => {
6652
+ const cookies = req.cookies;
6653
+ const auth = getAuth(cookies);
6654
+ if (auth.error) {
6655
+ return {
6656
+ status: 401,
6657
+ json: {
6658
+ message: auth.error
6659
+ }
6660
+ };
6661
+ }
6662
+ if (!options.project) {
6663
+ return {
6664
+ status: 500,
6665
+ json: {
6666
+ message: "Project is not configured"
6667
+ }
6668
+ };
6669
+ }
6670
+ const authDataRes = await getRemoteFileAuth();
6671
+ if (authDataRes.status !== 200) {
6672
+ return {
6673
+ status: 500,
6674
+ json: {
6675
+ message: authDataRes.json.message
6676
+ }
6677
+ };
6678
+ }
6679
+ const authData = authDataRes.json.remoteFileAuth;
6680
+ let headers;
6681
+ if (serverOps instanceof ValOpsFS) {
6682
+ headers = getProfileAuthHeaders(authData, null, "application/json");
6683
+ } else {
6684
+ if (!("id" in auth) || !auth.id) {
6685
+ return {
6686
+ status: 401,
6687
+ json: {
6688
+ message: "Unauthorized"
6689
+ }
6690
+ };
6691
+ }
6692
+ headers = getProfileAuthHeaders(authData, {
6693
+ sub: auth.id
6694
+ }, "application/json");
6695
+ }
6696
+ const execFetch = async () => {
6697
+ try {
6698
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/patches/${encodeURIComponent(req.body.patchId)}/files/from-session-file`;
6699
+ const upstreamRes = await fetch(upstreamUrl, {
6700
+ method: "POST",
6701
+ headers,
6702
+ body: JSON.stringify({
6703
+ files: req.body.files.map(f => ({
6704
+ filePath: f.filePath,
6705
+ key: f.key,
6706
+ ...(f.isRemote !== undefined ? {
6707
+ isRemote: f.isRemote
6708
+ } : {})
6709
+ }))
6710
+ })
6711
+ });
6712
+ if (!upstreamRes.ok) {
6713
+ var _upstreamJson;
6714
+ const text = await upstreamRes.text();
6715
+ let upstreamJson = null;
6716
+ try {
6717
+ upstreamJson = JSON.parse(text);
6718
+ } catch {
6719
+ upstreamJson = null;
6720
+ }
6721
+ if (upstreamRes.status === 400 && upstreamJson) {
6722
+ return {
6723
+ status: 400,
6724
+ json: {
6725
+ message: upstreamJson.message ?? `AI session image to patch failed: ${text}`,
6726
+ ...(upstreamJson.details ? {
6727
+ details: upstreamJson.details
6728
+ } : {})
6729
+ }
6730
+ };
6731
+ }
6732
+ return {
6733
+ status: 500,
6734
+ json: {
6735
+ message: `AI session image to patch failed: ${upstreamRes.status} ${((_upstreamJson = upstreamJson) === null || _upstreamJson === void 0 ? void 0 : _upstreamJson.message) ?? text}`
6736
+ }
6737
+ };
6738
+ }
6739
+ const rawUpstreamJson = await upstreamRes.json();
6740
+ const UpstreamResponse = zod.z.object({
6741
+ patchId: zod.z.string(),
6742
+ files: zod.z.array(zod.z.object({
6743
+ filePath: zod.z.string(),
6744
+ metadata: zod.z.object({
6745
+ width: zod.z.number(),
6746
+ height: zod.z.number(),
6747
+ mimeType: zod.z.string()
6748
+ })
6749
+ }))
6750
+ });
6751
+ const json = UpstreamResponse.safeParse(rawUpstreamJson);
6752
+ if (!json.success) {
6753
+ return {
6754
+ status: 500,
6755
+ json: {
6756
+ message: "Could not parse AI session image patch response: " + zodValidationError.fromError(json.error).toString()
6757
+ }
6758
+ };
6759
+ }
6760
+ if (json.data.patchId !== req.body.patchId) {
6761
+ return {
6762
+ status: 500,
6763
+ json: {
6764
+ message: `AI session image to patch upstream returned mismatched patchId: expected '${req.body.patchId}', got '${json.data.patchId}'`
6765
+ }
6766
+ };
6767
+ }
6768
+ if (serverOps instanceof ValOpsFS) {
6769
+ // Mirror the binaries from the content host into local patch
6770
+ // storage so /files?patch_id=... can serve them. Match upstream
6771
+ // entries to client-provided keys by filePath.
6772
+ const keysByFilePath = new Map(req.body.files.map(f => [f.filePath, f.key]));
6773
+ for (const file of json.data.files) {
6774
+ const sessionKey = keysByFilePath.get(file.filePath);
6775
+ if (!sessionKey) {
6776
+ return {
6777
+ status: 500,
6778
+ json: {
6779
+ message: `AI session image to patch: upstream returned file '${file.filePath}' which was not in the request`
6780
+ }
6781
+ };
6782
+ }
6783
+ const downloadUrl = `${options.valContentUrl}/v1/${options.project}/ai/images?key=${encodeURIComponent(sessionKey)}`;
6784
+ let binaryRes;
6785
+ try {
6786
+ binaryRes = await fetch(downloadUrl, {
6787
+ headers
6788
+ });
6789
+ } catch (err) {
6790
+ return {
6791
+ status: 500,
6792
+ json: {
6793
+ message: `Could not download AI session image '${file.filePath}' from content host: ${err instanceof Error ? err.message : String(err)}`
6794
+ }
6795
+ };
6796
+ }
6797
+ if (!binaryRes.ok) {
6798
+ return {
6799
+ status: 500,
6800
+ json: {
6801
+ message: `Could not download AI session image '${file.filePath}' from content host: ${binaryRes.status} ${binaryRes.statusText}`
6802
+ }
6803
+ };
6804
+ }
6805
+ const arrayBuffer = await binaryRes.arrayBuffer();
6806
+ const base64 = Buffer.from(arrayBuffer).toString("base64");
6807
+ const dataUrl = `data:${file.metadata.mimeType};base64,${base64}`;
6808
+ const type = file.metadata.mimeType.startsWith("image/") ? "image" : "file";
6809
+ const saveRes = await serverOps.saveBase64EncodedBinaryFileFromPatch(file.filePath, req.body.parentRef, req.body.patchId, dataUrl, type, file.metadata);
6810
+ if (saveRes.error) {
6811
+ return {
6812
+ status: 500,
6813
+ json: {
6814
+ message: `Could not save AI session image '${file.filePath}' to local patch storage: ${saveRes.error.message}`
6815
+ }
6816
+ };
6817
+ }
6818
+ }
6819
+ }
6820
+ return {
6821
+ status: 200,
6822
+ json: {
6823
+ patchId: req.body.patchId,
6824
+ files: json.data.files
6825
+ }
6826
+ };
6827
+ } catch (err) {
6828
+ return {
6829
+ status: 500,
6830
+ json: {
6831
+ message: err instanceof Error ? err.message : "AI session image to patch error"
6832
+ }
6833
+ };
6834
+ }
6835
+ };
6836
+ return execFetch();
6837
+ }
6838
+ },
6839
+ "/ai/images": {
6840
+ PATCH: async req => {
6841
+ const cookies = req.cookies;
6842
+ const auth = getAuth(cookies);
6843
+ if (auth.error) {
6844
+ return {
6845
+ status: 401,
6846
+ json: {
6847
+ message: auth.error
6848
+ }
6849
+ };
6850
+ }
6851
+ if (!options.project) {
6852
+ return {
6853
+ status: 500,
6854
+ json: {
6855
+ message: "Project is not configured"
6856
+ }
6857
+ };
6858
+ }
6859
+ const authDataRes = await getRemoteFileAuth();
6860
+ if (authDataRes.status !== 200) {
6861
+ return {
6862
+ status: 500,
6863
+ json: {
6864
+ message: authDataRes.json.message
6865
+ }
6866
+ };
6867
+ }
6868
+ const authData = authDataRes.json.remoteFileAuth;
6869
+ let headers;
6870
+ if (serverOps instanceof ValOpsFS) {
6871
+ headers = getProfileAuthHeaders(authData, null, "application/json");
6872
+ } else {
6873
+ if (!("id" in auth) || !auth.id) {
6874
+ return {
6875
+ status: 401,
6876
+ json: {
6877
+ message: "Unauthorized"
6878
+ }
6879
+ };
6880
+ }
6881
+ headers = getProfileAuthHeaders(authData, {
6882
+ sub: auth.id
6883
+ }, "application/json");
6884
+ }
6885
+ const execFetch = async () => {
6886
+ try {
6887
+ const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/images`;
6888
+ const upstreamRes = await fetch(upstreamUrl, {
6889
+ method: "PATCH",
6890
+ headers,
6891
+ body: JSON.stringify({
6892
+ key: req.body.key,
6893
+ metadata: req.body.metadata,
6894
+ contentType: req.body.contentType
6895
+ })
6896
+ });
6897
+ if (!upstreamRes.ok) {
6898
+ const text = await upstreamRes.text();
6899
+ return {
6900
+ status: 500,
6901
+ json: {
6902
+ message: `AI images patch failed: ${upstreamRes.status} ${text}`
6903
+ }
6904
+ };
6905
+ }
6906
+ const UpstreamResponse = zod.z.object({
6907
+ key: zod.z.string()
6908
+ });
6909
+ const json = UpstreamResponse.safeParse(await upstreamRes.json());
6910
+ if (!json.success) {
6911
+ return {
6912
+ status: 500,
6913
+ json: {
6914
+ message: "Could not parse AI images patch response: " + zodValidationError.fromError(json.error).toString()
6915
+ }
6916
+ };
6917
+ }
6918
+ return {
6919
+ status: 200,
6920
+ json: {
6921
+ key: json.data.key
6922
+ }
6923
+ };
6924
+ } catch (err) {
6925
+ return {
6926
+ status: 500,
6927
+ json: {
6928
+ message: err instanceof Error ? err.message : "AI images patch error"
6929
+ }
6930
+ };
6931
+ }
6932
+ };
6933
+ return execFetch();
6934
+ }
6935
+ },
6936
+ //#region files
6937
+ "/files": {
6938
+ GET: async req => {
6939
+ const query = req.query;
6940
+ const filePath = req.path;
6941
+ // NOTE: no auth here since you would need the patch_id to get something that is not published.
6942
+ // For everything that is published, well they are already public so no auth required there...
6943
+ // We could imagine adding auth just to be a 200% certain,
6944
+ // 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, ...).
6945
+ // So: 1) patch ids are not possible to guess (but possible to brute force)
6946
+ // 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)
6947
+ // 3) the benefit an attacker would get is an image that is not yet published (i.e. most cases: not very interesting)
6948
+ // Thus: attack surface + ease of attack + benefit = low probability of attack
6949
+ // If we couldn't argue that patch ids are secret enough, then this would be a problem.
6950
+ let cacheControl;
6951
+ let fileBuffer;
6952
+ let mimeType;
6953
+ const remote = query.remote === "true";
6954
+ if (query.patch_id) {
6955
+ fileBuffer = await serverOps.getBase64EncodedBinaryFileFromPatch(filePath, query.patch_id, remote);
6956
+ mimeType = core.Internal.filenameToMimeType(filePath);
6957
+ // TODO: reenable this:
6958
+ // cacheControl = "public, max-age=20000, immutable";
6959
+ } else {
6960
+ if (serverOps instanceof ValOpsHttp && remote) {
6961
+ console.error(`Remote file: ${filePath} requested without patch id. This is most likely a bug in Val.`);
6962
+ }
6963
+ fileBuffer = await serverOps.getBinaryFile(filePath);
6964
+ }
6965
+ if (fileBuffer) {
6966
+ return {
6967
+ status: 200,
6968
+ headers: {
6969
+ // TODO: we could use ETag and return 304 instead
6970
+ "Content-Type": mimeType || "application/octet-stream",
6971
+ "Cache-Control": cacheControl || "public, max-age=0, must-revalidate"
6972
+ },
6973
+ body: bufferToReadableStream(fileBuffer)
6974
+ };
6975
+ } else {
6976
+ return {
6977
+ status: 404,
6978
+ json: {
6979
+ message: "File not found"
6980
+ }
6981
+ };
6982
+ }
6983
+ }
6984
+ }
6985
+ };
6986
+ };
6987
+ function formatPatchSourceError(error) {
6988
+ if ("message" in error) {
6989
+ return error.message;
6990
+ } else if (Array.isArray(error)) {
6991
+ return error.map(formatPatchSourceError).join("\n");
6992
+ } else {
6993
+ const _exhaustiveCheck = error;
6994
+ return "Unknown patch source error: " + JSON.stringify(_exhaustiveCheck);
6995
+ }
6996
+ }
6997
+ function verifyCallbackReq(stateCookie, queryParams) {
6998
+ if (typeof stateCookie !== "string") {
6999
+ return {
7000
+ success: false,
7001
+ error: "No state cookie"
7002
+ };
7003
+ }
7004
+ const {
7005
+ code,
7006
+ state: tokenFromQuery
7007
+ } = queryParams;
7008
+ if (typeof code !== "string") {
7009
+ return {
7010
+ success: false,
7011
+ error: "No code query param"
7012
+ };
7013
+ }
6192
7014
  if (typeof tokenFromQuery !== "string") {
6193
7015
  return {
6194
7016
  success: false,
@@ -6332,6 +7154,21 @@ async function withAuth(secret, cookies, errorMessageType, handler) {
6332
7154
  };
6333
7155
  }
6334
7156
  }
7157
+ function getProfileAuthHeaders(auth, data, type) {
7158
+ if ("pat" in auth) {
7159
+ return {
7160
+ "x-val-pat": auth.pat,
7161
+ "Content-Type": type
7162
+ };
7163
+ }
7164
+ if ("apiKey" in auth && data) {
7165
+ return {
7166
+ ...getAuthHeaders(auth.apiKey, type),
7167
+ "x-val-profile-id": data.sub
7168
+ };
7169
+ }
7170
+ throw new Error("Invalid auth");
7171
+ }
6335
7172
  function getAuthHeaders(token, type) {
6336
7173
  if (!type) {
6337
7174
  return {