sh3-core 0.17.2 → 0.19.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.
Files changed (97) hide show
  1. package/dist/Sh3.svelte +59 -4
  2. package/dist/actions/CommandPalette.svelte +1 -2
  3. package/dist/actions/listeners.js +12 -1
  4. package/dist/api.d.ts +4 -0
  5. package/dist/app/store/storeShard.svelte.js +1 -21
  6. package/dist/app/store/version.d.ts +11 -0
  7. package/dist/app/store/version.js +39 -0
  8. package/dist/app/store/version.test.d.ts +1 -0
  9. package/dist/app/store/version.test.js +44 -0
  10. package/dist/apps/lifecycle.d.ts +6 -0
  11. package/dist/apps/lifecycle.js +5 -2
  12. package/dist/apps/lifecycle.test.js +30 -0
  13. package/dist/apps/types.d.ts +12 -0
  14. package/dist/assets/iconIds.generated.d.ts +1 -1
  15. package/dist/assets/iconIds.generated.js +5 -0
  16. package/dist/assets/icons.svg +31 -0
  17. package/dist/auth/auth.svelte.js +18 -8
  18. package/dist/auth/types.d.ts +6 -0
  19. package/dist/chrome/CompactChrome.svelte +54 -20
  20. package/dist/chrome/CompactChrome.svelte.test.js +112 -5
  21. package/dist/createShell.d.ts +9 -0
  22. package/dist/createShell.js +20 -7
  23. package/dist/createShell.remoteAuth.test.d.ts +1 -0
  24. package/dist/createShell.remoteAuth.test.js +71 -0
  25. package/dist/documents/http-backend.js +12 -11
  26. package/dist/env/client.js +11 -5
  27. package/dist/files/types.d.ts +106 -0
  28. package/dist/files/types.js +1 -0
  29. package/dist/gestures/gestureRegistry.d.ts +6 -0
  30. package/dist/gestures/gestureRegistry.js +190 -0
  31. package/dist/gestures/gestureRegistry.test.d.ts +1 -0
  32. package/dist/gestures/gestureRegistry.test.js +120 -0
  33. package/dist/gestures/index.d.ts +6 -0
  34. package/dist/gestures/index.js +12 -0
  35. package/dist/gestures/pointerClaim.d.ts +7 -0
  36. package/dist/gestures/pointerClaim.js +36 -0
  37. package/dist/gestures/pointerClaim.test.d.ts +1 -0
  38. package/dist/gestures/pointerClaim.test.js +64 -0
  39. package/dist/gestures/types.d.ts +83 -0
  40. package/dist/gestures/types.js +1 -0
  41. package/dist/host-entry.d.ts +1 -0
  42. package/dist/host-entry.js +1 -0
  43. package/dist/layout/LayoutRenderer.browser.test.js +15 -3
  44. package/dist/layout/LayoutRenderer.svelte +16 -3
  45. package/dist/layout/LayoutRenderer.svelte.d.ts +2 -0
  46. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-3-splitter-drag-updates-split-sizes-when-the-splitter-handle-is-dragged-1.png +0 -0
  47. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-5-splitter-collapse-toggle-toggles-collapsed-i--on-double-click-1.png +0 -0
  48. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-6-fixed-slots-hides-the-collapse-widget-on-a-fixed-pane-but-keeps-it-on-panes-with-a-non-fixed-neighbor-1.png +0 -0
  49. package/dist/layout/compact/CarouselTabs.svelte +362 -0
  50. package/dist/layout/compact/CarouselTabs.svelte.d.ts +10 -0
  51. package/dist/layout/compact/CarouselTabs.svelte.test.d.ts +1 -0
  52. package/dist/layout/compact/CarouselTabs.svelte.test.js +300 -0
  53. package/dist/layout/compact/CompactRenderer.svelte +1 -1
  54. package/dist/layout/compact/CompactRenderer.svelte.test.js +49 -0
  55. package/dist/layout/compact/derive.js +2 -0
  56. package/dist/layout/compact/derive.test.js +37 -0
  57. package/dist/layout/compact/enrichCarousels.d.ts +8 -0
  58. package/dist/layout/compact/enrichCarousels.js +44 -0
  59. package/dist/layout/compact/enrichCarousels.test.d.ts +1 -0
  60. package/dist/layout/compact/enrichCarousels.test.js +88 -0
  61. package/dist/layout/compact/types.d.ts +3 -0
  62. package/dist/layout/drag.svelte.js +13 -0
  63. package/dist/layout/store.schemaVersion.test.js +2 -2
  64. package/dist/layout/types.d.ts +9 -1
  65. package/dist/layout/types.js +1 -1
  66. package/dist/layout/types.test.d.ts +1 -0
  67. package/dist/layout/types.test.js +26 -0
  68. package/dist/overlays/ModalFrame.svelte +3 -1
  69. package/dist/overlays/ModalFrame.svelte.d.ts +1 -0
  70. package/dist/overlays/floatDismiss.js +5 -0
  71. package/dist/overlays/focusTrap.d.ts +11 -1
  72. package/dist/overlays/focusTrap.js +11 -9
  73. package/dist/overlays/modal.js +1 -0
  74. package/dist/overlays/popup.js +4 -0
  75. package/dist/overlays/types.d.ts +9 -0
  76. package/dist/primitives/Button.svelte +18 -0
  77. package/dist/primitives/Button.svelte.d.ts +6 -0
  78. package/dist/primitives/ResizableSplitter.svelte +71 -11
  79. package/dist/primitives/ResizableSplitter.svelte.d.ts +8 -0
  80. package/dist/primitives/ResizableSplitter.svelte.test.d.ts +1 -0
  81. package/dist/primitives/ResizableSplitter.svelte.test.js +74 -0
  82. package/dist/server-shard/types.d.ts +2 -1
  83. package/dist/shards/activate.svelte.js +16 -0
  84. package/dist/shards/ctx-fetch.test.d.ts +1 -0
  85. package/dist/shards/ctx-fetch.test.js +136 -0
  86. package/dist/shards/types.d.ts +29 -0
  87. package/dist/transport/apiFetch.d.ts +1 -0
  88. package/dist/transport/apiFetch.js +65 -0
  89. package/dist/transport/apiFetch.test.d.ts +1 -0
  90. package/dist/transport/apiFetch.test.js +37 -0
  91. package/dist/transport/authToken.d.ts +2 -0
  92. package/dist/transport/authToken.js +53 -0
  93. package/dist/transport/authToken.test.d.ts +1 -0
  94. package/dist/transport/authToken.test.js +33 -0
  95. package/dist/version.d.ts +1 -1
  96. package/dist/version.js +1 -1
  97. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  /*
3
3
  * Top app bar for compact mode. Three-column grid:
4
- * leading — one button per non-null drawer anchor (read from
4
+ * leading — one Button per non-null drawer anchor (read from
5
5
  * the active CompactRendering)
6
6
  * title — active app name
7
7
  * trailing — palette button + overflow (menu sheet) button
@@ -10,21 +10,49 @@
10
10
  * state and renders MenuSheet conditionally.
11
11
  */
12
12
  import { sh3 } from '../sh3Runtime.svelte';
13
- import { layoutStore } from '../layout/store.svelte';
13
+ import { layoutStore, getActiveRoot } from '../layout/store.svelte';
14
14
  import { derive } from '../layout/compact/derive';
15
15
  import { getLiveDispatcherState } from '../actions/state.svelte';
16
16
  import { getRegisteredApp } from '../apps/registry.svelte';
17
+ import { returnToHome } from '../apps/lifecycle';
18
+ import Button from '../primitives/Button.svelte';
17
19
  import MenuSheet from './MenuSheet.svelte';
18
20
  import type { DrawerAnchor } from '../layout/compact/types';
19
21
 
20
22
  const rendering = $derived(derive(layoutStore.root));
21
23
  const dispatcher = $derived(getLiveDispatcherState());
22
- const title = $derived.by(() => {
24
+ const onHome = $derived(getActiveRoot() === 'home');
25
+ const appLabel = $derived.by(() => {
23
26
  const id = dispatcher.activeAppId;
24
27
  if (!id) return 'SH3';
25
28
  return getRegisteredApp(id)?.manifest.label ?? id;
26
29
  });
27
30
 
31
+ /**
32
+ * Pick the topmost carousel — the one with the lexicographically smallest
33
+ * path key. `''` (root) sorts before `'0'` etc.; among siblings, smaller
34
+ * indices sort first. This deterministically selects the spatially-topmost
35
+ * full-width tab group when multiple carousels are stacked vertically.
36
+ */
37
+ const topmostCarouselLabel = $derived.by(() => {
38
+ if (rendering.carousels.size === 0) return null;
39
+ let bestKey: string | null = null;
40
+ let bestLabel = '';
41
+ for (const [key, info] of rendering.carousels) {
42
+ if (bestKey === null || key < bestKey) {
43
+ bestKey = key;
44
+ bestLabel = info.activeLabel;
45
+ }
46
+ }
47
+ return bestLabel;
48
+ });
49
+
50
+ const title = $derived.by(() => {
51
+ const carouselLabel = topmostCarouselLabel;
52
+ if (carouselLabel) return `${appLabel} › ${carouselLabel}`;
53
+ return appLabel;
54
+ });
55
+
28
56
  let menuOpen = $state(false);
29
57
 
30
58
  function toggleDrawer(anchor: DrawerAnchor) {
@@ -37,20 +65,34 @@
37
65
 
38
66
  <header class="sh3-compact-chrome" data-sh3-region="compact-chrome">
39
67
  <div class="leading">
68
+ <Button
69
+ variant="icon"
70
+ icon="house"
71
+ ariaLabel="Home"
72
+ title="Home"
73
+ disabled={onHome}
74
+ onclick={() => returnToHome()}
75
+ />
40
76
  {#if rendering.drawers.left}
41
- <button onclick={() => toggleDrawer('left')} aria-label="Toggle left drawer" data-sh3-anchor="left">≡</button>
77
+ <span data-sh3-anchor="left">
78
+ <Button variant="icon" icon="menu" ariaLabel="Toggle left drawer" title="Toggle left drawer" onclick={() => toggleDrawer('left')} />
79
+ </span>
42
80
  {/if}
43
81
  {#if rendering.drawers.right}
44
- <button onclick={() => toggleDrawer('right')} aria-label="Toggle right drawer" data-sh3-anchor="right">▣</button>
82
+ <span data-sh3-anchor="right">
83
+ <Button variant="icon" icon="panel-right" ariaLabel="Toggle right drawer" title="Toggle right drawer" onclick={() => toggleDrawer('right')} />
84
+ </span>
45
85
  {/if}
46
86
  {#if rendering.drawers.top}
47
- <button onclick={() => toggleDrawer('top')} aria-label="Toggle top drawer" data-sh3-anchor="top">⫶</button>
87
+ <span data-sh3-anchor="top">
88
+ <Button variant="icon" icon="panel-top" ariaLabel="Toggle top drawer" title="Toggle top drawer" onclick={() => toggleDrawer('top')} />
89
+ </span>
48
90
  {/if}
49
91
  </div>
50
92
  <div class="title">{title}</div>
51
93
  <div class="trailing">
52
- <button onclick={openPalette} aria-label="Open command palette">⌘</button>
53
- <button onclick={() => (menuOpen = true)} aria-label="Open menu">⋯</button>
94
+ <Button variant="icon" icon="command" ariaLabel="Open command palette" title="Open command palette" onclick={openPalette} />
95
+ <Button variant="icon" icon="ellipsis-vertical" ariaLabel="Open menu" title="Open menu" onclick={() => { menuOpen = true; }} />
54
96
  </div>
55
97
  </header>
56
98
 
@@ -71,20 +113,12 @@
71
113
  .leading,
72
114
  .trailing {
73
115
  display: inline-flex;
116
+ align-items: center;
74
117
  gap: var(--sh3-pad-xs);
75
118
  }
76
- button {
77
- width: 40px;
78
- height: 40px;
79
- font-size: var(--sh3-font-lg);
80
- border: none;
81
- background: none;
82
- cursor: pointer;
83
- border-radius: var(--sh3-radius-sm);
84
- color: var(--sh3-fg);
85
- }
86
- button:active {
87
- background: var(--sh3-bg-sunken);
119
+ .leading > span {
120
+ display: inline-flex;
121
+ align-items: center;
88
122
  }
89
123
  .title {
90
124
  font-weight: 600;
@@ -6,8 +6,10 @@
6
6
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
7
  import { mount, unmount, flushSync } from 'svelte';
8
8
  import CompactChrome from './CompactChrome.svelte';
9
- import { __resetLayoutStoreForTest, attachApp, detachApp, switchToApp, } from '../layout/store.svelte';
9
+ import { __resetLayoutStoreForTest, attachApp, detachApp, switchToApp, switchToHome, } from '../layout/store.svelte';
10
10
  import { drawerStore } from '../layout/compact/drawerStore.svelte';
11
+ import { setActiveApp, __resetDispatcherStateForTest } from '../actions/state.svelte';
12
+ import { registerApp, __resetAppRegistryForTest } from '../apps/registry.svelte';
11
13
  const CompactChromeAny = CompactChrome;
12
14
  function fakeApp() {
13
15
  return {
@@ -34,11 +36,22 @@ afterEach(() => {
34
36
  host = null;
35
37
  }
36
38
  detachApp();
39
+ __resetAppRegistryForTest();
40
+ __resetDispatcherStateForTest();
37
41
  });
38
42
  beforeEach(() => {
39
43
  __resetLayoutStoreForTest();
40
44
  drawerStore.__reset();
45
+ __resetAppRegistryForTest();
46
+ __resetDispatcherStateForTest();
41
47
  });
48
+ function attachAndActivate(app) {
49
+ var _a;
50
+ registerApp(app);
51
+ attachApp(app);
52
+ switchToApp();
53
+ setActiveApp(app.manifest.id, new Set((_a = app.manifest.requiredShards) !== null && _a !== void 0 ? _a : []));
54
+ }
42
55
  describe('CompactChrome (dom)', () => {
43
56
  it('renders a leading toggle for each present drawer anchor', () => {
44
57
  attachApp(fakeApp());
@@ -48,10 +61,9 @@ describe('CompactChrome (dom)', () => {
48
61
  document.body.appendChild(host);
49
62
  mounted = mount(CompactChromeAny, { target: host });
50
63
  flushSync();
51
- const leading = host.querySelectorAll('.leading button');
52
- expect(leading.length).toBe(2);
53
- expect(host.querySelector('.leading button[data-sh3-anchor="left"]')).not.toBeNull();
54
- expect(host.querySelector('.leading button[data-sh3-anchor="right"]')).not.toBeNull();
64
+ expect(host.querySelector('.leading [data-sh3-anchor="left"] button')).not.toBeNull();
65
+ expect(host.querySelector('.leading [data-sh3-anchor="right"] button')).not.toBeNull();
66
+ expect(host.querySelector('.leading [data-sh3-anchor="top"] button')).toBeNull();
55
67
  });
56
68
  it('renders palette + overflow buttons in the trailing section', () => {
57
69
  attachApp(fakeApp());
@@ -65,3 +77,98 @@ describe('CompactChrome (dom)', () => {
65
77
  expect(trailing.length).toBe(2);
66
78
  });
67
79
  });
80
+ describe('CompactChrome — home button', () => {
81
+ it('renders a home button as the first leading item', () => {
82
+ attachApp(fakeApp());
83
+ switchToApp();
84
+ flushSync();
85
+ host = document.createElement('div');
86
+ document.body.appendChild(host);
87
+ mounted = mount(CompactChromeAny, { target: host });
88
+ flushSync();
89
+ const homeBtn = host.querySelector('.leading button[aria-label="Home"]');
90
+ expect(homeBtn).not.toBeNull();
91
+ expect(homeBtn === null || homeBtn === void 0 ? void 0 : homeBtn.hasAttribute('disabled')).toBe(false);
92
+ });
93
+ it('disables the home button when on home', () => {
94
+ switchToHome();
95
+ flushSync();
96
+ host = document.createElement('div');
97
+ document.body.appendChild(host);
98
+ mounted = mount(CompactChromeAny, { target: host });
99
+ flushSync();
100
+ const homeBtn = host.querySelector('.leading button[aria-label="Home"]');
101
+ expect(homeBtn).not.toBeNull();
102
+ expect(homeBtn === null || homeBtn === void 0 ? void 0 : homeBtn.hasAttribute('disabled')).toBe(true);
103
+ });
104
+ });
105
+ describe('CompactChrome — breadcrumb', () => {
106
+ it('renders only AppName when no carousel is active', () => {
107
+ var _a;
108
+ const app = {
109
+ manifest: { id: 'plain-app', label: 'Plain App', layoutVersion: 6 },
110
+ initialLayout: { type: 'slot', slotId: 'b', viewId: null, role: 'body' },
111
+ };
112
+ attachAndActivate(app);
113
+ flushSync();
114
+ host = document.createElement('div');
115
+ document.body.appendChild(host);
116
+ mounted = mount(CompactChromeAny, { target: host });
117
+ flushSync();
118
+ const title = host.querySelector('.title');
119
+ expect((_a = title === null || title === void 0 ? void 0 : title.textContent) === null || _a === void 0 ? void 0 : _a.trim()).toBe('Plain App');
120
+ });
121
+ it('renders AppName › ActiveLabel when a carousel is active', () => {
122
+ var _a;
123
+ const app = {
124
+ manifest: { id: 'carousel-app', label: 'Carousel App', layoutVersion: 6 },
125
+ initialLayout: {
126
+ type: 'tabs',
127
+ activeTab: 1,
128
+ tabs: [
129
+ { slotId: 's0', viewId: null, label: 'First', role: 'body' },
130
+ { slotId: 's1', viewId: null, label: 'Second', role: 'body' },
131
+ ],
132
+ },
133
+ };
134
+ attachAndActivate(app);
135
+ flushSync();
136
+ host = document.createElement('div');
137
+ document.body.appendChild(host);
138
+ mounted = mount(CompactChromeAny, { target: host });
139
+ flushSync();
140
+ const title = host.querySelector('.title');
141
+ expect((_a = title === null || title === void 0 ? void 0 : title.textContent) === null || _a === void 0 ? void 0 : _a.trim()).toBe('Carousel App › Second');
142
+ });
143
+ it('with multiple stacked carousels, breadcrumb uses the topmost (lowest path-key sort order)', () => {
144
+ var _a;
145
+ const app = {
146
+ manifest: { id: 'stacked-app', label: 'Stacked', layoutVersion: 6 },
147
+ initialLayout: {
148
+ type: 'split',
149
+ direction: 'vertical',
150
+ sizes: [0.5, 0.5],
151
+ children: [
152
+ {
153
+ type: 'tabs',
154
+ activeTab: 0,
155
+ tabs: [{ slotId: 'top0', viewId: null, label: 'TopActive', role: 'body' }],
156
+ },
157
+ {
158
+ type: 'tabs',
159
+ activeTab: 0,
160
+ tabs: [{ slotId: 'bot0', viewId: null, label: 'BottomActive', role: 'body' }],
161
+ },
162
+ ],
163
+ },
164
+ };
165
+ attachAndActivate(app);
166
+ flushSync();
167
+ host = document.createElement('div');
168
+ document.body.appendChild(host);
169
+ mounted = mount(CompactChromeAny, { target: host });
170
+ flushSync();
171
+ const title = host.querySelector('.title');
172
+ expect((_a = title === null || title === void 0 ? void 0 : title.textContent) === null || _a === void 0 ? void 0 : _a.trim()).toBe('Stacked › TopActive');
173
+ });
174
+ });
@@ -22,5 +22,14 @@ export interface Sh3Config {
22
22
  target?: string | HTMLElement;
23
23
  /** Server base URL ('' for same-origin) */
24
24
  serverUrl?: string;
25
+ /**
26
+ * When true, override the local-owner short-circuit and run the
27
+ * full server-driven auth flow (boot config fetch, SignInWall when
28
+ * required) against `serverUrl`. Used by Tauri clients connecting
29
+ * to a remote sh3-server (e.g. Android), where Tauri presence makes
30
+ * `platform.localOwner` true even though authentication is server-
31
+ * gated.
32
+ */
33
+ remoteAuth?: boolean;
25
34
  }
26
35
  export declare function createShell(config?: Sh3Config): Promise<void>;
@@ -10,8 +10,9 @@ import { mount, unmount } from 'svelte';
10
10
  import { Sh3 } from './index';
11
11
  import { registerShard, registerApp, bootstrap, bootstrapSatellite, __setBackend, setLocalOwner, } from './host';
12
12
  import { resolvePlatform } from './platform/index';
13
+ import { apiFetch } from './transport/apiFetch';
13
14
  import { hydrateTokenOverrides } from './theme';
14
- import { __setEnvServerUrl } from './env/index';
15
+ import { __setEnvServerUrl, getEnvServerUrl } from './env/index';
15
16
  import { __setActiveScope } from './documents/config';
16
17
  import { initFromBoot } from './auth/index';
17
18
  import SignInWall from './auth/SignInWall.svelte';
@@ -83,11 +84,13 @@ export async function createShell(config) {
83
84
  mount(SatelliteShell, { target, props: { payload: satellite.payload } });
84
85
  return;
85
86
  }
86
- // 3. Fetch boot config (skip for local-owner platforms like Tauri/dev)
87
+ // 3. Fetch boot config (skip for purely-local owners; remoteAuth
88
+ // forces it for cross-origin Tauri clients).
87
89
  let bootConfig = null;
88
- if (!platform.localOwner) {
90
+ const useServerAuth = !platform.localOwner || (config === null || config === void 0 ? void 0 : config.remoteAuth) === true;
91
+ if (useServerAuth) {
89
92
  try {
90
- const res = await fetch(`${sUrl}/api/boot`, { credentials: 'include' });
93
+ const res = await apiFetch(`${sUrl}/api/boot`);
91
94
  if (res.ok) {
92
95
  bootConfig = await res.json();
93
96
  }
@@ -97,7 +100,7 @@ export async function createShell(config) {
97
100
  }
98
101
  }
99
102
  // 4. Auth decision point
100
- if (platform.localOwner) {
103
+ if (platform.localOwner && !(config === null || config === void 0 ? void 0 : config.remoteAuth)) {
101
104
  // Local-owner (Tauri/dev): no auth, no sign-in, scope is 'local'.
102
105
  // setLocalOwner() already called above — admin is assumed.
103
106
  __setActiveScope('local');
@@ -110,7 +113,7 @@ export async function createShell(config) {
110
113
  if (!session && auth.required && !auth.guestAllowed) {
111
114
  await showSignInWall(target, bootConfig);
112
115
  // After successful sign-in, re-fetch boot config
113
- const res = await fetch(`${sUrl}/api/boot`, { credentials: 'include' });
116
+ const res = await apiFetch(`${sUrl}/api/boot`);
114
117
  if (res.ok) {
115
118
  bootConfig = await res.json();
116
119
  initFromBoot(sUrl, bootConfig);
@@ -150,7 +153,17 @@ async function loadDiscoveredPackages(packages) {
150
153
  return;
151
154
  for (const pkg of packages) {
152
155
  try {
153
- const res = await fetch(pkg.bundleUrl);
156
+ // Server returns server-relative paths like `/packages/<id>/client.js`.
157
+ // In a cross-origin Tauri client these resolve against the webview
158
+ // origin (`tauri://localhost`) instead of the configured server, so
159
+ // we get the SPA's index.html back and the loader chokes on `<`.
160
+ // Resolve against the active serverUrl and route through apiFetch
161
+ // for the cross-origin-safe transport + bearer header.
162
+ const isAbsolute = pkg.bundleUrl.startsWith('http://') || pkg.bundleUrl.startsWith('https://');
163
+ const base = getEnvServerUrl();
164
+ const sep = pkg.bundleUrl.startsWith('/') ? '' : '/';
165
+ const url = isAbsolute ? pkg.bundleUrl : `${base}${sep}${pkg.bundleUrl}`;
166
+ const res = await apiFetch(url);
154
167
  if (!res.ok) {
155
168
  console.warn(`[sh3] Failed to fetch discovered package "${pkg.id}": HTTP ${res.status}`);
156
169
  continue;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ describe('createShell remoteAuth flag', () => {
3
+ let originalFetch;
4
+ beforeEach(() => {
5
+ originalFetch = globalThis.fetch;
6
+ vi.resetModules();
7
+ });
8
+ afterEach(() => {
9
+ globalThis.fetch = originalFetch;
10
+ vi.doUnmock('./platform/index');
11
+ vi.doUnmock('./host');
12
+ });
13
+ function shortCircuitAfterBoot() {
14
+ // Mock bootstrap so createShell throws right after the boot-config
15
+ // fetch we want to observe — and never reaches mount(Sh3, ...).
16
+ vi.doMock('./host', async () => {
17
+ const actual = await vi.importActual('./host');
18
+ return Object.assign(Object.assign({}, actual), { bootstrap: async () => {
19
+ throw new Error('test-skip-bootstrap');
20
+ } });
21
+ });
22
+ }
23
+ it('fetches /api/boot when remoteAuth is true even if localOwner is detected', async () => {
24
+ const calls = [];
25
+ globalThis.fetch = vi.fn(async (input) => {
26
+ calls.push(String(input));
27
+ return new Response(JSON.stringify({
28
+ version: '0.18.0',
29
+ tenantId: 't1',
30
+ auth: { required: false, guestAllowed: true, selfRegistration: false },
31
+ session: { token: 'tok', userId: 'u1', role: 'user', expiresAt: Number.MAX_SAFE_INTEGER },
32
+ user: { id: 'u1', username: 'u', displayName: 'U', role: 'user', createdAt: '', updatedAt: '' },
33
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
34
+ });
35
+ vi.doMock('./platform/index', () => ({
36
+ resolvePlatform: async () => ({ backends: null, localOwner: true }),
37
+ }));
38
+ shortCircuitAfterBoot();
39
+ document.body.innerHTML = '<div id="app"></div>';
40
+ const { createShell } = await import('./createShell');
41
+ // The full boot pipeline (mount, bootstrap, etc.) may throw in this
42
+ // test env — we only care that the boot-config fetch was attempted.
43
+ try {
44
+ await createShell({ serverUrl: 'https://remote.example.com', remoteAuth: true });
45
+ }
46
+ catch (_a) {
47
+ // ignore — assertion below is the contract.
48
+ }
49
+ expect(calls.some(u => u === 'https://remote.example.com/api/boot')).toBe(true);
50
+ });
51
+ it('keeps the legacy localOwner short-circuit when remoteAuth is absent', async () => {
52
+ const calls = [];
53
+ globalThis.fetch = vi.fn(async (input) => {
54
+ calls.push(String(input));
55
+ return new Response('ok');
56
+ });
57
+ vi.doMock('./platform/index', () => ({
58
+ resolvePlatform: async () => ({ backends: null, localOwner: true }),
59
+ }));
60
+ shortCircuitAfterBoot();
61
+ document.body.innerHTML = '<div id="app"></div>';
62
+ const { createShell } = await import('./createShell');
63
+ try {
64
+ await createShell({ serverUrl: '' });
65
+ }
66
+ catch (_a) {
67
+ // ignore — assertion below is the contract.
68
+ }
69
+ expect(calls.some(u => u.endsWith('/api/boot'))).toBe(false);
70
+ });
71
+ });
@@ -20,6 +20,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
20
20
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
21
21
  };
22
22
  var _HttpDocumentBackend_instances, _HttpDocumentBackend_baseUrl, _HttpDocumentBackend_apiKey, _HttpDocumentBackend_authHeaders;
23
+ import { apiFetch } from '../transport/apiFetch';
23
24
  export class HttpDocumentBackend {
24
25
  /**
25
26
  * @param baseUrl - The server origin (e.g. 'http://localhost:3000' or window.location.origin).
@@ -36,7 +37,7 @@ export class HttpDocumentBackend {
36
37
  async read(tenantId, shardId, path) {
37
38
  var _a;
38
39
  const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
39
- const res = await fetch(url, { credentials: 'include' });
40
+ const res = await apiFetch(url, { credentials: 'include' });
40
41
  if (res.status === 404)
41
42
  return null;
42
43
  if (!res.ok)
@@ -50,45 +51,45 @@ export class HttpDocumentBackend {
50
51
  async write(tenantId, shardId, path, content) {
51
52
  const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
52
53
  const headers = Object.assign(Object.assign({}, __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this)), { 'Content-Type': typeof content === 'string' ? 'text/plain' : 'application/octet-stream' });
53
- const res = await fetch(url, { method: 'PUT', headers, body: content, credentials: 'include' });
54
+ const res = await apiFetch(url, { method: 'PUT', headers, body: content, credentials: 'include' });
54
55
  if (!res.ok)
55
56
  throw new Error(`Document write failed: ${res.status}`);
56
57
  }
57
58
  async delete(tenantId, shardId, path) {
58
59
  const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
59
- const res = await fetch(url, { method: 'DELETE', headers: __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this), credentials: 'include' });
60
+ const res = await apiFetch(url, { method: 'DELETE', headers: __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this), credentials: 'include' });
60
61
  if (!res.ok)
61
62
  throw new Error(`Document delete failed: ${res.status}`);
62
63
  }
63
64
  async list(tenantId, shardId) {
64
65
  const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}`;
65
- const res = await fetch(url, { credentials: 'include' });
66
+ const res = await apiFetch(url, { credentials: 'include' });
66
67
  if (!res.ok)
67
68
  throw new Error(`Document list failed: ${res.status}`);
68
69
  return res.json();
69
70
  }
70
71
  async exists(tenantId, shardId, path) {
71
72
  const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
72
- const res = await fetch(url, { method: 'HEAD', credentials: 'include' });
73
+ const res = await apiFetch(url, { method: 'HEAD', credentials: 'include' });
73
74
  return res.ok;
74
75
  }
75
76
  async listAllShards(tenantId) {
76
77
  const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/_shards`;
77
- const res = await fetch(url, { credentials: 'include' });
78
+ const res = await apiFetch(url, { credentials: 'include' });
78
79
  if (!res.ok)
79
80
  throw new Error(`listAllShards failed: ${res.status}`);
80
81
  return res.json();
81
82
  }
82
83
  async listAllDocuments(tenantId) {
83
84
  const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/_all`;
84
- const res = await fetch(url, { credentials: 'include' });
85
+ const res = await apiFetch(url, { credentials: 'include' });
85
86
  if (!res.ok)
86
87
  throw new Error(`listAllDocuments failed: ${res.status}`);
87
88
  return res.json();
88
89
  }
89
90
  async readMeta(tenantId, shardId, path) {
90
91
  const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}?meta=1`;
91
- const res = await fetch(url, { credentials: 'include' });
92
+ const res = await apiFetch(url, { credentials: 'include' });
92
93
  if (!res.ok)
93
94
  throw new Error(`readMeta failed: ${res.status}`);
94
95
  const body = await res.json();
@@ -101,7 +102,7 @@ export class HttpDocumentBackend {
101
102
  const body = typeof choice === 'string'
102
103
  ? { choice }
103
104
  : { choice: choice.origin };
104
- const res = await fetch(url, {
105
+ const res = await apiFetch(url, {
105
106
  method: 'POST',
106
107
  credentials: 'include',
107
108
  headers: Object.assign(Object.assign({}, __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this)), { 'Content-Type': 'application/json' }),
@@ -112,7 +113,7 @@ export class HttpDocumentBackend {
112
113
  }
113
114
  async readBranch(tenantId, shardId, path, origin) {
114
115
  const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}/branch?origin=${encodeURIComponent(origin)}`;
115
- const res = await fetch(url, { credentials: 'include' });
116
+ const res = await apiFetch(url, { credentials: 'include' });
116
117
  if (res.status === 404)
117
118
  return null;
118
119
  if (!res.ok)
@@ -122,7 +123,7 @@ export class HttpDocumentBackend {
122
123
  async rename(tenantId, shardId, oldPath, newPath) {
123
124
  const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${oldPath}/rename`;
124
125
  const headers = Object.assign(Object.assign({}, __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this)), { 'Content-Type': 'application/json' });
125
- const res = await fetch(url, {
126
+ const res = await apiFetch(url, {
126
127
  method: 'POST',
127
128
  headers,
128
129
  body: JSON.stringify({ to: newPath }),
@@ -3,6 +3,7 @@
3
3
  * from the server.
4
4
  */
5
5
  import { getAuthHeader, isAdmin } from '../auth/index';
6
+ import { apiFetch } from '../transport/apiFetch';
6
7
  /** Server base URL, set once during configuration. */
7
8
  let serverUrl = '';
8
9
  /** Configure the server URL for env state operations. */
@@ -18,7 +19,9 @@ export function getEnvServerUrl() {
18
19
  * Returns an empty object if the server has no stored state.
19
20
  */
20
21
  export async function fetchEnvState(shardId) {
21
- const res = await fetch(`${serverUrl}/api/env-state/${encodeURIComponent(shardId)}`);
22
+ const res = await apiFetch(`${serverUrl}/api/env-state/${encodeURIComponent(shardId)}`, {
23
+ credentials: 'omit',
24
+ });
22
25
  if (!res.ok) {
23
26
  console.warn(`[sh3] Failed to fetch env state for "${shardId}": HTTP ${res.status}`);
24
27
  return {};
@@ -38,10 +41,11 @@ export async function putEnvState(shardId, state) {
38
41
  const headers = { 'Content-Type': 'application/json' };
39
42
  if (auth)
40
43
  headers['Authorization'] = auth;
41
- const res = await fetch(`${serverUrl}/api/env-state/${encodeURIComponent(shardId)}`, {
44
+ const res = await apiFetch(`${serverUrl}/api/env-state/${encodeURIComponent(shardId)}`, {
42
45
  method: 'PUT',
43
46
  headers,
44
47
  body: JSON.stringify(state),
48
+ credentials: 'omit',
45
49
  });
46
50
  if (!res.ok) {
47
51
  const body = await res.json().catch(() => ({}));
@@ -73,10 +77,11 @@ export async function serverInstallPackage(manifest, clientBundle, serverBundle)
73
77
  const headers = {};
74
78
  if (auth)
75
79
  headers['Authorization'] = auth;
76
- const res = await fetch(`${serverUrl}/api/packages/install`, {
80
+ const res = await apiFetch(`${serverUrl}/api/packages/install`, {
77
81
  method: 'POST',
78
82
  headers,
79
83
  body: form,
84
+ credentials: 'omit',
80
85
  });
81
86
  if (!res.ok) {
82
87
  let body = {};
@@ -104,10 +109,11 @@ export async function serverUninstallPackage(id) {
104
109
  const headers = { 'Content-Type': 'application/json' };
105
110
  if (auth)
106
111
  headers['Authorization'] = auth;
107
- const res = await fetch(`${serverUrl}/api/packages/uninstall`, {
112
+ const res = await apiFetch(`${serverUrl}/api/packages/uninstall`, {
108
113
  method: 'POST',
109
114
  headers,
110
115
  body: JSON.stringify({ id }),
116
+ credentials: 'omit',
111
117
  });
112
118
  if (!res.ok) {
113
119
  const body = await res.json().catch(() => ({}));
@@ -118,7 +124,7 @@ export async function serverUninstallPackage(id) {
118
124
  * Fetch the list of packages installed on the server.
119
125
  */
120
126
  export async function fetchServerPackages() {
121
- const res = await fetch(`${serverUrl}/api/packages`);
127
+ const res = await apiFetch(`${serverUrl}/api/packages`, { credentials: 'omit' });
122
128
  if (!res.ok)
123
129
  return [];
124
130
  return await res.json();