incognitolm commited on
Commit
76aa6fa
ยท
1 Parent(s): c635c63
Files changed (7) hide show
  1. Dockerfile +1 -0
  2. package.json +1 -1
  3. public/app.js +757 -46
  4. public/styles.css +136 -3
  5. server.js +154 -0
  6. src/hf-api.js +543 -27
  7. test/resource-mutations.test.js +232 -0
Dockerfile CHANGED
@@ -9,6 +9,7 @@ RUN mkdir -p /data
9
 
10
  COPY package.json package-lock.json ./
11
  RUN npm ci --omit=dev
 
12
 
13
  COPY server.js ./
14
  COPY src ./src
 
9
 
10
  COPY package.json package-lock.json ./
11
  RUN npm ci --omit=dev
12
+ RUN node -e "require('@huggingface/hub')"
13
 
14
  COPY server.js ./
15
  COPY src ./src
package.json CHANGED
@@ -5,7 +5,7 @@
5
  "description": "Secure Node server with a Hugging Face-style homepage and cookie-backed auth.",
6
  "scripts": {
7
  "start": "node server.js",
8
- "test": "node test/session-store.test.js && node test/server-smoke.test.js && node test/auth-flow.test.js"
9
  },
10
  "engines": {
11
  "node": ">=18.17"
 
5
  "description": "Secure Node server with a Hugging Face-style homepage and cookie-backed auth.",
6
  "scripts": {
7
  "start": "node server.js",
8
+ "test": "node test/session-store.test.js && node test/server-smoke.test.js && node test/auth-flow.test.js && node test/resource-mutations.test.js"
9
  },
10
  "engines": {
11
  "node": ">=18.17"
public/app.js CHANGED
@@ -79,6 +79,37 @@
79
  return `${current.toFixed(current >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
80
  }
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  function showFlash(message, tone = "info") {
83
  if (!flashStack) {
84
  return;
@@ -95,6 +126,10 @@
95
  }, 4200);
96
  }
97
 
 
 
 
 
98
  function setLoading() {
99
  root.setAttribute("aria-busy", "true");
100
  root.innerHTML = `
@@ -218,6 +253,30 @@
218
  .join("");
219
  }
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  function renderResourceRow(item) {
222
  const primary = item.title && item.title !== item.name ? item.title : `${item.owner}/${item.name}`;
223
  const secondaryBits = [];
@@ -284,6 +343,13 @@
284
  <div class="meta-badge-row">
285
  ${renderTokenBadge(state.viewer.tokenRole)}
286
  ${state.viewer.type === "user" ? `<span class="meta-badge">${state.viewer.orgs.length} orgs</span>` : ""}
 
 
 
 
 
 
 
287
  </div>
288
  </div>
289
  <div class="stats-grid">
@@ -376,6 +442,18 @@
376
  `;
377
  }
378
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  function renderSettings(page) {
380
  const viewer = page.account;
381
  return `
@@ -402,6 +480,22 @@
402
  <div><dt>Token created</dt><dd>${escapeHtml(formatDate(viewer.tokenCreatedAt))}</dd></div>
403
  </dl>
404
  </article>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  <article class="panel">
406
  <div class="panel-head">
407
  <div>
@@ -415,12 +509,22 @@
415
  <li>All writes in this interface require a CSRF token derived from the authenticated session.</li>
416
  </ul>
417
  </article>
 
 
 
 
 
 
 
 
 
 
418
  </section>
419
  <section class="content-section">
420
  <div class="section-head">
421
  <div>
422
- <div class="eyebrow">Namespaces</div>
423
- <h2>Organizations and user scopes</h2>
424
  </div>
425
  </div>
426
  ${renderOrgList(viewer.orgs || [])}
@@ -565,6 +669,7 @@
565
  function renderSpaceApp(page) {
566
  const canWrite = Boolean(page.permissions.canAttemptWrite);
567
  return `
 
568
  <section class="content-grid two-up">
569
  <article class="panel">
570
  <div class="panel-head between">
@@ -601,7 +706,7 @@
601
  </div>
602
  ${
603
  !canWrite
604
- ? `<div class="inline-notice">This token can view runtime details, but mutating Space actions may be rejected.</div>`
605
  : ""
606
  }
607
  </article>
@@ -716,6 +821,11 @@
716
  <div>${escapeHtml(file.path)}</div>
717
  <div>${escapeHtml(formatBytes(file.size))}${file.truncated ? " ยท preview truncated" : ""}</div>
718
  </div>
 
 
 
 
 
719
  ${
720
  file.editable
721
  ? `<textarea class="file-editor" id="file-editor-textarea">${escapeHtml(file.content)}</textarea>`
@@ -737,7 +847,7 @@
737
  function renderFilesPanel(page, sideContentTitle = "Recent updates") {
738
  const branchSelector =
739
  page.resourceType === "bucket"
740
- ? `<div class="inline-notice">Buckets are mutable object storage, so branches are not available.</div>`
741
  : `
742
  <label class="field-label">
743
  <span>Branch</span>
@@ -756,6 +866,7 @@
756
  `;
757
 
758
  return `
 
759
  <section class="content-grid files-grid">
760
  <article class="panel">
761
  <div class="panel-head between">
@@ -850,60 +961,467 @@
850
  `;
851
  }
852
 
853
- function renderResourceSettings(page) {
854
- const repoActions =
855
- page.resourceType === "space"
856
- ? `
857
- <div class="button-row">
858
- <button class="ghost-button" type="button" data-space-action="${escapeHtml(
859
- page.owner,
860
- )}/${escapeHtml(page.name)}:pause" data-can-write="${page.permissions.canAttemptWrite}">Pause</button>
861
- <button class="ghost-button" type="button" data-space-action="${escapeHtml(
862
- page.owner,
863
- )}/${escapeHtml(page.name)}:restart" data-can-write="${page.permissions.canAttemptWrite}">Restart</button>
864
  </div>
865
- `
866
- : "";
867
-
868
- const bucketNotice =
869
- page.resourceType === "bucket"
870
- ? `<div class="inline-notice">Buckets do not expose git branches, Community tabs, or an app runtime. This page sticks to the storage features that actually exist on the Hub.</div>`
871
- : "";
 
 
 
 
 
 
 
 
 
 
872
 
 
873
  return `
874
- <section class="content-grid two-up">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
875
  <article class="panel">
876
  <div class="panel-head">
877
  <div>
878
- <div class="eyebrow">Repository</div>
879
- <h2>Settings summary</h2>
880
  </div>
881
  </div>
882
- <dl class="detail-list">
883
- <div><dt>Repo</dt><dd>${escapeHtml(page.owner)}/${escapeHtml(page.name)}</dd></div>
884
- <div><dt>Visibility</dt><dd>${escapeHtml(page.private ? "Private" : "Public")}</dd></div>
885
- <div><dt>Token role</dt><dd>${escapeHtml(page.permissions.tokenRole)}</dd></div>
886
- ${page.sha ? `<div><dt>Current SHA</dt><dd class="mono">${escapeHtml(page.sha)}</dd></div>` : ""}
887
- </dl>
888
- ${bucketNotice}
889
- ${repoActions}
890
  </article>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
891
  <article class="panel">
892
  <div class="panel-head">
893
  <div>
894
- <div class="eyebrow">Hub</div>
895
- <h2>Native links</h2>
896
  </div>
897
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
898
  <div class="button-row">
899
- <a class="secondary-button link-button" href="${escapeHtml(page.hubUrl)}" target="_blank" rel="noreferrer">Open repo</a>
900
- ${
901
- page.appUrl
902
- ? `<a class="secondary-button link-button" href="${escapeHtml(page.appUrl)}" target="_blank" rel="noreferrer">Open app</a>`
903
- : ""
904
- }
905
  </div>
906
- </article>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
907
  </section>
908
  `;
909
  }
@@ -992,6 +1510,10 @@
992
  return parts.join("/");
993
  }
994
 
 
 
 
 
995
  function bindFileActions(page) {
996
  root.querySelectorAll("[data-file-editor]").forEach((editor) => {
997
  const canWrite = editor.dataset.canWrite === "true";
@@ -1009,7 +1531,7 @@
1009
  if (saveButton) {
1010
  saveButton.addEventListener("click", async () => {
1011
  if (!canWrite) {
1012
- showFlash("This access token does not have permission to update that file.", "warning");
1013
  return;
1014
  }
1015
 
@@ -1045,7 +1567,7 @@
1045
  if (deleteButton) {
1046
  deleteButton.addEventListener("click", async () => {
1047
  if (!canWrite) {
1048
- showFlash("This access token does not have permission to delete that file.", "warning");
1049
  return;
1050
  }
1051
 
@@ -1089,7 +1611,7 @@
1089
  const canWrite = button.dataset.canWrite === "true";
1090
  const [repoId, action] = String(button.dataset.spaceAction || "").split(":");
1091
  if (!canWrite) {
1092
- showFlash("This access token does not have permission to change that Space runtime.", "warning");
1093
  return;
1094
  }
1095
 
@@ -1110,6 +1632,183 @@
1110
  });
1111
  }
1112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1113
  function renderLiveUpdates(target, snapshot) {
1114
  if (!target) {
1115
  return;
@@ -1240,6 +1939,10 @@
1240
  bindFileActions(page);
1241
  connectUpdateStream(page.live.updatesUrl);
1242
  }
 
 
 
 
1243
  }
1244
 
1245
  if (page.kind === "bucket" && page.tab === "files") {
@@ -1247,11 +1950,19 @@
1247
  connectUpdateStream(page.live.updatesUrl);
1248
  }
1249
 
 
 
 
 
1250
  if ((page.kind === "model" || page.kind === "dataset") && page.tab === "files") {
1251
  bindBranchSelectors(page);
1252
  bindFileActions(page);
1253
  connectUpdateStream(page.live.updatesUrl);
1254
  }
 
 
 
 
1255
  }
1256
 
1257
  function renderPayload(payload) {
@@ -1318,7 +2029,7 @@
1318
 
1319
  const payload = await response.json();
1320
  if (!payload.total) {
1321
- searchResults.innerHTML = `<div class="search-empty">No owned resources matched โ€œ${escapeHtml(trimmed)}โ€.</div>`;
1322
  searchResults.classList.remove("is-hidden");
1323
  return;
1324
  }
 
79
  return `${current.toFixed(current >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
80
  }
81
 
82
+ function truncateText(value, maxLength = 120) {
83
+ const text = String(value || "").trim();
84
+ if (!text || text.length <= maxLength) {
85
+ return text || "Not set";
86
+ }
87
+
88
+ return `${text.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`;
89
+ }
90
+
91
+ function titleCase(value) {
92
+ const text = String(value || "").trim();
93
+ if (!text) {
94
+ return "Unknown";
95
+ }
96
+
97
+ return text
98
+ .split(/[\s_-]+/)
99
+ .filter(Boolean)
100
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
101
+ .join(" ");
102
+ }
103
+
104
+ function formatVisibility(value, fallbackPrivate = false) {
105
+ const normalized = String(value || "").trim().toLowerCase();
106
+ if (normalized === "private" || normalized === "public" || normalized === "protected") {
107
+ return titleCase(normalized);
108
+ }
109
+
110
+ return fallbackPrivate ? "Private" : "Public";
111
+ }
112
+
113
  function showFlash(message, tone = "info") {
114
  if (!flashStack) {
115
  return;
 
126
  }, 4200);
127
  }
128
 
129
+ function showPermissionDenied(action) {
130
+ showFlash(`You do not have permission to ${action} with this access token.`, "warning");
131
+ }
132
+
133
  function setLoading() {
134
  root.setAttribute("aria-busy", "true");
135
  root.innerHTML = `
 
253
  .join("");
254
  }
255
 
256
+ function renderInlineNotice(message, tone = "neutral") {
257
+ return `<div class="inline-notice tone-${escapeHtml(tone)}">${escapeHtml(message)}</div>`;
258
+ }
259
+
260
+ function renderPermissionBanner(page, actionLabel = "make changes") {
261
+ if (page.permissions?.canAttemptWrite) {
262
+ return "";
263
+ }
264
+
265
+ return renderInlineNotice(
266
+ `This access token can inspect this ${resourceLabel(page.resourceType).toLowerCase()}, but it does not have permission to ${actionLabel} in @${page.owner}.`,
267
+ "warning",
268
+ );
269
+ }
270
+
271
+ function renderExternalLink(href, label) {
272
+ return `
273
+ <a class="settings-link-row" href="${escapeHtml(href)}" target="_blank" rel="noreferrer">
274
+ <span>${escapeHtml(label)}</span>
275
+ <span class="settings-link-arrow">Open</span>
276
+ </a>
277
+ `;
278
+ }
279
+
280
  function renderResourceRow(item) {
281
  const primary = item.title && item.title !== item.name ? item.title : `${item.owner}/${item.name}`;
282
  const secondaryBits = [];
 
343
  <div class="meta-badge-row">
344
  ${renderTokenBadge(state.viewer.tokenRole)}
345
  ${state.viewer.type === "user" ? `<span class="meta-badge">${state.viewer.orgs.length} orgs</span>` : ""}
346
+ <span class="meta-badge">@${escapeHtml(state.viewer.username)}</span>
347
+ </div>
348
+ <div class="button-row">
349
+ <a class="primary-button link-button" href="/settings" data-nav>Manage account</a>
350
+ <a class="secondary-button link-button" href="${escapeHtml(state.viewer.profileUrl)}" target="_blank" rel="noreferrer">
351
+ View profile on Hugging Face
352
+ </a>
353
  </div>
354
  </div>
355
  <div class="stats-grid">
 
442
  `;
443
  }
444
 
445
+ function renderScopeList(namespaces) {
446
+ if (!Array.isArray(namespaces) || !namespaces.length) {
447
+ return `<div class="empty-state">No namespaces were returned for this access token.</div>`;
448
+ }
449
+
450
+ return `
451
+ <div class="badge-grid">
452
+ ${namespaces.map((namespace) => `<span class="meta-badge">${escapeHtml(namespace)}</span>`).join("")}
453
+ </div>
454
+ `;
455
+ }
456
+
457
  function renderSettings(page) {
458
  const viewer = page.account;
459
  return `
 
480
  <div><dt>Token created</dt><dd>${escapeHtml(formatDate(viewer.tokenCreatedAt))}</dd></div>
481
  </dl>
482
  </article>
483
+ <article class="panel">
484
+ <div class="panel-head">
485
+ <div>
486
+ <div class="eyebrow">Workspace</div>
487
+ <h2>Account links</h2>
488
+ </div>
489
+ </div>
490
+ <div class="settings-link-list">
491
+ ${renderExternalLink(page.links.profileUrl, "Open profile")}
492
+ ${renderExternalLink(page.links.repositoriesUrl, "Repository settings")}
493
+ ${renderExternalLink(page.links.webhooksUrl, "Webhooks")}
494
+ ${renderExternalLink(page.links.tokensUrl, "Access tokens")}
495
+ </div>
496
+ </article>
497
+ </section>
498
+ <section class="content-grid two-up">
499
  <article class="panel">
500
  <div class="panel-head">
501
  <div>
 
509
  <li>All writes in this interface require a CSRF token derived from the authenticated session.</li>
510
  </ul>
511
  </article>
512
+ <article class="panel">
513
+ <div class="panel-head">
514
+ <div>
515
+ <div class="eyebrow">Namespaces</div>
516
+ <h2>Token scopes</h2>
517
+ </div>
518
+ </div>
519
+ <p class="page-subtitle">Writes only succeed where this token is both writable and scoped to the owner namespace.</p>
520
+ ${renderScopeList(viewer.namespaces || [])}
521
+ </article>
522
  </section>
523
  <section class="content-section">
524
  <div class="section-head">
525
  <div>
526
+ <div class="eyebrow">Organizations</div>
527
+ <h2>Connected orgs</h2>
528
  </div>
529
  </div>
530
  ${renderOrgList(viewer.orgs || [])}
 
669
  function renderSpaceApp(page) {
670
  const canWrite = Boolean(page.permissions.canAttemptWrite);
671
  return `
672
+ ${renderPermissionBanner(page, "change this Space runtime")}
673
  <section class="content-grid two-up">
674
  <article class="panel">
675
  <div class="panel-head between">
 
706
  </div>
707
  ${
708
  !canWrite
709
+ ? renderInlineNotice("This token can view runtime details, but runtime actions will be rejected.", "warning")
710
  : ""
711
  }
712
  </article>
 
821
  <div>${escapeHtml(file.path)}</div>
822
  <div>${escapeHtml(formatBytes(file.size))}${file.truncated ? " ยท preview truncated" : ""}</div>
823
  </div>
824
+ ${
825
+ canAttemptWrite
826
+ ? ""
827
+ : renderInlineNotice("This token can read this file, but save and delete actions will be rejected.", "warning")
828
+ }
829
  ${
830
  file.editable
831
  ? `<textarea class="file-editor" id="file-editor-textarea">${escapeHtml(file.content)}</textarea>`
 
847
  function renderFilesPanel(page, sideContentTitle = "Recent updates") {
848
  const branchSelector =
849
  page.resourceType === "bucket"
850
+ ? renderInlineNotice("Buckets are mutable object storage, so branches are not available.")
851
  : `
852
  <label class="field-label">
853
  <span>Branch</span>
 
866
  `;
867
 
868
  return `
869
+ ${renderPermissionBanner(page, "edit or delete files")}
870
  <section class="content-grid files-grid">
871
  <article class="panel">
872
  <div class="panel-head between">
 
961
  `;
962
  }
963
 
964
+ function renderSettingsSummaryPanel(page) {
965
+ return `
966
+ <article class="panel">
967
+ <div class="panel-head">
968
+ <div>
969
+ <div class="eyebrow">Repository</div>
970
+ <h2>Settings summary</h2>
 
 
 
 
971
  </div>
972
+ </div>
973
+ <dl class="detail-list">
974
+ <div><dt>Repo</dt><dd>${escapeHtml(page.owner)}/${escapeHtml(page.name)}</dd></div>
975
+ <div><dt>Visibility</dt><dd>${escapeHtml(formatVisibility(page.settings?.visibility, page.private))}</dd></div>
976
+ <div><dt>Token role</dt><dd>${escapeHtml(page.permissions.tokenRole)}</dd></div>
977
+ <div><dt>Writable here</dt><dd>${page.permissions.canAttemptWrite ? "Yes" : "No"}</dd></div>
978
+ ${
979
+ typeof page.settings?.usedStorage === "number"
980
+ ? `<div><dt>Storage used</dt><dd>${escapeHtml(formatBytes(page.settings.usedStorage))}</dd></div>`
981
+ : ""
982
+ }
983
+ ${page.settings?.resourceGroup ? `<div><dt>Resource group</dt><dd>${escapeHtml(page.settings.resourceGroup)}</dd></div>` : ""}
984
+ ${page.sha ? `<div><dt>Current SHA</dt><dd class="mono">${escapeHtml(page.sha)}</dd></div>` : ""}
985
+ </dl>
986
+ </article>
987
+ `;
988
+ }
989
 
990
+ function renderNativeLinksPanel(page) {
991
  return `
992
+ <article class="panel">
993
+ <div class="panel-head">
994
+ <div>
995
+ <div class="eyebrow">Hub</div>
996
+ <h2>Native links</h2>
997
+ </div>
998
+ </div>
999
+ <div class="settings-link-list">
1000
+ ${renderExternalLink(page.settings?.repoUrl || page.hubUrl, "Open repository")}
1001
+ ${renderExternalLink(page.settings?.settingsUrl || page.hubUrl, "Open native settings")}
1002
+ ${
1003
+ page.settings?.communityUrl
1004
+ ? renderExternalLink(page.settings.communityUrl, "Open community")
1005
+ : ""
1006
+ }
1007
+ ${
1008
+ page.appUrl
1009
+ ? renderExternalLink(page.appUrl, "Open app")
1010
+ : ""
1011
+ }
1012
+ </div>
1013
+ </article>
1014
+ `;
1015
+ }
1016
+
1017
+ function renderVisibilityPanel(page) {
1018
+ if (!page.settings?.supportsVisibility) {
1019
+ return `
1020
  <article class="panel">
1021
  <div class="panel-head">
1022
  <div>
1023
+ <div class="eyebrow">Visibility</div>
1024
+ <h2>Visibility controls</h2>
1025
  </div>
1026
  </div>
1027
+ ${renderInlineNotice("This resource type does not expose visibility changes in this interface.")}
 
 
 
 
 
 
 
1028
  </article>
1029
+ `;
1030
+ }
1031
+
1032
+ return `
1033
+ <article class="panel">
1034
+ <div class="panel-head">
1035
+ <div>
1036
+ <div class="eyebrow">Visibility</div>
1037
+ <h2>Change access level</h2>
1038
+ </div>
1039
+ </div>
1040
+ <form class="action-form" data-visibility-form data-type="${escapeHtml(page.resourceType)}" data-repo-id="${escapeHtml(
1041
+ page.id,
1042
+ )}" data-can-write="${page.permissions.canAttemptWrite}">
1043
+ <label class="field-label">
1044
+ <span>Visibility</span>
1045
+ <select class="branch-select" name="visibility">
1046
+ ${["public", "private", "protected"]
1047
+ .map(
1048
+ (option) => `
1049
+ <option value="${option}" ${page.settings.visibility === option ? "selected" : ""}>
1050
+ ${escapeHtml(titleCase(option))}
1051
+ </option>
1052
+ `,
1053
+ )
1054
+ .join("")}
1055
+ </select>
1056
+ </label>
1057
+ <p class="form-help">Protected stays available for resource types and plans that support it on the Hub.</p>
1058
+ <div class="button-row">
1059
+ <button class="primary-button" type="submit">Save visibility</button>
1060
+ </div>
1061
+ </form>
1062
+ </article>
1063
+ `;
1064
+ }
1065
+
1066
+ function renderMovePanel(page) {
1067
+ if (!page.settings?.supportsMove) {
1068
+ return `
1069
  <article class="panel">
1070
  <div class="panel-head">
1071
  <div>
1072
+ <div class="eyebrow">Transfer</div>
1073
+ <h2>Rename or move</h2>
1074
  </div>
1075
  </div>
1076
+ ${renderInlineNotice("This resource type does not expose rename or transfer controls here.")}
1077
+ </article>
1078
+ `;
1079
+ }
1080
+
1081
+ return `
1082
+ <article class="panel">
1083
+ <div class="panel-head">
1084
+ <div>
1085
+ <div class="eyebrow">Transfer</div>
1086
+ <h2>Rename or move</h2>
1087
+ </div>
1088
+ </div>
1089
+ <form class="action-form" data-move-form data-type="${escapeHtml(page.resourceType)}" data-from-id="${escapeHtml(
1090
+ page.id,
1091
+ )}" data-can-write="${page.permissions.canAttemptWrite}">
1092
+ <div class="field-grid two-col">
1093
+ <label class="field-label">
1094
+ <span>Namespace</span>
1095
+ <input class="text-input" type="text" name="namespace" value="${escapeHtml(page.owner)}" required>
1096
+ </label>
1097
+ <label class="field-label">
1098
+ <span>Name</span>
1099
+ <input class="text-input" type="text" name="name" value="${escapeHtml(page.name)}" required>
1100
+ </label>
1101
+ </div>
1102
+ <p class="form-help">Use this to rename the repo or transfer it into another namespace you can write to.</p>
1103
  <div class="button-row">
1104
+ <button class="secondary-button" type="submit">Save repo path</button>
 
 
 
 
 
1105
  </div>
1106
+ </form>
1107
+ </article>
1108
+ `;
1109
+ }
1110
+
1111
+ function renderDangerZonePanel(page) {
1112
+ if (!page.settings?.supportsDelete) {
1113
+ return "";
1114
+ }
1115
+
1116
+ return `
1117
+ <article class="panel danger-panel">
1118
+ <div class="panel-head">
1119
+ <div>
1120
+ <div class="eyebrow">Danger zone</div>
1121
+ <h2>Delete resource</h2>
1122
+ </div>
1123
+ </div>
1124
+ <p class="body-copy">Deleting this resource removes it from the authenticated workspace and from the Hub.</p>
1125
+ <div class="button-row">
1126
+ <button class="danger-button" type="button" data-delete-resource data-type="${escapeHtml(
1127
+ page.resourceType,
1128
+ )}" data-repo-id="${escapeHtml(page.id)}" data-can-write="${page.permissions.canAttemptWrite}">
1129
+ Delete ${escapeHtml(resourceLabel(page.resourceType))}
1130
+ </button>
1131
+ </div>
1132
+ </article>
1133
+ `;
1134
+ }
1135
+
1136
+ function renderSpaceConfigEntries(entries, kind) {
1137
+ if (!entries.length) {
1138
+ return `<div class="empty-state">No ${escapeHtml(kind)}s are configured for this Space.</div>`;
1139
+ }
1140
+
1141
+ return `
1142
+ <div class="settings-item-list">
1143
+ ${entries
1144
+ .map(
1145
+ (entry) => `
1146
+ <article class="settings-item-card">
1147
+ <div class="settings-item-top">
1148
+ <div class="settings-item-title">${escapeHtml(entry.key)}</div>
1149
+ <div class="settings-item-meta">${escapeHtml(formatDate(entry.updatedAt))}</div>
1150
+ </div>
1151
+ <div class="settings-item-body">
1152
+ ${
1153
+ entry.description
1154
+ ? `<p class="body-copy compact">${escapeHtml(entry.description)}</p>`
1155
+ : `<p class="body-copy compact">No description was provided.</p>`
1156
+ }
1157
+ <div class="settings-item-value mono">${
1158
+ kind === "secret" ? "Secret value hidden" : escapeHtml(truncateText(entry.value))
1159
+ }</div>
1160
+ </div>
1161
+ </article>
1162
+ `,
1163
+ )
1164
+ .join("")}
1165
+ </div>
1166
+ `;
1167
+ }
1168
+
1169
+ function renderSpaceConfigPanel(page, kind) {
1170
+ const label = kind === "secret" ? "Secrets" : "Variables";
1171
+ const endpoint = kind === "secret" ? "secrets" : "variables";
1172
+ const entries = Array.isArray(page.settings?.[endpoint]) ? page.settings[endpoint] : [];
1173
+
1174
+ return `
1175
+ <article class="panel">
1176
+ <div class="panel-head">
1177
+ <div>
1178
+ <div class="eyebrow">Space config</div>
1179
+ <h2>${escapeHtml(label)}</h2>
1180
+ </div>
1181
+ </div>
1182
+ ${renderSpaceConfigEntries(entries, kind)}
1183
+ <form class="action-form" data-space-config-form data-kind="${escapeHtml(kind)}" data-owner="${escapeHtml(
1184
+ page.owner,
1185
+ )}" data-name="${escapeHtml(page.name)}" data-can-write="${page.permissions.canAttemptWrite}">
1186
+ <div class="field-grid two-col">
1187
+ <label class="field-label">
1188
+ <span>Key</span>
1189
+ <input class="text-input" type="text" name="key" placeholder="${kind === "secret" ? "API_KEY" : "MODEL_REPO"}" required>
1190
+ </label>
1191
+ <label class="field-label">
1192
+ <span>Description</span>
1193
+ <input class="text-input" type="text" name="description" placeholder="Optional note">
1194
+ </label>
1195
+ </div>
1196
+ <label class="field-label">
1197
+ <span>Value</span>
1198
+ <textarea class="form-textarea" name="value" rows="4" placeholder="${kind === "secret" ? "Secret value" : "Variable value"}" required></textarea>
1199
+ </label>
1200
+ <div class="button-row">
1201
+ <button class="secondary-button" type="submit">Save ${escapeHtml(kind)}</button>
1202
+ </div>
1203
+ </form>
1204
+ </article>
1205
+ `;
1206
+ }
1207
+
1208
+ function renderAttachedBuckets(page) {
1209
+ const attachedBuckets = Array.isArray(page.settings?.attachedBuckets) ? page.settings.attachedBuckets : [];
1210
+ const availableBuckets = Array.isArray(page.settings?.buckets) ? page.settings.buckets : [];
1211
+
1212
+ return `
1213
+ <article class="panel">
1214
+ <div class="panel-head">
1215
+ <div>
1216
+ <div class="eyebrow">Storage</div>
1217
+ <h2>Bucket mounts</h2>
1218
+ </div>
1219
+ </div>
1220
+ ${
1221
+ attachedBuckets.length
1222
+ ? `
1223
+ <div class="settings-item-list">
1224
+ ${attachedBuckets
1225
+ .map(
1226
+ (entry) => `
1227
+ <article class="settings-item-card">
1228
+ <div class="settings-item-top">
1229
+ <div class="settings-item-title">${escapeHtml(entry.bucketId || "Attached bucket")}</div>
1230
+ <div class="settings-item-meta">${escapeHtml(entry.mode || "mounted")}</div>
1231
+ </div>
1232
+ <div class="settings-item-value mono">${escapeHtml(entry.mountPath || "/data")}</div>
1233
+ </article>
1234
+ `,
1235
+ )
1236
+ .join("")}
1237
+ </div>
1238
+ `
1239
+ : renderInlineNotice("No mounted buckets were returned for this Space.")
1240
+ }
1241
+ <div class="panel-subsection">
1242
+ <div class="panel-subtitle">Owned buckets</div>
1243
+ ${
1244
+ availableBuckets.length
1245
+ ? `
1246
+ <div class="settings-item-list">
1247
+ ${availableBuckets
1248
+ .map(
1249
+ (bucket) => `
1250
+ <a class="settings-link-row" href="${escapeHtml(bucket.url)}" data-nav>
1251
+ <span>${escapeHtml(bucket.owner)}/${escapeHtml(bucket.name)}</span>
1252
+ <span>${escapeHtml(formatBytes(bucket.size))}</span>
1253
+ </a>
1254
+ `,
1255
+ )
1256
+ .join("")}
1257
+ </div>
1258
+ `
1259
+ : `<div class="empty-state">No owned buckets were returned for this namespace.</div>`
1260
+ }
1261
+ </div>
1262
+ </article>
1263
+ `;
1264
+ }
1265
+
1266
+ function renderSpaceWebhookPanel(page) {
1267
+ const webhooks = Array.isArray(page.settings?.webhooks) ? page.settings.webhooks : [];
1268
+ return `
1269
+ <article class="panel">
1270
+ <div class="panel-head">
1271
+ <div>
1272
+ <div class="eyebrow">Automation</div>
1273
+ <h2>Webhooks</h2>
1274
+ </div>
1275
+ </div>
1276
+ ${
1277
+ webhooks.length
1278
+ ? `
1279
+ <div class="settings-item-list">
1280
+ ${webhooks
1281
+ .map(
1282
+ (webhook) => `
1283
+ <article class="settings-item-card">
1284
+ <div class="settings-item-top">
1285
+ <div class="settings-item-title">${escapeHtml(webhook.url)}</div>
1286
+ <div class="settings-item-meta">${webhook.disabled ? "Disabled" : "Active"}</div>
1287
+ </div>
1288
+ <div class="settings-item-value">${escapeHtml(
1289
+ webhook.watched.map((item) => `${item.type}:${item.name}`).join(" ยท ") || "No watched scopes",
1290
+ )}</div>
1291
+ </article>
1292
+ `,
1293
+ )
1294
+ .join("")}
1295
+ </div>
1296
+ `
1297
+ : `<div class="empty-state">No matching webhooks were returned for this Space.</div>`
1298
+ }
1299
+ </article>
1300
+ `;
1301
+ }
1302
+
1303
+ function renderBucketStoragePanel(page) {
1304
+ return `
1305
+ <article class="panel">
1306
+ <div class="panel-head">
1307
+ <div>
1308
+ <div class="eyebrow">Storage</div>
1309
+ <h2>Bucket details</h2>
1310
+ </div>
1311
+ </div>
1312
+ <dl class="detail-list">
1313
+ <div><dt>Type</dt><dd>${escapeHtml(page.settings?.storageType || "Mutable storage bucket")}</dd></div>
1314
+ <div><dt>Region</dt><dd>${escapeHtml(page.region || "Unknown")}</dd></div>
1315
+ <div><dt>Total size</dt><dd>${escapeHtml(formatBytes(page.size))}</dd></div>
1316
+ <div><dt>Files</dt><dd>${escapeHtml(compactNumber(page.fileCount))}</dd></div>
1317
+ </dl>
1318
+ ${renderInlineNotice("Buckets intentionally stay focused on files and storage operations. App and community tabs are not surfaced because they are not native bucket views.")}
1319
+ </article>
1320
+ `;
1321
+ }
1322
+
1323
+ function renderResourceSettings(page) {
1324
+ const spacePanels =
1325
+ page.resourceType === "space"
1326
+ ? `
1327
+ <section class="content-grid two-up">
1328
+ <article class="panel">
1329
+ <div class="panel-head">
1330
+ <div>
1331
+ <div class="eyebrow">Runtime</div>
1332
+ <h2>Space runtime</h2>
1333
+ </div>
1334
+ </div>
1335
+ <dl class="detail-list">
1336
+ <div><dt>Stage</dt><dd>${escapeHtml(page.runtime?.stage || "Unknown")}</dd></div>
1337
+ <div><dt>SDK</dt><dd>${escapeHtml(page.settings?.sdk || page.sdk || "Unknown")}</dd></div>
1338
+ <div><dt>Current hardware</dt><dd>${escapeHtml(page.runtime?.hardware?.current || "Unknown")}</dd></div>
1339
+ <div><dt>Requested hardware</dt><dd>${escapeHtml(page.runtime?.hardware?.requested || "Unknown")}</dd></div>
1340
+ </dl>
1341
+ <div class="button-row">
1342
+ <button class="ghost-button" type="button" data-space-action="${escapeHtml(
1343
+ page.owner,
1344
+ )}/${escapeHtml(page.name)}:pause" data-can-write="${page.permissions.canAttemptWrite}">Pause</button>
1345
+ <button class="ghost-button" type="button" data-space-action="${escapeHtml(
1346
+ page.owner,
1347
+ )}/${escapeHtml(page.name)}:restart" data-can-write="${page.permissions.canAttemptWrite}">Restart</button>
1348
+ </div>
1349
+ </article>
1350
+ ${renderAttachedBuckets(page)}
1351
+ </section>
1352
+ <section class="content-grid two-up">
1353
+ ${renderSpaceConfigPanel(page, "variable")}
1354
+ ${renderSpaceConfigPanel(page, "secret")}
1355
+ </section>
1356
+ <section class="content-grid two-up">
1357
+ ${renderSpaceWebhookPanel(page)}
1358
+ <article class="panel">
1359
+ <div class="panel-head">
1360
+ <div>
1361
+ <div class="eyebrow">Links</div>
1362
+ <h2>Space destinations</h2>
1363
+ </div>
1364
+ </div>
1365
+ <div class="settings-link-list">
1366
+ ${page.appUrl ? renderExternalLink(page.appUrl, "Open app") : ""}
1367
+ ${page.settings?.analyticsUrl ? renderExternalLink(page.settings.analyticsUrl, "Analytics and runtime settings") : ""}
1368
+ ${page.settings?.storageOverviewUrl ? renderExternalLink(page.settings.storageOverviewUrl, "Storage overview") : ""}
1369
+ ${page.settings?.webhooksUrl ? renderExternalLink(page.settings.webhooksUrl, "Account webhooks") : ""}
1370
+ </div>
1371
+ </article>
1372
+ </section>
1373
+ `
1374
+ : "";
1375
+
1376
+ const bucketPanels =
1377
+ page.resourceType === "bucket"
1378
+ ? `
1379
+ <section class="content-grid two-up">
1380
+ ${renderBucketStoragePanel(page)}
1381
+ <article class="panel">
1382
+ <div class="panel-head">
1383
+ <div>
1384
+ <div class="eyebrow">Activity</div>
1385
+ <h2>Recent uploads</h2>
1386
+ </div>
1387
+ </div>
1388
+ ${
1389
+ Array.isArray(page.activity) && page.activity.length
1390
+ ? `
1391
+ <div class="activity-list">
1392
+ ${page.activity
1393
+ .map(
1394
+ (entry) => `
1395
+ <article class="activity-item">
1396
+ <div class="activity-item-title">${escapeHtml(entry.path)}</div>
1397
+ <div class="activity-item-meta">${escapeHtml(formatDate(entry.uploadedAt))}</div>
1398
+ </article>
1399
+ `,
1400
+ )
1401
+ .join("")}
1402
+ </div>
1403
+ `
1404
+ : `<div class="empty-state">No recent bucket uploads were returned.</div>`
1405
+ }
1406
+ </article>
1407
+ </section>
1408
+ `
1409
+ : "";
1410
+
1411
+ return `
1412
+ ${renderPermissionBanner(page, "change settings")}
1413
+ <section class="content-grid two-up">
1414
+ ${renderSettingsSummaryPanel(page)}
1415
+ ${renderNativeLinksPanel(page)}
1416
+ </section>
1417
+ ${spacePanels}
1418
+ ${bucketPanels}
1419
+ <section class="content-grid two-up">
1420
+ ${renderVisibilityPanel(page)}
1421
+ ${renderMovePanel(page)}
1422
+ </section>
1423
+ <section class="content-grid two-up">
1424
+ ${renderDangerZonePanel(page)}
1425
  </section>
1426
  `;
1427
  }
 
1510
  return parts.join("/");
1511
  }
1512
 
1513
+ function collectionUrlFor(resourceType) {
1514
+ return `/${resourceType}s`;
1515
+ }
1516
+
1517
  function bindFileActions(page) {
1518
  root.querySelectorAll("[data-file-editor]").forEach((editor) => {
1519
  const canWrite = editor.dataset.canWrite === "true";
 
1531
  if (saveButton) {
1532
  saveButton.addEventListener("click", async () => {
1533
  if (!canWrite) {
1534
+ showPermissionDenied("update that file");
1535
  return;
1536
  }
1537
 
 
1567
  if (deleteButton) {
1568
  deleteButton.addEventListener("click", async () => {
1569
  if (!canWrite) {
1570
+ showPermissionDenied("delete that file");
1571
  return;
1572
  }
1573
 
 
1611
  const canWrite = button.dataset.canWrite === "true";
1612
  const [repoId, action] = String(button.dataset.spaceAction || "").split(":");
1613
  if (!canWrite) {
1614
+ showPermissionDenied("change that Space runtime");
1615
  return;
1616
  }
1617
 
 
1632
  });
1633
  }
1634
 
1635
+ function bindVisibilityForms() {
1636
+ root.querySelectorAll("[data-visibility-form]").forEach((form) => {
1637
+ form.addEventListener("submit", async (event) => {
1638
+ event.preventDefault();
1639
+
1640
+ const canWrite = form.dataset.canWrite === "true";
1641
+ if (!canWrite) {
1642
+ showPermissionDenied("change this resource's visibility");
1643
+ return;
1644
+ }
1645
+
1646
+ const submitButton = form.querySelector('button[type="submit"]');
1647
+ const visibility = form.elements.visibility?.value || "private";
1648
+
1649
+ try {
1650
+ if (submitButton) {
1651
+ submitButton.disabled = true;
1652
+ }
1653
+
1654
+ await apiJson("/api/resources/visibility", {
1655
+ method: "POST",
1656
+ body: JSON.stringify({
1657
+ type: form.dataset.type,
1658
+ repoId: form.dataset.repoId,
1659
+ visibility,
1660
+ }),
1661
+ });
1662
+
1663
+ showFlash("Visibility updated.", "success");
1664
+ navigate(state.currentUrl, { replace: true });
1665
+ } catch (error) {
1666
+ showFlash(error.message, "danger");
1667
+ } finally {
1668
+ if (submitButton) {
1669
+ submitButton.disabled = false;
1670
+ }
1671
+ }
1672
+ });
1673
+ });
1674
+ }
1675
+
1676
+ function bindMoveForms() {
1677
+ root.querySelectorAll("[data-move-form]").forEach((form) => {
1678
+ form.addEventListener("submit", async (event) => {
1679
+ event.preventDefault();
1680
+
1681
+ const canWrite = form.dataset.canWrite === "true";
1682
+ if (!canWrite) {
1683
+ showPermissionDenied("rename or transfer this resource");
1684
+ return;
1685
+ }
1686
+
1687
+ const submitButton = form.querySelector('button[type="submit"]');
1688
+ const namespace = String(form.elements.namespace?.value || "").trim();
1689
+ const name = String(form.elements.name?.value || "").trim();
1690
+
1691
+ try {
1692
+ if (submitButton) {
1693
+ submitButton.disabled = true;
1694
+ }
1695
+
1696
+ const payload = await apiJson("/api/resources/move", {
1697
+ method: "POST",
1698
+ body: JSON.stringify({
1699
+ type: form.dataset.type,
1700
+ fromId: form.dataset.fromId,
1701
+ toId: `${namespace}/${name}`,
1702
+ }),
1703
+ });
1704
+
1705
+ showFlash("Resource path updated.", "success");
1706
+ navigate(payload.url || state.currentUrl, { replace: true });
1707
+ } catch (error) {
1708
+ showFlash(error.message, "danger");
1709
+ } finally {
1710
+ if (submitButton) {
1711
+ submitButton.disabled = false;
1712
+ }
1713
+ }
1714
+ });
1715
+ });
1716
+ }
1717
+
1718
+ function bindResourceDeleteButtons() {
1719
+ root.querySelectorAll("[data-delete-resource]").forEach((button) => {
1720
+ button.addEventListener("click", async () => {
1721
+ const canWrite = button.dataset.canWrite === "true";
1722
+ if (!canWrite) {
1723
+ showPermissionDenied("delete this resource");
1724
+ return;
1725
+ }
1726
+
1727
+ const type = button.dataset.type;
1728
+ const repoId = button.dataset.repoId;
1729
+ const confirmed = window.confirm(`Delete ${repoId}? This cannot be undone.`);
1730
+ if (!confirmed) {
1731
+ return;
1732
+ }
1733
+
1734
+ try {
1735
+ button.disabled = true;
1736
+ await apiJson("/api/resources/delete", {
1737
+ method: "POST",
1738
+ body: JSON.stringify({
1739
+ type,
1740
+ repoId,
1741
+ }),
1742
+ });
1743
+
1744
+ showFlash("Resource deleted.", "success");
1745
+ navigate(collectionUrlFor(type), { replace: true });
1746
+ } catch (error) {
1747
+ showFlash(error.message, "danger");
1748
+ } finally {
1749
+ button.disabled = false;
1750
+ }
1751
+ });
1752
+ });
1753
+ }
1754
+
1755
+ function bindSpaceConfigForms() {
1756
+ root.querySelectorAll("[data-space-config-form]").forEach((form) => {
1757
+ form.addEventListener("submit", async (event) => {
1758
+ event.preventDefault();
1759
+
1760
+ const canWrite = form.dataset.canWrite === "true";
1761
+ if (!canWrite) {
1762
+ showPermissionDenied(`save Space ${form.dataset.kind || "config"}`);
1763
+ return;
1764
+ }
1765
+
1766
+ const submitButton = form.querySelector('button[type="submit"]');
1767
+ const kind = form.dataset.kind === "secret" ? "secrets" : "variables";
1768
+ const owner = form.dataset.owner;
1769
+ const name = form.dataset.name;
1770
+
1771
+ try {
1772
+ if (submitButton) {
1773
+ submitButton.disabled = true;
1774
+ }
1775
+
1776
+ await apiJson(`/api/spaces/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${kind}`, {
1777
+ method: "POST",
1778
+ body: JSON.stringify({
1779
+ key: form.elements.key?.value || "",
1780
+ description: form.elements.description?.value || "",
1781
+ value: form.elements.value?.value || "",
1782
+ }),
1783
+ });
1784
+
1785
+ showFlash(`Space ${kind === "secrets" ? "secret" : "variable"} saved.`, "success");
1786
+ navigate(buildResourceHref("space", owner, name, "settings"), { replace: true });
1787
+ } catch (error) {
1788
+ showFlash(error.message, "danger");
1789
+ } finally {
1790
+ if (submitButton) {
1791
+ submitButton.disabled = false;
1792
+ }
1793
+ }
1794
+ });
1795
+ });
1796
+ }
1797
+
1798
+ function bindSettingsActions(page) {
1799
+ if (page.tab !== "settings") {
1800
+ return;
1801
+ }
1802
+
1803
+ bindVisibilityForms();
1804
+ bindMoveForms();
1805
+ bindResourceDeleteButtons();
1806
+
1807
+ if (page.kind === "space") {
1808
+ bindSpaceConfigForms();
1809
+ }
1810
+ }
1811
+
1812
  function renderLiveUpdates(target, snapshot) {
1813
  if (!target) {
1814
  return;
 
1939
  bindFileActions(page);
1940
  connectUpdateStream(page.live.updatesUrl);
1941
  }
1942
+
1943
+ if (page.tab === "settings") {
1944
+ bindSettingsActions(page);
1945
+ }
1946
  }
1947
 
1948
  if (page.kind === "bucket" && page.tab === "files") {
 
1950
  connectUpdateStream(page.live.updatesUrl);
1951
  }
1952
 
1953
+ if (page.kind === "bucket" && page.tab === "settings") {
1954
+ bindSettingsActions(page);
1955
+ }
1956
+
1957
  if ((page.kind === "model" || page.kind === "dataset") && page.tab === "files") {
1958
  bindBranchSelectors(page);
1959
  bindFileActions(page);
1960
  connectUpdateStream(page.live.updatesUrl);
1961
  }
1962
+
1963
+ if ((page.kind === "model" || page.kind === "dataset") && page.tab === "settings") {
1964
+ bindSettingsActions(page);
1965
+ }
1966
  }
1967
 
1968
  function renderPayload(payload) {
 
2029
 
2030
  const payload = await response.json();
2031
  if (!payload.total) {
2032
+ searchResults.innerHTML = `<div class="search-empty">No owned resources matched "${escapeHtml(trimmed)}".</div>`;
2033
  searchResults.classList.remove("is-hidden");
2034
  return;
2035
  }
public/styles.css CHANGED
@@ -150,7 +150,9 @@ button {
150
  .login-form input,
151
  .search-input,
152
  .branch-select,
153
- .file-editor {
 
 
154
  width: 100%;
155
  border: 1px solid var(--line);
156
  border-radius: var(--radius-md);
@@ -160,12 +162,14 @@ button {
160
 
161
  .login-form input,
162
  .search-input,
163
- .branch-select {
 
164
  min-height: 46px;
165
  padding: 0 14px;
166
  }
167
 
168
- .file-editor {
 
169
  min-height: 360px;
170
  padding: 16px;
171
  resize: vertical;
@@ -174,6 +178,7 @@ button {
174
 
175
  .primary-button,
176
  .secondary-button,
 
177
  .ghost-button,
178
  .menu-button,
179
  .menu-link,
@@ -203,6 +208,7 @@ button {
203
  }
204
 
205
  .secondary-button,
 
206
  .ghost-button,
207
  .menu-button,
208
  .menu-link,
@@ -213,6 +219,7 @@ button {
213
  }
214
 
215
  .secondary-button:hover,
 
216
  .ghost-button:hover,
217
  .menu-button:hover,
218
  .menu-link:hover {
@@ -220,6 +227,17 @@ button {
220
  border-color: #bcc7d2;
221
  }
222
 
 
 
 
 
 
 
 
 
 
 
 
223
  .ghost-button {
224
  color: var(--muted);
225
  }
@@ -669,6 +687,16 @@ button {
669
  color: var(--muted);
670
  }
671
 
 
 
 
 
 
 
 
 
 
 
672
  .error-banner {
673
  margin-top: 18px;
674
  background: #fef2f2;
@@ -717,11 +745,110 @@ button {
717
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
718
  }
719
 
 
 
 
 
 
 
 
720
  .mini-card {
721
  display: block;
722
  min-height: 110px;
723
  }
724
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
725
  .files-layout {
726
  margin-top: 18px;
727
  align-items: stretch;
@@ -875,6 +1002,12 @@ button {
875
  display: block;
876
  }
877
 
 
 
 
 
 
 
878
  .button-row,
879
  .meta-badge-row {
880
  margin-top: 14px;
 
150
  .login-form input,
151
  .search-input,
152
  .branch-select,
153
+ .file-editor,
154
+ .text-input,
155
+ .form-textarea {
156
  width: 100%;
157
  border: 1px solid var(--line);
158
  border-radius: var(--radius-md);
 
162
 
163
  .login-form input,
164
  .search-input,
165
+ .branch-select,
166
+ .text-input {
167
  min-height: 46px;
168
  padding: 0 14px;
169
  }
170
 
171
+ .file-editor,
172
+ .form-textarea {
173
  min-height: 360px;
174
  padding: 16px;
175
  resize: vertical;
 
178
 
179
  .primary-button,
180
  .secondary-button,
181
+ .danger-button,
182
  .ghost-button,
183
  .menu-button,
184
  .menu-link,
 
208
  }
209
 
210
  .secondary-button,
211
+ .danger-button,
212
  .ghost-button,
213
  .menu-button,
214
  .menu-link,
 
219
  }
220
 
221
  .secondary-button:hover,
222
+ .danger-button:hover,
223
  .ghost-button:hover,
224
  .menu-button:hover,
225
  .menu-link:hover {
 
227
  border-color: #bcc7d2;
228
  }
229
 
230
+ .danger-button {
231
+ background: #fff1f2;
232
+ border-color: #fecdd3;
233
+ color: #be123c;
234
+ }
235
+
236
+ .danger-button:hover {
237
+ background: #ffe4e6;
238
+ border-color: #fda4af;
239
+ }
240
+
241
  .ghost-button {
242
  color: var(--muted);
243
  }
 
687
  color: var(--muted);
688
  }
689
 
690
+ .inline-notice.tone-warning {
691
+ background: #fff7ed;
692
+ border-color: #fed7aa;
693
+ color: #9a3412;
694
+ }
695
+
696
+ .inline-notice.tone-neutral {
697
+ background: var(--surface-muted);
698
+ }
699
+
700
  .error-banner {
701
  margin-top: 18px;
702
  background: #fef2f2;
 
745
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
746
  }
747
 
748
+ .badge-grid,
749
+ .settings-link-list,
750
+ .settings-item-list {
751
+ display: grid;
752
+ gap: 12px;
753
+ }
754
+
755
  .mini-card {
756
  display: block;
757
  min-height: 110px;
758
  }
759
 
760
+ .settings-link-row,
761
+ .settings-item-card {
762
+ display: block;
763
+ padding: 16px;
764
+ border-radius: 16px;
765
+ border: 1px solid var(--line);
766
+ background: var(--surface-soft);
767
+ }
768
+
769
+ .settings-link-row {
770
+ display: flex;
771
+ align-items: center;
772
+ justify-content: space-between;
773
+ gap: 16px;
774
+ min-height: 68px;
775
+ }
776
+
777
+ .settings-link-row:hover {
778
+ background: #fff;
779
+ border-color: #bcc7d2;
780
+ }
781
+
782
+ .settings-link-arrow,
783
+ .settings-item-meta,
784
+ .form-help,
785
+ .panel-subtitle {
786
+ color: var(--muted);
787
+ font-size: 14px;
788
+ }
789
+
790
+ .settings-item-top {
791
+ display: flex;
792
+ align-items: flex-start;
793
+ justify-content: space-between;
794
+ gap: 16px;
795
+ }
796
+
797
+ .settings-item-title {
798
+ font-weight: 700;
799
+ }
800
+
801
+ .settings-item-body {
802
+ margin-top: 10px;
803
+ }
804
+
805
+ .settings-item-value {
806
+ margin-top: 10px;
807
+ word-break: break-word;
808
+ }
809
+
810
+ .body-copy.compact {
811
+ margin-top: 0;
812
+ }
813
+
814
+ .field-grid {
815
+ display: grid;
816
+ gap: 12px;
817
+ }
818
+
819
+ .field-grid.two-col {
820
+ grid-template-columns: repeat(2, minmax(0, 1fr));
821
+ }
822
+
823
+ .action-form {
824
+ display: grid;
825
+ gap: 14px;
826
+ margin-top: 18px;
827
+ }
828
+
829
+ .form-textarea {
830
+ min-height: 120px;
831
+ }
832
+
833
+ .form-help {
834
+ margin: 0;
835
+ line-height: 1.5;
836
+ }
837
+
838
+ .panel-subsection {
839
+ margin-top: 18px;
840
+ }
841
+
842
+ .panel-subtitle {
843
+ font-weight: 600;
844
+ margin-bottom: 10px;
845
+ }
846
+
847
+ .danger-panel {
848
+ border-color: #fecdd3;
849
+ background: linear-gradient(180deg, rgba(255, 241, 242, 0.92), rgba(255, 255, 255, 0.95));
850
+ }
851
+
852
  .files-layout {
853
  margin-top: 18px;
854
  align-items: stretch;
 
1002
  display: block;
1003
  }
1004
 
1005
+ .field-grid.two-col,
1006
+ .settings-item-top,
1007
+ .settings-link-row {
1008
+ display: block;
1009
+ }
1010
+
1011
  .button-row,
1012
  .meta-badge-row {
1013
  margin-top: 14px;
server.js CHANGED
@@ -6,14 +6,20 @@ const { SessionStore } = require("./src/session-store");
6
  const {
7
  HfApiError,
8
  buildSpaceStreamUrl,
 
 
9
  getDownloadBlob,
10
  getPageData,
11
  getResourceUpdates,
12
  getSearchResults,
13
  getViewer,
 
14
  performSpaceAction,
15
  removeFile,
 
 
16
  updateTextFile,
 
17
  } = require("./src/hf-api");
18
  const { renderAppShell, renderLoginPage } = require("./src/templates");
19
 
@@ -515,6 +521,120 @@ async function handleFileDelete(req, res) {
515
  }
516
  }
517
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
  async function handleSpaceAction(req, res, owner, name) {
519
  const auth = await requireMutationSession(req, res);
520
  if (!auth) {
@@ -693,6 +813,40 @@ async function handleRequest(req, res) {
693
  return;
694
  }
695
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
696
  const spaceActionMatch =
697
  req.method === "POST" ? pathname.match(/^\/api\/spaces\/([^/]+)\/([^/]+)\/action$/) : null;
698
  if (spaceActionMatch) {
 
6
  const {
7
  HfApiError,
8
  buildSpaceStreamUrl,
9
+ createResource,
10
+ deleteResource,
11
  getDownloadBlob,
12
  getPageData,
13
  getResourceUpdates,
14
  getSearchResults,
15
  getViewer,
16
+ moveResource,
17
  performSpaceAction,
18
  removeFile,
19
+ saveSpaceSecret,
20
+ saveSpaceVariable,
21
  updateTextFile,
22
+ updateResourceVisibility,
23
  } = require("./src/hf-api");
24
  const { renderAppShell, renderLoginPage } = require("./src/templates");
25
 
 
521
  }
522
  }
523
 
524
+ async function handleResourceCreate(req, res) {
525
+ const auth = await requireMutationSession(req, res);
526
+ if (!auth) {
527
+ return;
528
+ }
529
+
530
+ try {
531
+ const body = parseJsonBody(await readBody(req));
532
+ const payload = await createResource(auth.accessToken, {
533
+ type: body.type,
534
+ namespace: body.namespace,
535
+ name: body.name,
536
+ visibility: body.visibility,
537
+ sdk: body.sdk,
538
+ });
539
+ sendJson(res, 200, payload);
540
+ } catch (error) {
541
+ const safeError = sanitizeError(error);
542
+ sendJson(res, safeError.statusCode, { error: safeError.message });
543
+ }
544
+ }
545
+
546
+ async function handleResourceMove(req, res) {
547
+ const auth = await requireMutationSession(req, res);
548
+ if (!auth) {
549
+ return;
550
+ }
551
+
552
+ try {
553
+ const body = parseJsonBody(await readBody(req));
554
+ const payload = await moveResource(auth.accessToken, {
555
+ type: body.type,
556
+ fromId: body.fromId,
557
+ toId: body.toId,
558
+ });
559
+ sendJson(res, 200, payload);
560
+ } catch (error) {
561
+ const safeError = sanitizeError(error);
562
+ sendJson(res, safeError.statusCode, { error: safeError.message });
563
+ }
564
+ }
565
+
566
+ async function handleResourceVisibility(req, res) {
567
+ const auth = await requireMutationSession(req, res);
568
+ if (!auth) {
569
+ return;
570
+ }
571
+
572
+ try {
573
+ const body = parseJsonBody(await readBody(req));
574
+ const payload = await updateResourceVisibility(auth.accessToken, {
575
+ type: body.type,
576
+ repoId: body.repoId,
577
+ visibility: body.visibility,
578
+ private: body.private,
579
+ });
580
+ sendJson(res, 200, payload);
581
+ } catch (error) {
582
+ const safeError = sanitizeError(error);
583
+ sendJson(res, safeError.statusCode, { error: safeError.message });
584
+ }
585
+ }
586
+
587
+ async function handleResourceDelete(req, res) {
588
+ const auth = await requireMutationSession(req, res);
589
+ if (!auth) {
590
+ return;
591
+ }
592
+
593
+ try {
594
+ const body = parseJsonBody(await readBody(req));
595
+ const payload = await deleteResource(auth.accessToken, {
596
+ type: body.type,
597
+ repoId: body.repoId,
598
+ });
599
+ sendJson(res, 200, payload);
600
+ } catch (error) {
601
+ const safeError = sanitizeError(error);
602
+ sendJson(res, safeError.statusCode, { error: safeError.message });
603
+ }
604
+ }
605
+
606
+ async function handleSpaceSecretSave(req, res, owner, name) {
607
+ const auth = await requireMutationSession(req, res);
608
+ if (!auth) {
609
+ return;
610
+ }
611
+
612
+ try {
613
+ const body = parseJsonBody(await readBody(req));
614
+ const payload = await saveSpaceSecret(auth.accessToken, `${owner}/${name}`, body);
615
+ sendJson(res, 200, payload);
616
+ } catch (error) {
617
+ const safeError = sanitizeError(error);
618
+ sendJson(res, safeError.statusCode, { error: safeError.message });
619
+ }
620
+ }
621
+
622
+ async function handleSpaceVariableSave(req, res, owner, name) {
623
+ const auth = await requireMutationSession(req, res);
624
+ if (!auth) {
625
+ return;
626
+ }
627
+
628
+ try {
629
+ const body = parseJsonBody(await readBody(req));
630
+ const payload = await saveSpaceVariable(auth.accessToken, `${owner}/${name}`, body);
631
+ sendJson(res, 200, payload);
632
+ } catch (error) {
633
+ const safeError = sanitizeError(error);
634
+ sendJson(res, safeError.statusCode, { error: safeError.message });
635
+ }
636
+ }
637
+
638
  async function handleSpaceAction(req, res, owner, name) {
639
  const auth = await requireMutationSession(req, res);
640
  if (!auth) {
 
813
  return;
814
  }
815
 
816
+ if (req.method === "POST" && pathname === "/api/resources/create") {
817
+ await handleResourceCreate(req, res);
818
+ return;
819
+ }
820
+
821
+ if (req.method === "POST" && pathname === "/api/resources/move") {
822
+ await handleResourceMove(req, res);
823
+ return;
824
+ }
825
+
826
+ if (req.method === "POST" && pathname === "/api/resources/visibility") {
827
+ await handleResourceVisibility(req, res);
828
+ return;
829
+ }
830
+
831
+ if (req.method === "POST" && pathname === "/api/resources/delete") {
832
+ await handleResourceDelete(req, res);
833
+ return;
834
+ }
835
+
836
+ const spaceSecretMatch =
837
+ req.method === "POST" ? pathname.match(/^\/api\/spaces\/([^/]+)\/([^/]+)\/secrets$/) : null;
838
+ if (spaceSecretMatch) {
839
+ await handleSpaceSecretSave(req, res, decodeURIComponent(spaceSecretMatch[1]), decodeURIComponent(spaceSecretMatch[2]));
840
+ return;
841
+ }
842
+
843
+ const spaceVariableMatch =
844
+ req.method === "POST" ? pathname.match(/^\/api\/spaces\/([^/]+)\/([^/]+)\/variables$/) : null;
845
+ if (spaceVariableMatch) {
846
+ await handleSpaceVariableSave(req, res, decodeURIComponent(spaceVariableMatch[1]), decodeURIComponent(spaceVariableMatch[2]));
847
+ return;
848
+ }
849
+
850
  const spaceActionMatch =
851
  req.method === "POST" ? pathname.match(/^\/api\/spaces\/([^/]+)\/([^/]+)\/action$/) : null;
852
  if (spaceActionMatch) {
src/hf-api.js CHANGED
@@ -3,7 +3,9 @@ const { Blob } = require("node:buffer");
3
  const { TextDecoder } = require("node:util");
4
  const {
5
  HubApiError,
 
6
  deleteFile,
 
7
  downloadFile,
8
  listCommits,
9
  listDatasets,
@@ -42,6 +44,18 @@ function repoRef(type, repoId) {
42
  return { type, name: repoId };
43
  }
44
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  function hubRepoUrl(type, repoId) {
46
  if (type === "model") {
47
  return `${HUB_URL}/${repoId}`;
@@ -79,6 +93,28 @@ function splitRepoId(repoId) {
79
  };
80
  }
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  function normalizeRemotePath(rawPath) {
83
  const value = String(rawPath || "")
84
  .trim()
@@ -143,6 +179,23 @@ function makeHubFetch(accessToken) {
143
  };
144
  }
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  function toPermissionSummary(viewer, repoOwner) {
147
  const namespaces = new Set(viewer.namespaces || []);
148
  return {
@@ -242,21 +295,31 @@ function normalizeDatasetEntry(entry) {
242
  }
243
 
244
  function normalizeBucketEntry(entry) {
245
- const id = entry.id || entry.name || entry.bucket || "";
246
- const { owner, name } = splitRepoId(id);
 
 
 
 
 
 
 
 
 
 
247
 
248
  return {
249
  kind: "bucket",
250
  id,
251
- owner: owner || entry.namespace || entry.owner || "",
252
- name: name || entry.slug || "",
253
  private: Boolean(entry.private),
254
- size: Number(entry.size || entry.totalSize || 0),
255
- fileCount: Number(entry.fileCount || entry.totalFiles || 0),
256
- updatedAt: toIsoString(entry.updatedAt || entry.lastModified || entry.modifiedAt),
257
- createdAt: toIsoString(entry.createdAt),
258
- region: entry.region || null,
259
- url: `/buckets/${owner || entry.namespace || entry.owner}/${name || entry.slug}/files`,
260
  hubUrl: hubRepoUrl("bucket", id),
261
  };
262
  }
@@ -351,6 +414,226 @@ async function fetchJsonWithFallback(urls, accessToken) {
351
  throw new HfApiError(404, "Not found.");
352
  }
353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  async function getViewer(accessToken) {
355
  try {
356
  const identity = await whoAmI({
@@ -435,11 +718,17 @@ async function listOwnedDatasets(accessToken, namespaces, query = "") {
435
  return uniqueById(results.flat().map(normalizeDatasetEntry)).sort(compareByUpdatedDesc);
436
  }
437
 
438
- async function listBucketsForNamespace(accessToken, namespace) {
439
- const urls = [
440
- `${HUB_URL}/api/buckets?namespace=${encodeURIComponent(namespace)}`,
441
- `${HUB_URL}/api/buckets?author=${encodeURIComponent(namespace)}`,
442
- ];
 
 
 
 
 
 
443
 
444
  const payload = await fetchJsonWithFallback(urls, accessToken);
445
  const rows = Array.isArray(payload)
@@ -453,11 +742,11 @@ async function listBucketsForNamespace(accessToken, namespace) {
453
  return rows.map(normalizeBucketEntry);
454
  }
455
 
456
- async function listOwnedBuckets(accessToken, namespaces, query = "") {
457
  const trimmedQuery = String(query || "").trim().toLowerCase();
458
  const bucketGroups = await Promise.all(
459
- namespaces.map((namespace) =>
460
- safeList(async () => listBucketsForNamespace(accessToken, namespace)),
461
  ),
462
  );
463
 
@@ -478,7 +767,7 @@ async function getOwnedResourceIndex(accessToken, viewer, query = "") {
478
  listOwnedSpaces(accessToken, viewer.namespaces, query),
479
  listOwnedModels(accessToken, viewer.namespaces, query),
480
  listOwnedDatasets(accessToken, viewer.namespaces, query),
481
- listOwnedBuckets(accessToken, viewer.namespaces, query),
482
  ]);
483
 
484
  return { spaces, models, datasets, buckets };
@@ -871,7 +1160,19 @@ async function getSpaceDetail(accessToken, viewer, owner, name, tab, branch, req
871
  accessToken,
872
  hubUrl: HUB_URL,
873
  name: repoId,
874
- additionalFields: ["author", "cardData", "createdAt", "datasets", "models", "runtime", "sha", "subdomain", "tags"],
 
 
 
 
 
 
 
 
 
 
 
 
875
  }).catch((error) => {
876
  throw wrapHubError(error, "Couldn't load that Space.");
877
  });
@@ -881,6 +1182,51 @@ async function getSpaceDetail(accessToken, viewer, owner, name, tab, branch, req
881
  const files = activeTab === "files" ? await buildFilesView(accessToken, "space", owner, name, branch, requestedPath, refs.branches) : null;
882
  const commits = await getRecentCommits(accessToken, "space", repoId, files?.branch || refs.branches[0]?.name || "main");
883
  const discussions = activeTab === "community" ? await getRepoDiscussions(accessToken, "space", repoId) : [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
884
 
885
  return {
886
  kind: "space",
@@ -893,7 +1239,7 @@ async function getSpaceDetail(accessToken, viewer, owner, name, tab, branch, req
893
  private: Boolean(info.private),
894
  likes: info.likes || 0,
895
  sdk: info.sdk || info.cardData?.sdk || null,
896
- runtime: info.runtime || null,
897
  createdAt: toIsoString(info.createdAt),
898
  updatedAt: toIsoString(info.updatedAt || info.lastModified),
899
  sha: info.sha || null,
@@ -921,9 +1267,9 @@ async function getSpaceDetail(accessToken, viewer, owner, name, tab, branch, req
921
  (files?.branch ? `?branch=${encodeURIComponent(files.branch)}` : ""),
922
  },
923
  settings: {
 
924
  sdk: info.sdk || info.cardData?.sdk || null,
925
- runtime: info.runtime || null,
926
- repoUrl: hubRepoUrl("space", repoId),
927
  appUrl: spaceAppUrl(info),
928
  },
929
  };
@@ -935,7 +1281,16 @@ async function getModelDetail(accessToken, viewer, owner, name, tab, branch, req
935
  accessToken,
936
  hubUrl: HUB_URL,
937
  name: repoId,
938
- additionalFields: ["author", "cardData", "createdAt", "library_name", "pipeline_tag", "sha", "spaces", "tags"],
 
 
 
 
 
 
 
 
 
939
  }).catch((error) => {
940
  throw wrapHubError(error, "Couldn't load that model.");
941
  });
@@ -986,7 +1341,13 @@ async function getModelDetail(accessToken, viewer, owner, name, tab, branch, req
986
  (files?.branch ? `?branch=${encodeURIComponent(files.branch)}` : ""),
987
  },
988
  settings: {
989
- repoUrl: hubRepoUrl("model", repoId),
 
 
 
 
 
 
990
  },
991
  };
992
  }
@@ -1044,7 +1405,13 @@ async function getDatasetDetail(accessToken, viewer, owner, name, tab, branch, r
1044
  (files?.branch ? `?branch=${encodeURIComponent(files.branch)}` : ""),
1045
  },
1046
  settings: {
1047
- repoUrl: hubRepoUrl("dataset", repoId),
 
 
 
 
 
 
1048
  },
1049
  };
1050
  }
@@ -1108,9 +1475,14 @@ async function getBucketDetail(accessToken, viewer, owner, name, tab, requestedP
1108
  (files?.currentPath ? `?path=${encodeURIComponent(files.currentPath)}` : ""),
1109
  },
1110
  settings: {
1111
- repoUrl: hubRepoUrl("bucket", repoId),
 
1112
  storageType: "Mutable storage bucket",
1113
  branchUnavailable: true,
 
 
 
 
1114
  },
1115
  };
1116
  }
@@ -1170,6 +1542,12 @@ async function getSettingsDetail(viewer) {
1170
  kind: "settings",
1171
  title: "Account settings",
1172
  account: viewer,
 
 
 
 
 
 
1173
  };
1174
  }
1175
 
@@ -1372,6 +1750,138 @@ async function performSpaceAction(accessToken, repoId, action) {
1372
  }
1373
  }
1374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1375
  function buildSpaceStreamUrl(repoId, kind) {
1376
  if (kind === "events") {
1377
  return `${HUB_URL}/api/spaces/${repoId}/events`;
@@ -1392,15 +1902,21 @@ module.exports = {
1392
  HUB_URL,
1393
  HfApiError,
1394
  buildSpaceStreamUrl,
 
 
1395
  getDownloadBlob,
1396
  getPageData,
1397
  getResourceUpdates,
1398
  getSearchResults,
1399
  getViewer,
 
1400
  normalizeBranchName,
1401
  normalizeRemotePath,
1402
  performSpaceAction,
1403
  removeFile,
 
 
1404
  updateTextFile,
 
1405
  wrapHubError,
1406
  };
 
3
  const { TextDecoder } = require("node:util");
4
  const {
5
  HubApiError,
6
+ createRepo,
7
  deleteFile,
8
+ deleteRepo,
9
  downloadFile,
10
  listCommits,
11
  listDatasets,
 
44
  return { type, name: repoId };
45
  }
46
 
47
+ function defaultTabFor(type) {
48
+ if (type === "space") {
49
+ return "app";
50
+ }
51
+
52
+ if (type === "bucket") {
53
+ return "files";
54
+ }
55
+
56
+ return "overview";
57
+ }
58
+
59
  function hubRepoUrl(type, repoId) {
60
  if (type === "model") {
61
  return `${HUB_URL}/${repoId}`;
 
93
  };
94
  }
95
 
96
+ function normalizeRepoSegment(value, label) {
97
+ const trimmed = String(value || "").trim();
98
+ if (!trimmed) {
99
+ throw new HfApiError(400, `That ${label} is required.`);
100
+ }
101
+
102
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed)) {
103
+ throw new HfApiError(400, `That ${label} is invalid.`);
104
+ }
105
+
106
+ return trimmed;
107
+ }
108
+
109
+ function normalizeRepoIdInput(namespace, name) {
110
+ return `${normalizeRepoSegment(namespace, "namespace")}/${normalizeRepoSegment(name, "name")}`;
111
+ }
112
+
113
+ function workspaceResourceUrl(type, repoId) {
114
+ const { owner, name } = splitRepoId(repoId);
115
+ return `/${pluralFor(type)}/${owner}/${name}/${defaultTabFor(type)}`;
116
+ }
117
+
118
  function normalizeRemotePath(rawPath) {
119
  const value = String(rawPath || "")
120
  .trim()
 
179
  };
180
  }
181
 
182
+ async function parseJsonResponse(response) {
183
+ if (!response) {
184
+ return null;
185
+ }
186
+
187
+ const text = await response.text();
188
+ if (!text) {
189
+ return null;
190
+ }
191
+
192
+ try {
193
+ return JSON.parse(text);
194
+ } catch (error) {
195
+ return text;
196
+ }
197
+ }
198
+
199
  function toPermissionSummary(viewer, repoOwner) {
200
  const namespaces = new Set(viewer.namespaces || []);
201
  return {
 
295
  }
296
 
297
  function normalizeBucketEntry(entry) {
298
+ const rawName = String(entry.name || entry.bucket || entry.slug || "");
299
+ const rawId = String(entry.id || (rawName.includes("/") ? rawName : ""));
300
+ const namespace = String(entry.namespace || entry.owner || "");
301
+ const bucketName = rawId
302
+ ? splitRepoId(rawId).name
303
+ : rawName.includes("/")
304
+ ? splitRepoId(rawName).name
305
+ : rawName;
306
+ const id = rawId || (namespace && bucketName ? `${namespace}/${bucketName}` : rawName);
307
+ const parsedId = splitRepoId(id);
308
+ const owner = parsedId.name ? parsedId.owner : namespace || parsedId.owner;
309
+ const name = parsedId.name || bucketName || entry.slug || "";
310
 
311
  return {
312
  kind: "bucket",
313
  id,
314
+ owner,
315
+ name,
316
  private: Boolean(entry.private),
317
+ size: Number(entry.size || entry.totalSize || entry.total_size || 0),
318
+ fileCount: Number(entry.fileCount || entry.totalFiles || entry.total_files || 0),
319
+ updatedAt: toIsoString(entry.updatedAt || entry.updated_at || entry.lastModified || entry.modifiedAt || entry.modified_at),
320
+ createdAt: toIsoString(entry.createdAt || entry.created_at),
321
+ region: entry.region || entry.location || null,
322
+ url: `/buckets/${owner}/${name}/files`,
323
  hubUrl: hubRepoUrl("bucket", id),
324
  };
325
  }
 
414
  throw new HfApiError(404, "Not found.");
415
  }
416
 
417
+ async function hubJson(accessToken, url, init = {}, fallbackMessage = "Hugging Face request failed.") {
418
+ const response = await makeHubFetch(accessToken)(url, init).catch((error) => {
419
+ throw wrapHubError(error, fallbackMessage);
420
+ });
421
+ const payload = await parseJsonResponse(response);
422
+
423
+ if (!response.ok) {
424
+ const detail =
425
+ payload && typeof payload === "object"
426
+ ? payload.error || payload.message || payload.detail || JSON.stringify(payload)
427
+ : payload || response.statusText;
428
+ throw wrapHubError(new HfApiError(response.status, fallbackMessage, detail), fallbackMessage);
429
+ }
430
+
431
+ return payload;
432
+ }
433
+
434
+ async function optionalHubJson(accessToken, url, init = {}, fallbackMessage = "Hugging Face request failed.") {
435
+ try {
436
+ return await hubJson(accessToken, url, init, fallbackMessage);
437
+ } catch (error) {
438
+ const wrapped = wrapHubError(error, fallbackMessage);
439
+ if (wrapped.statusCode === 403 || wrapped.statusCode === 404) {
440
+ return null;
441
+ }
442
+ throw wrapped;
443
+ }
444
+ }
445
+
446
+ function extractRows(payload, keys = []) {
447
+ if (Array.isArray(payload)) {
448
+ return payload;
449
+ }
450
+
451
+ for (const key of keys) {
452
+ if (Array.isArray(payload?.[key])) {
453
+ return payload[key];
454
+ }
455
+ }
456
+
457
+ return [];
458
+ }
459
+
460
+ function normalizeVisibility(value, isPrivate = false) {
461
+ const normalized = String(value || "").trim().toLowerCase();
462
+ if (normalized === "public" || normalized === "private" || normalized === "protected") {
463
+ return normalized;
464
+ }
465
+ return isPrivate ? "private" : "public";
466
+ }
467
+
468
+ function buildNativeLinks(type, repoId) {
469
+ const repoUrl = hubRepoUrl(type, repoId);
470
+ const settingsUrl = `${repoUrl}/settings`;
471
+
472
+ return {
473
+ repoUrl,
474
+ settingsUrl,
475
+ analyticsUrl: settingsUrl,
476
+ communityUrl: type === "bucket" ? repoUrl : `${repoUrl}/discussions`,
477
+ storageUrl: settingsUrl,
478
+ storageOverviewUrl: `${HUB_URL}/settings/repositories`,
479
+ webhooksUrl: `${HUB_URL}/settings/webhooks`,
480
+ xetUrl: settingsUrl,
481
+ contributionsUrl: type === "bucket" ? repoUrl : `${repoUrl}/discussions`,
482
+ };
483
+ }
484
+
485
+ function normalizeSpaceConfigEntry(entry, kind) {
486
+ const key = String(entry?.key || entry?.name || entry?.id || "");
487
+ return {
488
+ key,
489
+ description: entry?.description || entry?.helper || entry?.help || "",
490
+ value:
491
+ kind === "variable"
492
+ ? entry?.value ?? entry?.content ?? entry?.rawValue ?? null
493
+ : null,
494
+ updatedAt: toIsoString(entry?.updatedAt || entry?.updated_at || entry?.lastModified),
495
+ hidden: kind === "secret" || entry?.value === undefined,
496
+ };
497
+ }
498
+
499
+ function normalizeWebhookEntry(entry) {
500
+ const watched = Array.isArray(entry?.watched)
501
+ ? entry.watched
502
+ : Array.isArray(entry?.watchedItems)
503
+ ? entry.watchedItems
504
+ : [];
505
+
506
+ return {
507
+ id: String(entry?.id || entry?._id || ""),
508
+ url: entry?.url || entry?.endpoint || "",
509
+ domains: Array.isArray(entry?.domains) ? entry.domains : [],
510
+ disabled: Boolean(entry?.disabled),
511
+ watched: watched
512
+ .map((item) => ({
513
+ type: String(item?.type || ""),
514
+ name: String(item?.name || item?.id || ""),
515
+ }))
516
+ .filter((item) => item.type && item.name),
517
+ };
518
+ }
519
+
520
+ function filterWebhooksForResource(webhooks, type, repoId, owner) {
521
+ return webhooks.filter((webhook) =>
522
+ webhook.watched.some(
523
+ (item) =>
524
+ (item.type === type && item.name === repoId) ||
525
+ (item.type === "user" && item.name === owner) ||
526
+ (item.type === "org" && item.name === owner),
527
+ ),
528
+ );
529
+ }
530
+
531
+ function normalizeAttachedBucket(entry) {
532
+ const bucketId = String(
533
+ entry?.bucket ||
534
+ entry?.bucketId ||
535
+ entry?.id ||
536
+ entry?.repoId ||
537
+ entry?.storageBucket ||
538
+ "",
539
+ );
540
+
541
+ return {
542
+ bucketId,
543
+ mountPath: entry?.mountPath || entry?.mount_path || entry?.path || entry?.targetPath || "",
544
+ mode: entry?.mode || entry?.type || "",
545
+ };
546
+ }
547
+
548
+ async function listSpaceVariables(accessToken, repoId) {
549
+ const payload = await optionalHubJson(
550
+ accessToken,
551
+ `${HUB_URL}/api/spaces/${repoId}/variables`,
552
+ {
553
+ headers: {
554
+ Accept: "application/json",
555
+ },
556
+ },
557
+ "Couldn't load that Space's variables.",
558
+ );
559
+
560
+ return extractRows(payload, ["variables", "items"]).map((entry) => normalizeSpaceConfigEntry(entry, "variable"));
561
+ }
562
+
563
+ async function listSpaceSecrets(accessToken, repoId) {
564
+ const payload = await optionalHubJson(
565
+ accessToken,
566
+ `${HUB_URL}/api/spaces/${repoId}/secrets`,
567
+ {
568
+ headers: {
569
+ Accept: "application/json",
570
+ },
571
+ },
572
+ "Couldn't load that Space's secrets.",
573
+ );
574
+
575
+ return extractRows(payload, ["secrets", "items"]).map((entry) => normalizeSpaceConfigEntry(entry, "secret"));
576
+ }
577
+
578
+ async function getSpaceRuntimeDetails(accessToken, repoId) {
579
+ return optionalHubJson(
580
+ accessToken,
581
+ `${HUB_URL}/api/spaces/${repoId}/runtime`,
582
+ {
583
+ headers: {
584
+ Accept: "application/json",
585
+ },
586
+ },
587
+ "Couldn't load that Space runtime.",
588
+ );
589
+ }
590
+
591
+ async function listAccountWebhooks(accessToken) {
592
+ const payload = await optionalHubJson(
593
+ accessToken,
594
+ `${HUB_URL}/api/settings/webhooks`,
595
+ {
596
+ headers: {
597
+ Accept: "application/json",
598
+ },
599
+ },
600
+ "Couldn't load your Hugging Face webhooks.",
601
+ );
602
+
603
+ return extractRows(payload, ["webhooks", "items"]).map(normalizeWebhookEntry);
604
+ }
605
+
606
+ async function upsertSpaceConfigEntry(accessToken, repoId, kind, params) {
607
+ const key = normalizeRepoSegment(params.key, `${kind} key`).toUpperCase();
608
+ const value = String(params.value || "");
609
+ const description = String(params.description || "");
610
+
611
+ if (!value) {
612
+ throw new HfApiError(400, `That ${kind} value is required.`);
613
+ }
614
+
615
+ await hubJson(
616
+ accessToken,
617
+ `${HUB_URL}/api/spaces/${repoId}/${kind === "secret" ? "secrets" : "variables"}`,
618
+ {
619
+ method: "POST",
620
+ headers: {
621
+ Accept: "application/json",
622
+ "Content-Type": "application/json",
623
+ },
624
+ body: JSON.stringify({
625
+ key,
626
+ name: key,
627
+ value,
628
+ description,
629
+ }),
630
+ },
631
+ `Couldn't save that Space ${kind}.`,
632
+ );
633
+
634
+ return { ok: true, key };
635
+ }
636
+
637
  async function getViewer(accessToken) {
638
  try {
639
  const identity = await whoAmI({
 
718
  return uniqueById(results.flat().map(normalizeDatasetEntry)).sort(compareByUpdatedDesc);
719
  }
720
 
721
+ async function listBucketsForNamespace(accessToken, namespace, includeViewerFallback = false) {
722
+ const urls = [];
723
+
724
+ if (namespace) {
725
+ urls.push(`${HUB_URL}/api/buckets?namespace=${encodeURIComponent(namespace)}`);
726
+ urls.push(`${HUB_URL}/api/buckets?author=${encodeURIComponent(namespace)}`);
727
+ }
728
+
729
+ if (includeViewerFallback || !namespace) {
730
+ urls.push(`${HUB_URL}/api/buckets`);
731
+ }
732
 
733
  const payload = await fetchJsonWithFallback(urls, accessToken);
734
  const rows = Array.isArray(payload)
 
742
  return rows.map(normalizeBucketEntry);
743
  }
744
 
745
+ async function listOwnedBuckets(accessToken, viewer, query = "") {
746
  const trimmedQuery = String(query || "").trim().toLowerCase();
747
  const bucketGroups = await Promise.all(
748
+ (viewer.namespaces || []).map((namespace) =>
749
+ safeList(async () => listBucketsForNamespace(accessToken, namespace, namespace === viewer.username)),
750
  ),
751
  );
752
 
 
767
  listOwnedSpaces(accessToken, viewer.namespaces, query),
768
  listOwnedModels(accessToken, viewer.namespaces, query),
769
  listOwnedDatasets(accessToken, viewer.namespaces, query),
770
+ listOwnedBuckets(accessToken, viewer, query),
771
  ]);
772
 
773
  return { spaces, models, datasets, buckets };
 
1160
  accessToken,
1161
  hubUrl: HUB_URL,
1162
  name: repoId,
1163
+ additionalFields: [
1164
+ "author",
1165
+ "cardData",
1166
+ "createdAt",
1167
+ "datasets",
1168
+ "models",
1169
+ "resourceGroup",
1170
+ "runtime",
1171
+ "sha",
1172
+ "subdomain",
1173
+ "tags",
1174
+ "usedStorage",
1175
+ ],
1176
  }).catch((error) => {
1177
  throw wrapHubError(error, "Couldn't load that Space.");
1178
  });
 
1182
  const files = activeTab === "files" ? await buildFilesView(accessToken, "space", owner, name, branch, requestedPath, refs.branches) : null;
1183
  const commits = await getRecentCommits(accessToken, "space", repoId, files?.branch || refs.branches[0]?.name || "main");
1184
  const discussions = activeTab === "community" ? await getRepoDiscussions(accessToken, "space", repoId) : [];
1185
+ const runtime = activeTab === "settings" ? (await getSpaceRuntimeDetails(accessToken, repoId)) || info.runtime || null : info.runtime || null;
1186
+ const nativeLinks = buildNativeLinks("space", repoId);
1187
+ const settings =
1188
+ activeTab === "settings"
1189
+ ? await (async () => {
1190
+ const [variables, secrets, webhooks, buckets] = await Promise.all([
1191
+ safeList(async () => listSpaceVariables(accessToken, repoId)),
1192
+ safeList(async () => listSpaceSecrets(accessToken, repoId)),
1193
+ safeList(async () => listAccountWebhooks(accessToken)),
1194
+ safeList(async () => listBucketsForNamespace(accessToken, owner, owner === viewer.username)),
1195
+ ]);
1196
+
1197
+ const attachedBuckets = Array.isArray(runtime?.volumes)
1198
+ ? runtime.volumes.map(normalizeAttachedBucket).filter((entry) => entry.bucketId || entry.mountPath)
1199
+ : [];
1200
+
1201
+ return {
1202
+ ...nativeLinks,
1203
+ visibility: normalizeVisibility(info.visibility, info.private),
1204
+ usedStorage: Number(info.usedStorage || runtime?.usedStorage || 0) || 0,
1205
+ resourceGroup: info.resourceGroup || null,
1206
+ variables,
1207
+ secrets,
1208
+ webhooks: filterWebhooksForResource(webhooks, "space", repoId, owner),
1209
+ buckets,
1210
+ attachedBuckets,
1211
+ supportsVisibility: true,
1212
+ supportsMove: true,
1213
+ supportsDelete: true,
1214
+ };
1215
+ })()
1216
+ : {
1217
+ ...nativeLinks,
1218
+ visibility: normalizeVisibility(info.visibility, info.private),
1219
+ usedStorage: Number(info.usedStorage || 0) || 0,
1220
+ resourceGroup: info.resourceGroup || null,
1221
+ variables: [],
1222
+ secrets: [],
1223
+ webhooks: [],
1224
+ buckets: [],
1225
+ attachedBuckets: [],
1226
+ supportsVisibility: true,
1227
+ supportsMove: true,
1228
+ supportsDelete: true,
1229
+ };
1230
 
1231
  return {
1232
  kind: "space",
 
1239
  private: Boolean(info.private),
1240
  likes: info.likes || 0,
1241
  sdk: info.sdk || info.cardData?.sdk || null,
1242
+ runtime,
1243
  createdAt: toIsoString(info.createdAt),
1244
  updatedAt: toIsoString(info.updatedAt || info.lastModified),
1245
  sha: info.sha || null,
 
1267
  (files?.branch ? `?branch=${encodeURIComponent(files.branch)}` : ""),
1268
  },
1269
  settings: {
1270
+ ...settings,
1271
  sdk: info.sdk || info.cardData?.sdk || null,
1272
+ runtime,
 
1273
  appUrl: spaceAppUrl(info),
1274
  },
1275
  };
 
1281
  accessToken,
1282
  hubUrl: HUB_URL,
1283
  name: repoId,
1284
+ additionalFields: [
1285
+ "author",
1286
+ "cardData",
1287
+ "createdAt",
1288
+ "library_name",
1289
+ "pipeline_tag",
1290
+ "sha",
1291
+ "spaces",
1292
+ "tags",
1293
+ ],
1294
  }).catch((error) => {
1295
  throw wrapHubError(error, "Couldn't load that model.");
1296
  });
 
1341
  (files?.branch ? `?branch=${encodeURIComponent(files.branch)}` : ""),
1342
  },
1343
  settings: {
1344
+ ...buildNativeLinks("model", repoId),
1345
+ visibility: normalizeVisibility(info.visibility, info.private),
1346
+ resourceGroup: info.resourceGroup || null,
1347
+ usedStorage: Number(info.usedStorage || 0) || 0,
1348
+ supportsVisibility: true,
1349
+ supportsMove: true,
1350
+ supportsDelete: true,
1351
  },
1352
  };
1353
  }
 
1405
  (files?.branch ? `?branch=${encodeURIComponent(files.branch)}` : ""),
1406
  },
1407
  settings: {
1408
+ ...buildNativeLinks("dataset", repoId),
1409
+ visibility: normalizeVisibility(info.visibility, info.private),
1410
+ resourceGroup: info.resourceGroup || null,
1411
+ usedStorage: Number(info.usedStorage || 0) || 0,
1412
+ supportsVisibility: true,
1413
+ supportsMove: true,
1414
+ supportsDelete: true,
1415
  },
1416
  };
1417
  }
 
1475
  (files?.currentPath ? `?path=${encodeURIComponent(files.currentPath)}` : ""),
1476
  },
1477
  settings: {
1478
+ ...buildNativeLinks("bucket", repoId),
1479
+ visibility: normalizeVisibility(info.visibility, info.private),
1480
  storageType: "Mutable storage bucket",
1481
  branchUnavailable: true,
1482
+ usedStorage: Number(info.size || 0) || 0,
1483
+ supportsVisibility: false,
1484
+ supportsMove: false,
1485
+ supportsDelete: true,
1486
  },
1487
  };
1488
  }
 
1542
  kind: "settings",
1543
  title: "Account settings",
1544
  account: viewer,
1545
+ links: {
1546
+ profileUrl: viewer.profileUrl,
1547
+ webhooksUrl: `${HUB_URL}/settings/webhooks`,
1548
+ repositoriesUrl: `${HUB_URL}/settings/repositories`,
1549
+ tokensUrl: `${HUB_URL}/settings/tokens`,
1550
+ },
1551
  };
1552
  }
1553
 
 
1750
  }
1751
  }
1752
 
1753
+ async function createResource(accessToken, params) {
1754
+ const type = String(params.type || "").trim();
1755
+ if (!["space", "model", "dataset", "bucket"].includes(type)) {
1756
+ throw new HfApiError(400, "That resource type is not supported.");
1757
+ }
1758
+
1759
+ const repoId = normalizeRepoIdInput(params.namespace, params.name);
1760
+ const visibility = String(params.visibility || "").trim().toLowerCase();
1761
+ const isPrivate = visibility === "private";
1762
+
1763
+ try {
1764
+ const created = await createRepo({
1765
+ accessToken,
1766
+ hubUrl: HUB_URL,
1767
+ repo: repoRef(type, repoId),
1768
+ private: isPrivate,
1769
+ ...(type === "space" ? { sdk: String(params.sdk || "gradio").trim() || "gradio" } : {}),
1770
+ });
1771
+
1772
+ const createdRepoId = created?.id || repoId;
1773
+ return {
1774
+ ok: true,
1775
+ repoId: createdRepoId,
1776
+ url: workspaceResourceUrl(type, createdRepoId),
1777
+ };
1778
+ } catch (error) {
1779
+ throw wrapHubError(error, "Couldn't create that resource.");
1780
+ }
1781
+ }
1782
+
1783
+ async function moveResource(accessToken, params) {
1784
+ const type = String(params.type || "").trim();
1785
+ if (!["space", "model", "dataset", "bucket"].includes(type)) {
1786
+ throw new HfApiError(400, "That resource type is not supported.");
1787
+ }
1788
+
1789
+ const fromId = normalizeRepoIdInput(...Object.values(splitRepoId(params.fromId || "")));
1790
+ const toId = normalizeRepoIdInput(...Object.values(splitRepoId(params.toId || "")));
1791
+ const targetUrl = type === "bucket" ? `${HUB_URL}/api/buckets/move` : `${HUB_URL}/api/repos/move`;
1792
+
1793
+ await hubJson(
1794
+ accessToken,
1795
+ targetUrl,
1796
+ {
1797
+ method: "POST",
1798
+ headers: {
1799
+ Accept: "application/json",
1800
+ "Content-Type": "application/json",
1801
+ },
1802
+ body: JSON.stringify({
1803
+ type,
1804
+ repoType: type,
1805
+ repo_type: type,
1806
+ fromId,
1807
+ from_id: fromId,
1808
+ fromRepo: fromId,
1809
+ toId,
1810
+ to_id: toId,
1811
+ toRepo: toId,
1812
+ }),
1813
+ },
1814
+ "Couldn't rename or transfer that resource.",
1815
+ );
1816
+
1817
+ return {
1818
+ ok: true,
1819
+ repoId: toId,
1820
+ url: workspaceResourceUrl(type, toId),
1821
+ };
1822
+ }
1823
+
1824
+ async function updateResourceVisibility(accessToken, params) {
1825
+ const type = String(params.type || "").trim();
1826
+ if (!["space", "model", "dataset"].includes(type)) {
1827
+ throw new HfApiError(400, "Visibility changes are only supported for Spaces, models, and datasets.");
1828
+ }
1829
+
1830
+ const repoId = normalizeRepoIdInput(...Object.values(splitRepoId(params.repoId || "")));
1831
+ const visibility = normalizeVisibility(params.visibility, params.private);
1832
+ const payload = {
1833
+ private: visibility === "private",
1834
+ visibility,
1835
+ };
1836
+
1837
+ await hubJson(
1838
+ accessToken,
1839
+ `${HUB_URL}/api/${pluralFor(type)}/${repoId}/settings`,
1840
+ {
1841
+ method: "PUT",
1842
+ headers: {
1843
+ Accept: "application/json",
1844
+ "Content-Type": "application/json",
1845
+ },
1846
+ body: JSON.stringify(payload),
1847
+ },
1848
+ "Couldn't update that resource's visibility.",
1849
+ );
1850
+
1851
+ return {
1852
+ ok: true,
1853
+ visibility,
1854
+ };
1855
+ }
1856
+
1857
+ async function deleteResource(accessToken, params) {
1858
+ const type = String(params.type || "").trim();
1859
+ if (!["space", "model", "dataset", "bucket"].includes(type)) {
1860
+ throw new HfApiError(400, "That resource type is not supported.");
1861
+ }
1862
+
1863
+ const repoId = normalizeRepoIdInput(...Object.values(splitRepoId(params.repoId || "")));
1864
+
1865
+ try {
1866
+ await deleteRepo({
1867
+ accessToken,
1868
+ hubUrl: HUB_URL,
1869
+ repo: repoRef(type, repoId),
1870
+ });
1871
+ return { ok: true };
1872
+ } catch (error) {
1873
+ throw wrapHubError(error, "Couldn't delete that resource.");
1874
+ }
1875
+ }
1876
+
1877
+ async function saveSpaceSecret(accessToken, repoId, params) {
1878
+ return upsertSpaceConfigEntry(accessToken, repoId, "secret", params);
1879
+ }
1880
+
1881
+ async function saveSpaceVariable(accessToken, repoId, params) {
1882
+ return upsertSpaceConfigEntry(accessToken, repoId, "variable", params);
1883
+ }
1884
+
1885
  function buildSpaceStreamUrl(repoId, kind) {
1886
  if (kind === "events") {
1887
  return `${HUB_URL}/api/spaces/${repoId}/events`;
 
1902
  HUB_URL,
1903
  HfApiError,
1904
  buildSpaceStreamUrl,
1905
+ createResource,
1906
+ deleteResource,
1907
  getDownloadBlob,
1908
  getPageData,
1909
  getResourceUpdates,
1910
  getSearchResults,
1911
  getViewer,
1912
+ moveResource,
1913
  normalizeBranchName,
1914
  normalizeRemotePath,
1915
  performSpaceAction,
1916
  removeFile,
1917
+ saveSpaceSecret,
1918
+ saveSpaceVariable,
1919
  updateTextFile,
1920
+ updateResourceVisibility,
1921
  wrapHubError,
1922
  };
test/resource-mutations.test.js ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs/promises");
3
+ const http = require("node:http");
4
+ const path = require("node:path");
5
+
6
+ process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || "resource-mutations-encryption-key";
7
+
8
+ async function makeTempDir() {
9
+ return fs.mkdtemp(path.join(process.cwd(), "tmp-resource-mutations-"));
10
+ }
11
+
12
+ async function readJsonBody(req) {
13
+ const chunks = [];
14
+
15
+ for await (const chunk of req) {
16
+ chunks.push(chunk);
17
+ }
18
+
19
+ const text = Buffer.concat(chunks).toString("utf8");
20
+ return text ? JSON.parse(text) : {};
21
+ }
22
+
23
+ function startMockHubServer(calls) {
24
+ const server = http.createServer(async (req, res) => {
25
+ const url = new URL(req.url, "http://127.0.0.1");
26
+
27
+ if (url.pathname === "/api/whoami-v2") {
28
+ res.writeHead(200, { "Content-Type": "application/json" });
29
+ res.end(
30
+ JSON.stringify({
31
+ id: "user-max",
32
+ type: "user",
33
+ email: "max@example.com",
34
+ emailVerified: true,
35
+ isPro: false,
36
+ orgs: [],
37
+ name: "max",
38
+ fullname: "Max Example",
39
+ canPay: true,
40
+ avatarUrl: "",
41
+ periodEnd: null,
42
+ billingMode: "prepaid",
43
+ auth: {
44
+ type: "access_token",
45
+ accessToken: {
46
+ displayName: "Workspace Token",
47
+ role: "write",
48
+ createdAt: "2026-01-01T00:00:00.000Z",
49
+ },
50
+ },
51
+ }),
52
+ );
53
+ return;
54
+ }
55
+
56
+ if (req.method === "PUT" && url.pathname === "/api/spaces/max/demo-space/settings") {
57
+ calls.visibility = await readJsonBody(req);
58
+ res.writeHead(200, { "Content-Type": "application/json" });
59
+ res.end(JSON.stringify({ ok: true }));
60
+ return;
61
+ }
62
+
63
+ if (req.method === "POST" && url.pathname === "/api/repos/move") {
64
+ calls.move = await readJsonBody(req);
65
+ res.writeHead(200, { "Content-Type": "application/json" });
66
+ res.end(JSON.stringify({ ok: true }));
67
+ return;
68
+ }
69
+
70
+ if (req.method === "POST" && url.pathname === "/api/spaces/max/demo-space/variables") {
71
+ calls.variable = await readJsonBody(req);
72
+ res.writeHead(200, { "Content-Type": "application/json" });
73
+ res.end(JSON.stringify({ ok: true }));
74
+ return;
75
+ }
76
+
77
+ if (req.method === "POST" && url.pathname === "/api/spaces/max/demo-space/secrets") {
78
+ calls.secret = await readJsonBody(req);
79
+ res.writeHead(200, { "Content-Type": "application/json" });
80
+ res.end(JSON.stringify({ ok: true }));
81
+ return;
82
+ }
83
+
84
+ res.writeHead(404, { "Content-Type": "application/json" });
85
+ res.end(JSON.stringify({ error: "Not found" }));
86
+ });
87
+
88
+ return new Promise((resolve, reject) => {
89
+ server.once("error", reject);
90
+ server.listen(0, "127.0.0.1", () => resolve(server));
91
+ });
92
+ }
93
+
94
+ function extractCsrfToken(html) {
95
+ const match = html.match(/meta name="csrf-token" content="([^"]+)"/);
96
+ return match ? match[1] : "";
97
+ }
98
+
99
+ async function loginAndGetSession(baseUrl) {
100
+ const loginResponse = await fetch(`${baseUrl}/auth/login`, {
101
+ method: "POST",
102
+ headers: {
103
+ "Content-Type": "application/x-www-form-urlencoded",
104
+ },
105
+ body: new URLSearchParams({
106
+ accessToken: "hf_test_token",
107
+ }),
108
+ redirect: "manual",
109
+ });
110
+
111
+ assert.equal(loginResponse.status, 302);
112
+ const setCookie = loginResponse.headers.get("set-cookie");
113
+ assert.match(setCookie || "", /hf_auth=/);
114
+ const cookie = String(setCookie || "").split(";")[0];
115
+
116
+ const shellResponse = await fetch(`${baseUrl}/`, {
117
+ headers: {
118
+ Cookie: cookie,
119
+ },
120
+ });
121
+
122
+ assert.equal(shellResponse.status, 200);
123
+ const shellHtml = await shellResponse.text();
124
+ const csrfToken = extractCsrfToken(shellHtml);
125
+ assert.ok(csrfToken);
126
+
127
+ return { cookie, csrfToken };
128
+ }
129
+
130
+ async function postJson(baseUrl, pathName, cookie, csrfToken, body) {
131
+ const response = await fetch(`${baseUrl}${pathName}`, {
132
+ method: "POST",
133
+ headers: {
134
+ Accept: "application/json",
135
+ "Content-Type": "application/json",
136
+ Cookie: cookie,
137
+ "X-CSRF-Token": csrfToken,
138
+ },
139
+ body: JSON.stringify(body),
140
+ });
141
+
142
+ const payload = await response.json();
143
+ return { response, payload };
144
+ }
145
+
146
+ async function run() {
147
+ const storageDir = await makeTempDir();
148
+ const calls = {};
149
+ const mockHubServer = await startMockHubServer(calls);
150
+ const mockHubAddress = mockHubServer.address();
151
+
152
+ process.env.SESSION_STORE_DIR = storageDir;
153
+ process.env.HF_API_BASE = `http://127.0.0.1:${mockHubAddress.port}`;
154
+
155
+ const { startServer } = require("../server");
156
+ const appServer = await startServer({ port: 0, host: "127.0.0.1" });
157
+
158
+ try {
159
+ const appAddress = appServer.address();
160
+ const baseUrl = `http://127.0.0.1:${appAddress.port}`;
161
+ const { cookie, csrfToken } = await loginAndGetSession(baseUrl);
162
+
163
+ const visibilityResult = await postJson(baseUrl, "/api/resources/visibility", cookie, csrfToken, {
164
+ type: "space",
165
+ repoId: "max/demo-space",
166
+ visibility: "private",
167
+ });
168
+ assert.equal(visibilityResult.response.status, 200);
169
+ assert.equal(visibilityResult.payload.visibility, "private");
170
+ assert.deepEqual(calls.visibility, { private: true, visibility: "private" });
171
+
172
+ const moveResult = await postJson(baseUrl, "/api/resources/move", cookie, csrfToken, {
173
+ type: "space",
174
+ fromId: "max/demo-space",
175
+ toId: "max/renamed-space",
176
+ });
177
+ assert.equal(moveResult.response.status, 200);
178
+ assert.equal(moveResult.payload.repoId, "max/renamed-space");
179
+ assert.equal(moveResult.payload.url, "/spaces/max/renamed-space/app");
180
+ assert.equal(calls.move.fromId, "max/demo-space");
181
+ assert.equal(calls.move.toId, "max/renamed-space");
182
+
183
+ const variableResult = await postJson(baseUrl, "/api/spaces/max/demo-space/variables", cookie, csrfToken, {
184
+ key: "model_repo",
185
+ description: "Primary model",
186
+ value: "max/demo-model",
187
+ });
188
+ assert.equal(variableResult.response.status, 200);
189
+ assert.equal(variableResult.payload.key, "MODEL_REPO");
190
+ assert.equal(calls.variable.key, "MODEL_REPO");
191
+ assert.equal(calls.variable.value, "max/demo-model");
192
+
193
+ const secretResult = await postJson(baseUrl, "/api/spaces/max/demo-space/secrets", cookie, csrfToken, {
194
+ key: "api_key",
195
+ description: "Secret value",
196
+ value: "super-secret",
197
+ });
198
+ assert.equal(secretResult.response.status, 200);
199
+ assert.equal(secretResult.payload.key, "API_KEY");
200
+ assert.equal(calls.secret.key, "API_KEY");
201
+ assert.equal(calls.secret.value, "super-secret");
202
+
203
+ console.log("resource mutation smoke test passed");
204
+ } finally {
205
+ await new Promise((resolve, reject) => {
206
+ appServer.close((error) => {
207
+ if (error) {
208
+ reject(error);
209
+ return;
210
+ }
211
+ resolve();
212
+ });
213
+ });
214
+
215
+ await new Promise((resolve, reject) => {
216
+ mockHubServer.close((error) => {
217
+ if (error) {
218
+ reject(error);
219
+ return;
220
+ }
221
+ resolve();
222
+ });
223
+ });
224
+
225
+ await fs.rm(storageDir, { recursive: true, force: true });
226
+ }
227
+ }
228
+
229
+ run().catch((error) => {
230
+ console.error(error);
231
+ process.exitCode = 1;
232
+ });