@valbuild/server 0.95.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.
@@ -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: {
@@ -3931,7 +4001,7 @@ class ValOpsHttp extends ValOps {
3931
4001
  return {
3932
4002
  status: "error",
3933
4003
  error: {
3934
- message: "Could not get nonce." + message
4004
+ message: "Could not get nonce. " + message
3935
4005
  }
3936
4006
  };
3937
4007
  }
@@ -4678,6 +4748,14 @@ function hasRemoteFileSchema(schema) {
4678
4748
 
4679
4749
  /* eslint-disable @typescript-eslint/no-unused-vars */
4680
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)]);
4681
4759
  const ProfilesResponse = zod.z.object({
4682
4760
  profiles: zod.z.array(zod.z.object({
4683
4761
  profileId: zod.z.string(),
@@ -5475,15 +5553,38 @@ const ValServer = (valModules, options, callbacks) => {
5475
5553
  };
5476
5554
  }
5477
5555
  if (serverOps instanceof ValOpsFS) {
5478
- // In FS mode we do not use the remote server at all and just return an url that points to this server
5479
- // which has an endpoint that handles this
5480
- // 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.
5481
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
+ }
5482
5580
  return {
5483
5581
  status: 200,
5484
5582
  json: {
5485
5583
  nonce: null,
5486
- 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
5487
5588
  }
5488
5589
  };
5489
5590
  }
@@ -5509,7 +5610,9 @@ const ValServer = (valModules, options, callbacks) => {
5509
5610
  status: 200,
5510
5611
  json: {
5511
5612
  nonce: presignedAuthNonce.data.nonce,
5512
- baseUrl: presignedAuthNonce.data.baseUrl
5613
+ baseUrl: presignedAuthNonce.data.baseUrl,
5614
+ contentBaseUrl: presignedAuthNonce.data.baseUrl,
5615
+ contentAuthNonce: presignedAuthNonce.data.nonce
5513
5616
  }
5514
5617
  };
5515
5618
  }
@@ -6179,7 +6282,7 @@ const ValServer = (valModules, options, callbacks) => {
6179
6282
  }
6180
6283
  if (!options.project) {
6181
6284
  return {
6182
- status: 500,
6285
+ status: 401,
6183
6286
  json: {
6184
6287
  message: "Project is not configured"
6185
6288
  }
@@ -6188,7 +6291,7 @@ const ValServer = (valModules, options, callbacks) => {
6188
6291
  const authDataRes = await getRemoteFileAuth();
6189
6292
  if (authDataRes.status !== 200) {
6190
6293
  return {
6191
- status: 500,
6294
+ status: 401,
6192
6295
  json: {
6193
6296
  message: authDataRes.json.message
6194
6297
  }
@@ -6481,14 +6584,19 @@ const ValServer = (valModules, options, callbacks) => {
6481
6584
  const SessionMessagesResponse = zod.z.object({
6482
6585
  messages: zod.z.array(zod.z.object({
6483
6586
  role: zod.z.string(),
6484
- content: zod.z.string()
6587
+ content: AIMessageContent
6485
6588
  })),
6486
6589
  nextCursor: zod.z.object({
6487
6590
  updatedAt: zod.z.string(),
6488
6591
  id: zod.z.string()
6489
6592
  }).nullable().optional()
6490
6593
  });
6491
- const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/sessions/${encodeURIComponent(sessionId)}/messages`;
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}` : "");
6492
6600
  const upstreamRes = await fetch(upstreamUrl, {
6493
6601
  headers
6494
6602
  });
@@ -6539,6 +6647,292 @@ const ValServer = (valModules, options, callbacks) => {
6539
6647
  });
6540
6648
  }
6541
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
+ },
6542
6936
  //#region files
6543
6937
  "/files": {
6544
6938
  GET: async req => {