@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.
@@ -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: {
@@ -3900,7 +3970,7 @@ class ValOpsHttp extends ValOps {
3900
3970
  return {
3901
3971
  status: "error",
3902
3972
  error: {
3903
- message: "Could not get nonce." + message
3973
+ message: "Could not get nonce. " + message
3904
3974
  }
3905
3975
  };
3906
3976
  }
@@ -4647,6 +4717,14 @@ function hasRemoteFileSchema(schema) {
4647
4717
 
4648
4718
  /* eslint-disable @typescript-eslint/no-unused-vars */
4649
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)]);
4650
4728
  const ProfilesResponse = z.object({
4651
4729
  profiles: z.array(z.object({
4652
4730
  profileId: z.string(),
@@ -5444,15 +5522,38 @@ const ValServer = (valModules, options, callbacks) => {
5444
5522
  };
5445
5523
  }
5446
5524
  if (serverOps instanceof ValOpsFS) {
5447
- // In FS mode we do not use the remote server at all and just return an url that points to this server
5448
- // which has an endpoint that handles this
5449
- // 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.
5450
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
+ }
5451
5549
  return {
5452
5550
  status: 200,
5453
5551
  json: {
5454
5552
  nonce: null,
5455
- 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
5456
5557
  }
5457
5558
  };
5458
5559
  }
@@ -5478,7 +5579,9 @@ const ValServer = (valModules, options, callbacks) => {
5478
5579
  status: 200,
5479
5580
  json: {
5480
5581
  nonce: presignedAuthNonce.data.nonce,
5481
- baseUrl: presignedAuthNonce.data.baseUrl
5582
+ baseUrl: presignedAuthNonce.data.baseUrl,
5583
+ contentBaseUrl: presignedAuthNonce.data.baseUrl,
5584
+ contentAuthNonce: presignedAuthNonce.data.nonce
5482
5585
  }
5483
5586
  };
5484
5587
  }
@@ -6148,7 +6251,7 @@ const ValServer = (valModules, options, callbacks) => {
6148
6251
  }
6149
6252
  if (!options.project) {
6150
6253
  return {
6151
- status: 500,
6254
+ status: 401,
6152
6255
  json: {
6153
6256
  message: "Project is not configured"
6154
6257
  }
@@ -6157,7 +6260,7 @@ const ValServer = (valModules, options, callbacks) => {
6157
6260
  const authDataRes = await getRemoteFileAuth();
6158
6261
  if (authDataRes.status !== 200) {
6159
6262
  return {
6160
- status: 500,
6263
+ status: 401,
6161
6264
  json: {
6162
6265
  message: authDataRes.json.message
6163
6266
  }
@@ -6450,14 +6553,19 @@ const ValServer = (valModules, options, callbacks) => {
6450
6553
  const SessionMessagesResponse = z.object({
6451
6554
  messages: z.array(z.object({
6452
6555
  role: z.string(),
6453
- content: z.string()
6556
+ content: AIMessageContent
6454
6557
  })),
6455
6558
  nextCursor: z.object({
6456
6559
  updatedAt: z.string(),
6457
6560
  id: z.string()
6458
6561
  }).nullable().optional()
6459
6562
  });
6460
- const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/sessions/${encodeURIComponent(sessionId)}/messages`;
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}` : "");
6461
6569
  const upstreamRes = await fetch(upstreamUrl, {
6462
6570
  headers
6463
6571
  });
@@ -6508,6 +6616,292 @@ const ValServer = (valModules, options, callbacks) => {
6508
6616
  });
6509
6617
  }
6510
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
+ },
6511
6905
  //#region files
6512
6906
  "/files": {
6513
6907
  GET: async req => {
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "types": "dist/valbuild-server.cjs.d.ts",
19
- "version": "0.95.0",
19
+ "version": "0.96.0",
20
20
  "devDependencies": {
21
21
  "@prettier/sync": "^0.6.1",
22
22
  "@types/jest": "^30.0.0"
@@ -30,9 +30,9 @@
30
30
  "typescript": "^5.9.3",
31
31
  "zod": "^4.3.5",
32
32
  "zod-validation-error": "^5.0.0",
33
- "@valbuild/core": "0.95.0",
34
- "@valbuild/shared": "0.95.0",
35
- "@valbuild/ui": "0.95.0"
33
+ "@valbuild/core": "0.96.0",
34
+ "@valbuild/ui": "0.96.0",
35
+ "@valbuild/shared": "0.96.0"
36
36
  },
37
37
  "engines": {
38
38
  "node": ">=18.17.0"