sh3-core 0.22.5 → 0.23.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 (77) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/api.js +1 -1
  3. package/dist/app/admin/adminApp.js +2 -0
  4. package/dist/app/admin/adminShard.svelte.js +1 -0
  5. package/dist/app/store/storeApp.js +3 -1
  6. package/dist/app/store/storeShard.svelte.js +1 -0
  7. package/dist/app-appearance/appearanceShard.svelte.js +1 -0
  8. package/dist/apps/lifecycle.js +22 -10
  9. package/dist/apps/lifecycle.test.js +53 -1
  10. package/dist/apps/types.d.ts +9 -0
  11. package/dist/chrome/CompactChrome.svelte +11 -7
  12. package/dist/createShell.js +40 -0
  13. package/dist/documents/picker-api.test.js +40 -0
  14. package/dist/documents/picker-primitive.d.ts +39 -1
  15. package/dist/documents/picker-primitive.js +5 -4
  16. package/dist/host.js +30 -7
  17. package/dist/layout/slotHostPool.svelte.d.ts +11 -0
  18. package/dist/layout/slotHostPool.svelte.js +41 -17
  19. package/dist/layout/slotHostPool.test.js +45 -1
  20. package/dist/layouts-shard/layoutsShard.svelte.js +1 -0
  21. package/dist/overlays/OverlayRoots.svelte +15 -4
  22. package/dist/overlays/__test__/OverlayBindHarness.svelte +20 -0
  23. package/dist/overlays/__test__/OverlayBindHarness.svelte.d.ts +3 -0
  24. package/dist/overlays/float-compact-bind.svelte.test.d.ts +1 -0
  25. package/dist/overlays/float-compact-bind.svelte.test.js +51 -0
  26. package/dist/overlays/modal.js +3 -0
  27. package/dist/overlays/modal.test.js +45 -0
  28. package/dist/overlays/types.d.ts +9 -0
  29. package/dist/primitives/widgets/ShardPicker.svelte +38 -0
  30. package/dist/primitives/widgets/ShardPicker.svelte.d.ts +9 -0
  31. package/dist/primitives/widgets/_DocumentBrowser.svelte +11 -3
  32. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +2 -0
  33. package/dist/projects/scope-gate.d.ts +4 -0
  34. package/dist/projects/scope-gate.js +51 -0
  35. package/dist/projects/scope-gate.test.d.ts +1 -0
  36. package/dist/projects/scope-gate.test.js +92 -0
  37. package/dist/projects-shard/ProjectManage.svelte +42 -2
  38. package/dist/projects-shard/ProjectManage.svelte.test.js +10 -9
  39. package/dist/projects-shard/projectsApi.d.ts +3 -2
  40. package/dist/projects-shard/projectsApi.test.js +1 -1
  41. package/dist/projects-shard/projectsShard.svelte.js +1 -0
  42. package/dist/runtime/runVerb.d.ts +9 -0
  43. package/dist/runtime/runVerb.js +4 -4
  44. package/dist/runtime/runVerb.test.js +29 -0
  45. package/dist/sh3Api/headless.d.ts +7 -0
  46. package/dist/sh3Api/headless.js +3 -1
  47. package/dist/sh3Api/headless.svelte.test.js +42 -0
  48. package/dist/sh3core-shard/Sh3Home.svelte +3 -3
  49. package/dist/sh3core-shard/sh3coreShard.svelte.js +1 -0
  50. package/dist/shards/lifecycle.svelte.d.ts +8 -2
  51. package/dist/shards/lifecycle.svelte.js +65 -7
  52. package/dist/shards/lifecycle.test.js +110 -1
  53. package/dist/shards/types.d.ts +13 -0
  54. package/dist/shell-shard/Terminal.svelte +1 -4
  55. package/dist/shell-shard/Terminal.svelte.d.ts +0 -2
  56. package/dist/shell-shard/dispatch.d.ts +0 -2
  57. package/dist/shell-shard/dispatch.js +0 -2
  58. package/dist/shell-shard/display-cwd.test.js +4 -4
  59. package/dist/shell-shard/manifest.js +1 -0
  60. package/dist/shell-shard/shellShard.svelte.d.ts +1 -1
  61. package/dist/shell-shard/shellShard.svelte.js +9 -4
  62. package/dist/shell-shard/verbs/cat.js +3 -3
  63. package/dist/shell-shard/verbs/cat.test.js +1 -2
  64. package/dist/shell-shard/verbs/ls.js +2 -2
  65. package/dist/shell-shard/verbs/ls.test.js +1 -2
  66. package/dist/shell-shard/verbs/mkdir.js +3 -3
  67. package/dist/shell-shard/verbs/mkdir.test.js +1 -2
  68. package/dist/shell-shard/verbs/mv.js +3 -3
  69. package/dist/shell-shard/verbs/mv.test.js +1 -2
  70. package/dist/shell-shard/verbs/rm.js +3 -3
  71. package/dist/shell-shard/verbs/rm.test.js +1 -2
  72. package/dist/shell-shard/verbs/xfer.js +5 -5
  73. package/dist/shell-shard/verbs/xfer.test.js +2 -2
  74. package/dist/verbs/types.d.ts +10 -2
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +1 -1
@@ -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
+ });
@@ -93,7 +93,7 @@
93
93
  <button
94
94
  type="button"
95
95
  class="sh3-home-card"
96
- class:sh3-home-card--tinted={appearance?.color}
96
+ class:sh3-home-card--tinted={appearance?.color ?? manifest.color}
97
97
  style:--card-color={appearance?.color ?? manifest.color ?? 'transparent'}
98
98
  data-sh3-scope="element:app"
99
99
  onclick={() => launchApp(manifest.id)}
@@ -119,8 +119,8 @@
119
119
  <button
120
120
  type="button"
121
121
  class="sh3-home-card"
122
- class:sh3-home-card--tinted={appearance?.color}
123
- style:--card-color={appearance?.color ?? 'transparent'}
122
+ class:sh3-home-card--tinted={appearance?.color ?? manifest.color}
123
+ style:--card-color={appearance?.color ?? manifest.color ?? 'transparent'}
124
124
  data-sh3-scope="element:app"
125
125
  onclick={() => launchApp(manifest.id)}
126
126
  oncontextmenu={(e) => openAppContextMenu(e, manifest.id)}
@@ -60,6 +60,7 @@ export const sh3coreShard = {
60
60
  id: '__sh3core__',
61
61
  label: 'SH3 Core',
62
62
  version: VERSION,
63
+ kind: 'system',
63
64
  views: [
64
65
  { id: 'sh3core:home', label: 'Home' },
65
66
  { id: 'sh3:keys-and-peers', label: 'Keys & Peers' },
@@ -1,9 +1,15 @@
1
- import type { Shard, ShardContext } from './types';
1
+ import type { Shard, ShardContext, ShardManifest } from './types';
2
2
  /**
3
3
  * Reactive registry of every shard known to the host. Keys are shard ids.
4
4
  * Populated by `registerShard`.
5
5
  */
6
6
  export declare const registeredShards: Map<string, Shard>;
7
+ /**
8
+ * Reactive snapshot of every registered shard's manifest. Mirrors
9
+ * `listRegisteredApps()` — used by project manage UI / ShardPicker to
10
+ * enumerate service-kind shards.
11
+ */
12
+ export declare function listRegisteredShards(): ShardManifest[];
7
13
  /**
8
14
  * Reactive map of shard ids that failed during the lifecycle. Populated
9
15
  * by registerAllShards and related operations.
@@ -53,7 +59,7 @@ export declare const activeShards: Map<string, Shard>;
53
59
  * an already-entered shard is a no-op. Errors are recorded in
54
60
  * `erroredShards` with phase 'register'; one failure does not block others.
55
61
  */
56
- export declare function registerAllShards(): Promise<void>;
62
+ export declare function registerAllShards(allowed?: Set<string> | null): Promise<void>;
57
63
  export declare function runAppActivate(shardId: string, appId: string): Promise<void>;
58
64
  export declare function runAppDeactivate(shardId: string, appId: string): Promise<void>;
59
65
  /**
@@ -17,6 +17,7 @@ import { PERMISSION_STATE_MANAGE } from '../state/types';
17
17
  import { PERMISSION_DOCUMENTS_BROWSE, PERMISSION_DOCUMENTS_READ, PERMISSION_DOCUMENTS_WRITE, } from '../documents/types';
18
18
  import { createBrowseCapability } from '../documents/browse';
19
19
  import { createDocumentPicker } from '../documents/picker-primitive';
20
+ import { documentChanges } from '../documents/notifications';
20
21
  import { createShardKeysApi } from '../keys/client';
21
22
  import { PERMISSION_KEYS_MINT } from '../keys/types';
22
23
  import { makeSh3Api } from '../sh3Api/headless';
@@ -29,11 +30,65 @@ import { clearSelectionForShard } from '../actions/selection.svelte';
29
30
  import { fetchEnvState } from '../env/client';
30
31
  import { subscribe as subscribeKeyRevocation } from '../keys/revocation-bus.svelte';
31
32
  const shardAppBindings = $state(new Map());
33
+ /**
34
+ * Build the picker-primitive options for a shard: backend-backed
35
+ * `listFolders` so empty folders surface in the modal, a mutation `handle`
36
+ * that emits the right documentChanges events, and a `readOnlyShard`
37
+ * predicate that blocks cross-shard mutations unless the caller holds
38
+ * `documents:write`.
39
+ */
40
+ function makePickerOptions(callerShardId, permissions, lockToShard) {
41
+ const backend = getDocumentBackend();
42
+ const hasWrite = permissions.includes(PERMISSION_DOCUMENTS_WRITE);
43
+ return {
44
+ listFolders: (sid, prefix) => backend.listFolders(getActiveScopeId(), sid, prefix),
45
+ handle: {
46
+ mkdir: async (sid, path) => {
47
+ const tid = getActiveScopeId();
48
+ await backend.mkdir(tid, sid, path);
49
+ documentChanges.emit({ type: 'folder-create', path, tenantId: tid, shardId: sid });
50
+ },
51
+ rmdir: async (sid, path, opts) => {
52
+ const tid = getActiveScopeId();
53
+ await backend.rmdir(tid, sid, path, opts);
54
+ documentChanges.emit({ type: 'folder-delete', path, tenantId: tid, shardId: sid });
55
+ },
56
+ renameFolder: async (sid, oldPath, newPath) => {
57
+ const tid = getActiveScopeId();
58
+ await backend.renameFolder(tid, sid, oldPath, newPath);
59
+ documentChanges.emit({ type: 'folder-rename', path: newPath, oldPath, tenantId: tid, shardId: sid });
60
+ },
61
+ rename: async (sid, oldPath, newPath) => {
62
+ const tid = getActiveScopeId();
63
+ await backend.rename(tid, sid, oldPath, newPath);
64
+ documentChanges.emit({ type: 'rename', path: newPath, oldPath, tenantId: tid, shardId: sid });
65
+ },
66
+ delete: async (sid, path) => {
67
+ const tid = getActiveScopeId();
68
+ const existed = await backend.exists(tid, sid, path);
69
+ await backend.delete(tid, sid, path);
70
+ if (existed)
71
+ documentChanges.emit({ type: 'delete', path, tenantId: tid, shardId: sid });
72
+ },
73
+ },
74
+ readOnlyShard: (sid) => sid !== callerShardId && !hasWrite,
75
+ initialShardId: callerShardId,
76
+ lockToShard,
77
+ };
78
+ }
32
79
  /**
33
80
  * Reactive registry of every shard known to the host. Keys are shard ids.
34
81
  * Populated by `registerShard`.
35
82
  */
36
83
  export const registeredShards = $state(new Map());
84
+ /**
85
+ * Reactive snapshot of every registered shard's manifest. Mirrors
86
+ * `listRegisteredApps()` — used by project manage UI / ShardPicker to
87
+ * enumerate service-kind shards.
88
+ */
89
+ export function listRegisteredShards() {
90
+ return Array.from(registeredShards.values()).map((s) => s.manifest);
91
+ }
37
92
  export const erroredShards = $state(new Map());
38
93
  /** Read the app id currently bound to this shard, or null. */
39
94
  export function getShardBinding(shardId) {
@@ -65,7 +120,7 @@ export function __setScopeResolver(resolver) {
65
120
  * `entry.activeAppId`); otherwise they go to the boot bag.
66
121
  */
67
122
  export function buildShardContext(shard, entry) {
68
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
123
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
69
124
  const id = shard.manifest.id;
70
125
  function trackDisposer(fn) {
71
126
  var _a;
@@ -200,15 +255,15 @@ export function buildShardContext(shard, entry) {
200
255
  : undefined,
201
256
  browse: browseCap,
202
257
  documentPicker: browseCap
203
- ? createDocumentPicker(() => browseCap.listDocuments())
258
+ ? createDocumentPicker(() => browseCap.listDocuments(), makePickerOptions(id, (_g = shard.manifest.permissions) !== null && _g !== void 0 ? _g : [], false))
204
259
  : createDocumentPicker(async () => {
205
260
  const docs = await getDocumentBackend().list(getActiveScopeId(), id);
206
261
  return docs.map(d => (Object.assign(Object.assign({}, d), { shardId: id })));
207
- }),
208
- keys: ((_g = shard.manifest.permissions) === null || _g === void 0 ? void 0 : _g.includes(PERMISSION_KEYS_MINT))
262
+ }, makePickerOptions(id, (_h = shard.manifest.permissions) !== null && _h !== void 0 ? _h : [], true)),
263
+ keys: ((_j = shard.manifest.permissions) === null || _j === void 0 ? void 0 : _j.includes(PERMISSION_KEYS_MINT))
209
264
  ? createShardKeysApi({
210
265
  shardId: id,
211
- shardPermissions: (_h = shard.manifest.permissions) !== null && _h !== void 0 ? _h : [],
266
+ shardPermissions: (_k = shard.manifest.permissions) !== null && _k !== void 0 ? _k : [],
212
267
  })
213
268
  : undefined,
214
269
  contributions,
@@ -225,9 +280,10 @@ export function buildShardContext(shard, entry) {
225
280
  sh3: makeSh3Api({
226
281
  callerKind: 'shard',
227
282
  callerShardId: id,
228
- zones: ((_j = shard.manifest.permissions) === null || _j === void 0 ? void 0 : _j.includes(PERMISSION_STATE_MANAGE))
283
+ zones: ((_l = shard.manifest.permissions) === null || _l === void 0 ? void 0 : _l.includes(PERMISSION_STATE_MANAGE))
229
284
  ? createZoneManager()
230
285
  : undefined,
286
+ docs: browseCap,
231
287
  }),
232
288
  };
233
289
  // Stash env state on the ctx for registerAllShards' hydration step.
@@ -241,10 +297,12 @@ export const activeShards = $state(new Map());
241
297
  * an already-entered shard is a no-op. Errors are recorded in
242
298
  * `erroredShards` with phase 'register'; one failure does not block others.
243
299
  */
244
- export async function registerAllShards() {
300
+ export async function registerAllShards(allowed = null) {
245
301
  for (const [id, shard] of registeredShards) {
246
302
  if (shardEntries.has(id))
247
303
  continue;
304
+ if (allowed !== null && !allowed.has(id))
305
+ continue;
248
306
  const entry = {
249
307
  shard,
250
308
  ctx: undefined,
@@ -1,6 +1,14 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
- import { getShardBinding, rotateShardDocumentNamespace, __resetLifecycleForTest, registerAllShards, runAppActivate, runAppDeactivate, rebuildShardEntry, shardEntries, } from './lifecycle.svelte';
2
+ import { __setActiveScope, __setDocumentBackend } from '../documents/config';
3
+ import { MemoryDocumentBackend } from '../documents/backends';
4
+ import { getShardBinding, rotateShardDocumentNamespace, __resetLifecycleForTest, registerAllShards, runAppActivate, runAppDeactivate, rebuildShardEntry, shardEntries, listRegisteredShards, } from './lifecycle.svelte';
3
5
  import { registerShard, __resetShardRegistryForTest, erroredShards } from './lifecycle.svelte';
6
+ function makeShard(id, registerFn) {
7
+ return {
8
+ manifest: { id, label: id, version: '0.0.0', views: [] },
9
+ register: registerFn,
10
+ };
11
+ }
4
12
  describe('shards/lifecycle — binding map', () => {
5
13
  beforeEach(() => __resetLifecycleForTest());
6
14
  it('returns null for an unbound shard', () => {
@@ -137,3 +145,104 @@ describe('hot-swap on re-register', () => {
137
145
  expect((_c = shardEntries.get('hot-s')) === null || _c === void 0 ? void 0 : _c.shard.manifest.version).toBe('0.0.1');
138
146
  });
139
147
  });
148
+ describe('registerAllShards with allowed-set gate', () => {
149
+ beforeEach(() => {
150
+ __resetLifecycleForTest();
151
+ __resetShardRegistryForTest();
152
+ });
153
+ it('skips shards not in the allowed set when one is provided', async () => {
154
+ const calls = [];
155
+ const a = makeShard('shard-a', () => { calls.push('a'); });
156
+ const b = makeShard('shard-b', () => { calls.push('b'); });
157
+ const c = makeShard('shard-c', () => { calls.push('c'); });
158
+ registerShard(a);
159
+ registerShard(b);
160
+ registerShard(c);
161
+ await registerAllShards(new Set(['shard-a', 'shard-c']));
162
+ expect(calls).toEqual(['a', 'c']);
163
+ expect(shardEntries.has('shard-a')).toBe(true);
164
+ expect(shardEntries.has('shard-b')).toBe(false);
165
+ expect(shardEntries.has('shard-c')).toBe(true);
166
+ });
167
+ it('registers everything when allowed-set is null', async () => {
168
+ const calls = [];
169
+ const a = makeShard('shard-a', () => { calls.push('a'); });
170
+ const b = makeShard('shard-b', () => { calls.push('b'); });
171
+ registerShard(a);
172
+ registerShard(b);
173
+ await registerAllShards(null);
174
+ expect(calls).toEqual(['a', 'b']);
175
+ });
176
+ it('registers everything when allowed-set arg is omitted (back-compat)', async () => {
177
+ const calls = [];
178
+ const a = makeShard('shard-a', () => { calls.push('a'); });
179
+ registerShard(a);
180
+ await registerAllShards();
181
+ expect(calls).toEqual(['a']);
182
+ });
183
+ });
184
+ describe('listRegisteredShards', () => {
185
+ beforeEach(() => {
186
+ __resetLifecycleForTest();
187
+ __resetShardRegistryForTest();
188
+ });
189
+ it('returns the manifest of every registered shard', () => {
190
+ var _a;
191
+ const a = {
192
+ manifest: { id: 'a', label: 'A', version: '1.0.0', views: [] },
193
+ register: () => { },
194
+ };
195
+ const b = {
196
+ manifest: { id: 'b', label: 'B', version: '1.0.0', views: [], kind: 'service' },
197
+ register: () => { },
198
+ };
199
+ registerShard(a);
200
+ registerShard(b);
201
+ const manifests = listRegisteredShards();
202
+ expect(manifests.map((m) => m.id).sort()).toEqual(['a', 'b']);
203
+ expect((_a = manifests.find((m) => m.id === 'b')) === null || _a === void 0 ? void 0 : _a.kind).toBe('service');
204
+ });
205
+ });
206
+ describe('shards/lifecycle — ctx.sh3.docs wiring', () => {
207
+ beforeEach(() => {
208
+ __resetLifecycleForTest();
209
+ __resetShardRegistryForTest();
210
+ __setDocumentBackend(new MemoryDocumentBackend());
211
+ __setActiveScope('tenant-test');
212
+ });
213
+ it('mirrors ctx.browse onto ctx.sh3.docs for browse-permitted shards', async () => {
214
+ let captured = { sh3Docs: 'untouched', ctxBrowse: 'untouched' };
215
+ const shard = {
216
+ manifest: {
217
+ id: 'browse-test',
218
+ label: 'Browse Test',
219
+ version: '0.0.1',
220
+ views: [],
221
+ permissions: ['documents:browse', 'documents:read', 'documents:write'],
222
+ },
223
+ register(ctx) {
224
+ captured = { sh3Docs: ctx.sh3.docs, ctxBrowse: ctx.browse };
225
+ },
226
+ };
227
+ registerShard(shard);
228
+ await registerAllShards();
229
+ expect(captured.sh3Docs).toBeDefined();
230
+ expect(captured.sh3Docs).toBe(captured.ctxBrowse);
231
+ });
232
+ it('leaves ctx.sh3.docs undefined when shard lacks documents:browse', async () => {
233
+ let docsRef = 'untouched';
234
+ const shard = {
235
+ manifest: {
236
+ id: 'no-browse-test',
237
+ label: 'No Browse',
238
+ version: '0.0.1',
239
+ views: [],
240
+ permissions: [],
241
+ },
242
+ register(ctx) { docsRef = ctx.sh3.docs; },
243
+ };
244
+ registerShard(shard);
245
+ await registerAllShards();
246
+ expect(docsRef).toBeUndefined();
247
+ });
248
+ });
@@ -176,6 +176,19 @@ export interface ShardManifest {
176
176
  * by sh3-validate at build time.
177
177
  */
178
178
  verbNamespace?: string;
179
+ /**
180
+ * Project-allowlist classification.
181
+ * - 'system' : always allowed in every project scope. The shard is part
182
+ * of the framework/OS surface (e.g. shell-shard). Surfaced
183
+ * read-only in the project manage view.
184
+ * - 'service' : opt-in per project. Appears in the Shards tab of the
185
+ * project manage view; admin ticks it to grant write access.
186
+ * - omitted : regular shard. Reached only via an app's
187
+ * requiredShards / bundledShards (existing behavior).
188
+ *
189
+ * Apps never set this — shard-only.
190
+ */
191
+ kind?: 'system' | 'service';
179
192
  }
180
193
  /**
181
194
  * Source-declared shape of a shard manifest — what external package authors
@@ -28,7 +28,6 @@
28
28
  import BusySlot from './toolbar/slots/BusySlot.svelte';
29
29
  import { registerTerminalView, mintTerminalId, type TerminalHandle } from './terminal-registry';
30
30
  import { makeDispatchToTerminal } from './dispatch-to-terminal';
31
- import type { BrowseCapability } from '../documents/browse';
32
31
 
33
32
  interface Props {
34
33
  shell: Sh3Api;
@@ -36,9 +35,8 @@
36
35
  userId: string;
37
36
  role: ShellRole;
38
37
  contributions: ContributionsApi;
39
- docs?: BrowseCapability;
40
38
  }
41
- let { shell, wsUrl, userId, role, contributions, docs }: Props = $props();
39
+ let { shell, wsUrl, userId, role, contributions }: Props = $props();
42
40
 
43
41
  // Per-mode buffer map. Each ModeBuffer bundles a Scrollback + history +
44
42
  // locked flag and is materialized lazily on first switch into that mode.
@@ -252,7 +250,6 @@
252
250
  session,
253
251
  sh3: shellWithModes,
254
252
  fs,
255
- docs,
256
253
  cwd: () => session.cwd,
257
254
  busy: acquireBusy,
258
255
  customMode: (id: string) => contributedModes.find((d) => d.id === id) ?? null,
@@ -1,14 +1,12 @@
1
1
  import { type Sh3Api } from './registry';
2
2
  import type { ShellRole } from './modes/types';
3
3
  import type { ContributionsApi } from '../contributions/types';
4
- import type { BrowseCapability } from '../documents/browse';
5
4
  interface Props {
6
5
  shell: Sh3Api;
7
6
  wsUrl: string;
8
7
  userId: string;
9
8
  role: ShellRole;
10
9
  contributions: ContributionsApi;
11
- docs?: BrowseCapability;
12
10
  }
13
11
  declare const Terminal: import("svelte").Component<Props, {}, "">;
14
12
  type Terminal = ReturnType<typeof Terminal>;