sh3-core 0.19.6 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/app/admin/AuthSettingsView.svelte +3 -9
  2. package/dist/app/admin/MountsView.svelte +276 -0
  3. package/dist/app/admin/MountsView.svelte.d.ts +3 -0
  4. package/dist/app/admin/SystemView.svelte +6 -6
  5. package/dist/app/admin/UsersView.svelte +103 -7
  6. package/dist/app/admin/adminApp.js +1 -0
  7. package/dist/app/admin/adminShard.svelte.js +10 -0
  8. package/dist/apps/lifecycle.js +1 -0
  9. package/dist/apps/types.d.ts +7 -0
  10. package/dist/assets/iconIds.generated.d.ts +1 -1
  11. package/dist/assets/iconIds.generated.js +1 -0
  12. package/dist/assets/icons.svg +5 -0
  13. package/dist/auth/admin-users.svelte.js +2 -1
  14. package/dist/auth/auth.svelte.d.ts +4 -5
  15. package/dist/auth/auth.svelte.js +5 -6
  16. package/dist/auth/types.d.ts +0 -2
  17. package/dist/chrome/CompactChrome.svelte +25 -6
  18. package/dist/chrome/FloatsSheet.svelte +7 -32
  19. package/dist/chrome/FloatsSheet.svelte.d.ts +1 -2
  20. package/dist/chrome/FloatsSheet.svelte.test.js +8 -14
  21. package/dist/chrome/MenuSheet.svelte +154 -148
  22. package/dist/chrome/MenuSheet.svelte.d.ts +1 -2
  23. package/dist/chrome/MenuSheet.svelte.test.js +24 -12
  24. package/dist/createShell.js +32 -21
  25. package/dist/createShell.remoteAuth.test.js +9 -3
  26. package/dist/documents/browse.d.ts +18 -1
  27. package/dist/documents/browse.js +40 -7
  28. package/dist/documents/browse.test.js +35 -35
  29. package/dist/documents/config.d.ts +4 -0
  30. package/dist/documents/config.js +15 -2
  31. package/dist/documents/handle.js +25 -17
  32. package/dist/documents/http-backend.js +10 -2
  33. package/dist/documents/index.d.ts +2 -2
  34. package/dist/documents/index.js +1 -1
  35. package/dist/documents/picker-api.d.ts +4 -2
  36. package/dist/documents/picker-api.test.d.ts +1 -1
  37. package/dist/documents/picker-api.test.js +87 -57
  38. package/dist/documents/picker-primitive.d.ts +4 -0
  39. package/dist/documents/picker-primitive.js +27 -29
  40. package/dist/documents/types.d.ts +17 -5
  41. package/dist/documents/types.js +2 -0
  42. package/dist/layout/presets.test.js +4 -4
  43. package/dist/layout/types.d.ts +1 -1
  44. package/dist/layouts-shard/LayoutsSection.svelte +3 -16
  45. package/dist/primitives/widgets/DocumentFilePicker.svelte +4 -4
  46. package/dist/primitives/widgets/PickerList.svelte +1 -0
  47. package/dist/primitives/widgets/_DocumentBrowser.svelte +5 -8
  48. package/dist/projects-shard/DeleteProjectDialog.svelte +32 -1
  49. package/dist/projects-shard/ProjectManage.svelte +197 -28
  50. package/dist/projects-shard/ProjectManage.svelte.test.d.ts +1 -0
  51. package/dist/projects-shard/ProjectManage.svelte.test.js +320 -0
  52. package/dist/projects-shard/ProjectsSection.svelte +3 -16
  53. package/dist/projects-shard/projectsApi.js +2 -1
  54. package/dist/registry/permission-descriptions.js +4 -0
  55. package/dist/server-shard/types.d.ts +21 -0
  56. package/dist/sh3core-shard/HomeSection.svelte +107 -0
  57. package/dist/sh3core-shard/HomeSection.svelte.d.ts +10 -0
  58. package/dist/sh3core-shard/Sh3Home.svelte +9 -23
  59. package/dist/shards/activate.svelte.d.ts +4 -0
  60. package/dist/shards/activate.svelte.js +9 -1
  61. package/dist/shards/types.d.ts +7 -0
  62. package/dist/shell-shard/tenant-fs-client.js +2 -1
  63. package/dist/transport/apiFetch.js +12 -5
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  /*
2
- * DOM smoke for MenuSheet — verifies open/closed rendering. The
3
- * container/item resolution is exercised via the same model functions
4
- * MenuBar uses; their unit tests cover the resolution semantics so
5
- * this test only asserts the wrapper structure.
2
+ * DOM smoke for MenuSheet — verifies the push-navigation modal card
3
+ * renders correctly. The container/item resolution is exercised via
4
+ * the same model functions MenuBar uses; their unit tests cover the
5
+ * resolution semantics so this test only asserts the wrapper structure.
6
6
  */
7
7
  import { describe, it, expect, afterEach } from 'vitest';
8
8
  import { mount, unmount, flushSync } from 'svelte';
@@ -21,26 +21,38 @@ afterEach(() => {
21
21
  }
22
22
  });
23
23
  describe('MenuSheet (dom)', () => {
24
- it('renders nothing when closed', () => {
24
+ it('renders the menu sheet region with a title "Menu" when no actions registered', () => {
25
25
  host = document.createElement('div');
26
26
  document.body.appendChild(host);
27
27
  mounted = mount(MenuSheetAny, {
28
28
  target: host,
29
- props: { open: false, onClose: () => { } },
29
+ props: { close: () => { } },
30
30
  });
31
31
  flushSync();
32
- expect(host.querySelector('[data-sh3-region="menu-sheet"]')).toBeNull();
32
+ const sheet = host.querySelector('[data-sh3-region="menu-sheet"]');
33
+ expect(sheet).not.toBeNull();
34
+ expect(sheet.querySelector('.title').textContent).toContain('Menu');
33
35
  });
34
- it('renders a sheet with a Cancel button when open', () => {
36
+ it('does not show back button at root level', () => {
35
37
  host = document.createElement('div');
36
38
  document.body.appendChild(host);
37
39
  mounted = mount(MenuSheetAny, {
38
40
  target: host,
39
- props: { open: true, onClose: () => { } },
41
+ props: { close: () => { } },
40
42
  });
41
43
  flushSync();
42
- const sheet = host.querySelector('[data-sh3-region="menu-sheet"]');
43
- expect(sheet).not.toBeNull();
44
- expect(sheet.querySelector('.cancel').textContent).toContain('Cancel');
44
+ const backBtn = host.querySelector('button[aria-label="Back"]');
45
+ expect(backBtn).toBeNull();
46
+ });
47
+ it('renders empty state when no items available', () => {
48
+ host = document.createElement('div');
49
+ document.body.appendChild(host);
50
+ mounted = mount(MenuSheetAny, {
51
+ target: host,
52
+ props: { close: () => { } },
53
+ });
54
+ flushSync();
55
+ const empty = host.querySelector('.empty');
56
+ expect(empty).not.toBeNull();
45
57
  });
46
58
  });
@@ -13,7 +13,8 @@ import { resolvePlatform } from './platform/index';
13
13
  import { apiFetch } from './transport/apiFetch';
14
14
  import { hydrateTokenOverrides } from './theme';
15
15
  import { __setEnvServerUrl, getEnvServerUrl } from './env/index';
16
- import { __setActiveScope } from './documents/config';
16
+ import { __setActiveScope, __setScopeResolver } from './documents/config';
17
+ import { __setScopeResolver as __setShardScopeResolver } from './shards/activate.svelte';
17
18
  import { initFromBoot } from './auth/index';
18
19
  import SignInWall from './auth/SignInWall.svelte';
19
20
  import { loadBundleModule } from './registry/loader';
@@ -21,6 +22,7 @@ import { registerLoadedBundle } from './registry/register';
21
22
  import { attachGlobalListeners } from './actions/listeners';
22
23
  import { detectSatelliteMode } from './boot/satelliteMode';
23
24
  import { MemoryBackend } from './state/backends';
25
+ import { sessionState } from './projects/session-state.svelte';
24
26
  import SatelliteShell from './satellite/SatelliteShell.svelte';
25
27
  export async function createShell(config) {
26
28
  var _a, _b;
@@ -31,9 +33,6 @@ export async function createShell(config) {
31
33
  __setBackend('workspace', platform.backends.workspace);
32
34
  __setBackend('user', platform.backends.user);
33
35
  }
34
- if (platform.localOwner) {
35
- setLocalOwner();
36
- }
37
36
  __setEnvServerUrl(sUrl);
38
37
  hydrateTokenOverrides();
39
38
  // 2. Resolve mount target early (needed for both sign-in wall and sh3)
@@ -61,6 +60,8 @@ export async function createShell(config) {
61
60
  // but pop-out is currently a Tauri-only POC so we don't fetch it.
62
61
  if (platform.localOwner)
63
62
  __setActiveScope('local');
63
+ __setScopeResolver(() => sessionState.activeProjectId);
64
+ __setShardScopeResolver(() => sessionState.activeProjectId ? 'project' : 'tenant');
64
65
  if (config === null || config === void 0 ? void 0 : config.shards)
65
66
  for (const shard of config.shards)
66
67
  registerShard(shard);
@@ -84,33 +85,38 @@ export async function createShell(config) {
84
85
  mount(SatelliteShell, { target, props: { payload: satellite.payload } });
85
86
  return;
86
87
  }
87
- // 3. Fetch boot config (skip for purely-local owners; remoteAuth
88
- // forces it for cross-origin Tauri clients).
88
+ // 3. Fetch boot config. Always fetched — local-owner (Tauri sidecar)
89
+ // needs the real sh3s_ session token minted by /api/boot.
89
90
  let bootConfig = null;
90
- const useServerAuth = !platform.localOwner || (config === null || config === void 0 ? void 0 : config.remoteAuth) === true;
91
- if (useServerAuth) {
92
- try {
93
- const res = await apiFetch(`${sUrl}/api/boot`);
94
- if (res.ok) {
95
- bootConfig = await res.json();
96
- }
97
- }
98
- catch (_c) {
99
- // Server unreachable — boot without auth (offline mode)
91
+ try {
92
+ const res = await apiFetch(`${sUrl}/api/boot`);
93
+ if (res.ok) {
94
+ bootConfig = await res.json();
100
95
  }
101
96
  }
97
+ catch (_c) {
98
+ // Server unreachable — boot without auth (offline mode)
99
+ }
102
100
  // 4. Auth decision point
103
101
  if (platform.localOwner && !(config === null || config === void 0 ? void 0 : config.remoteAuth)) {
104
- // Local-owner (Tauri/dev): no auth, no sign-in, scope is 'local'.
105
- // setLocalOwner() already called above admin is assumed.
106
- __setActiveScope('local');
102
+ // Local-owner (Tauri sidecar): boot minted a real sh3s_ session.
103
+ // initFromBoot stores the token; fall back to synthetic local if
104
+ // the server was unreachable (should never happen in sidecar mode).
105
+ if (bootConfig) {
106
+ initFromBoot(sUrl, bootConfig);
107
+ __setActiveScope(bootConfig.tenantId);
108
+ }
109
+ else {
110
+ setLocalOwner();
111
+ __setActiveScope('local');
112
+ }
107
113
  }
108
114
  else if (bootConfig) {
109
115
  initFromBoot(sUrl, bootConfig);
110
116
  __setActiveScope(bootConfig.tenantId);
111
117
  const { auth, session } = bootConfig;
112
- // Hard gate: no session, auth required, no guest allowed → sign-in wall
113
- if (!session && auth.required && !auth.guestAllowed) {
118
+ // Hard gate: no session and no guest allowed → sign-in wall
119
+ if (!session && !auth.guestAllowed) {
114
120
  await showSignInWall(target, bootConfig);
115
121
  // After successful sign-in, re-fetch boot config
116
122
  const res = await apiFetch(`${sUrl}/api/boot`);
@@ -137,6 +143,11 @@ export async function createShell(config) {
137
143
  if (config === null || config === void 0 ? void 0 : config.excludeShards)
138
144
  bootstrapConfig.excludeShards = config.excludeShards;
139
145
  await bootstrap(bootstrapConfig);
146
+ // 7b. Wire the document zone's scope resolver to the active project.
147
+ // When the user enters a project, getActiveScopeId() returns the project
148
+ // id so all document operations use the project's virtual tenant.
149
+ __setScopeResolver(() => sessionState.activeProjectId);
150
+ __setShardScopeResolver(() => sessionState.activeProjectId ? 'project' : 'tenant');
140
151
  // 8. Attach document-level keyboard / focus listeners
141
152
  attachGlobalListeners();
142
153
  // 9. Mount the sh3
@@ -48,11 +48,17 @@ describe('createShell remoteAuth flag', () => {
48
48
  }
49
49
  expect(calls.some(u => u === 'https://remote.example.com/api/boot')).toBe(true);
50
50
  });
51
- it('keeps the legacy localOwner short-circuit when remoteAuth is absent', async () => {
51
+ it('fetches /api/boot in localOwner mode (sidecar needs the real sh3s_ token)', async () => {
52
52
  const calls = [];
53
53
  globalThis.fetch = vi.fn(async (input) => {
54
54
  calls.push(String(input));
55
- return new Response('ok');
55
+ return new Response(JSON.stringify({
56
+ version: '0.20.0',
57
+ tenantId: 'local',
58
+ auth: { guestAllowed: false, selfRegistration: false },
59
+ session: { token: 'sh3s_abc', userId: 'local', role: 'admin', expiresAt: Number.MAX_SAFE_INTEGER },
60
+ user: { id: 'local', username: 'local', displayName: 'Local Owner', role: 'admin', createdAt: '', updatedAt: '' },
61
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
56
62
  });
57
63
  vi.doMock('./platform/index', () => ({
58
64
  resolvePlatform: async () => ({ backends: null, localOwner: true }),
@@ -66,6 +72,6 @@ describe('createShell remoteAuth flag', () => {
66
72
  catch (_a) {
67
73
  // ignore — assertion below is the contract.
68
74
  }
69
- expect(calls.some(u => u.endsWith('/api/boot'))).toBe(false);
75
+ expect(calls.some(u => u.endsWith('/api/boot'))).toBe(true);
70
76
  });
71
77
  });
@@ -91,6 +91,23 @@ export interface BrowseCapability {
91
91
  * `typeof ctx.browse.deleteFrom === 'function'`.
92
92
  */
93
93
  deleteFrom?(shardId: string, path: string): Promise<void>;
94
+ /**
95
+ * Copy or move a document from the active tenant to another scope.
96
+ * Available only when the caller declares both `documents:browse` and
97
+ * `documents:write`. Used for transferring documents between a user's
98
+ * personal scope and a project scope (or vice versa).
99
+ *
100
+ * When `opts.delete` is true, the source document is removed after
101
+ * copying (effectively a move/transfer). Default: false (copy only).
102
+ *
103
+ * Absent (undefined) on the capability object when `documents:write`
104
+ * is not declared; feature-detect with
105
+ * `typeof ctx.browse.transferToScope === 'function'`.
106
+ */
107
+ transferToScope?(shardId: string, path: string, targetScope: string, opts?: {
108
+ targetShardId?: string;
109
+ delete?: boolean;
110
+ }): Promise<void>;
94
111
  }
95
112
  export interface BrowseCapabilityOptions {
96
113
  /** When true, the returned capability exposes `readFrom`. */
@@ -98,4 +115,4 @@ export interface BrowseCapabilityOptions {
98
115
  /** When true, the returned capability exposes `writeTo`. */
99
116
  canWrite: boolean;
100
117
  }
101
- export declare function createBrowseCapability(tenantId: string, backend: DocumentBackend, options?: BrowseCapabilityOptions): BrowseCapability;
118
+ export declare function createBrowseCapability(getTenantId: () => string, backend: DocumentBackend, options?: BrowseCapabilityOptions): BrowseCapability;
@@ -4,33 +4,39 @@
4
4
  * Exposed on ShardContext as `ctx.browse` when the shard declares the
5
5
  * 'documents:browse' permission. Read-only: writes still flow through
6
6
  * the owning shard's own ctx.documents() handle.
7
+ *
8
+ * Tenant id is resolved lazily via a callback so that the browse
9
+ * capability automatically follows the active scope (personal tenant
10
+ * vs. active project). The callback is wired by the framework at
11
+ * shard activation time.
7
12
  */
8
13
  import { documentChanges } from './notifications';
9
- export function createBrowseCapability(tenantId, backend, options = { canRead: false, canWrite: false }) {
14
+ export function createBrowseCapability(getTenantId, backend, options = { canRead: false, canWrite: false }) {
10
15
  const capability = {
11
- listDocuments: () => backend.listAllDocuments(tenantId),
12
- listShards: () => backend.listAllShards(tenantId),
16
+ listDocuments: () => backend.listAllDocuments(getTenantId()),
17
+ listShards: () => backend.listAllShards(getTenantId()),
13
18
  watchDocuments: (callback) => documentChanges.subscribe((change) => {
14
- if (change.tenantId !== tenantId)
19
+ if (change.tenantId !== getTenantId())
15
20
  return;
16
21
  callback(change);
17
22
  }),
18
23
  };
19
24
  if (options.canRead) {
20
- capability.readFrom = (shardId, path) => backend.read(tenantId, shardId, path);
25
+ capability.readFrom = (shardId, path) => backend.read(getTenantId(), shardId, path);
21
26
  capability.statusFrom = async (shardId, path) => {
22
27
  if (!backend.readMeta)
23
28
  return null;
24
- return backend.readMeta(tenantId, shardId, path);
29
+ return backend.readMeta(getTenantId(), shardId, path);
25
30
  };
26
31
  capability.readBranchFrom = async (shardId, path, origin) => {
27
32
  if (!backend.readBranch)
28
33
  return null;
29
- return backend.readBranch(tenantId, shardId, path, origin);
34
+ return backend.readBranch(getTenantId(), shardId, path, origin);
30
35
  };
31
36
  }
32
37
  if (options.canWrite) {
33
38
  capability.writeTo = async (shardId, path, content) => {
39
+ const tenantId = getTenantId();
34
40
  const existed = await backend.exists(tenantId, shardId, path);
35
41
  await backend.write(tenantId, shardId, path, content);
36
42
  documentChanges.emit({
@@ -43,6 +49,7 @@ export function createBrowseCapability(tenantId, backend, options = { canRead: f
43
49
  capability.resolveConflictFrom = async (shardId, path, choice) => {
44
50
  if (!backend.resolve)
45
51
  throw new Error('Backend does not support resolveConflict');
52
+ const tenantId = getTenantId();
46
53
  await backend.resolve(tenantId, shardId, path, choice);
47
54
  documentChanges.emit({ type: 'update', path, tenantId, shardId });
48
55
  };
@@ -50,6 +57,7 @@ export function createBrowseCapability(tenantId, backend, options = { canRead: f
50
57
  if ((opts === null || opts === void 0 ? void 0 : opts.newShardId) !== undefined) {
51
58
  throw new Error('Cross-shard move is not yet supported (ADR-018 amendment 2026-04-27)');
52
59
  }
60
+ const tenantId = getTenantId();
53
61
  await backend.rename(tenantId, shardId, oldPath, newPath);
54
62
  documentChanges.emit({
55
63
  type: 'rename',
@@ -60,12 +68,37 @@ export function createBrowseCapability(tenantId, backend, options = { canRead: f
60
68
  });
61
69
  };
62
70
  capability.deleteFrom = async (shardId, path) => {
71
+ const tenantId = getTenantId();
63
72
  const existed = await backend.exists(tenantId, shardId, path);
64
73
  await backend.delete(tenantId, shardId, path);
65
74
  if (existed) {
66
75
  documentChanges.emit({ type: 'delete', path, tenantId, shardId });
67
76
  }
68
77
  };
78
+ capability.transferToScope = async (shardId, path, targetScope, opts) => {
79
+ var _a;
80
+ const tenantId = getTenantId();
81
+ if (targetScope === tenantId) {
82
+ throw new Error('targetScope must differ from the active tenant');
83
+ }
84
+ const targetShard = (_a = opts === null || opts === void 0 ? void 0 : opts.targetShardId) !== null && _a !== void 0 ? _a : shardId;
85
+ const content = await backend.read(tenantId, shardId, path);
86
+ if (content === null) {
87
+ throw new Error(`Document not found at ${shardId}/${path} in scope ${tenantId}`);
88
+ }
89
+ const existed = await backend.exists(targetScope, targetShard, path);
90
+ await backend.write(targetScope, targetShard, path, content);
91
+ documentChanges.emit({
92
+ type: existed ? 'update' : 'create',
93
+ path,
94
+ tenantId: targetScope,
95
+ shardId: targetShard,
96
+ });
97
+ if (opts === null || opts === void 0 ? void 0 : opts.delete) {
98
+ await backend.delete(tenantId, shardId, path);
99
+ documentChanges.emit({ type: 'delete', path, tenantId, shardId });
100
+ }
101
+ };
69
102
  }
70
103
  return capability;
71
104
  }
@@ -7,7 +7,7 @@ describe('BrowseCapability', () => {
7
7
  const be = new MemoryDocumentBackend();
8
8
  await be.write('t1', 'a', 'x.txt', '1');
9
9
  await be.write('t1', 'b', 'y.txt', '22');
10
- const browse = createBrowseCapability('t1', be);
10
+ const browse = createBrowseCapability(() => 't1', be);
11
11
  const docs = await browse.listDocuments();
12
12
  expect(docs.map((d) => d.shardId).sort()).toEqual(['a', 'b']);
13
13
  });
@@ -15,12 +15,12 @@ describe('BrowseCapability', () => {
15
15
  const be = new MemoryDocumentBackend();
16
16
  await be.write('t1', 'a', 'x.txt', '1');
17
17
  await be.write('t1', 'b', 'y.txt', '2');
18
- const browse = createBrowseCapability('t1', be);
18
+ const browse = createBrowseCapability(() => 't1', be);
19
19
  expect((await browse.listShards()).sort()).toEqual(['a', 'b']);
20
20
  });
21
21
  it('watchDocuments fires with shardId on tenant-wide emits; filters other tenants', () => {
22
22
  const be = new MemoryDocumentBackend();
23
- const browse = createBrowseCapability('t1', be);
23
+ const browse = createBrowseCapability(() => 't1', be);
24
24
  const cb = vi.fn();
25
25
  const unsub = browse.watchDocuments(cb);
26
26
  documentChanges.emit({ type: 'create', path: 'f.txt', tenantId: 't1', shardId: 's1' });
@@ -31,7 +31,7 @@ describe('BrowseCapability', () => {
31
31
  });
32
32
  it('watchDocuments unsubscribe stops callbacks', () => {
33
33
  const be = new MemoryDocumentBackend();
34
- const browse = createBrowseCapability('t1', be);
34
+ const browse = createBrowseCapability(() => 't1', be);
35
35
  const cb = vi.fn();
36
36
  const unsub = browse.watchDocuments(cb);
37
37
  unsub();
@@ -41,46 +41,46 @@ describe('BrowseCapability', () => {
41
41
  describe('readFrom (documents:read gate)', () => {
42
42
  it('is absent when canRead is false', () => {
43
43
  const be = new MemoryDocumentBackend();
44
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
44
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: false });
45
45
  expect(browse.readFrom).toBeUndefined();
46
46
  });
47
47
  it('is present when canRead is true', () => {
48
48
  const be = new MemoryDocumentBackend();
49
- const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
49
+ const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
50
50
  expect(typeof browse.readFrom).toBe('function');
51
51
  });
52
52
  it('reads content from a foreign shard namespace', async () => {
53
53
  const be = new MemoryDocumentBackend();
54
54
  await be.write('t1', 'other', 'notes/ideas.md', 'hello');
55
- const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
55
+ const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
56
56
  expect(await browse.readFrom('other', 'notes/ideas.md')).toBe('hello');
57
57
  });
58
58
  it('returns null when the foreign document does not exist', async () => {
59
59
  const be = new MemoryDocumentBackend();
60
- const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
60
+ const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
61
61
  expect(await browse.readFrom('other', 'nope.txt')).toBeNull();
62
62
  });
63
63
  it('never crosses tenants: a t1 capability cannot read t2 docs', async () => {
64
64
  const be = new MemoryDocumentBackend();
65
65
  await be.write('t2', 's', 'secret.txt', 'hidden');
66
- const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
66
+ const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
67
67
  expect(await browse.readFrom('s', 'secret.txt')).toBeNull();
68
68
  });
69
69
  });
70
70
  describe('writeTo (documents:write gate)', () => {
71
71
  it('is absent when canWrite is false', () => {
72
72
  const be = new MemoryDocumentBackend();
73
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
73
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: false });
74
74
  expect(browse.writeTo).toBeUndefined();
75
75
  });
76
76
  it('is present when canWrite is true', () => {
77
77
  const be = new MemoryDocumentBackend();
78
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
78
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
79
79
  expect(typeof browse.writeTo).toBe('function');
80
80
  });
81
81
  it('writes into the target shard namespace and emits a create change', async () => {
82
82
  const be = new MemoryDocumentBackend();
83
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
83
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
84
84
  const events = [];
85
85
  const unsub = documentChanges.subscribe((c) => events.push(c));
86
86
  await browse.writeTo('target-shard', 'notes/ideas.md', 'hello');
@@ -93,7 +93,7 @@ describe('BrowseCapability', () => {
93
93
  it('emits update (not create) when overwriting an existing document', async () => {
94
94
  const be = new MemoryDocumentBackend();
95
95
  await be.write('t1', 'target-shard', 'a.txt', 'v1');
96
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
96
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
97
97
  const events = [];
98
98
  const unsub = documentChanges.subscribe((c) => events.push(c));
99
99
  await browse.writeTo('target-shard', 'a.txt', 'v2');
@@ -105,7 +105,7 @@ describe('BrowseCapability', () => {
105
105
  });
106
106
  it('never crosses tenants: cannot be tricked into writing into tenant b', async () => {
107
107
  const be = new MemoryDocumentBackend();
108
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
108
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
109
109
  await browse.writeTo('s', 'a.txt', 'x');
110
110
  expect(await be.read('t1', 's', 'a.txt')).toBe('x');
111
111
  expect(await be.read('t2', 's', 'a.txt')).toBeNull();
@@ -114,7 +114,7 @@ describe('BrowseCapability', () => {
114
114
  describe('read + write combined', () => {
115
115
  it('round-trips content through readFrom after writeTo', async () => {
116
116
  const be = new MemoryDocumentBackend();
117
- const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: true });
117
+ const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: true });
118
118
  await browse.writeTo('shard-x', 'doc.txt', 'roundtrip');
119
119
  expect(await browse.readFrom('shard-x', 'doc.txt')).toBe('roundtrip');
120
120
  });
@@ -122,13 +122,13 @@ describe('BrowseCapability', () => {
122
122
  describe('statusFrom / readBranchFrom (documents:read gate)', () => {
123
123
  it('absent when canRead is false', () => {
124
124
  const be = new MemoryDocumentBackend();
125
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
125
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: false });
126
126
  expect(browse.statusFrom).toBeUndefined();
127
127
  expect(browse.readBranchFrom).toBeUndefined();
128
128
  });
129
129
  it('present when canRead is true', () => {
130
130
  const be = new MemoryDocumentBackend();
131
- const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
131
+ const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
132
132
  expect(typeof browse.statusFrom).toBe('function');
133
133
  expect(typeof browse.readBranchFrom).toBe('function');
134
134
  });
@@ -144,13 +144,13 @@ describe('BrowseCapability', () => {
144
144
  listAllDocuments: vi.fn(async () => []),
145
145
  readMeta: vi.fn(async (...args) => { calls.push(args); return null; }),
146
146
  };
147
- const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
147
+ const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
148
148
  await browse.statusFrom('other', 'a.txt');
149
149
  expect(calls[0]).toEqual(['t1', 'other', 'a.txt']);
150
150
  });
151
151
  it('statusFrom returns null when backend has no readMeta', async () => {
152
152
  const be = new MemoryDocumentBackend();
153
- const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
153
+ const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
154
154
  expect(await browse.statusFrom('other', 'a.txt')).toBeNull();
155
155
  });
156
156
  it('readBranchFrom delegates to backend.readBranch with tenant baked in', async () => {
@@ -165,32 +165,32 @@ describe('BrowseCapability', () => {
165
165
  listAllDocuments: vi.fn(async () => []),
166
166
  readBranch: vi.fn(async (...args) => { calls.push(args); return 'x'; }),
167
167
  };
168
- const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
168
+ const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
169
169
  const out = await browse.readBranchFrom('other', 'a.txt', 'peer-1');
170
170
  expect(out).toBe('x');
171
171
  expect(calls[0]).toEqual(['t1', 'other', 'a.txt', 'peer-1']);
172
172
  });
173
173
  it('readBranchFrom returns null when backend has no readBranch', async () => {
174
174
  const be = new MemoryDocumentBackend();
175
- const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
175
+ const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
176
176
  expect(await browse.readBranchFrom('other', 'a.txt', 'peer-1')).toBeNull();
177
177
  });
178
178
  });
179
179
  describe('renameFrom (documents:write gate)', () => {
180
180
  it('absent when canWrite is false', () => {
181
181
  const be = new MemoryDocumentBackend();
182
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
182
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: false });
183
183
  expect(browse.renameFrom).toBeUndefined();
184
184
  });
185
185
  it('present when canWrite is true', () => {
186
186
  const be = new MemoryDocumentBackend();
187
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
187
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
188
188
  expect(typeof browse.renameFrom).toBe('function');
189
189
  });
190
190
  it('renames in the target shard namespace and emits a rename event', async () => {
191
191
  const be = new MemoryDocumentBackend();
192
192
  await be.write('t1', 'target-shard', 'old.txt', 'hello');
193
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
193
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
194
194
  const events = [];
195
195
  const unsub = documentChanges.subscribe((c) => events.push(c));
196
196
  await browse.renameFrom('target-shard', 'old.txt', 'new.txt');
@@ -210,7 +210,7 @@ describe('BrowseCapability', () => {
210
210
  it('never crosses tenants: cannot be tricked into renaming in tenant b', async () => {
211
211
  const be = new MemoryDocumentBackend();
212
212
  await be.write('t2', 's', 'old.txt', 'hidden');
213
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
213
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
214
214
  await expect(browse.renameFrom('s', 'old.txt', 'new.txt'))
215
215
  .rejects.toThrow(/not found/);
216
216
  expect(await be.read('t2', 's', 'old.txt')).toBe('hidden');
@@ -218,7 +218,7 @@ describe('BrowseCapability', () => {
218
218
  it('throws when opts.newShardId is provided (cross-shard move reserved)', async () => {
219
219
  const be = new MemoryDocumentBackend();
220
220
  await be.write('t1', 's1', 'old.txt', 'hello');
221
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
221
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
222
222
  await expect(browse.renameFrom('s1', 'old.txt', 'new.txt', { newShardId: 's2' })).rejects.toThrow(/Cross-shard move is not yet supported/);
223
223
  expect(await be.read('t1', 's1', 'old.txt')).toBe('hello');
224
224
  });
@@ -226,12 +226,12 @@ describe('BrowseCapability', () => {
226
226
  describe('resolveConflictFrom (documents:write gate)', () => {
227
227
  it('absent when canWrite is false', () => {
228
228
  const be = new MemoryDocumentBackend();
229
- const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
229
+ const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
230
230
  expect(browse.resolveConflictFrom).toBeUndefined();
231
231
  });
232
232
  it('present when canWrite is true', () => {
233
233
  const be = new MemoryDocumentBackend();
234
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
234
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
235
235
  expect(typeof browse.resolveConflictFrom).toBe('function');
236
236
  });
237
237
  it('delegates to backend.resolve with tenant baked in and emits an update', async () => {
@@ -246,7 +246,7 @@ describe('BrowseCapability', () => {
246
246
  listAllDocuments: vi.fn(async () => []),
247
247
  resolve: vi.fn(async (...args) => { calls.push(args); }),
248
248
  };
249
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
249
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
250
250
  const events = [];
251
251
  const unsub = documentChanges.subscribe((e) => events.push(e));
252
252
  await browse.resolveConflictFrom('other', 'a.txt', { origin: 'peer-1' });
@@ -258,7 +258,7 @@ describe('BrowseCapability', () => {
258
258
  });
259
259
  it('throws if backend does not support resolve', async () => {
260
260
  const be = new MemoryDocumentBackend();
261
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
261
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
262
262
  await expect(browse.resolveConflictFrom('other', 'a.txt', { origin: 'peer-1' }))
263
263
  .rejects.toThrow(/does not support resolveConflict/);
264
264
  });
@@ -266,18 +266,18 @@ describe('BrowseCapability', () => {
266
266
  describe('deleteFrom (documents:write gate)', () => {
267
267
  it('absent when canWrite is false', () => {
268
268
  const be = new MemoryDocumentBackend();
269
- const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
269
+ const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
270
270
  expect(browse.deleteFrom).toBeUndefined();
271
271
  });
272
272
  it('present when canWrite is true', () => {
273
273
  const be = new MemoryDocumentBackend();
274
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
274
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
275
275
  expect(typeof browse.deleteFrom).toBe('function');
276
276
  });
277
277
  it('deletes from the target shard namespace and emits a delete event', async () => {
278
278
  const be = new MemoryDocumentBackend();
279
279
  await be.write('t1', 'target-shard', 'a.txt', 'hello');
280
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
280
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
281
281
  const events = [];
282
282
  const unsub = documentChanges.subscribe((c) => events.push(c));
283
283
  await browse.deleteFrom('target-shard', 'a.txt');
@@ -289,7 +289,7 @@ describe('BrowseCapability', () => {
289
289
  });
290
290
  it('is idempotent on missing paths and emits no event', async () => {
291
291
  const be = new MemoryDocumentBackend();
292
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
292
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
293
293
  const events = [];
294
294
  const unsub = documentChanges.subscribe((c) => events.push(c));
295
295
  await expect(browse.deleteFrom('target-shard', 'nope.txt')).resolves.toBeUndefined();
@@ -299,7 +299,7 @@ describe('BrowseCapability', () => {
299
299
  it('never crosses tenants: a t1 capability cannot delete t2 docs', async () => {
300
300
  const be = new MemoryDocumentBackend();
301
301
  await be.write('t2', 's', 'secret.txt', 'hidden');
302
- const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
302
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
303
303
  await browse.deleteFrom('s', 'secret.txt');
304
304
  expect(await be.read('t2', 's', 'secret.txt')).toBe('hidden');
305
305
  });
@@ -1,4 +1,8 @@
1
1
  import type { DocumentBackend } from './types';
2
+ /** Host-only. Register a callback that resolves the active project scope.
3
+ * When non-null and returns a string, that string overrides the static
4
+ * scopeId for all document operations. Wired by createShell after bootstrap. */
5
+ export declare function __setScopeResolver(resolver: (() => string | null) | null): void;
2
6
  export declare function getActiveScopeId(): string;
3
7
  /** @deprecated use getActiveScopeId — kept until callers migrate. */
4
8
  export declare function getTenantId(): string;
@@ -5,18 +5,31 @@
5
5
  * calls __setActiveScope and __setDocumentBackend before bootstrap() to
6
6
  * configure multi-scope routing and swap backends (e.g. Tauri FS).
7
7
  *
8
+ * Project scope: when a project is active (sessionState.activeProjectId),
9
+ * the scope resolver returns the project id so all document operations
10
+ * operate on the project's virtual tenant instead of the user's personal
11
+ * scope. The resolver is wired by createShell after bootstrap.
12
+ *
8
13
  * Defaults: scopeId='local' (single-user self-hosted), backend=IndexedDB.
9
14
  */
10
15
  import { IndexedDBDocumentBackend } from './backends';
11
16
  const DEFAULT_SCOPE = 'local';
12
17
  let scopeId = DEFAULT_SCOPE;
13
18
  let backend = new IndexedDBDocumentBackend();
19
+ let scopeResolver = null;
20
+ /** Host-only. Register a callback that resolves the active project scope.
21
+ * When non-null and returns a string, that string overrides the static
22
+ * scopeId for all document operations. Wired by createShell after bootstrap. */
23
+ export function __setScopeResolver(resolver) {
24
+ scopeResolver = resolver;
25
+ }
14
26
  export function getActiveScopeId() {
15
- return scopeId;
27
+ var _a;
28
+ return (_a = scopeResolver === null || scopeResolver === void 0 ? void 0 : scopeResolver()) !== null && _a !== void 0 ? _a : scopeId;
16
29
  }
17
30
  /** @deprecated use getActiveScopeId — kept until callers migrate. */
18
31
  export function getTenantId() {
19
- return scopeId;
32
+ return getActiveScopeId();
20
33
  }
21
34
  export function getDocumentBackend() {
22
35
  return backend;