@wix/himalaya-cli 0.1484.0 → 0.1485.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -83,6 +83,20 @@
83
83
  "hiddenPath": "$service:products.hideServerError"
84
84
  }
85
85
  },
86
+ {
87
+ "id": "error.membership",
88
+ "body": {
89
+ "package": "himalaya",
90
+ "type": "EmptyState",
91
+ "props": {
92
+ "title": "Join this site to view products",
93
+ "subtitle": "Ask the owner of this store to add you as a member."
94
+ }
95
+ },
96
+ "modifiers": {
97
+ "hiddenPath": "$service:products.hideMembershipError"
98
+ }
99
+ },
86
100
  {
87
101
  "id": "error.permission",
88
102
  "body": {
@@ -24,19 +24,21 @@ interface ProductRow {
24
24
  initial: string; // derived presentation field (avatar letter)
25
25
  }
26
26
 
27
- // A public catalog has no sign-in flow, so a 401 reads as "permission" here — every
28
- // class this worker can return has a node in the descriptor.
29
- type ErrorKind = "" | "offline" | "server" | "permission";
27
+ // Every class the recipe's classifier returns …
28
+ type ErrorKind = "" | "offline" | "server" | "signIn" | "membership" | "permission";
29
+ // … and the ones this screen shows: it has no sign-in flow (see shownKind()).
30
+ type ShownKind = Exclude<ErrorKind, "" | "signIn">;
30
31
 
31
32
  interface ProductsState {
32
33
  status: "loading" | "ready" | "error";
33
34
  items: ProductRow[]; // ← itemsPath binds here; MUST initialize as an array
34
- errorKind: ErrorKind; // why the last load failed; "" unless status is "error"
35
+ errorKind: "" | ShownKind; // why the last load failed; "" unless status is "error"
35
36
  // hiddenPath hides a node when its flag is TRUE.
36
37
  hideLoading: boolean; // skeleton: first load, nothing on screen
37
38
  notEmpty: boolean; // EmptyState: ready with zero rows
38
39
  hideOfflineError: boolean; // one EmptyState per error class …
39
40
  hideServerError: boolean;
41
+ hideMembershipError: boolean;
40
42
  hidePermissionError: boolean;
41
43
  hideRefreshError: boolean; // … and a Banner when a refresh fails over kept rows
42
44
  selectedId: string; // row identity from onTap params — drive a detail view from it
@@ -62,7 +64,7 @@ function normalize(raw: StoreProduct): ProductRow {
62
64
  function shape(s: Partial<ProductsState>): ProductsState {
63
65
  const status = s.status ?? "ready";
64
66
  const items = s.items ?? [];
65
- const errorKind: ErrorKind = status === "error" ? (s.errorKind || "server") : "";
67
+ const errorKind: "" | ShownKind = status === "error" ? (s.errorKind || "server") : "";
66
68
  const blank = items.length === 0; // nothing on screen to keep
67
69
  return {
68
70
  status,
@@ -72,14 +74,17 @@ function shape(s: Partial<ProductsState>): ProductsState {
72
74
  notEmpty: !(status === "ready" && blank),
73
75
  hideOfflineError: !(blank && errorKind === "offline"),
74
76
  hideServerError: !(blank && errorKind === "server"),
77
+ hideMembershipError: !(blank && errorKind === "membership"),
75
78
  hidePermissionError: !(blank && errorKind === "permission"),
76
79
  hideRefreshError: !(!blank && (errorKind === "offline" || errorKind === "server")),
77
80
  selectedId: s.selectedId ?? "",
78
81
  };
79
82
  }
80
83
 
84
+ // The classifier below is `himi docs recipe-four-state`'s, VERBATIM — a test holds
85
+ // the two byte-identical, so the rules an agent copies from here are the documented ones.
81
86
  // The HTTP status rides in the message: `HTTP 503: …` on a device, `Wix 503 for …`
82
- // under `himi test` / `himi render`. Only a transport failure is "offline".
87
+ // under `himi test` / `himi render`.
83
88
  const STATUS = /\b(?:HTTP|Wix) (\d{3})\b/;
84
89
  // No answer at all — what a dropped connection says on every platform.
85
90
  const TRANSPORT = /NSURLErrorDomain|(?:UnknownHost|Connect|SocketTimeout|NoRouteToHost|SSLHandshake)Exception|Failed to connect|Unable to resolve host|Connection (?:refused|reset)|Network is unreachable|Software caused connection abort|ECONNRESET|ETIMEDOUT|EPIPE|Broken pipe|stream was reset|unexpected end of stream|timed out|^timeout$|Failed to fetch|NetworkError|^(?:TypeError: )?Load failed$|net::ERR_|^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i;
@@ -88,21 +93,45 @@ const TRANSPORT = /NSURLErrorDomain|(?:UnknownHost|Connect|SocketTimeout|NoRoute
88
93
  // A pinned site with no member session: iOS `siteNotPermitted(requested: "account", …)`,
89
94
  // Android `site account is not the bound site …`; the web host `HTTP 501: {"error":"webwix_owner_no_session"}`.
90
95
  const SIGNED_OUT = /wixApi not enabled|session unavailable|PRINCIPAL_CHANGED|not signed in|siteNotPermitted\(requested: "account"|site account is not the bound site|webwix_owner_no_session/i;
96
+ // The account is not a member of this site: iOS `NotSiteMember(metaSiteId: …)`,
97
+ // Android `not a member of site …`. NOT `WDE0027` — that is Wix Data's generic
98
+ // permission refusal, which a signed-in member and a visitor both get.
99
+ const NOT_MEMBER = /\bNotSiteMember\b|\bnot a member of site\b/i;
91
100
 
92
- // The same classifier as `himi docs recipe-four-state`, except that a public screen with no sign-in flow shows the permission state for a sign-in failure.
93
- function classifyError(e: unknown): ErrorKind {
101
+ function classifyError(e: unknown, authState: "signedIn" | "signedOut" | "unknown" = "unknown"): ErrorKind {
94
102
  const msg = String((e as Error)?.message ?? e ?? "");
95
- const code = String((e as { code?: unknown })?.code ?? "");
103
+ // Some bridges put the Wix code in the response body rather than Error.code.
104
+ const code = String((e as { code?: unknown })?.code ?? /"(?:code|error)"\s*:\s*"([^"]+)"/.exec(msg)?.[1] ?? "");
96
105
  const status = Number(STATUS.exec(msg)?.[1] ?? 0);
97
- if (status === 401) return "permission";
98
- if (status === 403) return "permission";
99
- if (SIGNED_OUT.test(msg)) return "permission";
100
- if (code === "offline_write_blocked") return "offline"; // a write refused before it left
106
+ if (code === "AccountSessionExpired" || code === "webwix_account_signed_out" || SIGNED_OUT.test(msg)) return "signIn";
107
+ if (code === "NotSiteMember" || NOT_MEMBER.test(msg)) return "membership";
108
+ // A 401 is a missing or expired session — unless the host knows the user is signed
109
+ // in, when it is a policy refusal. Never "server": a retry sends the same 401 back.
110
+ if (status === 401) return authState === "signedIn" ? "permission" : "signIn";
111
+ // A 403 is a missing permission — but a visitor the host knows is signed out gets
112
+ // `403 WDE0027` on member-only data too, and signing in is what fixes that.
113
+ if (status === 403) return authState === "signedOut" ? "signIn" : "permission";
114
+ if (code === "offline_write_blocked") return "offline"; // a write refused before it left
101
115
  // A web call that never reached Wix arrives as
102
116
  // `HTTP 502: {"error":"webwix_direct_failed","detail":"Failed to fetch"}` — judge its detail.
103
- if (status && !msg.includes("webwix_direct_failed")) return "server";
104
- if (TRANSPORT.test(msg)) return "offline";
105
- return "server"; // a bug or a malformed row is not the user's connection
117
+ // The top-level 502 is the bridge wrapper, not the underlying transport result.
118
+ const detail = msg.includes("webwix_direct_failed")
119
+ ? /"detail"\s*:\s*"([^"]+)"/.exec(msg)?.[1] ?? msg
120
+ : msg;
121
+ if (status && detail === msg) return "server";
122
+ if (TRANSPORT.test(detail)) return "offline";
123
+ return "server"; // anything else — a bug, a malformed row — is not the user's connection
124
+ }
125
+
126
+ // App-specific host integration. If the host does not expose auth state here, keep
127
+ // "unknown": a 401 then reads as a session that ended.
128
+ function currentAuthState(): "signedIn" | "signedOut" | "unknown" { return "unknown"; }
129
+
130
+ // This screen has no sign-in flow, so a sign-in failure shows the permission state —
131
+ // every class it can show has a node in the descriptor.
132
+ function shownKind(e: unknown): ShownKind {
133
+ const kind = classifyError(e, currentAuthState());
134
+ return kind === "signIn" ? "permission" : kind || "server";
106
135
  }
107
136
 
108
137
  async function loadList(s: Partial<ProductsState>): Promise<ProductsState> {
@@ -111,9 +140,10 @@ async function loadList(s: Partial<ProductsState>): Promise<ProductsState> {
111
140
  const items = (res.products ?? []).filter((p) => p?.id).map(normalize);
112
141
  return shape({ status: "ready", items, selectedId: s.selectedId });
113
142
  } catch (e) {
114
- const errorKind = classifyError(e);
143
+ const errorKind = shownKind(e);
115
144
  // Keep the rows on a refresh failure; clear them when access is gone.
116
- const items = errorKind === "permission" ? [] : s.items ?? [];
145
+ const lostAccess = errorKind === "membership" || errorKind === "permission";
146
+ const items = lostAccess ? [] : s.items ?? [];
117
147
  return shape({ ...s, status: "error", items, errorKind });
118
148
  }
119
149
  }
@@ -78,8 +78,7 @@
78
78
  "title": "Create post",
79
79
  "style": "primary",
80
80
  "fullWidth": true,
81
- "action": { "actionId": "tier5.postForm.submit" },
82
- "disabledPath": "$service:postForm.cannotSave"
81
+ "action": { "actionId": "tier5.postForm.submit" }
83
82
  }
84
83
  }
85
84
  }
@@ -25,7 +25,6 @@ interface PostFormState {
25
25
  savedMessage: string; // "" unless saved → collapseWhenEmpty
26
26
  hideSavedLink: boolean; // gates the "Open post" button via hiddenPath
27
27
  savedScreenId: string; // deep-link target for the created entity
28
- cannotSave: boolean; // true after a permission failure → the submit's disabledPath
29
28
  }
30
29
 
31
30
  function shape(s: Partial<PostFormState>): PostFormState {
@@ -39,22 +38,26 @@ function shape(s: Partial<PostFormState>): PostFormState {
39
38
  savedMessage: s.savedMessage ?? "",
40
39
  savedScreenId,
41
40
  hideSavedLink: status !== "saved" || !savedScreenId,
42
- cannotSave: s.cannotSave ?? false,
43
41
  };
44
42
  }
45
43
 
46
44
  // A failed save is an ACTION error: one user-facing sentence per class, shown next to
47
45
  // the submit button, which is the retry (`himi docs recipe-four-state`). Never e.message.
48
- type ErrorKind = "offline" | "server" | "signIn" | "permission";
49
- const SAVE_FAILED: Record<ErrorKind, string> = {
46
+ // Submit stays available after every class: the user retries once they reconnect,
47
+ // sign in, join the site or are granted access — without restarting the form.
48
+ type ErrorKind = "" | "offline" | "server" | "signIn" | "membership" | "permission";
49
+ const SAVE_FAILED: Record<Exclude<ErrorKind, "">, string> = {
50
50
  offline: "You're offline, so nothing was saved. Try again when you reconnect.",
51
51
  server: "Couldn't save your changes. Try again.",
52
52
  signIn: "Your session has ended. Sign in, then save again.",
53
+ membership: "Join this site before saving.",
53
54
  permission: "You don't have permission to save this.",
54
55
  };
55
56
 
57
+ // The classifier below is `himi docs recipe-four-state`'s, VERBATIM — a test holds
58
+ // the two byte-identical, so the rules an agent copies from here are the documented ones.
56
59
  // The HTTP status rides in the message: `HTTP 503: …` on a device, `Wix 503 for …`
57
- // under `himi test` / `himi render`. Only a transport failure is "offline".
60
+ // under `himi test` / `himi render`.
58
61
  const STATUS = /\b(?:HTTP|Wix) (\d{3})\b/;
59
62
  // No answer at all — what a dropped connection says on every platform.
60
63
  const TRANSPORT = /NSURLErrorDomain|(?:UnknownHost|Connect|SocketTimeout|NoRouteToHost|SSLHandshake)Exception|Failed to connect|Unable to resolve host|Connection (?:refused|reset)|Network is unreachable|Software caused connection abort|ECONNRESET|ETIMEDOUT|EPIPE|Broken pipe|stream was reset|unexpected end of stream|timed out|^timeout$|Failed to fetch|NetworkError|^(?:TypeError: )?Load failed$|net::ERR_|^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i;
@@ -63,37 +66,54 @@ const TRANSPORT = /NSURLErrorDomain|(?:UnknownHost|Connect|SocketTimeout|NoRoute
63
66
  // A pinned site with no member session: iOS `siteNotPermitted(requested: "account", …)`,
64
67
  // Android `site account is not the bound site …`; the web host `HTTP 501: {"error":"webwix_owner_no_session"}`.
65
68
  const SIGNED_OUT = /wixApi not enabled|session unavailable|PRINCIPAL_CHANGED|not signed in|siteNotPermitted\(requested: "account"|site account is not the bound site|webwix_owner_no_session/i;
69
+ // The account is not a member of this site: iOS `NotSiteMember(metaSiteId: …)`,
70
+ // Android `not a member of site …`. NOT `WDE0027` — that is Wix Data's generic
71
+ // permission refusal, which a signed-in member and a visitor both get.
72
+ const NOT_MEMBER = /\bNotSiteMember\b|\bnot a member of site\b/i;
66
73
 
67
- // The same classifier as `himi docs recipe-four-state`.
68
- function classifyError(e: unknown): ErrorKind {
74
+ function classifyError(e: unknown, authState: "signedIn" | "signedOut" | "unknown" = "unknown"): ErrorKind {
69
75
  const msg = String((e as Error)?.message ?? e ?? "");
70
- const code = String((e as { code?: unknown })?.code ?? "");
76
+ // Some bridges put the Wix code in the response body rather than Error.code.
77
+ const code = String((e as { code?: unknown })?.code ?? /"(?:code|error)"\s*:\s*"([^"]+)"/.exec(msg)?.[1] ?? "");
71
78
  const status = Number(STATUS.exec(msg)?.[1] ?? 0);
72
- if (status === 401) return "signIn";
73
- if (status === 403) return "permission";
74
- if (SIGNED_OUT.test(msg)) return "signIn";
75
- if (code === "offline_write_blocked") return "offline"; // a write refused before it left
79
+ if (code === "AccountSessionExpired" || code === "webwix_account_signed_out" || SIGNED_OUT.test(msg)) return "signIn";
80
+ if (code === "NotSiteMember" || NOT_MEMBER.test(msg)) return "membership";
81
+ // A 401 is a missing or expired session — unless the host knows the user is signed
82
+ // in, when it is a policy refusal. Never "server": a retry sends the same 401 back.
83
+ if (status === 401) return authState === "signedIn" ? "permission" : "signIn";
84
+ // A 403 is a missing permission — but a visitor the host knows is signed out gets
85
+ // `403 WDE0027` on member-only data too, and signing in is what fixes that.
86
+ if (status === 403) return authState === "signedOut" ? "signIn" : "permission";
87
+ if (code === "offline_write_blocked") return "offline"; // a write refused before it left
76
88
  // A web call that never reached Wix arrives as
77
89
  // `HTTP 502: {"error":"webwix_direct_failed","detail":"Failed to fetch"}` — judge its detail.
78
- if (status && !msg.includes("webwix_direct_failed")) return "server";
79
- if (TRANSPORT.test(msg)) return "offline";
80
- return "server"; // a bug or a malformed row is not the user's connection
90
+ // The top-level 502 is the bridge wrapper, not the underlying transport result.
91
+ const detail = msg.includes("webwix_direct_failed")
92
+ ? /"detail"\s*:\s*"([^"]+)"/.exec(msg)?.[1] ?? msg
93
+ : msg;
94
+ if (status && detail === msg) return "server";
95
+ if (TRANSPORT.test(detail)) return "offline";
96
+ return "server"; // anything else — a bug, a malformed row — is not the user's connection
81
97
  }
82
98
 
99
+ // App-specific host integration. If the host does not expose auth state here, keep
100
+ // "unknown": a 401 then reads as a session that ended.
101
+ function currentAuthState(): "signedIn" | "signedOut" | "unknown" { return "unknown"; }
102
+
83
103
  // Blog draft bodies are rich content — wrap the plain text minimally.
84
104
  function toRichContent(text: string): WixRichContent {
85
105
  return { nodes: [{ type: "PARAGRAPH", nodes: [{ type: "TEXT", textData: { text, decorations: [] } }] }] };
86
106
  }
87
107
 
88
108
  // Every key that describes the LAST attempt, not this run: a cold boot starts from the declared values.
89
- const OUTCOME_KEYS = ["status", "error", "savedMessage", "savedScreenId", "hideSavedLink", "cannotSave"] as const;
109
+ const OUTCOME_KEYS = ["status", "error", "savedMessage", "savedScreenId", "hideSavedLink"] as const;
90
110
 
91
111
  const svc = defineService<PostFormState, {
92
112
  submit: (s: PostFormState, values?: Record<string, unknown>) => Promise<PostFormState>;
93
113
  }>("postForm", {
94
114
  state: shape({}),
95
- // Outcome keys are per-attempt: a cold boot must not restore a stale error, or a submit
96
- // disabled by a 403 the user has since been granted past.
115
+ // Outcome keys are per-attempt: a cold boot must not restore a stale error or a
116
+ // "Draft created." line from an earlier run.
97
117
  notHydrated: OUTCOME_KEYS,
98
118
  actions: {
99
119
  // `values` is the ONLY channel for what the user typed — keyed by FormField id.
@@ -115,18 +135,18 @@ const svc = defineService<PostFormState, {
115
135
  savedScreenId: id ? `my_post_${id}` : "", // wire a templated detail route per the tier-5 guide
116
136
  });
117
137
  } catch (e) {
118
- const kind = classifyError(e);
138
+ const kind = classifyError(e, currentAuthState()) || "server";
119
139
  // Echo what the user typed back into the prefill, so a failed save loses nothing.
120
140
  // Clear the last success line: a failed attempt must not also read "Draft created.".
121
- return shape({ ...s, title, body, status: "error", error: SAVE_FAILED[kind], savedMessage: "", cannotSave: kind === "permission" });
141
+ return shape({ ...s, title, body, status: "error", error: SAVE_FAILED[kind], savedMessage: "" });
122
142
  }
123
143
  },
124
144
  },
125
145
  });
126
146
 
127
147
  // An older runtime ignores `notHydrated` (it does not set `himalaya.hydrationOptOut`) and restores the
128
- // whole snapshot: reset the outcome keys in place before any action runs, or a 403's disabled submit
129
- // would come back on every launch with no way to clear it.
148
+ // whole snapshot: reset the outcome keys in place before any action runs, or last run's error line
149
+ // would come back on every launch.
130
150
  if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut !== true) {
131
151
  const booted = (svc as unknown as { getState?: () => Record<string, unknown> | undefined }).getState?.();
132
152
  const fresh = shape({}) as unknown as Record<string, unknown>;
@@ -67,8 +67,8 @@ const TRANSPORT = /NSURLErrorDomain|(?:UnknownHost|Connect|SocketTimeout|NoRoute
67
67
  // Android reports an unknown host as the bare hostname (`users.wix.com`) — hence the last form.
68
68
  // No session on the device: the native bridge refuses before any request leaves.
69
69
  // A pinned site with no member session: iOS `siteNotPermitted(requested: "account", …)`,
70
- // Android `site account is not the bound site …`.
71
- const SIGNED_OUT = /wixApi not enabled|session unavailable|PRINCIPAL_CHANGED|not signed in|siteNotPermitted\(requested: "account"|site account is not the bound site/i;
70
+ // Android `site account is not the bound site …`; the web host `HTTP 501: {"error":"webwix_owner_no_session"}`.
71
+ const SIGNED_OUT = /wixApi not enabled|session unavailable|PRINCIPAL_CHANGED|not signed in|siteNotPermitted\(requested: "account"|site account is not the bound site|webwix_owner_no_session/i;
72
72
  // The account is not a member of this site: iOS `NotSiteMember(metaSiteId: …)`,
73
73
  // Android `not a member of site …`. NOT `WDE0027` — that is Wix Data's generic
74
74
  // permission refusal, which a signed-in member and a visitor both get.
@@ -110,6 +110,20 @@
110
110
  "hiddenPath": "$service:posts.hideServerError"
111
111
  }
112
112
  },
113
+ {
114
+ "id": "error.membership",
115
+ "body": {
116
+ "package": "himalaya",
117
+ "type": "EmptyState",
118
+ "props": {
119
+ "title": "Join this site to view posts",
120
+ "subtitle": "Ask the owner of this blog to add you as a member."
121
+ }
122
+ },
123
+ "modifiers": {
124
+ "hiddenPath": "$service:posts.hideMembershipError"
125
+ }
126
+ },
113
127
  {
114
128
  "id": "error.permission",
115
129
  "body": {
@@ -21,15 +21,17 @@ interface PostRow {
21
21
  categoryIds: string[];
22
22
  }
23
23
 
24
- // A public blog has no sign-in flow, so a 401 reads as "permission" here.
25
- type ErrorKind = "" | "offline" | "server" | "permission";
24
+ // Every class the recipe's classifier returns …
25
+ type ErrorKind = "" | "offline" | "server" | "signIn" | "membership" | "permission";
26
+ // … and the ones this screen shows: it has no sign-in flow (see shownKind()).
27
+ type ShownKind = Exclude<ErrorKind, "" | "signIn">;
26
28
 
27
29
  interface PostsState {
28
30
  status: "loading" | "ready" | "error";
29
31
  all: PostRow[]; // the full fetched set — NOT bound by the descriptor
30
32
  items: PostRow[]; // ← itemsPath binds here; ALWAYS derived from all + query
31
33
  query: string;
32
- errorKind: ErrorKind;
34
+ errorKind: "" | ShownKind;
33
35
  // hiddenPath flags (hide when TRUE) — see `himi docs recipe-four-state`
34
36
  hideLoading: boolean;
35
37
  hideNoPosts: boolean; // EmptyState: the blog has no posts at all
@@ -37,6 +39,7 @@ interface PostsState {
37
39
  notEmpty: boolean; // false while either EmptyState shows
38
40
  hideOfflineError: boolean;
39
41
  hideServerError: boolean;
42
+ hideMembershipError: boolean;
40
43
  hidePermissionError: boolean;
41
44
  hideRefreshError: boolean;
42
45
  }
@@ -64,7 +67,7 @@ function shape(s: Partial<PostsState>): PostsState {
64
67
  const all = s.all ?? [];
65
68
  const query = s.query ?? "";
66
69
  const items = s.items ?? visible(all, query);
67
- const errorKind: ErrorKind = status === "error" ? (s.errorKind || "server") : "";
70
+ const errorKind: "" | ShownKind = status === "error" ? (s.errorKind || "server") : "";
68
71
  const blank = all.length === 0; // nothing loaded to keep
69
72
  return {
70
73
  status,
@@ -80,13 +83,16 @@ function shape(s: Partial<PostsState>): PostsState {
80
83
  hideLoading: !(status === "loading" && blank),
81
84
  hideOfflineError: !(blank && errorKind === "offline"),
82
85
  hideServerError: !(blank && errorKind === "server"),
86
+ hideMembershipError: !(blank && errorKind === "membership"),
83
87
  hidePermissionError: !(blank && errorKind === "permission"),
84
88
  hideRefreshError: !(!blank && (errorKind === "offline" || errorKind === "server")),
85
89
  };
86
90
  }
87
91
 
92
+ // The classifier below is `himi docs recipe-four-state`'s, VERBATIM — a test holds
93
+ // the two byte-identical, so the rules an agent copies from here are the documented ones.
88
94
  // The HTTP status rides in the message: `HTTP 503: …` on a device, `Wix 503 for …`
89
- // under `himi test` / `himi render`. Only a transport failure is "offline".
95
+ // under `himi test` / `himi render`.
90
96
  const STATUS = /\b(?:HTTP|Wix) (\d{3})\b/;
91
97
  // No answer at all — what a dropped connection says on every platform.
92
98
  const TRANSPORT = /NSURLErrorDomain|(?:UnknownHost|Connect|SocketTimeout|NoRouteToHost|SSLHandshake)Exception|Failed to connect|Unable to resolve host|Connection (?:refused|reset)|Network is unreachable|Software caused connection abort|ECONNRESET|ETIMEDOUT|EPIPE|Broken pipe|stream was reset|unexpected end of stream|timed out|^timeout$|Failed to fetch|NetworkError|^(?:TypeError: )?Load failed$|net::ERR_|^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i;
@@ -95,21 +101,45 @@ const TRANSPORT = /NSURLErrorDomain|(?:UnknownHost|Connect|SocketTimeout|NoRoute
95
101
  // A pinned site with no member session: iOS `siteNotPermitted(requested: "account", …)`,
96
102
  // Android `site account is not the bound site …`; the web host `HTTP 501: {"error":"webwix_owner_no_session"}`.
97
103
  const SIGNED_OUT = /wixApi not enabled|session unavailable|PRINCIPAL_CHANGED|not signed in|siteNotPermitted\(requested: "account"|site account is not the bound site|webwix_owner_no_session/i;
104
+ // The account is not a member of this site: iOS `NotSiteMember(metaSiteId: …)`,
105
+ // Android `not a member of site …`. NOT `WDE0027` — that is Wix Data's generic
106
+ // permission refusal, which a signed-in member and a visitor both get.
107
+ const NOT_MEMBER = /\bNotSiteMember\b|\bnot a member of site\b/i;
98
108
 
99
- // The same classifier as `himi docs recipe-four-state`, except that a public screen with no sign-in flow shows the permission state for a sign-in failure.
100
- function classifyError(e: unknown): ErrorKind {
109
+ function classifyError(e: unknown, authState: "signedIn" | "signedOut" | "unknown" = "unknown"): ErrorKind {
101
110
  const msg = String((e as Error)?.message ?? e ?? "");
102
- const code = String((e as { code?: unknown })?.code ?? "");
111
+ // Some bridges put the Wix code in the response body rather than Error.code.
112
+ const code = String((e as { code?: unknown })?.code ?? /"(?:code|error)"\s*:\s*"([^"]+)"/.exec(msg)?.[1] ?? "");
103
113
  const status = Number(STATUS.exec(msg)?.[1] ?? 0);
104
- if (status === 401) return "permission";
105
- if (status === 403) return "permission";
106
- if (SIGNED_OUT.test(msg)) return "permission";
107
- if (code === "offline_write_blocked") return "offline"; // a write refused before it left
114
+ if (code === "AccountSessionExpired" || code === "webwix_account_signed_out" || SIGNED_OUT.test(msg)) return "signIn";
115
+ if (code === "NotSiteMember" || NOT_MEMBER.test(msg)) return "membership";
116
+ // A 401 is a missing or expired session — unless the host knows the user is signed
117
+ // in, when it is a policy refusal. Never "server": a retry sends the same 401 back.
118
+ if (status === 401) return authState === "signedIn" ? "permission" : "signIn";
119
+ // A 403 is a missing permission — but a visitor the host knows is signed out gets
120
+ // `403 WDE0027` on member-only data too, and signing in is what fixes that.
121
+ if (status === 403) return authState === "signedOut" ? "signIn" : "permission";
122
+ if (code === "offline_write_blocked") return "offline"; // a write refused before it left
108
123
  // A web call that never reached Wix arrives as
109
124
  // `HTTP 502: {"error":"webwix_direct_failed","detail":"Failed to fetch"}` — judge its detail.
110
- if (status && !msg.includes("webwix_direct_failed")) return "server";
111
- if (TRANSPORT.test(msg)) return "offline";
112
- return "server"; // a bug or a malformed row is not the user's connection
125
+ // The top-level 502 is the bridge wrapper, not the underlying transport result.
126
+ const detail = msg.includes("webwix_direct_failed")
127
+ ? /"detail"\s*:\s*"([^"]+)"/.exec(msg)?.[1] ?? msg
128
+ : msg;
129
+ if (status && detail === msg) return "server";
130
+ if (TRANSPORT.test(detail)) return "offline";
131
+ return "server"; // anything else — a bug, a malformed row — is not the user's connection
132
+ }
133
+
134
+ // App-specific host integration. If the host does not expose auth state here, keep
135
+ // "unknown": a 401 then reads as a session that ended.
136
+ function currentAuthState(): "signedIn" | "signedOut" | "unknown" { return "unknown"; }
137
+
138
+ // This screen has no sign-in flow, so a sign-in failure shows the permission state —
139
+ // every class it can show has a node in the descriptor.
140
+ function shownKind(e: unknown): ShownKind {
141
+ const kind = classifyError(e, currentAuthState());
142
+ return kind === "signIn" ? "permission" : kind || "server";
113
143
  }
114
144
 
115
145
  // The SearchField onChange arg shape varies by surface — read it defensively.
@@ -132,9 +162,10 @@ async function loadList(s: Partial<PostsState>): Promise<PostsState> {
132
162
  .filter((r) => r.id);
133
163
  return shape({ status: "ready", all, query: currentQuery });
134
164
  } catch (e) {
135
- const errorKind = classifyError(e);
165
+ const errorKind = shownKind(e);
136
166
  // Keep what was loaded on a refresh failure; clear it when access is gone.
137
- const all = errorKind === "permission" ? [] : s.all ?? [];
167
+ const lostAccess = errorKind === "membership" || errorKind === "permission";
168
+ const all = lostAccess ? [] : s.all ?? [];
138
169
  return shape({ status: "error", all, query: currentQuery, errorKind });
139
170
  }
140
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1484.0",
3
+ "version": "0.1485.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -71,5 +71,5 @@
71
71
  "artifactId": "himalaya-cli"
72
72
  }
73
73
  },
74
- "falconPackageHash": "310eec6f7de3de9c4b4e96b9c25cb5913642adb3b8408fde6c3fc8a2"
74
+ "falconPackageHash": "b348966433fe41b3836cc5030d8aac7869b50966ab9ac20254d62046"
75
75
  }