sbuilder-mcp 0.87.0 → 0.88.1

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ All notable changes to this project are documented in this file.
6
6
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.88.1] - 2026-10-12
10
+
11
+ ### Added
12
+ - The API catalog is refreshed to 853 operations, including device sign-in for the CLI; the device approval operation cannot be called with a session.
13
+
14
+ ### Fixed
15
+ - Every tool call keeps working after the access token expires, because a rejected session token is refreshed once and the call is retried once; API keys are never refreshed.
16
+ - Concurrent rejections share a single token refresh, and signing out or back in during a refresh is no longer overwritten.
17
+ - `sb_media_upload` and `sb_live_join` reconnects refresh the token before it expires, so uploads and live sessions survive past the token lifetime.
18
+
19
+ ## [0.88.0] - 2026-10-12
20
+
21
+ ### Added
22
+ - `sb_review` reports `default_seed_copy` for pages built earlier that still carry the English seed copy, such as "Add To Cart", on a Vietnamese site, with the Vietnamese wording to use instead.
23
+
24
+ ### Changed
25
+ - `sb_page_create` writes the store page seeds in Vietnamese for a Vietnamese site, taking the locale from the site's settings when none is passed.
26
+ - `sb_page_create` uses English thank-you copy on the completed-order page of an `en-US` site.
27
+ - `sb_review` words its `categoryImages` note to say that a collection tile without an image now shows a placeholder instead of a broken image.
28
+
9
29
  ## [0.87.0] - 2026-10-12
10
30
 
11
31
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,26 @@ Mọi thay đổi đáng chú ý của dự án được ghi lại trong file n
6
6
  Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  và dự án tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.88.1] - 2026-10-12
10
+
11
+ ### Added
12
+ - Catalog API được làm mới lên 853 operation, gồm đăng nhập bằng thiết bị cho CLI; operation duyệt thiết bị không gọi được bằng phiên đăng nhập.
13
+
14
+ ### Fixed
15
+ - Mọi lời gọi tool tiếp tục chạy sau khi access token hết hạn, vì token phiên bị từ chối sẽ được làm mới một lần và lời gọi được thử lại một lần; API key không bao giờ bị làm mới.
16
+ - Các lần bị từ chối đồng thời dùng chung một lượt làm mới token, và việc đăng xuất hoặc đăng nhập lại trong lúc làm mới không còn bị ghi đè.
17
+ - `sb_media_upload` và việc kết nối lại của `sb_live_join` làm mới token trước khi hết hạn, nên upload và phiên live vẫn chạy được sau thời hạn của token.
18
+
19
+ ## [0.88.0] - 2026-10-12
20
+
21
+ ### Added
22
+ - `sb_review` báo `default_seed_copy` cho trang dựng trước đó còn mang chữ mẫu tiếng Anh, như "Add To Cart", trên site tiếng Việt, kèm bản tiếng Việt nên dùng.
23
+
24
+ ### Changed
25
+ - `sb_page_create` viết seed trang cửa hàng bằng tiếng Việt cho site tiếng Việt, lấy locale từ cài đặt của site khi không truyền.
26
+ - `sb_page_create` dùng câu cảm ơn tiếng Anh cho trang hoàn tất đơn hàng của site `en-US`.
27
+ - `sb_review` sửa câu báo `categoryImages`: ô danh mục thiếu ảnh giờ hiện placeholder thay vì ảnh vỡ.
28
+
9
29
  ## [0.87.0] - 2026-10-12
10
30
 
11
31
  ### Added
package/README.md CHANGED
@@ -118,7 +118,7 @@ in this client: one would put the very key the platform exists to hold back into
118
118
  | `sb_store` | Run a store flow that must happen in a fixed order — `checkout` (the four writes that make a working one), `checkout_sync` (the store's current payment and delivery methods into every existing order form, checkout page republished), `form` (any of the platform's 17 templates with its own field document), `chrome` (one shared header or footer on a real site menu — desktop menu, mobile drawer, cart and account icons), `menu` (a menu node bound to the site's menu, its links resolved), `overlay_attach` (a pop-up or quick view on the open page), `cart` (the site's cart drawer, when it has none; `relocalize` puts an existing one in the site's language) and `app` (a built-in app plus the pages it needs), `global_attach` / `global_detach` (put an EXISTING shared section on the open page, or take it off), `global_promote` (make a section on the page a NEW shared section) and `template_save` (save a section as a reusable template) |
119
119
  | `sb_undo` | Undo / redo this session's writes — page and form edits and `sb_api_call` PUTs; a page is refused if someone else saved it since. The platform's versions and history (`sb_page_version`) outlive this process |
120
120
 
121
- Twenty-eight tools, **849 API operations** (312 of the 393 writes carrying a body shape read
121
+ Twenty-eight tools, **853 API operations** (316 of the 397 writes carrying a body shape read
122
122
  off the handler), 123 elements, 94 binding sources. `sb_api_find`
123
123
  is an index rather than a tool per endpoint, so the tool list stays short while everything
124
124
  the platform can do stays reachable — and operations added to the platform arrive with the
package/README.vi.md CHANGED
@@ -115,7 +115,7 @@ nhét ngược lại vào mọi bản cài.
115
115
  | `sb_store` | Chạy một luồng cửa hàng bắt buộc đúng thứ tự — `checkout` (bốn lệnh ghi tạo nên trang thanh toán), `checkout_sync` (đưa phương thức thanh toán và giao hàng hiện tại vào mọi form đặt hàng đã có, publish lại trang thanh toán), `form` (một trong 17 template của nền tảng kèm field document của nó), `chrome` (một header hoặc footer dùng chung trên menu thật của site — menu desktop, drawer mobile, icon giỏ hàng và tài khoản), `menu` (một node menu bind vào menu của site, link đã phân giải), `overlay_attach` (một pop-up hay quick view trên trang đang mở), `cart` (ngăn giỏ hàng của site, khi chưa có; `relocalize` đưa ngăn giỏ đã có về ngôn ngữ của site), `app` (một app dựng sẵn kèm những trang nó cần), và `global_attach` / `global_detach` (đặt một section dùng chung ĐÃ CÓ lên trang đang mở, hoặc gỡ ra), `global_promote` (biến một section trên trang thành section dùng chung MỚI) và `template_save` (lưu một section làm mẫu dùng lại) |
116
116
  | `sb_undo` | Hoàn tác / làm lại các thao tác ghi trong phiên — chỉnh sửa trang, form và PUT qua `sb_api_call`; trang bị từ chối nếu người khác đã lưu sau đó. Version và history của nền tảng (`sb_page_version`) sống lâu hơn process này |
117
117
 
118
- Hai mươi tám tool, **849 operation API** (312 trong 393 lệnh ghi có hình dạng body đọc thẳng
118
+ Hai mươi tám tool, **853 operation API** (316 trong 397 lệnh ghi có hình dạng body đọc thẳng
119
119
  từ handler), 123 element, 94 nguồn binding. `sb_api_find` là
120
120
  một chỉ mục chứ không phải mỗi endpoint một tool, nên danh sách tool vẫn ngắn trong khi mọi
121
121
  thứ nền tảng làm được vẫn với tới — và operation mới thêm bên nền tảng sẽ tự có sau lần
@@ -1,8 +1,8 @@
1
1
  export const SWAGGER_SOURCE = {
2
- "operations": 849,
3
- "definitions": 280,
4
- "bodyCarrying": 376,
5
- "bodyUndescribed": 102,
2
+ "operations": 853,
3
+ "definitions": 282,
4
+ "bodyCarrying": 380,
5
+ "bodyUndescribed": 106,
6
6
  "generatedFrom": "server/docs/swagger.json"
7
7
  };
8
8
  export const API_OPERATIONS = [
@@ -1900,6 +1900,90 @@ export const API_OPERATIONS = [
1900
1900
  "bodyRef": null,
1901
1901
  "credential": "none"
1902
1902
  },
1903
+ {
1904
+ "id": "post:/api/auth/device/approve",
1905
+ "method": "POST",
1906
+ "path": "/api/auth/device/approve",
1907
+ "tags": [
1908
+ "auth"
1909
+ ],
1910
+ "summary": "Approve or deny a pending device login",
1911
+ "params": [
1912
+ {
1913
+ "name": "body",
1914
+ "in": "body",
1915
+ "required": true,
1916
+ "type": "object",
1917
+ "description": "userCode + decision (approve|deny)"
1918
+ }
1919
+ ],
1920
+ "bodyDescribed": false,
1921
+ "bodyRef": null,
1922
+ "credential": "none"
1923
+ },
1924
+ {
1925
+ "id": "post:/api/auth/device/inspect",
1926
+ "method": "POST",
1927
+ "path": "/api/auth/device/inspect",
1928
+ "tags": [
1929
+ "auth"
1930
+ ],
1931
+ "summary": "Describe a pending device login by its typed user code",
1932
+ "params": [
1933
+ {
1934
+ "name": "body",
1935
+ "in": "body",
1936
+ "required": true,
1937
+ "type": "object",
1938
+ "description": "userCode"
1939
+ }
1940
+ ],
1941
+ "bodyDescribed": false,
1942
+ "bodyRef": null,
1943
+ "credential": "none"
1944
+ },
1945
+ {
1946
+ "id": "post:/api/auth/device/start",
1947
+ "method": "POST",
1948
+ "path": "/api/auth/device/start",
1949
+ "tags": [
1950
+ "auth"
1951
+ ],
1952
+ "summary": "Start a browser device login (sb CLI)",
1953
+ "params": [
1954
+ {
1955
+ "name": "body",
1956
+ "in": "body",
1957
+ "required": true,
1958
+ "type": "object",
1959
+ "description": "client + clientVersion"
1960
+ }
1961
+ ],
1962
+ "bodyDescribed": false,
1963
+ "bodyRef": null,
1964
+ "credential": "none"
1965
+ },
1966
+ {
1967
+ "id": "post:/api/auth/device/token",
1968
+ "method": "POST",
1969
+ "path": "/api/auth/device/token",
1970
+ "tags": [
1971
+ "auth"
1972
+ ],
1973
+ "summary": "Poll a device login; returns a token pair once approved",
1974
+ "params": [
1975
+ {
1976
+ "name": "body",
1977
+ "in": "body",
1978
+ "required": true,
1979
+ "type": "object",
1980
+ "description": "deviceCode"
1981
+ }
1982
+ ],
1983
+ "bodyDescribed": false,
1984
+ "bodyRef": null,
1985
+ "credential": "none"
1986
+ },
1903
1987
  {
1904
1988
  "id": "post:/api/auth/forgot-password",
1905
1989
  "method": "POST",
@@ -27046,6 +27130,43 @@ export const API_OPERATIONS = [
27046
27130
  }
27047
27131
  ];
27048
27132
  export const API_DEFINITIONS = {
27133
+ "github_com_webbuilder_server_internal_auth.DeviceRequest": {
27134
+ "type": "object",
27135
+ "properties": {
27136
+ "client": {
27137
+ "type": "string"
27138
+ },
27139
+ "clientVersion": {
27140
+ "type": "string"
27141
+ },
27142
+ "createdAt": {
27143
+ "type": "string"
27144
+ },
27145
+ "expiresAt": {
27146
+ "type": "string"
27147
+ }
27148
+ }
27149
+ },
27150
+ "github_com_webbuilder_server_internal_auth.DeviceStart": {
27151
+ "type": "object",
27152
+ "properties": {
27153
+ "deviceCode": {
27154
+ "type": "string"
27155
+ },
27156
+ "expiresIn": {
27157
+ "type": "integer"
27158
+ },
27159
+ "interval": {
27160
+ "type": "integer"
27161
+ },
27162
+ "userCode": {
27163
+ "type": "string"
27164
+ },
27165
+ "verificationUri": {
27166
+ "type": "string"
27167
+ }
27168
+ }
27169
+ },
27049
27170
  "github_com_webbuilder_server_internal_codefiles.CodeFile": {
27050
27171
  "type": "object",
27051
27172
  "properties": {
@@ -1,6 +1,6 @@
1
1
  export const DEAD_KEY_SOURCE = {
2
- "files": 5691,
3
- "identifiers": 104085,
2
+ "files": 5704,
3
+ "identifiers": 104277,
4
4
  "seededKeys": 445,
5
5
  "dead": 0
6
6
  };
@@ -1,10 +1,10 @@
1
1
  export const SHAPE_SOURCE = {
2
- "writeOperations": 393,
3
- "shaped": 312,
4
- "fromHandlers": 302,
2
+ "writeOperations": 397,
3
+ "shaped": 316,
4
+ "fromHandlers": 306,
5
5
  "fromSwaggerOnly": 10,
6
6
  "withReadOnly": 30,
7
- "structsRead": 3226
7
+ "structsRead": 3237
8
8
  };
9
9
  export const REQUEST_SHAPES = {
10
10
  "post:/api/sites/{id}/ai-credits/purchase": {
@@ -656,6 +656,58 @@ export const REQUEST_SHAPES = {
656
656
  "source": "go",
657
657
  "goType": "(inline)"
658
658
  },
659
+ "post:/api/auth/device/start": {
660
+ "fields": [
661
+ {
662
+ "name": "client",
663
+ "type": "string"
664
+ },
665
+ {
666
+ "name": "clientVersion",
667
+ "type": "string"
668
+ }
669
+ ],
670
+ "source": "go",
671
+ "goType": "(inline)"
672
+ },
673
+ "post:/api/auth/device/inspect": {
674
+ "fields": [
675
+ {
676
+ "name": "userCode",
677
+ "type": "string"
678
+ },
679
+ {
680
+ "name": "decision",
681
+ "type": "string"
682
+ }
683
+ ],
684
+ "source": "go",
685
+ "goType": "deviceDecision"
686
+ },
687
+ "post:/api/auth/device/approve": {
688
+ "fields": [
689
+ {
690
+ "name": "userCode",
691
+ "type": "string"
692
+ },
693
+ {
694
+ "name": "decision",
695
+ "type": "string"
696
+ }
697
+ ],
698
+ "source": "go",
699
+ "goType": "deviceDecision"
700
+ },
701
+ "post:/api/auth/device/token": {
702
+ "fields": [
703
+ {
704
+ "name": "deviceCode",
705
+ "type": "string"
706
+ }
707
+ ],
708
+ "source": "go",
709
+ "goType": "(inline)"
710
+ },
659
711
  "post:/api/auth/register": {
660
712
  "fields": [
661
713
  {
@@ -1,7 +1,7 @@
1
1
  // GENERATED by scripts/gen-catalog.ts — do not edit by hand.
2
2
  // The platform commit every other generated file here describes.
3
3
  export const PLATFORM_SOURCE = {
4
- "commit": "df9accca9c8a0e402ca5520beddcb52c0843f7f5",
5
- "committedAt": "2026-10-12T09:14:30+07:00",
4
+ "commit": "e69eb03e217da1518783d08db53c776038293778",
5
+ "committedAt": "2026-10-12T11:10:11+07:00",
6
6
  "dirty": false
7
7
  };
@@ -612,11 +612,11 @@ export function readinessGaps(input) {
612
612
  '/api/media/{siteId}/from-url ingests straight from a url in one hop.',
613
613
  });
614
614
  }
615
- // A COLLECTION TILE WITH NO PICTURE IS A BROKEN-IMAGE ICON, not a grey card:
616
- // render/nodes/collection-media writes `<img src="">` (the image element has a
617
- // placeholder; this one does not). Measured on the sb_category_strip a store
618
- // built here put on its home page — two broken icons above the category names.
619
- // Asked only where a collection tile is actually on screen.
615
+ // A COLLECTION TILE WITH NO PICTURE. Measured on the sb_category_strip a store
616
+ // built here put on its home page: render/nodes/collection-media wrote
617
+ // `<img src="">`, two broken icons above the category names. web_builder PR
618
+ // #118 (74ca59dd2) gives it the image element's grey placeholder instead —
619
+ // better, still not the category. Asked only where a tile is on screen.
620
620
  if ((input.categoriesUnpictured ?? 0) > 0 &&
621
621
  [...input.pageNodes, ...(input.globalNodes ?? [])].some((n) => n.data?.type === 'collection-media')) {
622
622
  const n = input.categoriesUnpictured;
@@ -624,7 +624,8 @@ export function readinessGaps(input) {
624
624
  id: 'categoryImages',
625
625
  draft: false,
626
626
  problem: `${n} product categor${n === 1 ? 'y has' : 'ies have'} no image, and this page shows category ` +
627
- 'tiles — each renders a broken-image icon above its name.',
627
+ "tiles — each draws the platform's grey placeholder (a broken-image icon on a platform " +
628
+ 'older than web_builder 74ca59dd2) where the picture goes.',
628
629
  fix: 'Upload a picture (sb_media_upload), then PUT the category back with image: url ' +
629
630
  '(sb_api_call put:/api/sites/{siteId}/product-categories/{id}, merge:true).',
630
631
  });
@@ -3,6 +3,7 @@ import { childrenOf, childrenWithSatellites, isOverlay, isSharedSection, pageChi
3
3
  import { STUCK_STATE, stickyBlockedBy, stuckHostOf, isPinnedNode } from './sticky.js';
4
4
  import { bandOf, isGlobal } from './traps.js';
5
5
  import { exampleCopy } from './examplecopy.js';
6
+ import { SEED_COPY_VI, langOf } from './seedcopy.js';
6
7
  import { HOVER_STATE, hoverHome } from './hover.js';
7
8
  import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY, SATELLITE_RULES, ELEMENT_SEEDS } from '../../catalog/elements.generated.js';
8
9
  import { fill } from './findings.js';
@@ -867,6 +868,23 @@ opts = {}) {
867
868
  fix: fill('default_seed_copy', { id, key }),
868
869
  });
869
870
  }
871
+ // THE STORE SEEDS' ENGLISH COPY ON A VIETNAMESE SITE ("Add To Cart") —
872
+ // fine on an English site, so asked only when the site's language is
873
+ // known. sb_page_create now seeds it in Vietnamese; this is for pages
874
+ // made before that, or with locale:"en" on a Vietnamese site.
875
+ else if (typeof value === 'string' &&
876
+ SEED_COPY_VI[value] &&
877
+ langOf(opts.siteLocale) === 'vi') {
878
+ out.push({
879
+ code: 'default_seed_copy',
880
+ nodeId: id,
881
+ type,
882
+ problem: `Still the platform's English seed copy (${JSON.stringify(value)}) on a Vietnamese site — ` +
883
+ `"${SEED_COPY_VI[value]}" is what this storefront should say.`,
884
+ key,
885
+ fix: fill('default_seed_copy', { id, key }),
886
+ });
887
+ }
870
888
  // A BUILT-IN LAYOUT'S EXAMPLE COPY — see examplecopy.ts.
871
889
  else if (typeof value === 'string' && exampleCopy().has(value)) {
872
890
  out.push({
@@ -0,0 +1,28 @@
1
+ /**
2
+ * THE STORE SEEDS' ENGLISH COPY, IN VIETNAMESE — applied when the page is made
3
+ * for a Vietnamese site.
4
+ *
5
+ * The platform seeds in English on purpose (editor/src/element/emptyState.ts:
6
+ * "a seed is a seed", decided 2026-09-17) and a table per language was offered
7
+ * there and declined. Here the agent IS the author, and it knows the site's
8
+ * language: measured on a store built end to end, the product page published
9
+ * "Add To Cart" / "Buy It Now" to a Vietnamese storefront and every page
10
+ * reviewed clean. sb_page_create seeds through this; sb_review (which reads the
11
+ * site's locale) reports a page still carrying the English. Exact strings only; a seed string
12
+ * this table does not carry is left as it was — and test/store-page-seeds
13
+ * fails when codegen brings one in, so the table cannot fall behind silently.
14
+ */
15
+ export const SEED_COPY_VI = {
16
+ 'Add To Cart': 'Thêm vào giỏ',
17
+ 'Buy It Now': 'Mua ngay',
18
+ 'This item is unavailable': 'Sản phẩm tạm hết',
19
+ 'It may have been removed. Have a look at the rest of the shop.': 'Sản phẩm có thể đã ngừng bán. Mời bạn xem các sản phẩm khác.',
20
+ 'No products yet': 'Chưa có sản phẩm',
21
+ 'New arrivals will show up here. Check back soon.': 'Sản phẩm mới sẽ sớm có ở đây — mời bạn quay lại sau.',
22
+ 'No posts yet': 'Chưa có bài viết',
23
+ 'New stories will show up here. Check back soon.': 'Bài viết mới sẽ sớm có ở đây.',
24
+ };
25
+ /** The language part of a locale tag — "vi-VN", "vi_VN" and "VI" are all "vi". */
26
+ export function langOf(locale) {
27
+ return String(locale ?? '').toLowerCase().split(/[-_]/)[0];
28
+ }
@@ -1,3 +1,4 @@
1
+ import { SEED_COPY_VI, langOf } from './seedcopy.js';
1
2
  import { COMPLETION_HEADLINE, COMPLETION_HEADLINE_SENTINEL, PAGE_LAYOUT_SEEDS, PAGE_TYPES, STORE_PAGE_SEEDS } from '../../catalog/storepages.generated.js';
2
3
  import { BINDING_SOURCES } from '../../catalog/elements.generated.js';
3
4
  import { withFreshIds } from './ids.js';
@@ -40,22 +41,44 @@ export function seededTypes() {
40
41
  * An explicit `headline` wins over both — a merchant's own wording is not the
41
42
  * client's to overrule.
42
43
  */
44
+ /** Rewrite every exact seed string the table carries, in place. */
45
+ function localizeSeed(doc, table) {
46
+ for (const n of Object.values(doc.nodes)) {
47
+ const sp = n.specials;
48
+ if (!sp)
49
+ continue;
50
+ for (const k of ['text', 'label']) {
51
+ const v = sp[k];
52
+ if (typeof v === 'string' && table[v])
53
+ sp[k] = table[v];
54
+ }
55
+ }
56
+ }
57
+ export { SEED_COPY_VI } from './seedcopy.js';
43
58
  export function seedDocument(type, opts = {}) {
44
59
  const seed = STORE_PAGE_SEEDS[type];
45
60
  if (!seed)
46
61
  return null;
47
62
  const raw = JSON.stringify(seed);
63
+ const vi = langOf(opts.locale ?? 'vi') === 'vi';
64
+ const out = (doc) => {
65
+ if (vi)
66
+ localizeSeed(doc, SEED_COPY_VI);
67
+ return withFreshIds(doc);
68
+ };
48
69
  // Fresh ids per create, so two pages from one seed share nothing but ROOT.
49
70
  if (!raw.includes(COMPLETION_HEADLINE_SENTINEL)) {
50
- return withFreshIds(JSON.parse(raw));
71
+ return out(JSON.parse(raw));
51
72
  }
52
- const lang = opts.locale && opts.locale in COMPLETION_HEADLINE ? opts.locale : 'vi';
73
+ // Normalized: a site whose locale is "en-US" reads its own language here too.
74
+ const tag = langOf(opts.locale);
75
+ const lang = tag && tag in COMPLETION_HEADLINE ? tag : 'vi';
53
76
  const headline = opts.headline || COMPLETION_HEADLINE[lang];
54
77
  // JSON.stringify already escaped the seed; the substitute has to be escaped
55
78
  // the same way or an apostrophe in a merchant's own headline breaks the
56
79
  // document. Slicing the quotes off a stringified string is that escaping.
57
80
  const escaped = JSON.stringify(headline).slice(1, -1);
58
- return withFreshIds(JSON.parse(raw.split(COMPLETION_HEADLINE_SENTINEL).join(escaped)));
81
+ return out(JSON.parse(raw.split(COMPLETION_HEADLINE_SENTINEL).join(escaped)));
59
82
  }
60
83
  /**
61
84
  * What to put on an ENTITY TEMPLATE that opens empty, or null.
@@ -272,7 +272,9 @@ export function liveTokenFor(ctx) {
272
272
  export function joinRoom(ctx, session, siteId) {
273
273
  const tokenFn = liveTokenFor(ctx);
274
274
  const wsBase = ctx.base.replace(/^http/, 'ws').replace(/\/$/, '');
275
- const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(siteId)}`, tokenFn);
275
+ const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(siteId)}`, tokenFn, undefined,
276
+ // A key never expires this way; a session renews near its end of life.
277
+ ctx.apiKey ? undefined : () => ctx.session.ensureFresh());
276
278
  const live = new LiveSession(socket, {
277
279
  onRemote: (patches) => session.applyRemote(patches),
278
280
  onDesync: (reason) => session.roomDesync(reason),
@@ -47,8 +47,8 @@ import { projectList, PAGE_FIELDS, TEMPLATE_FIELDS } from './project.js';
47
47
  * copies of a directive is how one of them quietly loses it. The fix for each
48
48
  * KIND is sent once under `fixes`, and the directive once per process.
49
49
  */
50
- export function reviewField(ctx, doc, formTypes, pageType, formDocType, formRedirects) {
51
- const all = reviewDesign(doc, { formTypes, pageType, formDocType, formRedirects });
50
+ export function reviewField(ctx, doc, formTypes, pageType, formDocType, formRedirects, siteLocale) {
51
+ const all = reviewDesign(doc, { formTypes, pageType, formDocType, formRedirects, siteLocale });
52
52
  if (all.length === 0)
53
53
  return {};
54
54
  // MAINTENANCE IS ADVICE, kept out of `findings`: it works for a visitor, so
@@ -1807,6 +1807,7 @@ export function registerPageTools(server, ctx) {
1807
1807
  // The page's TYPE says which record it supplies — `no_data_context` stays
1808
1808
  // silent without it rather than guessing.
1809
1809
  let pageType;
1810
+ let siteLocale;
1810
1811
  try {
1811
1812
  const { siteId, pageId } = session.location();
1812
1813
  const input = await gatherReadiness(ctx, siteId, Object.values(doc.doc.nodes), pageId);
@@ -1817,6 +1818,7 @@ export function registerPageTools(server, ctx) {
1817
1818
  return f.id && a?.action === 'redirect' && (a.url || a.pageId) ? [f.id] : [];
1818
1819
  }));
1819
1820
  pageType = input.pages?.find((p) => p.id === pageId)?.type;
1821
+ siteLocale = input.siteLocale;
1820
1822
  const gaps = readinessGaps(input);
1821
1823
  const lang = siteLanguage(input.siteLocale, input.pageNodes);
1822
1824
  const said = lang ? ctx.notices.once(`site-language:${siteId}`, lang) : undefined;
@@ -1830,7 +1832,7 @@ export function registerPageTools(server, ctx) {
1830
1832
  catch {
1831
1833
  // Readiness is additional information, never the reason a review fails.
1832
1834
  }
1833
- const field = reviewField(ctx, doc, formTypes, pageType, session.formType(), formRedirects);
1835
+ const field = reviewField(ctx, doc, formTypes, pageType, session.formType(), formRedirects, siteLocale);
1834
1836
  // Clean = no finding a visitor meets; `advice` (maintenance) rides beside the verdict.
1835
1837
  const clean = !field.findings;
1836
1838
  return text({
@@ -2237,7 +2239,7 @@ export function registerPageTools(server, ctx) {
2237
2239
  settings: z.record(z.unknown()).optional(),
2238
2240
  seed: z.boolean().optional().describe('Default true; false creates a blank page.'),
2239
2241
  chrome: z.boolean().optional().describe("Carry the site's header and footer, default true"),
2240
- locale: z.string().optional().describe("vi (default) or en — the complete page's wording."),
2242
+ locale: z.string().optional().describe("vi or en — a store seed's wording; defaults to the site's language, else vi."),
2241
2243
  headline: z.string().optional().describe("The complete page's thank-you line."),
2242
2244
  dry_run: z.boolean().optional(),
2243
2245
  },
@@ -2420,7 +2422,25 @@ export function registerPageTools(server, ctx) {
2420
2422
  let seeded = null;
2421
2423
  const newId = res.page?.id;
2422
2424
  if ((willSeed || layout) && typeof newId === 'string' && newId) {
2423
- const document = layout ? layoutDocument(layout) : seedDocument(type ?? '', { locale, headline });
2425
+ // The SITE's language when the caller named none — the seed's English
2426
+ // copy is rewritten for a Vietnamese site (SEED_COPY_VI). Unreadable
2427
+ // settings fall back to the old default rather than block the create.
2428
+ const lang = locale ??
2429
+ (layout
2430
+ ? undefined
2431
+ : await request({
2432
+ base: ctx.base,
2433
+ method: 'GET',
2434
+ path: `/api/sites/${encodeURIComponent(site_id)}/settings`,
2435
+ token: siteToken(ctx),
2436
+ fetchImpl: ctx.fetchImpl,
2437
+ })
2438
+ .then((r) => {
2439
+ const l = r?.settings?.locale;
2440
+ return typeof l === 'string' && l ? l : undefined;
2441
+ })
2442
+ .catch(() => undefined));
2443
+ const document = layout ? layoutDocument(layout) : seedDocument(type ?? '', { locale: lang, headline });
2424
2444
  if (document) {
2425
2445
  try {
2426
2446
  await request({
@@ -1,9 +1,9 @@
1
- import { request } from './http.js';
1
+ import { onUnauthorized, request } from './http.js';
2
2
  /**
3
3
  * A user session against the platform's private API.
4
4
  *
5
5
  * `token()` is a GETTER, deliberately, and every consumer must call it per use
6
- * rather than capture its result. The access token lives ~15 minutes and rotates
6
+ * rather than capture its result. The access token's lifetime is the operator's (AUTH_ACCESS_TTL) and it rotates
7
7
  * on refresh; a client holding the string it was built with replays an expired
8
8
  * token forever, and the failure is SILENT — a rejected socket auth still fires
9
9
  * onopen, so there is no error event and nothing in any UI. The editor shipped
@@ -15,12 +15,39 @@ export class Session {
15
15
  base;
16
16
  fetchImpl;
17
17
  access = null;
18
+ /** When the access token stops working (ms epoch), from the pair's expiresIn; 0 = unknown. */
19
+ expiresAt = 0;
18
20
  refreshToken = null;
19
21
  name = '';
20
22
  constructor(base, fetchImpl) {
21
23
  this.base = base;
22
24
  this.fetchImpl = fetchImpl;
23
25
  }
26
+ /**
27
+ * Answer a 401 on THIS session's current token with one refresh — see
28
+ * onUnauthorized in http.ts. Concurrent 401s share one refresh: refresh
29
+ * tokens are single-use, and presenting one twice ends the session.
30
+ * Registered while signed in only, so a signed-out session holds nothing.
31
+ */
32
+ unrenew = null;
33
+ listen() {
34
+ this.unrenew ??= onUnauthorized(async (stale) => {
35
+ // A request that carried a token THIS session already replaced (a second
36
+ // 401 landing after the first one renewed) takes the current one.
37
+ if (this.retired.has(stale) && this.access)
38
+ return this.access;
39
+ if (stale !== this.access || !this.refreshToken)
40
+ return null;
41
+ this.renewing ??= this.refresh().finally(() => {
42
+ this.renewing = null;
43
+ });
44
+ await this.renewing;
45
+ return this.access;
46
+ });
47
+ }
48
+ renewing = null;
49
+ /** Access tokens this session has replaced, so a late 401 on one still renews. */
50
+ retired = new Set();
24
51
  get userName() {
25
52
  return this.name;
26
53
  }
@@ -52,37 +79,65 @@ export class Session {
52
79
  }));
53
80
  this.access = out.tokens.accessToken;
54
81
  this.refreshToken = out.tokens.refreshToken;
82
+ this.expiresAt = expiry(out.tokens.expiresIn);
55
83
  this.name = out.user?.name ?? '';
84
+ this.listen();
56
85
  }
57
86
  /** Rotate. The platform issues a NEW refresh token each time; keep that one. */
58
87
  async refresh() {
59
- if (!this.refreshToken)
88
+ const rt = this.refreshToken;
89
+ if (!rt)
60
90
  throw new Error('sbuilder: no refresh token — log in first');
61
91
  const out = (await request({
62
92
  base: this.base,
63
93
  method: 'POST',
64
94
  path: '/api/auth/refresh',
65
- body: { refreshToken: this.refreshToken },
95
+ body: { refreshToken: rt },
66
96
  fetchImpl: this.fetchImpl,
67
97
  }));
98
+ // Signed out, or signed in afresh, while this was in flight: the pair it
99
+ // brought back belongs to a session that no longer exists here.
100
+ if (this.refreshToken !== rt)
101
+ return;
102
+ if (this.access)
103
+ this.retired.add(this.access);
68
104
  this.access = out.tokens.accessToken;
69
105
  this.refreshToken = out.tokens.refreshToken;
106
+ this.expiresAt = expiry(out.tokens.expiresIn);
107
+ }
108
+ /**
109
+ * Renew BEFORE a call that cannot retry itself — a media upload sends its
110
+ * own fetch, outside the 401 retry in http.ts. Within 30s of the lifetime the
111
+ * pair was issued with, refresh first (shared with any 401 renewal in flight).
112
+ */
113
+ async ensureFresh() {
114
+ if (!this.refreshToken || !this.expiresAt || Date.now() < this.expiresAt - 30_000)
115
+ return;
116
+ this.renewing ??= this.refresh().finally(() => {
117
+ this.renewing = null;
118
+ });
119
+ await this.renewing;
70
120
  }
71
121
  /**
72
122
  * Sign this device out. Best effort, never throws.
73
123
  *
74
124
  * EVERY password sign-in is its own DEVICE SESSION since web_builder 9db1b2a9e,
75
125
  * capped at 10 per account; past the cap the one that has gone longest
76
- * without a REFRESH is deleted. This client never refreshes (the access token
77
- * lives 30 days), so a process that exits without signing out leaves a slot
78
- * held, and about ten agent runs later the evicted session is the merchant's
126
+ * without a REFRESH is deleted. This client refreshes only when refused (the
127
+ * access token lives 30 days by default), so a process that exits without
128
+ * signing out leaves a slot held, and about ten agent runs later the evicted session is the merchant's
79
129
  * own editor — signed out mid-edit, by nothing they can see. A SIGKILLed
80
130
  * process still leaks its slot; nothing on this side can prevent that.
81
131
  */
82
132
  async logout() {
133
+ // A renewal in flight is about to rotate the refresh token; revoke the one it
134
+ // brings back, not the one it just spent (presenting a used one ends the session).
135
+ await this.renewing?.catch(() => undefined);
83
136
  const refreshToken = this.refreshToken;
84
137
  this.access = null;
85
138
  this.refreshToken = null;
139
+ this.unrenew?.();
140
+ this.unrenew = null;
86
141
  if (!refreshToken)
87
142
  return;
88
143
  try {
@@ -99,3 +154,7 @@ export class Session {
99
154
  }
100
155
  }
101
156
  }
157
+ /** expiresIn seconds → an epoch deadline; 0 when the pair carried none. */
158
+ function expiry(seconds) {
159
+ return typeof seconds === 'number' && seconds > 0 ? Date.now() + seconds * 1000 : 0;
160
+ }
@@ -222,7 +222,12 @@ async function announced(opts, siteId, pageId, doc) {
222
222
  }
223
223
  return out;
224
224
  }
225
- async function send(opts) {
225
+ const renewers = new Set();
226
+ export function onUnauthorized(r) {
227
+ renewers.add(r);
228
+ return () => renewers.delete(r);
229
+ }
230
+ async function send(opts, retried = false) {
226
231
  const doFetch = opts.fetchImpl ?? fetch;
227
232
  // Identity rides on EVERY call rather than on a handshake of its own. There is
228
233
  // no "connect" request to hang it off — the first thing this server does is
@@ -243,6 +248,15 @@ async function send(opts) {
243
248
  headers,
244
249
  body: body === undefined ? undefined : multipart ? body : JSON.stringify(body),
245
250
  });
251
+ if (res.status === 401 && opts.token && !retried) {
252
+ for (const renew of renewers) {
253
+ const fresh = await renew(opts.token).catch(() => null);
254
+ if (fresh && fresh !== opts.token) {
255
+ void res.body?.cancel().catch(() => undefined); // release the refused response
256
+ return send({ ...opts, token: fresh }, true);
257
+ }
258
+ }
259
+ }
246
260
  if (opts.binary && res.ok)
247
261
  return new Uint8Array(await res.arrayBuffer());
248
262
  const raw = await res.text();
@@ -86,6 +86,9 @@ export function typeForName(name) {
86
86
  */
87
87
  async function fromUrl(ctx, siteId, url, source) {
88
88
  const doFetch = ctx.fetchImpl ?? fetch;
89
+ // Its own fetch, so the 401 renewal in http.ts never sees it — renew first.
90
+ if (!ctx.apiKey && ctx.session.loggedIn())
91
+ await ctx.session.ensureFresh().catch(() => undefined);
89
92
  const res = await doFetch(`${ctx.base.replace(/\/$/, '')}/api/media/${encodeURIComponent(siteId)}/from-url`, {
90
93
  method: 'POST',
91
94
  headers: {
@@ -311,6 +314,8 @@ export async function fetchPublic(ctx, doFetch, url) {
311
314
  }
312
315
  export async function uploadMedia(ctx, siteId, source) {
313
316
  const doFetch = ctx.fetchImpl ?? fetch;
317
+ if (!ctx.apiKey && ctx.session.loggedIn())
318
+ await ctx.session.ensureFresh().catch(() => undefined);
314
319
  // THE SERVER'S OWN DOOR FIRST, when the source is a URL.
315
320
  //
316
321
  // `POST /api/media/{siteId}/from-url` fetches it where the platform already
@@ -17,6 +17,7 @@ export class RealtimeSocket {
17
17
  url;
18
18
  token;
19
19
  factory;
20
+ prepare;
20
21
  ws = null;
21
22
  closed = false;
22
23
  attempt = 0;
@@ -42,10 +43,17 @@ export class RealtimeSocket {
42
43
  * expired token on every reconnect, and the failure is SILENT — a rejected
43
44
  * auth still fires `onopen`, so no error event ever surfaces.
44
45
  */
45
- token, factory = (u) => new WebSocket(u)) {
46
+ token, factory = (u) => new WebSocket(u),
47
+ /**
48
+ * Run before each RECONNECT — renews a session token near its end of life,
49
+ * since the getter above is synchronous and a reconnect is the moment an
50
+ * expired token would be presented. Failure never blocks the attempt.
51
+ */
52
+ prepare) {
46
53
  this.url = url;
47
54
  this.token = token;
48
55
  this.factory = factory;
56
+ this.prepare = prepare;
49
57
  }
50
58
  get attempts() {
51
59
  return this.attempt;
@@ -127,7 +135,13 @@ export class RealtimeSocket {
127
135
  if (this.closed)
128
136
  return;
129
137
  const wait = Math.min(RETRY_BASE_MS * 2 ** this.attempt, RETRY_MAX_MS);
130
- this.timer = setTimeout(() => this.connect(), wait);
138
+ this.timer = setTimeout(() => {
139
+ if (!this.prepare)
140
+ return this.connect();
141
+ void this.prepare()
142
+ .catch(() => undefined)
143
+ .then(() => this.connect());
144
+ }, wait);
131
145
  };
132
146
  ws.onerror = () => {
133
147
  // Genuinely rare: a policy close is not an error. Nothing to do here —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.87.0",
3
+ "version": "0.88.1",
4
4
  "description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
5
5
  "mcpName": "io.github.vuluu2k/sbuilder-mcp",
6
6
  "type": "module",