sh3-core 0.22.5 → 0.24.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.
Files changed (89) hide show
  1. package/dist/Sh3.svelte +4 -4
  2. package/dist/actions/listActive.js +1 -0
  3. package/dist/actions/listActive.test.js +13 -0
  4. package/dist/actions/types.d.ts +12 -0
  5. package/dist/api.d.ts +3 -1
  6. package/dist/api.js +3 -1
  7. package/dist/app/admin/adminApp.js +2 -0
  8. package/dist/app/admin/adminShard.svelte.js +1 -0
  9. package/dist/app/store/StoreView.svelte +1 -1
  10. package/dist/app/store/storeApp.js +3 -1
  11. package/dist/app/store/storeShard.svelte.js +1 -0
  12. package/dist/app-appearance/appearanceShard.svelte.js +1 -0
  13. package/dist/apps/lifecycle.js +22 -10
  14. package/dist/apps/lifecycle.test.js +53 -1
  15. package/dist/apps/types.d.ts +9 -0
  16. package/dist/chrome/CompactChrome.svelte +11 -7
  17. package/dist/chrome/MenuSheet.svelte +19 -6
  18. package/dist/contributions/contextSource.d.ts +48 -0
  19. package/dist/contributions/contextSource.js +21 -0
  20. package/dist/createShell.js +40 -0
  21. package/dist/documents/picker-api.test.js +40 -0
  22. package/dist/documents/picker-primitive.d.ts +37 -8
  23. package/dist/documents/picker-primitive.js +5 -13
  24. package/dist/host.js +30 -7
  25. package/dist/layout/slotHostPool.svelte.d.ts +11 -0
  26. package/dist/layout/slotHostPool.svelte.js +41 -17
  27. package/dist/layout/slotHostPool.test.js +45 -1
  28. package/dist/layouts-shard/layoutsShard.svelte.js +1 -0
  29. package/dist/overlays/OverlayRoots.svelte +15 -4
  30. package/dist/overlays/__test__/OverlayBindHarness.svelte +20 -0
  31. package/dist/overlays/__test__/OverlayBindHarness.svelte.d.ts +3 -0
  32. package/dist/overlays/float-compact-bind.svelte.test.d.ts +1 -0
  33. package/dist/overlays/float-compact-bind.svelte.test.js +51 -0
  34. package/dist/overlays/modal.js +3 -0
  35. package/dist/overlays/modal.test.js +45 -0
  36. package/dist/overlays/types.d.ts +9 -0
  37. package/dist/primitives/widgets/DocumentFilePicker.svelte +9 -7
  38. package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +44 -27
  39. package/dist/primitives/widgets/ShardPicker.svelte +38 -0
  40. package/dist/primitives/widgets/ShardPicker.svelte.d.ts +9 -0
  41. package/dist/primitives/widgets/_DocumentBrowser.svelte +15 -7
  42. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +2 -0
  43. package/dist/projects/scope-gate.d.ts +4 -0
  44. package/dist/projects/scope-gate.js +51 -0
  45. package/dist/projects/scope-gate.test.d.ts +1 -0
  46. package/dist/projects/scope-gate.test.js +92 -0
  47. package/dist/projects-shard/ProjectManage.svelte +42 -2
  48. package/dist/projects-shard/ProjectManage.svelte.test.js +10 -9
  49. package/dist/projects-shard/projectsApi.d.ts +3 -2
  50. package/dist/projects-shard/projectsApi.test.js +1 -1
  51. package/dist/projects-shard/projectsShard.svelte.js +1 -0
  52. package/dist/runtime/runVerb.d.ts +9 -0
  53. package/dist/runtime/runVerb.js +4 -4
  54. package/dist/runtime/runVerb.test.js +29 -0
  55. package/dist/sh3Api/headless.d.ts +7 -0
  56. package/dist/sh3Api/headless.js +3 -1
  57. package/dist/sh3Api/headless.svelte.test.js +42 -0
  58. package/dist/sh3core-shard/Sh3Home.svelte +3 -4
  59. package/dist/sh3core-shard/sh3coreShard.svelte.js +1 -0
  60. package/dist/shards/lifecycle.svelte.d.ts +8 -2
  61. package/dist/shards/lifecycle.svelte.js +65 -7
  62. package/dist/shards/lifecycle.test.js +110 -1
  63. package/dist/shards/types.d.ts +13 -0
  64. package/dist/shell-shard/Terminal.svelte +1 -4
  65. package/dist/shell-shard/Terminal.svelte.d.ts +0 -2
  66. package/dist/shell-shard/dispatch.d.ts +0 -2
  67. package/dist/shell-shard/dispatch.js +0 -2
  68. package/dist/shell-shard/display-cwd.test.js +4 -4
  69. package/dist/shell-shard/manifest.js +1 -0
  70. package/dist/shell-shard/shellShard.svelte.d.ts +1 -1
  71. package/dist/shell-shard/shellShard.svelte.js +9 -4
  72. package/dist/shell-shard/verbs/cat.js +3 -3
  73. package/dist/shell-shard/verbs/cat.test.js +1 -2
  74. package/dist/shell-shard/verbs/ls.js +2 -2
  75. package/dist/shell-shard/verbs/ls.test.js +1 -2
  76. package/dist/shell-shard/verbs/mkdir.js +3 -3
  77. package/dist/shell-shard/verbs/mkdir.test.js +1 -2
  78. package/dist/shell-shard/verbs/mv.js +3 -3
  79. package/dist/shell-shard/verbs/mv.test.js +1 -2
  80. package/dist/shell-shard/verbs/rm.js +3 -3
  81. package/dist/shell-shard/verbs/rm.test.js +1 -2
  82. package/dist/shell-shard/verbs/xfer.js +5 -5
  83. package/dist/shell-shard/verbs/xfer.test.js +2 -2
  84. package/dist/transport/apiFetch.js +21 -3
  85. package/dist/transport/apiFetch.test.js +63 -0
  86. package/dist/verbs/types.d.ts +10 -2
  87. package/dist/version.d.ts +1 -1
  88. package/dist/version.js +1 -1
  89. package/package.json +1 -1
@@ -0,0 +1,9 @@
1
+ import type { CommitOnlyEvents } from './_contract';
2
+ type $$ComponentProps = {
3
+ value?: string[];
4
+ disabled?: boolean;
5
+ size?: 'sm' | 'md';
6
+ } & CommitOnlyEvents<string[]>;
7
+ declare const ShardPicker: import("svelte").Component<$$ComponentProps, {}, "value">;
8
+ type ShardPicker = ReturnType<typeof ShardPicker>;
9
+ export default ShardPicker;
@@ -26,6 +26,8 @@
26
26
  listFolders,
27
27
  handle,
28
28
  readOnlyShard,
29
+ initialShardId = null,
30
+ lockToShard = false,
29
31
  }: {
30
32
  mode: 'open' | 'save';
31
33
  docs: DocEntry[];
@@ -43,6 +45,8 @@
43
45
  delete: (shardId: string, path: string) => Promise<void>;
44
46
  };
45
47
  readOnlyShard?: (shardId: string) => boolean;
48
+ initialShardId?: string | null;
49
+ lockToShard?: boolean;
46
50
  } = $props();
47
51
 
48
52
  type Selected =
@@ -50,12 +54,11 @@
50
54
  | { kind: 'folder'; fullPath: string; name: string }
51
55
  | null;
52
56
 
53
- let shardId = $state<string | null>(null);
57
+ let shardId = $state<string | null>(untrack(() => initialShardId));
54
58
  let prefix = $state('');
55
59
  let selected = $state<Selected>(null);
56
60
  let filename = $state(untrack(() => suggestedName));
57
61
  let activeIdx = $state(0);
58
- let listEl = $state<HTMLElement | undefined>(undefined);
59
62
 
60
63
  // Folder state loaded via listFolders
61
64
  let folders = $state<string[]>([]);
@@ -76,7 +79,11 @@
76
79
  });
77
80
 
78
81
  const items = $derived(buildTree(docs, folders, shardId, prefix));
79
- const crumbs = $derived(breadcrumbSegments(shardId, prefix));
82
+ const crumbs = $derived(
83
+ lockToShard
84
+ ? breadcrumbSegments(shardId, prefix).filter((c) => c.level > 0)
85
+ : breadcrumbSegments(shardId, prefix),
86
+ );
80
87
 
81
88
  $effect(() => {
82
89
  items;
@@ -352,7 +359,7 @@
352
359
  parts.pop();
353
360
  prefix = parts.join('/');
354
361
  activeIdx = 0;
355
- } else if (shardId) {
362
+ } else if (shardId && !lockToShard) {
356
363
  e.preventDefault();
357
364
  shardId = null;
358
365
  activeIdx = 0;
@@ -451,11 +458,12 @@
451
458
  </div>
452
459
  {/if}
453
460
 
454
- <div class="sh3-doc-browser__list" bind:this={listEl}>
461
+ <div class="sh3-doc-browser__list">
455
462
  {#if confirmDelete}
456
- {@const childCount = confirmDelete.kind === 'folder'
463
+ {@const folderPath = confirmDelete.kind === 'folder' ? confirmDelete.fullPath : null}
464
+ {@const childCount = folderPath
457
465
  ? docs.filter((d) =>
458
- d.shardId === shardId && d.path.startsWith(confirmDelete!.fullPath + '/'),
466
+ d.shardId === shardId && d.path.startsWith(folderPath + '/'),
459
467
  ).length
460
468
  : 0}
461
469
  <div class="sh3-doc-browser__confirm-overlay">
@@ -18,6 +18,8 @@ type $$ComponentProps = {
18
18
  delete: (shardId: string, path: string) => Promise<void>;
19
19
  };
20
20
  readOnlyShard?: (shardId: string) => boolean;
21
+ initialShardId?: string | null;
22
+ lockToShard?: boolean;
21
23
  };
22
24
  declare const DocumentBrowser: import("svelte").Component<$$ComponentProps, {}, "">;
23
25
  type DocumentBrowser = ReturnType<typeof DocumentBrowser>;
@@ -0,0 +1,4 @@
1
+ import type { App } from '../apps/types';
2
+ import type { ProjectRecord } from '../projects-shard/projectsApi';
3
+ import type { ShardManifest } from '../shards/types';
4
+ export declare function resolveAllowedShardIds(project: ProjectRecord | null, apps: ReadonlyMap<string, App>, shardManifests: readonly ShardManifest[]): Set<string> | null;
@@ -0,0 +1,51 @@
1
+ /*
2
+ * Project-scope register() gate — pure resolution of the shard ids that
3
+ * may register at boot under a given project's allowlist. Mirrors the
4
+ * server-side `project-allowlist` middleware so client and server agree
5
+ * on the closure.
6
+ *
7
+ * Returns `null` (no gating) only when the project is null (personal scope)
8
+ * OR when both `appAllowlist` and `shardAllowlist` are empty. Otherwise the
9
+ * allowed set is the union of:
10
+ * - system-kind shards (always allowed)
11
+ * - the resolved `requiredShards`/`bundledShards` of each allowlisted app
12
+ * - service-kind shards listed in `project.shardAllowlist`
13
+ *
14
+ * The set of system-kind shards is derived at call time from the
15
+ * registered-shards map; mark framework shards with `kind: 'system'` in
16
+ * their manifest to keep them reachable inside any project.
17
+ */
18
+ export function resolveAllowedShardIds(project, apps, shardManifests) {
19
+ var _a;
20
+ if (!project)
21
+ return null;
22
+ if (project.appAllowlist.length === 0 && project.shardAllowlist.length === 0)
23
+ return null;
24
+ const allowed = new Set();
25
+ const systemShardIds = new Set();
26
+ const serviceShardIds = new Set();
27
+ for (const m of shardManifests) {
28
+ if (m.kind === 'system') {
29
+ allowed.add(m.id);
30
+ systemShardIds.add(m.id);
31
+ }
32
+ else if (m.kind === 'service') {
33
+ serviceShardIds.add(m.id);
34
+ }
35
+ }
36
+ for (const appId of project.appAllowlist) {
37
+ const app = apps.get(appId);
38
+ if (!app)
39
+ continue;
40
+ allowed.add(appId);
41
+ for (const s of app.manifest.requiredShards)
42
+ allowed.add(s);
43
+ for (const s of (_a = app.manifest.bundledShards) !== null && _a !== void 0 ? _a : [])
44
+ allowed.add(s);
45
+ }
46
+ for (const shardId of project.shardAllowlist) {
47
+ if (serviceShardIds.has(shardId))
48
+ allowed.add(shardId);
49
+ }
50
+ return allowed;
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveAllowedShardIds } from './scope-gate';
3
+ function makeApp(id, requiredShards = [], bundledShards) {
4
+ const manifest = Object.assign({ id, label: id, version: '0.0.0', requiredShards, layoutVersion: 1 }, (bundledShards ? { bundledShards } : {}));
5
+ return { manifest, initialLayout: { kind: 'leaf', viewId: 'x' } };
6
+ }
7
+ function makeProject(appAllowlist, shardAllowlist = []) {
8
+ return {
9
+ id: 'acme-1234',
10
+ name: 'Acme',
11
+ members: ['u1'],
12
+ appAllowlist,
13
+ shardAllowlist,
14
+ createdBy: 'u1',
15
+ createdAt: 0,
16
+ updatedAt: 0,
17
+ };
18
+ }
19
+ const SYSTEM_SHARDS = [
20
+ { id: 'shell', label: 'Shell', version: '0', kind: 'system', views: [] },
21
+ { id: '__sh3core__', label: 'SH3 Core', version: '0', kind: 'system', views: [] },
22
+ ];
23
+ describe('resolveAllowedShardIds', () => {
24
+ it('returns null (no gating) when project is null', () => {
25
+ expect(resolveAllowedShardIds(null, new Map(), SYSTEM_SHARDS)).toBeNull();
26
+ });
27
+ it('returns null (no gating) when both allowlists are empty', () => {
28
+ const project = makeProject([], []);
29
+ expect(resolveAllowedShardIds(project, new Map(), SYSTEM_SHARDS)).toBeNull();
30
+ });
31
+ it('returns system-kind shards only when allowlist contains no resolvable apps', () => {
32
+ const project = makeProject(['ghost-app']);
33
+ const apps = new Map();
34
+ const allowed = resolveAllowedShardIds(project, apps, SYSTEM_SHARDS);
35
+ expect(allowed.has('shell')).toBe(true);
36
+ expect(allowed.has('__sh3core__')).toBe(true);
37
+ expect(allowed.has('ghost-app')).toBe(false);
38
+ });
39
+ it('includes app id + requiredShards for each resolved app, plus system shards', () => {
40
+ const project = makeProject(['notes']);
41
+ const apps = new Map([
42
+ ['notes', makeApp('notes', ['notes-shard', 'editor'])],
43
+ ]);
44
+ const allowed = resolveAllowedShardIds(project, apps, SYSTEM_SHARDS);
45
+ expect(allowed.has('shell')).toBe(true);
46
+ expect(allowed.has('__sh3core__')).toBe(true);
47
+ expect(allowed.has('notes')).toBe(true);
48
+ expect(allowed.has('notes-shard')).toBe(true);
49
+ expect(allowed.has('editor')).toBe(true);
50
+ });
51
+ it('includes bundledShards when the app declares them', () => {
52
+ const project = makeProject(['guml-ide']);
53
+ const apps = new Map([
54
+ ['guml-ide', makeApp('guml-ide', ['guml.core'], ['guml.preview'])],
55
+ ]);
56
+ const allowed = resolveAllowedShardIds(project, apps, SYSTEM_SHARDS);
57
+ expect(allowed.has('guml.core')).toBe(true);
58
+ expect(allowed.has('guml.preview')).toBe(true);
59
+ });
60
+ it('unions across multiple allowlisted apps', () => {
61
+ const project = makeProject(['notes', 'files']);
62
+ const apps = new Map([
63
+ ['notes', makeApp('notes', ['notes-shard'])],
64
+ ['files', makeApp('files', ['files-shard'])],
65
+ ]);
66
+ const allowed = resolveAllowedShardIds(project, apps, SYSTEM_SHARDS);
67
+ expect(allowed.has('notes-shard')).toBe(true);
68
+ expect(allowed.has('files-shard')).toBe(true);
69
+ });
70
+ it('includes service-kind shards listed in shardAllowlist', () => {
71
+ const project = makeProject([], ['svc-a']);
72
+ const manifests = [
73
+ ...SYSTEM_SHARDS,
74
+ { id: 'svc-a', label: 'Svc A', version: '0', kind: 'service', views: [] },
75
+ { id: 'svc-b', label: 'Svc B', version: '0', kind: 'service', views: [] },
76
+ ];
77
+ const allowed = resolveAllowedShardIds(project, new Map(), manifests);
78
+ expect(allowed.has('svc-a')).toBe(true);
79
+ expect(allowed.has('svc-b')).toBe(false);
80
+ expect(allowed.has('shell')).toBe(true);
81
+ });
82
+ it('ignores shardAllowlist entries that are not service-kind', () => {
83
+ const project = makeProject([], ['not-a-service']);
84
+ const manifests = [
85
+ ...SYSTEM_SHARDS,
86
+ { id: 'not-a-service', label: 'X', version: '0', views: [] },
87
+ ];
88
+ const allowed = resolveAllowedShardIds(project, new Map(), manifests);
89
+ expect(allowed.has('not-a-service')).toBe(false);
90
+ expect(allowed.has('shell')).toBe(true);
91
+ });
92
+ });
@@ -13,7 +13,9 @@
13
13
  import { refreshProjects } from './projectsShard.svelte';
14
14
  import { getUser } from '../auth/auth.svelte';
15
15
  import AppPicker from '../primitives/widgets/AppPicker.svelte';
16
+ import ShardPicker from '../primitives/widgets/ShardPicker.svelte';
16
17
  import UserPicker from '../primitives/widgets/UserPicker.svelte';
18
+ import { listRegisteredShards } from '../shards/lifecycle.svelte';
17
19
  import TabbedPanel from '../primitives/TabbedPanel.svelte';
18
20
  import { modalManager } from '../overlays/modal';
19
21
  import DeleteProjectDialog from './DeleteProjectDialog.svelte';
@@ -50,6 +52,9 @@
50
52
  let appAllowlist = $state<string[]>(
51
53
  untrack(() => (project ? [...project.appAllowlist] : [])),
52
54
  );
55
+ let shardAllowlist = $state<string[]>(
56
+ untrack(() => (project ? [...(project.shardAllowlist ?? [])] : [])),
57
+ );
53
58
  let saving = $state(false);
54
59
  let error = $state<string | null>(null);
55
60
  let activeTab = $state(0);
@@ -58,6 +63,9 @@
58
63
  let mountsLoading = $state(true);
59
64
  let mountsLoadError = $state<string | null>(null);
60
65
  let baselineApps = $state<string[]>(untrack(() => (project ? [...project.appAllowlist] : [])));
66
+ let baselineShards = $state<string[]>(
67
+ untrack(() => (project ? [...(project.shardAllowlist ?? [])] : [])),
68
+ );
61
69
  let baselineMembers = $state<string[]>(
62
70
  untrack(() => {
63
71
  if (project) return [...project.members];
@@ -80,9 +88,16 @@
80
88
  }
81
89
 
82
90
  const appsDirty = $derived(!arrayEq(appAllowlist, baselineApps));
91
+ const shardsDirty = $derived(!arrayEq(shardAllowlist, baselineShards));
83
92
  const membersDirty = $derived(!arrayEq(members, baselineMembers));
84
93
  const mountsDirty = $derived(!setEq(mountAttached, baselineMounts));
85
94
 
95
+ const systemShards = $derived(
96
+ listRegisteredShards()
97
+ .filter((m) => m.kind === 'system')
98
+ .sort((a, b) => a.label.localeCompare(b.label)),
99
+ );
100
+
86
101
  async function save() {
87
102
  if (!name.trim()) {
88
103
  error = 'Name is required';
@@ -95,6 +110,7 @@
95
110
  description: description.trim() || undefined,
96
111
  members,
97
112
  appAllowlist,
113
+ shardAllowlist,
98
114
  };
99
115
  try {
100
116
  const saved = isEdit && project
@@ -234,10 +250,10 @@
234
250
 
235
251
  <div class="tabs">
236
252
  <TabbedPanel
237
- labels={['Apps', 'Users', 'Mounts']}
253
+ labels={['Apps', 'Shards', 'Users', 'Mounts']}
238
254
  {activeTab}
239
255
  onActiveChange={(i) => (activeTab = i)}
240
- dirty={[appsDirty, membersDirty, mountsDirty]}
256
+ dirty={[appsDirty, shardsDirty, membersDirty, mountsDirty]}
241
257
  body={tabBody}
242
258
  />
243
259
  </div>
@@ -265,6 +281,19 @@
265
281
  <AppPicker bind:value={appAllowlist} disabled={saving} />
266
282
  </label>
267
283
  {:else if i === 1}
284
+ <label class="field">
285
+ <span>Services</span>
286
+ <ShardPicker bind:value={shardAllowlist} disabled={saving} />
287
+ </label>
288
+ <details class="system-block">
289
+ <summary>System (always allowed)</summary>
290
+ <ul class="system-list">
291
+ {#each systemShards as m (m.id)}
292
+ <li><span class="system-label">{m.label}</span><span class="system-id">{m.id}</span></li>
293
+ {/each}
294
+ </ul>
295
+ </details>
296
+ {:else if i === 2}
268
297
  <label class="field">
269
298
  <span>Members</span>
270
299
  <UserPicker bind:value={members} disabled={saving} />
@@ -385,4 +414,15 @@
385
414
  }
386
415
  .actions button.danger { margin-left: auto; color: var(--sh3-error, #c33); }
387
416
  .actions button:disabled { opacity: 0.5; cursor: not-allowed; }
417
+ .system-block { margin-top: 16px; font-size: 13px; }
418
+ .system-block summary {
419
+ cursor: pointer;
420
+ color: var(--sh3-fg-muted);
421
+ padding: 4px 0;
422
+ user-select: none;
423
+ }
424
+ .system-list { list-style: none; margin: 4px 0 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
425
+ .system-list li { display: flex; justify-content: space-between; gap: 8px; color: var(--sh3-fg-muted); padding: 4px 0; }
426
+ .system-id { font-size: 11px; font-family: var(--sh3-font-mono, monospace); }
427
+ .system-label { font-weight: 500; }
388
428
  </style>
@@ -6,7 +6,7 @@ let host;
6
6
  let cmp = null;
7
7
  let originalFetch;
8
8
  function makeProject(overrides = {}) {
9
- return Object.assign({ id: 'acme-1234', name: 'Acme', description: '', members: ['user-1'], appAllowlist: [], createdBy: 'user-1', createdAt: 0, updatedAt: 0 }, overrides);
9
+ return Object.assign({ id: 'acme-1234', name: 'Acme', description: '', members: ['user-1'], appAllowlist: [], shardAllowlist: [], createdBy: 'user-1', createdAt: 0, updatedAt: 0 }, overrides);
10
10
  }
11
11
  beforeEach(() => {
12
12
  __resetAdminUsersForTest();
@@ -33,14 +33,14 @@ afterEach(() => {
33
33
  globalThis.fetch = originalFetch;
34
34
  });
35
35
  describe('ProjectManage tabs', () => {
36
- it('renders three tabs: Apps, Users, Mounts', async () => {
36
+ it('renders four tabs: Apps, Shards, Users, Mounts', async () => {
37
37
  cmp = mount(ProjectManage, {
38
38
  target: host,
39
39
  props: { project: makeProject(), onClose: () => { } },
40
40
  });
41
41
  await tick();
42
42
  const labels = Array.from(host.querySelectorAll('[role="tab"]')).map((el) => { var _a, _b; return (_b = (_a = el.textContent) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : ''; });
43
- expect(labels).toEqual(['Apps', 'Users', 'Mounts']);
43
+ expect(labels).toEqual(['Apps', 'Shards', 'Users', 'Mounts']);
44
44
  });
45
45
  });
46
46
  describe('ProjectManage mount fetch', () => {
@@ -119,7 +119,7 @@ describe('ProjectManage mount list', () => {
119
119
  await new Promise((r) => setTimeout(r, 0));
120
120
  await tick();
121
121
  const tabs = host.querySelectorAll('[role="tab"]');
122
- tabs[2].click();
122
+ tabs[3].click();
123
123
  await tick();
124
124
  const rows = host.querySelectorAll('[data-mount-row]');
125
125
  expect(rows.length).toBe(2);
@@ -150,7 +150,7 @@ describe('ProjectManage mount list', () => {
150
150
  await new Promise((r) => setTimeout(r, 0));
151
151
  await tick();
152
152
  const tabs = host.querySelectorAll('[role="tab"]');
153
- tabs[2].click();
153
+ tabs[3].click();
154
154
  await tick();
155
155
  const cb = host.querySelector('[data-mount-row="assets"] input[type="checkbox"]');
156
156
  expect(cb.checked).toBe(false);
@@ -183,15 +183,16 @@ describe('ProjectManage dirty indicators', () => {
183
183
  await tick();
184
184
  const tabs = host.querySelectorAll('[role="tab"]');
185
185
  expect(host.querySelectorAll('.tab-dirty').length).toBe(0);
186
- tabs[2].click();
186
+ tabs[3].click();
187
187
  await tick();
188
188
  const cb = host.querySelector('[data-mount-row="assets"] input[type="checkbox"]');
189
189
  cb.click();
190
190
  await tick();
191
- const dirtyDot = tabs[2].querySelector('.tab-dirty');
191
+ const dirtyDot = tabs[3].querySelector('.tab-dirty');
192
192
  expect(dirtyDot).not.toBeNull();
193
193
  expect(tabs[0].querySelector('.tab-dirty')).toBeNull();
194
194
  expect(tabs[1].querySelector('.tab-dirty')).toBeNull();
195
+ expect(tabs[2].querySelector('.tab-dirty')).toBeNull();
195
196
  });
196
197
  });
197
198
  describe('ProjectManage save mount diff', () => {
@@ -235,7 +236,7 @@ describe('ProjectManage save mount diff', () => {
235
236
  await new Promise((r) => setTimeout(r, 0));
236
237
  await tick();
237
238
  const tabs = host.querySelectorAll('[role="tab"]');
238
- tabs[2].click();
239
+ tabs[3].click();
239
240
  await tick();
240
241
  host.querySelector('[data-mount-row="assets"] input').click();
241
242
  await tick();
@@ -313,7 +314,7 @@ describe('ProjectManage mount empty state', () => {
313
314
  await new Promise((r) => setTimeout(r, 0));
314
315
  await tick();
315
316
  const tabs = host.querySelectorAll('[role="tab"]');
316
- tabs[2].click();
317
+ tabs[3].click();
317
318
  await tick();
318
319
  expect(host.textContent).toContain('No mounts configured. Create mounts first.');
319
320
  });
@@ -4,6 +4,7 @@ export interface ProjectRecord {
4
4
  description?: string;
5
5
  members: string[];
6
6
  appAllowlist: string[];
7
+ shardAllowlist: string[];
7
8
  createdBy: string;
8
9
  createdAt: number;
9
10
  updatedAt: number;
@@ -12,8 +13,8 @@ export declare const projectsApi: {
12
13
  list(): Promise<ProjectRecord[]>;
13
14
  listAll(): Promise<ProjectRecord[]>;
14
15
  get(id: string): Promise<ProjectRecord>;
15
- create(input: Pick<ProjectRecord, "name" | "description" | "members" | "appAllowlist">): Promise<ProjectRecord>;
16
- update(id: string, patch: Partial<Pick<ProjectRecord, "name" | "description" | "members" | "appAllowlist">>): Promise<ProjectRecord>;
16
+ create(input: Pick<ProjectRecord, "name" | "description" | "members" | "appAllowlist" | "shardAllowlist">): Promise<ProjectRecord>;
17
+ update(id: string, patch: Partial<Pick<ProjectRecord, "name" | "description" | "members" | "appAllowlist" | "shardAllowlist">>): Promise<ProjectRecord>;
17
18
  delete(id: string, opts?: {
18
19
  wipeData?: boolean;
19
20
  }): Promise<void>;
@@ -27,7 +27,7 @@ describe('projectsApi', () => {
27
27
  ok: true,
28
28
  json: async () => ({ id: 'x-1234', name: 'X', members: [], appAllowlist: [], createdBy: 'admin', createdAt: 0, updatedAt: 0 }),
29
29
  });
30
- const project = await projectsApi.create({ name: 'X', members: [], appAllowlist: [] });
30
+ const project = await projectsApi.create({ name: 'X', members: [], appAllowlist: [], shardAllowlist: [] });
31
31
  expect(globalThis.fetch).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ method: 'POST' }));
32
32
  expect(project.id).toBe('x-1234');
33
33
  });
@@ -54,6 +54,7 @@ export const projectsShard = {
54
54
  id: '__projects__',
55
55
  label: 'Projects',
56
56
  version: VERSION,
57
+ kind: 'system',
57
58
  views: [{ id: PROJECTS_MANAGE_VIEW, label: 'Project Manager' }],
58
59
  },
59
60
  register(ctx) {
@@ -1,7 +1,16 @@
1
1
  import { type ScrollbackEntry } from '../shell-shard/scrollback.svelte';
2
+ import type { BrowseCapability } from '../documents/browse';
2
3
  export interface RunVerbOpts {
3
4
  signal?: AbortSignal;
4
5
  structured?: unknown;
6
+ /**
7
+ * BrowseCapability to expose at `ctx.sh3.docs` inside the invoked verb.
8
+ * Callers (Sh3Api.runVerb closures) pass their own capability so the
9
+ * verb inherits the caller's documents:* gating. Undefined means no
10
+ * docs access — the document-touching verbs will print
11
+ * "document capability not available".
12
+ */
13
+ docs?: BrowseCapability;
5
14
  }
6
15
  export interface RunVerbResult {
7
16
  result: unknown;
@@ -51,9 +51,9 @@ export async function runVerbProgrammatic(shardId, name, args, opts) {
51
51
  return { result, scrollback: captured };
52
52
  }
53
53
  async function buildProgrammaticContext(b) {
54
- var _a, _b;
54
+ var _a, _b, _c;
55
55
  const ctx = {
56
- sh3: makeSh3Api({ callerKind: 'verb' }),
56
+ sh3: makeSh3Api({ callerKind: 'verb', docs: (_a = b.opts) === null || _a === void 0 ? void 0 : _a.docs }),
57
57
  scrollback: b.sinkScrollback,
58
58
  session: makeStubSession(),
59
59
  cwd: '/',
@@ -73,8 +73,8 @@ async function buildProgrammaticContext(b) {
73
73
  const innerCtx = Object.assign(Object.assign({}, ctx), { structuredArgs: undefined });
74
74
  await resolved.verb.run(innerCtx, parts);
75
75
  },
76
- structuredArgs: (_a = b.opts) === null || _a === void 0 ? void 0 : _a.structured,
77
- signal: (_b = b.opts) === null || _b === void 0 ? void 0 : _b.signal,
76
+ structuredArgs: (_b = b.opts) === null || _b === void 0 ? void 0 : _b.structured,
77
+ signal: (_c = b.opts) === null || _c === void 0 ? void 0 : _c.signal,
78
78
  };
79
79
  return ctx;
80
80
  }
@@ -146,4 +146,33 @@ describe('runVerbProgrammatic', () => {
146
146
  error: 'no-active-context',
147
147
  });
148
148
  });
149
+ it('propagates opts.docs into ctx.sh3.docs for the invoked verb', async () => {
150
+ let seenDocs = 'untouched';
151
+ registerShard({
152
+ manifest: { id: 'docs-probe', label: 'D', version: '0.0.0', views: [] },
153
+ register(ctx) {
154
+ ctx.registerVerb(makeVerb('peek', true, async (vctx) => {
155
+ seenDocs = vctx.sh3.docs;
156
+ }));
157
+ },
158
+ });
159
+ await activateShard('docs-probe');
160
+ const fakeBrowse = { tag: 'fake-browse' };
161
+ await runVerbProgrammatic('docs-probe', 'docs-probe:peek', [], { docs: fakeBrowse });
162
+ expect(seenDocs).toBe(fakeBrowse);
163
+ });
164
+ it('leaves ctx.sh3.docs undefined when opts.docs is not provided', async () => {
165
+ let seenDocs = 'untouched';
166
+ registerShard({
167
+ manifest: { id: 'docs-probe-2', label: 'D2', version: '0.0.0', views: [] },
168
+ register(ctx) {
169
+ ctx.registerVerb(makeVerb('peek', true, async (vctx) => {
170
+ seenDocs = vctx.sh3.docs;
171
+ }));
172
+ },
173
+ });
174
+ await activateShard('docs-probe-2');
175
+ await runVerbProgrammatic('docs-probe-2', 'docs-probe-2:peek', []);
176
+ expect(seenDocs).toBeUndefined();
177
+ });
149
178
  });
@@ -1,11 +1,18 @@
1
1
  import type { Sh3Api } from '../shell-shard/registry';
2
2
  import type { ZoneManager } from '../state/types';
3
+ import type { BrowseCapability } from '../documents/browse';
3
4
  export interface MakeSh3ApiOpts {
4
5
  callerKind: 'shard' | 'verb';
5
6
  /** Present when callerKind === 'shard' (used by future permission gates). */
6
7
  callerShardId?: string;
7
8
  /** Cross-shard zone manager — passed in by ShardContext when permitted. */
8
9
  zones?: ZoneManager;
10
+ /**
11
+ * Tenant-wide BrowseCapability for the calling shard, minted at the call
12
+ * site from `shard.manifest.permissions`. The factory does not gate; the
13
+ * caller decides whether to mint and pass one in.
14
+ */
15
+ docs?: BrowseCapability;
9
16
  }
10
17
  export declare function makeSh3Api(opts?: MakeSh3ApiOpts): Sh3Api;
11
18
  /** @deprecated Renamed to makeSh3Api(opts?). Kept for one minor cycle. */
@@ -52,6 +52,7 @@ export function makeSh3Api(opts) {
52
52
  void (opts === null || opts === void 0 ? void 0 : opts.callerKind);
53
53
  void (opts === null || opts === void 0 ? void 0 : opts.callerShardId);
54
54
  const zones = opts === null || opts === void 0 ? void 0 : opts.zones;
55
+ const docs = opts === null || opts === void 0 ? void 0 : opts.docs;
55
56
  function listViewsImpl() {
56
57
  try {
57
58
  const { root } = inspectActiveLayout();
@@ -298,7 +299,7 @@ export function makeSh3Api(opts) {
298
299
  return programmaticOnly ? out.filter((v) => v.programmatic === true) : out;
299
300
  },
300
301
  async runVerb(shardId, name, args, runOpts) {
301
- return runVerbProgrammatic(shardId, name, args, runOpts);
302
+ return runVerbProgrammatic(shardId, name, args, Object.assign(Object.assign({}, runOpts), { docs }));
302
303
  },
303
304
  listActions(actionOpts) {
304
305
  const all = listActionsFromEntries(listActionEntriesFromRegistry(), getLiveDispatcherState());
@@ -327,6 +328,7 @@ export function makeSh3Api(opts) {
327
328
  listProjects() {
328
329
  return projectsState.projects.map((p) => ({ id: p.id, name: p.name }));
329
330
  },
331
+ docs,
330
332
  };
331
333
  }
332
334
  /** @deprecated Renamed to makeSh3Api(opts?). Kept for one minor cycle. */
@@ -101,3 +101,45 @@ describe('sh3Api listActions submenu filter', () => {
101
101
  expect(ids).toEqual(['p:a']);
102
102
  });
103
103
  });
104
+ describe('sh3Api docs capability', () => {
105
+ it('exposes docs capability when passed via opts', () => {
106
+ const fakeBrowse = {
107
+ listDocuments: async () => [],
108
+ listShards: async () => [],
109
+ watchDocuments: () => () => { },
110
+ };
111
+ const api = makeSh3Api({ callerKind: 'shard', callerShardId: 'x', docs: fakeBrowse });
112
+ expect(api.docs).toBe(fakeBrowse);
113
+ });
114
+ it('leaves docs undefined when opts.docs is omitted', () => {
115
+ const api = makeSh3Api({ callerKind: 'verb' });
116
+ expect(api.docs).toBeUndefined();
117
+ });
118
+ it('forwards opts.docs through Sh3Api.runVerb into the invoked verb', async () => {
119
+ const { registerShard, activateShard, __resetShardRegistryForTest } = await import('../shards/lifecycle.svelte');
120
+ const { __resetViewRegistryForTest } = await import('../shards/registry');
121
+ const { MemoryDocumentBackend } = await import('../documents/backends');
122
+ const { __setDocumentBackend, __setActiveScope } = await import('../documents/config');
123
+ __resetShardRegistryForTest();
124
+ __resetViewRegistryForTest();
125
+ __setDocumentBackend(new MemoryDocumentBackend());
126
+ __setActiveScope('tenant-test');
127
+ let seenDocs = 'untouched';
128
+ registerShard({
129
+ manifest: { id: 'docs-via-api', label: 'D', version: '0.0.0', views: [] },
130
+ register(ctx) {
131
+ ctx.registerVerb({
132
+ name: 'peek',
133
+ summary: 'peek',
134
+ programmatic: true,
135
+ async run(vctx) { seenDocs = vctx.sh3.docs; },
136
+ });
137
+ },
138
+ });
139
+ await activateShard('docs-via-api');
140
+ const fakeBrowse = { tag: 'forwarded' };
141
+ const api = makeSh3Api({ callerKind: 'shard', callerShardId: 'docs-via-api', docs: fakeBrowse });
142
+ await api.runVerb('docs-via-api', 'docs-via-api:peek', []);
143
+ expect(seenDocs).toBe(fakeBrowse);
144
+ });
145
+ });
@@ -18,7 +18,6 @@
18
18
  import { makeSelectionApi } from '../actions/selection.svelte';
19
19
  import { getAppearance } from '../app-appearance';
20
20
  import iconsUrl from '../assets/icons.svg';
21
- import { manifest } from '../shell-shard/manifest';
22
21
 
23
22
  const homeSelection = makeSelectionApi('__sh3core__');
24
23
 
@@ -93,7 +92,7 @@
93
92
  <button
94
93
  type="button"
95
94
  class="sh3-home-card"
96
- class:sh3-home-card--tinted={appearance?.color}
95
+ class:sh3-home-card--tinted={appearance?.color ?? manifest.color}
97
96
  style:--card-color={appearance?.color ?? manifest.color ?? 'transparent'}
98
97
  data-sh3-scope="element:app"
99
98
  onclick={() => launchApp(manifest.id)}
@@ -119,8 +118,8 @@
119
118
  <button
120
119
  type="button"
121
120
  class="sh3-home-card"
122
- class:sh3-home-card--tinted={appearance?.color}
123
- style:--card-color={appearance?.color ?? 'transparent'}
121
+ class:sh3-home-card--tinted={appearance?.color ?? manifest.color}
122
+ style:--card-color={appearance?.color ?? manifest.color ?? 'transparent'}
124
123
  data-sh3-scope="element:app"
125
124
  onclick={() => launchApp(manifest.id)}
126
125
  oncontextmenu={(e) => openAppContextMenu(e, manifest.id)}