sh3-core 0.8.0 → 0.8.2

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 (78) hide show
  1. package/dist/Shell.svelte +19 -0
  2. package/dist/api.d.ts +5 -1
  3. package/dist/api.js +6 -1
  4. package/dist/app/admin/ApiKeysView.svelte +16 -27
  5. package/dist/app/admin/SystemView.svelte +149 -11
  6. package/dist/documents/backends.d.ts +8 -0
  7. package/dist/documents/backends.js +87 -0
  8. package/dist/documents/backends.test.d.ts +1 -0
  9. package/dist/documents/backends.test.js +33 -0
  10. package/dist/documents/browse.d.ts +12 -0
  11. package/dist/documents/browse.js +19 -0
  12. package/dist/documents/browse.test.d.ts +1 -0
  13. package/dist/documents/browse.test.js +41 -0
  14. package/dist/documents/http-backend.d.ts +4 -0
  15. package/dist/documents/http-backend.js +14 -0
  16. package/dist/documents/sync/index.d.ts +1 -2
  17. package/dist/documents/sync/index.js +0 -2
  18. package/dist/documents/sync/observer.d.ts +3 -0
  19. package/dist/documents/sync/observer.js +45 -0
  20. package/dist/documents/sync/registry.d.ts +3 -0
  21. package/dist/documents/sync/registry.js +8 -1
  22. package/dist/documents/sync/registry.test.js +11 -0
  23. package/dist/documents/types.d.ts +18 -0
  24. package/dist/documents/types.js +6 -1
  25. package/dist/keys/ConsentDialog.svelte +176 -0
  26. package/dist/keys/ConsentDialog.svelte.d.ts +3 -0
  27. package/dist/keys/client.d.ts +13 -0
  28. package/dist/keys/client.js +65 -0
  29. package/dist/keys/client.test.d.ts +1 -0
  30. package/dist/keys/client.test.js +44 -0
  31. package/dist/keys/consent.svelte.d.ts +16 -0
  32. package/dist/keys/consent.svelte.js +29 -0
  33. package/dist/keys/consent.test.d.ts +1 -0
  34. package/dist/keys/consent.test.js +53 -0
  35. package/dist/keys/revocation-bus.svelte.d.ts +35 -0
  36. package/dist/keys/revocation-bus.svelte.js +92 -0
  37. package/dist/keys/revocation-bus.test.d.ts +1 -0
  38. package/dist/keys/revocation-bus.test.js +95 -0
  39. package/dist/keys/types.d.ts +32 -0
  40. package/dist/keys/types.js +13 -0
  41. package/dist/layout/inspection.d.ts +17 -0
  42. package/dist/layout/inspection.js +53 -0
  43. package/dist/server-shard/types.d.ts +21 -2
  44. package/dist/server-sync.d.ts +6 -0
  45. package/dist/server-sync.js +634 -0
  46. package/dist/server-sync.js.map +7 -0
  47. package/dist/sh3core-shard/ShellHome.svelte +140 -63
  48. package/dist/sh3core-shard/sh3coreShard.svelte.js +12 -1
  49. package/dist/shards/activate-browse.test.d.ts +1 -0
  50. package/dist/shards/activate-browse.test.js +36 -0
  51. package/dist/shards/activate-on-key-revoked.test.d.ts +1 -0
  52. package/dist/shards/activate-on-key-revoked.test.js +60 -0
  53. package/dist/shards/activate-sync-registry.test.d.ts +1 -0
  54. package/dist/shards/activate-sync-registry.test.js +42 -0
  55. package/dist/shards/activate-tenantid.test.d.ts +1 -0
  56. package/dist/shards/activate-tenantid.test.js +21 -0
  57. package/dist/shards/activate.svelte.d.ts +12 -0
  58. package/dist/shards/activate.svelte.js +55 -3
  59. package/dist/shards/types.d.ts +42 -0
  60. package/dist/shards/types.js +1 -1
  61. package/dist/shell/views/KeysAndPeers.svelte +110 -0
  62. package/dist/shell/views/KeysAndPeers.svelte.d.ts +3 -0
  63. package/dist/shell-shard/Terminal.svelte +0 -11
  64. package/dist/shell-shard/manifest.js +1 -1
  65. package/dist/shell-shard/shellShard.svelte.js +52 -4
  66. package/dist/shell-shard/toolbar/Toolbar.svelte +11 -32
  67. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +0 -2
  68. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +29 -62
  69. package/dist/shell-shard/verbs/index.js +3 -1
  70. package/dist/shell-shard/verbs/views.d.ts +2 -0
  71. package/dist/shell-shard/verbs/views.js +103 -2
  72. package/dist/testing.d.ts +3 -0
  73. package/dist/testing.js +77 -0
  74. package/dist/testing.js.map +7 -0
  75. package/dist/verbs/types.d.ts +19 -0
  76. package/dist/version.d.ts +1 -1
  77. package/dist/version.js +1 -1
  78. package/package.json +10 -2
package/dist/Shell.svelte CHANGED
@@ -25,6 +25,8 @@
25
25
  import { isAuthenticated, isLocalOwner, getUser, logout } from './auth/index';
26
26
  import iconsUrl from './assets/icons.svg';
27
27
  import GuestBanner from './auth/GuestBanner.svelte';
28
+ import ConsentDialog from './keys/ConsentDialog.svelte';
29
+ import { startServerSideStream } from './keys/revocation-bus.svelte';
28
30
 
29
31
  const authenticated = $derived(isAuthenticated());
30
32
  const user = $derived(getUser());
@@ -97,6 +99,15 @@
97
99
  }));
98
100
  return () => unbindFloatStore();
99
101
  });
102
+
103
+ // Open the server-sent events stream for key revocations.
104
+ // Forwards server-side revocations to the local revocation bus so that
105
+ // onKeyRevoked fires on the owning shard even when the user revokes from
106
+ // the Keys & Peers shell view (not via the shard's own ctx.keys.revoke).
107
+ $effect(() => {
108
+ const stop = startServerSideStream();
109
+ return stop;
110
+ });
100
111
  </script>
101
112
 
102
113
  <div class="shell">
@@ -165,6 +176,14 @@
165
176
  </div>
166
177
  {/each}
167
178
  </div>
179
+
180
+ <!--
181
+ Shell-owned consent dialog for ctx.keys.mint().
182
+ Mounted unconditionally so the listener is always registered; it renders
183
+ nothing until a shard calls ctx.keys.mint().
184
+ z-index 9999 (inline in the component) keeps it above all overlay layers.
185
+ -->
186
+ <ConsentDialog />
168
187
  </div>
169
188
 
170
189
  <style>
package/dist/api.d.ts CHANGED
@@ -17,9 +17,10 @@ export { listRegisteredApps, getActiveApp } from './apps/registry.svelte';
17
17
  export { launchApp, returnToHome, unregisterApp } from './apps/lifecycle';
18
18
  export { inspectActiveLayout, spliceIntoActiveLayout, dockIntoActiveLayout, focusTab, focusView, collapseChild, expandChild, closeTab, } from './layout/inspection';
19
19
  export type { DocumentHandle, DocumentHandleOptions, DocumentFormat, DocumentMeta, DocumentChange, AutosaveController, } from './documents/types';
20
+ export { PERMISSION_DOCUMENTS_BROWSE } from './documents/types';
21
+ export type { BrowseCapability } from './documents/browse';
20
22
  export type { SyncHandle, SyncScope, ManifestEntry, ApplyEntry, ApplyOpts, ApplyOutcome, ApplyBatchResult, ConflictPolicy, ConflictResolution, ConflictContext, JournalEntry, ChangePage, GrantRecord, } from './documents/sync/types';
21
23
  export { PERMISSION_DOCUMENTS_SYNC, ScopeNotGrantedError, ScopeRevokedError, TenantMismatchError, } from './documents/sync/types';
22
- export { createSyncRegistry } from './documents/sync/registry';
23
24
  export type { SyncRegistry } from './documents/sync/registry';
24
25
  export { default as SyncGrantPicker } from './documents/sync/components/SyncGrantPicker.svelte';
25
26
  export { default as DocumentSyncExplorer } from './documents/sync/components/DocumentSyncExplorer.svelte';
@@ -28,6 +29,9 @@ export type { RegistryIndex, PackageEntry, PackageVersion, RequiredDependency, I
28
29
  export type { ResolvedPackage } from './registry/client';
29
30
  export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
30
31
  export { validateRegistryIndex } from './registry/schema';
32
+ export { PERMISSION_KEYS_MINT, ScopeEscalationError, ConsentDeniedError, type ShardContextKeys, type ApiKeyPublic, type MintOpts, } from './keys/types';
33
+ export { registerConsentListener, resolveConsent, type ConsentRequest } from './keys/consent.svelte';
34
+ export { subscribe as subscribeKeyRevocation } from './keys/revocation-bus.svelte';
31
35
  export { isAdmin, isAuthenticated, isGuest, getUser, getAuthHeader } from './auth/index';
32
36
  export type { AuthUser, AuthSession, BootConfig } from './auth/types';
33
37
  /** Runtime feature flags for target-dependent behavior. */
package/dist/api.js CHANGED
@@ -29,8 +29,8 @@ export { listRegisteredApps, getActiveApp } from './apps/registry.svelte';
29
29
  export { launchApp, returnToHome, unregisterApp } from './apps/lifecycle';
30
30
  // Layout inspection / mutation for advanced shards (diagnostic, etc.).
31
31
  export { inspectActiveLayout, spliceIntoActiveLayout, dockIntoActiveLayout, focusTab, focusView, collapseChild, expandChild, closeTab, } from './layout/inspection';
32
+ export { PERMISSION_DOCUMENTS_BROWSE } from './documents/types';
32
33
  export { PERMISSION_DOCUMENTS_SYNC, ScopeNotGrantedError, ScopeRevokedError, TenantMismatchError, } from './documents/sync/types';
33
- export { createSyncRegistry } from './documents/sync/registry';
34
34
  export { default as SyncGrantPicker } from './documents/sync/components/SyncGrantPicker.svelte';
35
35
  export { default as DocumentSyncExplorer } from './documents/sync/components/DocumentSyncExplorer.svelte';
36
36
  // Shard introspection — read-only reactive maps exposing which shards are
@@ -41,6 +41,11 @@ export { default as DocumentSyncExplorer } from './documents/sync/components/Doc
41
41
  export { registeredShards, activeShards } from './shards/activate.svelte';
42
42
  export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
43
43
  export { validateRegistryIndex } from './registry/schema';
44
+ // Key mint/revoke types — client shards that declare `keys:mint` get ctx.keys.
45
+ export { PERMISSION_KEYS_MINT, ScopeEscalationError, ConsentDeniedError, } from './keys/types';
46
+ export { registerConsentListener, resolveConsent } from './keys/consent.svelte';
47
+ // Revocation bus — subscribe to key revocation events (for advanced integrations).
48
+ export { subscribe as subscribeKeyRevocation } from './keys/revocation-bus.svelte';
44
49
  // Admin mode (framework-internal components read admin status).
45
50
  export { isAdmin, isAuthenticated, isGuest, getUser, getAuthHeader } from './auth/index';
46
51
  /** Runtime feature flags for target-dependent behavior. */
@@ -3,14 +3,13 @@
3
3
  * Admin API Keys view — list, create, reveal, revoke API keys.
4
4
  */
5
5
 
6
- interface ApiKey {
6
+ interface ApiKeyPublic {
7
7
  id: string;
8
- key: string;
9
8
  label: string;
10
9
  createdAt: string;
11
10
  }
12
11
 
13
- let keys = $state<ApiKey[]>([]);
12
+ let keys = $state<ApiKeyPublic[]>([]);
14
13
  let loading = $state(true);
15
14
  let error = $state<string | null>(null);
16
15
 
@@ -19,8 +18,9 @@
19
18
  let newLabel = $state('');
20
19
  let createError = $state<string | null>(null);
21
20
 
22
- // Reveal statetracks which key ids have been revealed
23
- let revealed = $state<Set<string>>(new Set());
21
+ // Just-created keythe raw bearer value is returned once by the server.
22
+ // Displayed until the admin dismisses it, then never recoverable.
23
+ let justCreated = $state<{ id: string; key: string } | null>(null);
24
24
 
25
25
  // Delete confirmation
26
26
  let confirmingId = $state<string | null>(null);
@@ -53,9 +53,8 @@
53
53
  createError = body.error || 'Failed to create key';
54
54
  return;
55
55
  }
56
- const created: ApiKey = await res.json();
57
- revealed.add(created.id);
58
- revealed = new Set(revealed);
56
+ const created: { id: string; key: string } = await res.json();
57
+ justCreated = { id: created.id, key: created.key };
59
58
  newLabel = '';
60
59
  showCreate = false;
61
60
  await fetchKeys();
@@ -64,15 +63,6 @@
64
63
  }
65
64
  }
66
65
 
67
- function toggleReveal(id: string) {
68
- if (revealed.has(id)) {
69
- revealed.delete(id);
70
- } else {
71
- revealed.add(id);
72
- }
73
- revealed = new Set(revealed);
74
- }
75
-
76
66
  async function revokeKey(id: string) {
77
67
  confirmingId = null;
78
68
  try {
@@ -80,8 +70,7 @@
80
70
  method: 'DELETE',
81
71
  credentials: 'include',
82
72
  });
83
- revealed.delete(id);
84
- revealed = new Set(revealed);
73
+ if (justCreated?.id === id) justCreated = null;
85
74
  await fetchKeys();
86
75
  } catch { /* ignore */ }
87
76
  }
@@ -92,10 +81,6 @@
92
81
  });
93
82
  }
94
83
 
95
- function maskKey(key: string): string {
96
- return key.slice(0, 8) + '\u2026' + key.slice(-4);
97
- }
98
-
99
84
  fetchKeys();
100
85
  </script>
101
86
 
@@ -107,6 +92,14 @@
107
92
  </button>
108
93
  </div>
109
94
 
95
+ {#if justCreated}
96
+ <div class="admin-key-created">
97
+ <div class="admin-key-created-label">New key — copy now, it won't be shown again:</div>
98
+ <code class="admin-key-value">{justCreated.key}</code>
99
+ <button type="button" class="admin-btn-secondary" onclick={() => { justCreated = null; }}>Dismiss</button>
100
+ </div>
101
+ {/if}
102
+
110
103
  {#if showCreate}
111
104
  <form class="admin-create-form" onsubmit={(e) => { e.preventDefault(); createKey(); }}>
112
105
  <input class="admin-input" type="text" placeholder="Key label" bind:value={newLabel} />
@@ -128,12 +121,8 @@
128
121
  <div class="admin-key-info">
129
122
  <span class="admin-key-label">{k.label}</span>
130
123
  <span class="admin-key-meta">{k.id} &middot; {formatDate(k.createdAt)}</span>
131
- <code class="admin-key-value">{revealed.has(k.id) ? k.key : maskKey(k.key)}</code>
132
124
  </div>
133
125
  <div class="admin-key-actions">
134
- <button type="button" class="admin-btn-secondary" onclick={() => toggleReveal(k.id)}>
135
- {revealed.has(k.id) ? 'Hide' : 'Reveal'}
136
- </button>
137
126
  {#if confirmingId === k.id}
138
127
  <button type="button" class="admin-btn-danger" onclick={() => revokeKey(k.id)}>Confirm</button>
139
128
  <button type="button" class="admin-btn-secondary" onclick={() => { confirmingId = null; }}>Cancel</button>
@@ -1,12 +1,38 @@
1
1
  <script lang="ts">
2
2
  /**
3
- * Admin System view — server status and restart.
3
+ * Admin System view — server status, restart, and package-bundle cache policy.
4
4
  */
5
5
 
6
+ const SNAP_POINTS: Array<{ value: number; label: string }> = [
7
+ { value: 0, label: 'Off (no-store)' },
8
+ { value: 5, label: '5s (dev)' },
9
+ { value: 60, label: '1 min' },
10
+ { value: 3600, label: '1 hour' },
11
+ { value: 86400, label: '1 day' },
12
+ { value: 31536000, label: '1 year' },
13
+ ];
14
+
6
15
  let version = $state('...');
7
16
  let restarting = $state(false);
8
17
  let restartError = $state<string | null>(null);
9
18
 
19
+ let cacheMaxAge = $state(31536000);
20
+ let loadedMaxAge = $state(31536000);
21
+ let savingCache = $state(false);
22
+ let cacheError = $state<string | null>(null);
23
+
24
+ const dirty = $derived(cacheMaxAge !== loadedMaxAge);
25
+ const humanized = $derived(humanize(cacheMaxAge));
26
+
27
+ function humanize(sec: number): string {
28
+ if (sec === 0) return '0 seconds (off — browsers never cache)';
29
+ if (sec < 60) return `${sec} seconds`;
30
+ if (sec < 3600) return `${Math.round(sec / 60)} minutes`;
31
+ if (sec < 86400) return `${Math.round(sec / 3600)} hours`;
32
+ if (sec < 31536000) return `${Math.round(sec / 86400)} days`;
33
+ return `${Math.round(sec / 31536000)} years`;
34
+ }
35
+
10
36
  async function fetchVersion() {
11
37
  try {
12
38
  const res = await fetch('/api/version');
@@ -17,6 +43,43 @@
17
43
  } catch { /* ignore */ }
18
44
  }
19
45
 
46
+ async function fetchSettings() {
47
+ try {
48
+ const res = await fetch('/api/admin/settings', { credentials: 'include' });
49
+ if (res.ok) {
50
+ const body = await res.json();
51
+ const age = body.packages?.cacheMaxAge ?? 31536000;
52
+ cacheMaxAge = age;
53
+ loadedMaxAge = age;
54
+ }
55
+ } catch { /* ignore */ }
56
+ }
57
+
58
+ async function saveCache() {
59
+ savingCache = true;
60
+ cacheError = null;
61
+ try {
62
+ const res = await fetch('/api/admin/settings', {
63
+ method: 'PUT',
64
+ credentials: 'include',
65
+ headers: { 'Content-Type': 'application/json' },
66
+ body: JSON.stringify({ packages: { cacheMaxAge } }),
67
+ });
68
+ if (!res.ok) {
69
+ const body = await res.json().catch(() => ({}));
70
+ cacheError = body.error || 'Save failed';
71
+ } else {
72
+ const body = await res.json();
73
+ loadedMaxAge = body.packages?.cacheMaxAge ?? cacheMaxAge;
74
+ cacheMaxAge = loadedMaxAge;
75
+ }
76
+ } catch {
77
+ cacheError = 'Network error';
78
+ } finally {
79
+ savingCache = false;
80
+ }
81
+ }
82
+
20
83
  async function restart() {
21
84
  restarting = true;
22
85
  restartError = null;
@@ -38,6 +101,7 @@
38
101
  }
39
102
 
40
103
  fetchVersion();
104
+ fetchSettings();
41
105
  </script>
42
106
 
43
107
  <div class="admin-system">
@@ -50,24 +114,98 @@
50
114
  </div>
51
115
  </div>
52
116
 
53
- <div class="admin-system-actions">
54
- <button type="button" class="admin-btn-danger" onclick={restart} disabled={restarting}>
55
- {restarting ? 'Restarting...' : 'Restart server'}
56
- </button>
57
- {#if restartError}
58
- <div class="admin-error">{restartError}</div>
59
- {/if}
60
- </div>
117
+ <section class="admin-system-section">
118
+ <h3>Package bundle cache</h3>
119
+ <p class="admin-system-hint">
120
+ Controls the <code>Cache-Control</code> header on <code>/packages/:id/client.js</code>.
121
+ Set to <strong>0</strong> during development so drop-in bundle replacements take effect on F5.
122
+ Use a long value in production.
123
+ </p>
124
+
125
+ <input
126
+ type="range"
127
+ min="0"
128
+ max="31536000"
129
+ step="1"
130
+ bind:value={cacheMaxAge}
131
+ disabled={savingCache}
132
+ />
133
+ <div class="admin-system-readout">
134
+ <code>{cacheMaxAge}</code> — {humanized}
135
+ </div>
136
+
137
+ <div class="admin-system-snaps">
138
+ {#each SNAP_POINTS as snap (snap.value)}
139
+ <button
140
+ type="button"
141
+ class="admin-snap"
142
+ class:active={cacheMaxAge === snap.value}
143
+ onclick={() => (cacheMaxAge = snap.value)}
144
+ disabled={savingCache}
145
+ >
146
+ {snap.label}
147
+ </button>
148
+ {/each}
149
+ </div>
150
+
151
+ <div class="admin-system-actions">
152
+ <button
153
+ type="button"
154
+ class="admin-btn"
155
+ onclick={saveCache}
156
+ disabled={!dirty || savingCache}
157
+ >
158
+ {savingCache ? 'Saving...' : 'Save'}
159
+ </button>
160
+ <button
161
+ type="button"
162
+ class="admin-btn-ghost"
163
+ onclick={() => (cacheMaxAge = loadedMaxAge)}
164
+ disabled={!dirty || savingCache}
165
+ >
166
+ Reset
167
+ </button>
168
+ {#if cacheError}
169
+ <div class="admin-error">{cacheError}</div>
170
+ {/if}
171
+ </div>
172
+ </section>
173
+
174
+ <section class="admin-system-section">
175
+ <h3>Server control</h3>
176
+ <div class="admin-system-actions">
177
+ <button type="button" class="admin-btn-danger" onclick={restart} disabled={restarting}>
178
+ {restarting ? 'Restarting...' : 'Restart server'}
179
+ </button>
180
+ {#if restartError}
181
+ <div class="admin-error">{restartError}</div>
182
+ {/if}
183
+ </div>
184
+ </section>
61
185
  </div>
62
186
 
63
187
  <style>
64
188
  .admin-system { padding: 24px; font-family: system-ui, sans-serif; color: var(--shell-fg); }
65
189
  .admin-system h2 { margin: 0 0 16px; font-size: 18px; }
190
+ .admin-system h3 { margin: 0 0 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--shell-fg-subtle); }
66
191
  .admin-system-info { margin-bottom: 24px; }
67
192
  .admin-system-row { display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--shell-border, #3a3a5c); font-size: 13px; }
68
193
  .admin-system-label { color: var(--shell-fg-subtle); min-width: 140px; }
69
- .admin-system-actions { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
70
- .admin-btn-danger { padding: 8px 16px; background: transparent; color: var(--shell-error, #d32f2f); border: 1px solid var(--shell-error, #d32f2f); font-weight: 600; }
194
+ .admin-system-section { margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--shell-border, #3a3a5c); }
195
+ .admin-system-section:last-child { border-bottom: none; }
196
+ .admin-system-hint { font-size: 13px; color: var(--shell-fg-subtle); margin: 0 0 12px; }
197
+ .admin-system-readout { font-size: 13px; margin: 8px 0 12px; }
198
+ .admin-system-snaps { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
199
+ .admin-snap { padding: 4px 10px; font-size: 12px; background: transparent; border: 1px solid var(--shell-border, #3a3a5c); color: var(--shell-fg); cursor: pointer; border-radius: var(--shell-radius-sm, 3px); }
200
+ .admin-snap.active { background: var(--shell-accent, #4a7bd4); color: var(--shell-bg); border-color: var(--shell-accent, #4a7bd4); }
201
+ .admin-snap:disabled { opacity: 0.5; cursor: not-allowed; }
202
+ .admin-system-actions { display: flex; flex-direction: row; gap: 8px; align-items: center; flex-wrap: wrap; }
203
+ .admin-btn { padding: 8px 16px; background: var(--shell-accent, #4a7bd4); color: var(--shell-bg); border: 1px solid var(--shell-accent, #4a7bd4); font-weight: 600; cursor: pointer; border-radius: var(--shell-radius-sm, 3px); }
204
+ .admin-btn:disabled { opacity: 0.5; cursor: not-allowed; }
205
+ .admin-btn-ghost { padding: 8px 16px; background: transparent; color: var(--shell-fg); border: 1px solid var(--shell-border, #3a3a5c); cursor: pointer; border-radius: var(--shell-radius-sm, 3px); }
206
+ .admin-btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
207
+ .admin-btn-danger { padding: 8px 16px; background: transparent; color: var(--shell-error, #d32f2f); border: 1px solid var(--shell-error, #d32f2f); font-weight: 600; cursor: pointer; border-radius: var(--shell-radius-sm, 3px); }
71
208
  .admin-btn-danger:disabled { opacity: 0.6; cursor: not-allowed; }
72
209
  .admin-error { color: var(--shell-error, #d32f2f); font-size: 13px; }
210
+ input[type="range"] { width: 100%; max-width: 480px; }
73
211
  </style>
@@ -6,6 +6,10 @@ export declare class MemoryDocumentBackend implements DocumentBackend {
6
6
  delete(tenantId: string, shardId: string, path: string): Promise<void>;
7
7
  list(tenantId: string, shardId: string): Promise<DocumentMeta[]>;
8
8
  exists(tenantId: string, shardId: string, path: string): Promise<boolean>;
9
+ listAllShards(tenantId: string): Promise<string[]>;
10
+ listAllDocuments(tenantId: string): Promise<Array<DocumentMeta & {
11
+ shardId: string;
12
+ }>>;
9
13
  }
10
14
  export declare class IndexedDBDocumentBackend implements DocumentBackend {
11
15
  #private;
@@ -14,4 +18,8 @@ export declare class IndexedDBDocumentBackend implements DocumentBackend {
14
18
  delete(tenantId: string, shardId: string, path: string): Promise<void>;
15
19
  list(tenantId: string, shardId: string): Promise<DocumentMeta[]>;
16
20
  exists(tenantId: string, shardId: string, path: string): Promise<boolean>;
21
+ listAllShards(tenantId: string): Promise<string[]>;
22
+ listAllDocuments(tenantId: string): Promise<Array<DocumentMeta & {
23
+ shardId: string;
24
+ }>>;
17
25
  }
@@ -62,6 +62,36 @@ export class MemoryDocumentBackend {
62
62
  async exists(tenantId, shardId, path) {
63
63
  return __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").has(compositeKey(tenantId, shardId, path));
64
64
  }
65
+ async listAllShards(tenantId) {
66
+ const prefix = `${tenantId}/`;
67
+ const shards = new Set();
68
+ for (const key of __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").keys()) {
69
+ if (!key.startsWith(prefix))
70
+ continue;
71
+ const rest = key.slice(prefix.length);
72
+ const slash = rest.indexOf('/');
73
+ if (slash < 0)
74
+ continue;
75
+ shards.add(rest.slice(0, slash));
76
+ }
77
+ return [...shards];
78
+ }
79
+ async listAllDocuments(tenantId) {
80
+ const prefix = `${tenantId}/`;
81
+ const out = [];
82
+ for (const [key, entry] of __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f")) {
83
+ if (!key.startsWith(prefix))
84
+ continue;
85
+ const rest = key.slice(prefix.length);
86
+ const slash = rest.indexOf('/');
87
+ if (slash < 0)
88
+ continue;
89
+ const shardId = rest.slice(0, slash);
90
+ const path = rest.slice(slash + 1);
91
+ out.push({ shardId, path, size: entry.size, lastModified: entry.lastModified });
92
+ }
93
+ return out;
94
+ }
65
95
  }
66
96
  _MemoryDocumentBackend_store = new WeakMap();
67
97
  // ---------------------------------------------------------------------------
@@ -126,6 +156,63 @@ export class IndexedDBDocumentBackend {
126
156
  const result = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_tx).call(this, 'readonly', (s) => s.getKey(key));
127
157
  return result !== undefined;
128
158
  }
159
+ async listAllShards(tenantId) {
160
+ const prefix = `${tenantId}/`;
161
+ const db = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_db).call(this);
162
+ return new Promise((resolve, reject) => {
163
+ const tx = db.transaction(IDB_STORE, 'readonly');
164
+ const store = tx.objectStore(IDB_STORE);
165
+ const range = IDBKeyRange.bound(prefix, prefix + '\uffff', false, false);
166
+ const req = store.openKeyCursor(range);
167
+ const shards = new Set();
168
+ req.onsuccess = () => {
169
+ const cursor = req.result;
170
+ if (cursor) {
171
+ const rest = cursor.key.slice(prefix.length);
172
+ const slash = rest.indexOf('/');
173
+ if (slash >= 0)
174
+ shards.add(rest.slice(0, slash));
175
+ cursor.continue();
176
+ }
177
+ else {
178
+ resolve([...shards]);
179
+ }
180
+ };
181
+ req.onerror = () => reject(req.error);
182
+ });
183
+ }
184
+ async listAllDocuments(tenantId) {
185
+ const prefix = `${tenantId}/`;
186
+ const db = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_db).call(this);
187
+ return new Promise((resolve, reject) => {
188
+ const tx = db.transaction(IDB_STORE, 'readonly');
189
+ const store = tx.objectStore(IDB_STORE);
190
+ const range = IDBKeyRange.bound(prefix, prefix + '\uffff', false, false);
191
+ const req = store.openCursor(range);
192
+ const out = [];
193
+ req.onsuccess = () => {
194
+ const cursor = req.result;
195
+ if (cursor) {
196
+ const rest = cursor.key.slice(prefix.length);
197
+ const slash = rest.indexOf('/');
198
+ if (slash >= 0) {
199
+ const entry = cursor.value;
200
+ out.push({
201
+ shardId: rest.slice(0, slash),
202
+ path: rest.slice(slash + 1),
203
+ size: entry.size,
204
+ lastModified: entry.lastModified,
205
+ });
206
+ }
207
+ cursor.continue();
208
+ }
209
+ else {
210
+ resolve(out);
211
+ }
212
+ };
213
+ req.onerror = () => reject(req.error);
214
+ });
215
+ }
129
216
  }
130
217
  _IndexedDBDocumentBackend_dbPromise = new WeakMap(), _IndexedDBDocumentBackend_instances = new WeakSet(), _IndexedDBDocumentBackend_db = function _IndexedDBDocumentBackend_db() {
131
218
  if (!__classPrivateFieldGet(this, _IndexedDBDocumentBackend_dbPromise, "f")) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { MemoryDocumentBackend } from './backends';
3
+ describe('DocumentBackend tenant-wide primitives', () => {
4
+ it('listAllShards returns every shard that has content for a tenant', async () => {
5
+ const be = new MemoryDocumentBackend();
6
+ await be.write('t1', 'shard-a', 'x.txt', 'a');
7
+ await be.write('t1', 'shard-b', 'y.txt', 'b');
8
+ await be.write('t1', 'shard-b', 'nested/z.txt', 'bb');
9
+ await be.write('t2', 'shard-c', 'z.txt', 'c');
10
+ const shards = await be.listAllShards('t1');
11
+ expect(shards.sort()).toEqual(['shard-a', 'shard-b']);
12
+ const other = await be.listAllShards('t2');
13
+ expect(other).toEqual(['shard-c']);
14
+ });
15
+ it('listAllDocuments returns docs with shardId attached across the tenant', async () => {
16
+ const be = new MemoryDocumentBackend();
17
+ await be.write('t1', 'shard-a', 'x.txt', 'a');
18
+ await be.write('t1', 'shard-b', 'nested/y.txt', 'bb');
19
+ await be.write('t2', 'shard-c', 'z.txt', 'c');
20
+ const docs = await be.listAllDocuments('t1');
21
+ expect(docs).toHaveLength(2);
22
+ expect(docs.map((d) => `${d.shardId}/${d.path}`).sort()).toEqual([
23
+ 'shard-a/x.txt',
24
+ 'shard-b/nested/y.txt',
25
+ ]);
26
+ });
27
+ it('listAllDocuments returns an empty array for an unknown tenant', async () => {
28
+ const be = new MemoryDocumentBackend();
29
+ await be.write('t1', 'shard-a', 'x.txt', 'a');
30
+ expect(await be.listAllDocuments('ghost')).toEqual([]);
31
+ expect(await be.listAllShards('ghost')).toEqual([]);
32
+ });
33
+ });
@@ -0,0 +1,12 @@
1
+ import type { DocumentBackend, DocumentChange, DocumentMeta } from './types';
2
+ export interface BrowseCapability {
3
+ /** Every document in the tenant across all shards, each tagged with its owning shardId. */
4
+ listDocuments(): Promise<Array<DocumentMeta & {
5
+ shardId: string;
6
+ }>>;
7
+ /** Subscribe to tenant-wide document changes. Returns an unsubscribe. */
8
+ watchDocuments(callback: (change: DocumentChange) => void): () => void;
9
+ /** Enumerate shard ids with at least one document in the tenant. */
10
+ listShards(): Promise<string[]>;
11
+ }
12
+ export declare function createBrowseCapability(tenantId: string, backend: DocumentBackend): BrowseCapability;
@@ -0,0 +1,19 @@
1
+ /*
2
+ * BrowseCapability — tenant-wide document observation surface.
3
+ *
4
+ * Exposed on ShardContext as `ctx.browse` when the shard declares the
5
+ * 'documents:browse' permission. Read-only: writes still flow through
6
+ * the owning shard's own ctx.documents() handle.
7
+ */
8
+ import { documentChanges } from './notifications';
9
+ export function createBrowseCapability(tenantId, backend) {
10
+ return {
11
+ listDocuments: () => backend.listAllDocuments(tenantId),
12
+ listShards: () => backend.listAllShards(tenantId),
13
+ watchDocuments: (callback) => documentChanges.subscribe((change) => {
14
+ if (change.tenantId !== tenantId)
15
+ return;
16
+ callback(change);
17
+ }),
18
+ };
19
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { MemoryDocumentBackend } from './backends';
3
+ import { createBrowseCapability } from './browse';
4
+ import { documentChanges } from './notifications';
5
+ describe('BrowseCapability', () => {
6
+ it('lists documents tenant-wide with shardId attached', async () => {
7
+ const be = new MemoryDocumentBackend();
8
+ await be.write('t1', 'a', 'x.txt', '1');
9
+ await be.write('t1', 'b', 'y.txt', '22');
10
+ const browse = createBrowseCapability('t1', be);
11
+ const docs = await browse.listDocuments();
12
+ expect(docs.map((d) => d.shardId).sort()).toEqual(['a', 'b']);
13
+ });
14
+ it('listShards enumerates tenant shards', async () => {
15
+ const be = new MemoryDocumentBackend();
16
+ await be.write('t1', 'a', 'x.txt', '1');
17
+ await be.write('t1', 'b', 'y.txt', '2');
18
+ const browse = createBrowseCapability('t1', be);
19
+ expect((await browse.listShards()).sort()).toEqual(['a', 'b']);
20
+ });
21
+ it('watchDocuments fires with shardId on tenant-wide emits; filters other tenants', () => {
22
+ const be = new MemoryDocumentBackend();
23
+ const browse = createBrowseCapability('t1', be);
24
+ const cb = vi.fn();
25
+ const unsub = browse.watchDocuments(cb);
26
+ documentChanges.emit({ type: 'create', path: 'f.txt', tenantId: 't1', shardId: 's1' });
27
+ documentChanges.emit({ type: 'create', path: 'g.txt', tenantId: 't2', shardId: 's2' });
28
+ expect(cb).toHaveBeenCalledTimes(1);
29
+ expect(cb).toHaveBeenCalledWith(expect.objectContaining({ shardId: 's1', path: 'f.txt', tenantId: 't1' }));
30
+ unsub();
31
+ });
32
+ it('watchDocuments unsubscribe stops callbacks', () => {
33
+ const be = new MemoryDocumentBackend();
34
+ const browse = createBrowseCapability('t1', be);
35
+ const cb = vi.fn();
36
+ const unsub = browse.watchDocuments(cb);
37
+ unsub();
38
+ documentChanges.emit({ type: 'create', path: 'f.txt', tenantId: 't1', shardId: 's1' });
39
+ expect(cb).not.toHaveBeenCalled();
40
+ });
41
+ });
@@ -19,4 +19,8 @@ export declare class HttpDocumentBackend implements DocumentBackend {
19
19
  delete(tenantId: string, shardId: string, path: string): Promise<void>;
20
20
  list(tenantId: string, shardId: string): Promise<DocumentMeta[]>;
21
21
  exists(tenantId: string, shardId: string, path: string): Promise<boolean>;
22
+ listAllShards(tenantId: string): Promise<string[]>;
23
+ listAllDocuments(tenantId: string): Promise<Array<DocumentMeta & {
24
+ shardId: string;
25
+ }>>;
22
26
  }
@@ -70,6 +70,20 @@ export class HttpDocumentBackend {
70
70
  const res = await fetch(url, { method: 'HEAD' });
71
71
  return res.ok;
72
72
  }
73
+ async listAllShards(tenantId) {
74
+ const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/_shards`;
75
+ const res = await fetch(url);
76
+ if (!res.ok)
77
+ throw new Error(`listAllShards failed: ${res.status}`);
78
+ return res.json();
79
+ }
80
+ async listAllDocuments(tenantId) {
81
+ const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/_all`;
82
+ const res = await fetch(url);
83
+ if (!res.ok)
84
+ throw new Error(`listAllDocuments failed: ${res.status}`);
85
+ return res.json();
86
+ }
73
87
  }
74
88
  _HttpDocumentBackend_baseUrl = new WeakMap(), _HttpDocumentBackend_apiKey = new WeakMap(), _HttpDocumentBackend_instances = new WeakSet(), _HttpDocumentBackend_authHeaders = function _HttpDocumentBackend_authHeaders() {
75
89
  if (!__classPrivateFieldGet(this, _HttpDocumentBackend_apiKey, "f"))