sh3-core 0.9.0 → 0.10.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 (40) hide show
  1. package/dist/api.d.ts +2 -1
  2. package/dist/api.js +1 -1
  3. package/dist/app/store/InstalledView.svelte +55 -1
  4. package/dist/app/store/PermissionConfirmModal.svelte +232 -0
  5. package/dist/app/store/PermissionConfirmModal.svelte.d.ts +17 -0
  6. package/dist/app/store/StoreView.svelte +119 -5
  7. package/dist/app/store/storeShard.svelte.d.ts +10 -1
  8. package/dist/app/store/storeShard.svelte.js +51 -7
  9. package/dist/app/store/storeShard.svelte.test.d.ts +1 -0
  10. package/dist/app/store/storeShard.svelte.test.js +34 -0
  11. package/dist/contributions/index.d.ts +2 -0
  12. package/dist/contributions/index.js +8 -0
  13. package/dist/contributions/registry.d.ts +21 -0
  14. package/dist/contributions/registry.js +89 -0
  15. package/dist/contributions/registry.test.d.ts +1 -0
  16. package/dist/contributions/registry.test.js +109 -0
  17. package/dist/contributions/types.d.ts +24 -0
  18. package/dist/contributions/types.js +10 -0
  19. package/dist/documents/browse.d.ts +31 -1
  20. package/dist/documents/browse.js +18 -2
  21. package/dist/documents/browse.test.js +81 -0
  22. package/dist/documents/types.d.ts +29 -0
  23. package/dist/documents/types.js +29 -0
  24. package/dist/registry/client.js +3 -0
  25. package/dist/registry/installer.d.ts +4 -1
  26. package/dist/registry/installer.js +25 -11
  27. package/dist/registry/permission-descriptions.d.ts +21 -0
  28. package/dist/registry/permission-descriptions.js +67 -0
  29. package/dist/registry/permission-descriptions.test.d.ts +1 -0
  30. package/dist/registry/permission-descriptions.test.js +86 -0
  31. package/dist/registry/schema.js +19 -6
  32. package/dist/registry/types.d.ts +17 -5
  33. package/dist/shards/activate-browse.test.js +87 -3
  34. package/dist/shards/activate-contributions.test.d.ts +1 -0
  35. package/dist/shards/activate-contributions.test.js +110 -0
  36. package/dist/shards/activate.svelte.js +28 -2
  37. package/dist/shards/types.d.ts +7 -0
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +1 -1
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { diffPermissions } from './storeShard.svelte';
3
+ describe('diffPermissions', () => {
4
+ it('returns empty added and removed when the sets are identical', () => {
5
+ expect(diffPermissions(['a', 'b'], ['a', 'b'])).toEqual({
6
+ added: [],
7
+ removed: [],
8
+ });
9
+ });
10
+ it('detects permissions added by the new version', () => {
11
+ expect(diffPermissions(['a'], ['a', 'b'])).toEqual({
12
+ added: ['b'],
13
+ removed: [],
14
+ });
15
+ });
16
+ it('detects permissions removed by the new version', () => {
17
+ expect(diffPermissions(['a', 'b'], ['a'])).toEqual({
18
+ added: [],
19
+ removed: ['b'],
20
+ });
21
+ });
22
+ it('reports both added and removed when the set changes in both directions', () => {
23
+ expect(diffPermissions(['a', 'b'], ['b', 'c'])).toEqual({
24
+ added: ['c'],
25
+ removed: ['a'],
26
+ });
27
+ });
28
+ it('treats an empty old set as "everything is new"', () => {
29
+ expect(diffPermissions([], ['a', 'b'])).toEqual({
30
+ added: ['a', 'b'],
31
+ removed: [],
32
+ });
33
+ });
34
+ });
@@ -0,0 +1,2 @@
1
+ export type { ContributionsApi } from './types';
2
+ export { register, list, listPoints, onChange, __resetContributionsForTest } from './registry';
@@ -0,0 +1,8 @@
1
+ /*
2
+ * Contribution-points barrel — internal re-exports.
3
+ *
4
+ * The public type (ContributionsApi) reaches shards via api.ts; this
5
+ * file is internal-only, re-exporting the registry for activate.svelte.ts
6
+ * and for tests.
7
+ */
8
+ export { register, list, listPoints, onChange, __resetContributionsForTest } from './registry';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Register a descriptor under the given point. Returns an unregister
3
+ * function; calling it more than once is a safe no-op.
4
+ */
5
+ export declare function register<T = unknown>(pointId: string, descriptor: T): () => void;
6
+ /** Enumerate descriptors at the named point in registration order. */
7
+ export declare function list<T = unknown>(pointId: string): T[];
8
+ /** Enumerate every point id with at least one registration. */
9
+ export declare function listPoints(): string[];
10
+ /**
11
+ * Subscribe to registration changes at the named point. The callback
12
+ * receives no arguments — subscribers call `list` themselves to read
13
+ * the current state. Returns an unsubscribe; double-unsubscribe is a
14
+ * safe no-op.
15
+ */
16
+ export declare function onChange(pointId: string, cb: () => void): () => void;
17
+ /**
18
+ * Test-only reset. Not exported from the barrel; tests import it
19
+ * directly from this module.
20
+ */
21
+ export declare function __resetContributionsForTest(): void;
@@ -0,0 +1,89 @@
1
+ /*
2
+ * Contribution point registry — shard-to-shard runtime collaboration.
3
+ *
4
+ * One module-level map holds every point's descriptors. Handles are
5
+ * Symbols so two registrations with identical content stay distinct.
6
+ * Emitters are a Set of zero-arg callbacks; subscribers re-read `list`
7
+ * themselves when notified.
8
+ *
9
+ * No tenant partitioning: the shell is single-tenant per session; a
10
+ * future multi-tenant client would add a tenant dimension to the
11
+ * outer map (see direction spec §5.2).
12
+ */
13
+ const points = new Map();
14
+ const listeners = new Map();
15
+ function emit(pointId) {
16
+ const set = listeners.get(pointId);
17
+ if (!set)
18
+ return;
19
+ for (const cb of set)
20
+ cb();
21
+ }
22
+ /**
23
+ * Register a descriptor under the given point. Returns an unregister
24
+ * function; calling it more than once is a safe no-op.
25
+ */
26
+ export function register(pointId, descriptor) {
27
+ const handle = Symbol();
28
+ let map = points.get(pointId);
29
+ if (!map) {
30
+ map = new Map();
31
+ points.set(pointId, map);
32
+ }
33
+ map.set(handle, descriptor);
34
+ emit(pointId);
35
+ let disposed = false;
36
+ return () => {
37
+ if (disposed)
38
+ return;
39
+ disposed = true;
40
+ const m = points.get(pointId);
41
+ if (!m)
42
+ return;
43
+ if (m.delete(handle)) {
44
+ if (m.size === 0)
45
+ points.delete(pointId);
46
+ emit(pointId);
47
+ }
48
+ };
49
+ }
50
+ /** Enumerate descriptors at the named point in registration order. */
51
+ export function list(pointId) {
52
+ const m = points.get(pointId);
53
+ return m ? Array.from(m.values()) : [];
54
+ }
55
+ /** Enumerate every point id with at least one registration. */
56
+ export function listPoints() {
57
+ return Array.from(points.keys());
58
+ }
59
+ /**
60
+ * Subscribe to registration changes at the named point. The callback
61
+ * receives no arguments — subscribers call `list` themselves to read
62
+ * the current state. Returns an unsubscribe; double-unsubscribe is a
63
+ * safe no-op.
64
+ */
65
+ export function onChange(pointId, cb) {
66
+ let set = listeners.get(pointId);
67
+ if (!set) {
68
+ set = new Set();
69
+ listeners.set(pointId, set);
70
+ }
71
+ set.add(cb);
72
+ let disposed = false;
73
+ return () => {
74
+ if (disposed)
75
+ return;
76
+ disposed = true;
77
+ set.delete(cb);
78
+ if (set.size === 0)
79
+ listeners.delete(pointId);
80
+ };
81
+ }
82
+ /**
83
+ * Test-only reset. Not exported from the barrel; tests import it
84
+ * directly from this module.
85
+ */
86
+ export function __resetContributionsForTest() {
87
+ points.clear();
88
+ listeners.clear();
89
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { register, list, listPoints, onChange, __resetContributionsForTest, } from './registry';
3
+ describe('contributions registry', () => {
4
+ beforeEach(() => {
5
+ __resetContributionsForTest();
6
+ });
7
+ describe('register / list', () => {
8
+ it('returns an unregister function', () => {
9
+ const unreg = register('p', { id: 'a' });
10
+ expect(typeof unreg).toBe('function');
11
+ });
12
+ it('register then list returns the descriptor', () => {
13
+ register('p', { id: 'a' });
14
+ expect(list('p')).toEqual([{ id: 'a' }]);
15
+ });
16
+ it('lists multiple descriptors in registration order', () => {
17
+ register('p', { id: 'a' });
18
+ register('p', { id: 'b' });
19
+ register('p', { id: 'c' });
20
+ expect(list('p').map((d) => d.id)).toEqual(['a', 'b', 'c']);
21
+ });
22
+ it('separates descriptors by pointId', () => {
23
+ register('p1', { id: 'a' });
24
+ register('p2', { id: 'b' });
25
+ expect(list('p1')).toEqual([{ id: 'a' }]);
26
+ expect(list('p2')).toEqual([{ id: 'b' }]);
27
+ });
28
+ it('returns an empty array for an unknown pointId', () => {
29
+ expect(list('nope')).toEqual([]);
30
+ });
31
+ it('unregister removes the descriptor', () => {
32
+ const unreg = register('p', { id: 'a' });
33
+ unreg();
34
+ expect(list('p')).toEqual([]);
35
+ });
36
+ it('double-unregister is a no-op', () => {
37
+ const unreg = register('p', { id: 'a' });
38
+ unreg();
39
+ unreg();
40
+ expect(list('p')).toEqual([]);
41
+ });
42
+ it('allows duplicate descriptors (framework does not inspect content)', () => {
43
+ register('p', { id: 'a' });
44
+ register('p', { id: 'a' });
45
+ expect(list('p')).toHaveLength(2);
46
+ });
47
+ });
48
+ describe('listPoints', () => {
49
+ it('returns empty when nothing is registered', () => {
50
+ expect(listPoints()).toEqual([]);
51
+ });
52
+ it('returns every pointId with at least one registration', () => {
53
+ register('p1', { id: 'a' });
54
+ register('p2', { id: 'b' });
55
+ expect(listPoints().sort()).toEqual(['p1', 'p2']);
56
+ });
57
+ it('excludes pointIds whose last registration was unregistered', () => {
58
+ const u1 = register('p1', { id: 'a' });
59
+ register('p2', { id: 'b' });
60
+ u1();
61
+ expect(listPoints()).toEqual(['p2']);
62
+ });
63
+ });
64
+ describe('onChange', () => {
65
+ it('fires on register', () => {
66
+ const cb = vi.fn();
67
+ onChange('p', cb);
68
+ register('p', { id: 'a' });
69
+ expect(cb).toHaveBeenCalledTimes(1);
70
+ });
71
+ it('fires on unregister', () => {
72
+ const cb = vi.fn();
73
+ const unreg = register('p', { id: 'a' });
74
+ onChange('p', cb);
75
+ unreg();
76
+ expect(cb).toHaveBeenCalledTimes(1);
77
+ });
78
+ it('does not fire for other pointIds', () => {
79
+ const cb = vi.fn();
80
+ onChange('p1', cb);
81
+ register('p2', { id: 'a' });
82
+ expect(cb).not.toHaveBeenCalled();
83
+ });
84
+ it('supports multiple subscribers', () => {
85
+ const a = vi.fn();
86
+ const b = vi.fn();
87
+ onChange('p', a);
88
+ onChange('p', b);
89
+ register('p', { id: 'x' });
90
+ expect(a).toHaveBeenCalledTimes(1);
91
+ expect(b).toHaveBeenCalledTimes(1);
92
+ });
93
+ it('unsubscribe stops callbacks', () => {
94
+ const cb = vi.fn();
95
+ const off = onChange('p', cb);
96
+ off();
97
+ register('p', { id: 'x' });
98
+ expect(cb).not.toHaveBeenCalled();
99
+ });
100
+ it('double-unsubscribe is a no-op', () => {
101
+ const cb = vi.fn();
102
+ const off = onChange('p', cb);
103
+ off();
104
+ off();
105
+ register('p', { id: 'x' });
106
+ expect(cb).not.toHaveBeenCalled();
107
+ });
108
+ });
109
+ });
@@ -0,0 +1,24 @@
1
+ export interface ContributionsApi {
2
+ /**
3
+ * Register `descriptor` under `pointId`. Descriptors are freeform;
4
+ * the framework does not inspect them. The type parameter exists
5
+ * for ergonomics — provider and contributor agree on the shape via
6
+ * a type-only import of the provider's public types.
7
+ *
8
+ * Returns an unregister function. Calling it is optional (the
9
+ * framework auto-unregisters on shard deactivate) and safe to call
10
+ * more than once.
11
+ */
12
+ register<T = unknown>(pointId: string, descriptor: T): () => void;
13
+ /** Enumerate descriptors at `pointId` in registration order. */
14
+ list<T = unknown>(pointId: string): T[];
15
+ /** Enumerate every point id with at least one registration. */
16
+ listPoints(): string[];
17
+ /**
18
+ * Subscribe to registration changes at `pointId`. The callback
19
+ * receives no arguments — call `list` from inside to read the
20
+ * current state. Returns an unsubscribe; auto-unsubscribed on
21
+ * shard deactivate.
22
+ */
23
+ onChange(pointId: string, cb: () => void): () => void;
24
+ }
@@ -0,0 +1,10 @@
1
+ /*
2
+ * ContributionsApi — the per-shard surface exposed on ShardContext.
3
+ *
4
+ * See docs/sh3-rfcs/2026-04-20-shard-contribution-points.md for
5
+ * motivation and semantics. Every registration and every onChange
6
+ * subscription made through this API is auto-cleaned when the owning
7
+ * shard deactivates; callers should not need to call the returned
8
+ * disposer unless they want to release early.
9
+ */
10
+ export {};
@@ -8,5 +8,35 @@ export interface BrowseCapability {
8
8
  watchDocuments(callback: (change: DocumentChange) => void): () => void;
9
9
  /** Enumerate shard ids with at least one document in the tenant. */
10
10
  listShards(): Promise<string[]>;
11
+ /**
12
+ * Read content from any shard's document namespace within the active
13
+ * tenant. Available only when the caller declares both
14
+ * `documents:browse` and `documents:read`. Returns the content string
15
+ * (text-format docs), ArrayBuffer (binary-format docs), or null if the
16
+ * document does not exist. Tenant-scoped — cannot cross tenants.
17
+ *
18
+ * Absent (undefined) on the capability object when `documents:read`
19
+ * is not declared; feature-detect with
20
+ * `typeof ctx.browse.readFrom === 'function'`.
21
+ */
22
+ readFrom?(shardId: string, path: string): Promise<string | ArrayBuffer | null>;
23
+ /**
24
+ * Write content into any shard's document namespace within the active
25
+ * tenant. Available only when the caller declares both
26
+ * `documents:browse` and `documents:write`. Emits a normal
27
+ * `DocumentChange` so other shards and the file-explorer pick up the
28
+ * new or updated document. Tenant-scoped — cannot cross tenants.
29
+ *
30
+ * Absent (undefined) on the capability object when `documents:write`
31
+ * is not declared; feature-detect with
32
+ * `typeof ctx.browse.writeTo === 'function'`.
33
+ */
34
+ writeTo?(shardId: string, path: string, content: string | ArrayBuffer): Promise<void>;
11
35
  }
12
- export declare function createBrowseCapability(tenantId: string, backend: DocumentBackend): BrowseCapability;
36
+ export interface BrowseCapabilityOptions {
37
+ /** When true, the returned capability exposes `readFrom`. */
38
+ canRead: boolean;
39
+ /** When true, the returned capability exposes `writeTo`. */
40
+ canWrite: boolean;
41
+ }
42
+ export declare function createBrowseCapability(tenantId: string, backend: DocumentBackend, options?: BrowseCapabilityOptions): BrowseCapability;
@@ -6,8 +6,8 @@
6
6
  * the owning shard's own ctx.documents() handle.
7
7
  */
8
8
  import { documentChanges } from './notifications';
9
- export function createBrowseCapability(tenantId, backend) {
10
- return {
9
+ export function createBrowseCapability(tenantId, backend, options = { canRead: false, canWrite: false }) {
10
+ const capability = {
11
11
  listDocuments: () => backend.listAllDocuments(tenantId),
12
12
  listShards: () => backend.listAllShards(tenantId),
13
13
  watchDocuments: (callback) => documentChanges.subscribe((change) => {
@@ -16,4 +16,20 @@ export function createBrowseCapability(tenantId, backend) {
16
16
  callback(change);
17
17
  }),
18
18
  };
19
+ if (options.canRead) {
20
+ capability.readFrom = (shardId, path) => backend.read(tenantId, shardId, path);
21
+ }
22
+ if (options.canWrite) {
23
+ capability.writeTo = async (shardId, path, content) => {
24
+ const existed = await backend.exists(tenantId, shardId, path);
25
+ await backend.write(tenantId, shardId, path, content);
26
+ documentChanges.emit({
27
+ type: existed ? 'update' : 'create',
28
+ path,
29
+ tenantId,
30
+ shardId,
31
+ });
32
+ };
33
+ }
34
+ return capability;
19
35
  }
@@ -38,4 +38,85 @@ describe('BrowseCapability', () => {
38
38
  documentChanges.emit({ type: 'create', path: 'f.txt', tenantId: 't1', shardId: 's1' });
39
39
  expect(cb).not.toHaveBeenCalled();
40
40
  });
41
+ describe('readFrom (documents:read gate)', () => {
42
+ it('is absent when canRead is false', () => {
43
+ const be = new MemoryDocumentBackend();
44
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
45
+ expect(browse.readFrom).toBeUndefined();
46
+ });
47
+ it('is present when canRead is true', () => {
48
+ const be = new MemoryDocumentBackend();
49
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
50
+ expect(typeof browse.readFrom).toBe('function');
51
+ });
52
+ it('reads content from a foreign shard namespace', async () => {
53
+ const be = new MemoryDocumentBackend();
54
+ await be.write('t1', 'other', 'notes/ideas.md', 'hello');
55
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
56
+ expect(await browse.readFrom('other', 'notes/ideas.md')).toBe('hello');
57
+ });
58
+ it('returns null when the foreign document does not exist', async () => {
59
+ const be = new MemoryDocumentBackend();
60
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
61
+ expect(await browse.readFrom('other', 'nope.txt')).toBeNull();
62
+ });
63
+ it('never crosses tenants: a t1 capability cannot read t2 docs', async () => {
64
+ const be = new MemoryDocumentBackend();
65
+ await be.write('t2', 's', 'secret.txt', 'hidden');
66
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
67
+ expect(await browse.readFrom('s', 'secret.txt')).toBeNull();
68
+ });
69
+ });
70
+ describe('writeTo (documents:write gate)', () => {
71
+ it('is absent when canWrite is false', () => {
72
+ const be = new MemoryDocumentBackend();
73
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
74
+ expect(browse.writeTo).toBeUndefined();
75
+ });
76
+ it('is present when canWrite is true', () => {
77
+ const be = new MemoryDocumentBackend();
78
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
79
+ expect(typeof browse.writeTo).toBe('function');
80
+ });
81
+ it('writes into the target shard namespace and emits a create change', async () => {
82
+ const be = new MemoryDocumentBackend();
83
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
84
+ const events = [];
85
+ const unsub = documentChanges.subscribe((c) => events.push(c));
86
+ await browse.writeTo('target-shard', 'notes/ideas.md', 'hello');
87
+ expect(await be.read('t1', 'target-shard', 'notes/ideas.md')).toBe('hello');
88
+ expect(events).toEqual([
89
+ { type: 'create', path: 'notes/ideas.md', tenantId: 't1', shardId: 'target-shard' },
90
+ ]);
91
+ unsub();
92
+ });
93
+ it('emits update (not create) when overwriting an existing document', async () => {
94
+ const be = new MemoryDocumentBackend();
95
+ await be.write('t1', 'target-shard', 'a.txt', 'v1');
96
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
97
+ const events = [];
98
+ const unsub = documentChanges.subscribe((c) => events.push(c));
99
+ await browse.writeTo('target-shard', 'a.txt', 'v2');
100
+ expect(await be.read('t1', 'target-shard', 'a.txt')).toBe('v2');
101
+ expect(events).toEqual([
102
+ { type: 'update', path: 'a.txt', tenantId: 't1', shardId: 'target-shard' },
103
+ ]);
104
+ unsub();
105
+ });
106
+ it('never crosses tenants: cannot be tricked into writing into tenant b', async () => {
107
+ const be = new MemoryDocumentBackend();
108
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
109
+ await browse.writeTo('s', 'a.txt', 'x');
110
+ expect(await be.read('t1', 's', 'a.txt')).toBe('x');
111
+ expect(await be.read('t2', 's', 'a.txt')).toBeNull();
112
+ });
113
+ });
114
+ describe('read + write combined', () => {
115
+ it('round-trips content through readFrom after writeTo', async () => {
116
+ const be = new MemoryDocumentBackend();
117
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: true });
118
+ await browse.writeTo('shard-x', 'doc.txt', 'roundtrip');
119
+ expect(await browse.readFrom('shard-x', 'doc.txt')).toBe('roundtrip');
120
+ });
121
+ });
41
122
  });
@@ -5,6 +5,35 @@
5
5
  * through the owning shard's own `ctx.documents()` handle.
6
6
  */
7
7
  export declare const PERMISSION_DOCUMENTS_BROWSE = "documents:browse";
8
+ /**
9
+ * Manifest permission string: grants the shard the ability to read
10
+ * document content from any shard's namespace within the active tenant
11
+ * via `ctx.browse.readFrom(shardId, path)`. Requires `documents:browse`
12
+ * in the same manifest — the read surface hangs off `ctx.browse`, which
13
+ * only exists when `browse` is granted.
14
+ *
15
+ * `documents:browse` alone is metadata-only (listDocuments, listShards,
16
+ * watchDocuments). `documents:read` is the separate content-read gate so
17
+ * observer shards granted `browse` today do not silently gain content
18
+ * access.
19
+ *
20
+ * Tenant-scoped: reads cannot cross tenants.
21
+ */
22
+ export declare const PERMISSION_DOCUMENTS_READ = "documents:read";
23
+ /**
24
+ * Manifest permission string: grants the shard the ability to write
25
+ * documents into any shard's namespace within the active tenant via
26
+ * `ctx.browse.writeTo(shardId, path, content)`. Requires
27
+ * `documents:browse` in the same manifest — the write surface hangs off
28
+ * `ctx.browse`, which only exists when `browse` is granted.
29
+ *
30
+ * Tenant-scoped: writes cannot cross tenants. A shard declaring
31
+ * `browse` + `write` can overwrite any document owned by any shard in
32
+ * the current tenant, which is why the install-time consent modal
33
+ * surfaces this as a distinct line item rather than folding it into
34
+ * `browse`.
35
+ */
36
+ export declare const PERMISSION_DOCUMENTS_WRITE = "documents:write";
8
37
  /**
9
38
  * Format hint for document content. Determines whether reads return a string
10
39
  * (`text`) or an `ArrayBuffer` (`binary`).
@@ -16,3 +16,32 @@
16
16
  * through the owning shard's own `ctx.documents()` handle.
17
17
  */
18
18
  export const PERMISSION_DOCUMENTS_BROWSE = 'documents:browse';
19
+ /**
20
+ * Manifest permission string: grants the shard the ability to read
21
+ * document content from any shard's namespace within the active tenant
22
+ * via `ctx.browse.readFrom(shardId, path)`. Requires `documents:browse`
23
+ * in the same manifest — the read surface hangs off `ctx.browse`, which
24
+ * only exists when `browse` is granted.
25
+ *
26
+ * `documents:browse` alone is metadata-only (listDocuments, listShards,
27
+ * watchDocuments). `documents:read` is the separate content-read gate so
28
+ * observer shards granted `browse` today do not silently gain content
29
+ * access.
30
+ *
31
+ * Tenant-scoped: reads cannot cross tenants.
32
+ */
33
+ export const PERMISSION_DOCUMENTS_READ = 'documents:read';
34
+ /**
35
+ * Manifest permission string: grants the shard the ability to write
36
+ * documents into any shard's namespace within the active tenant via
37
+ * `ctx.browse.writeTo(shardId, path, content)`. Requires
38
+ * `documents:browse` in the same manifest — the write surface hangs off
39
+ * `ctx.browse`, which only exists when `browse` is granted.
40
+ *
41
+ * Tenant-scoped: writes cannot cross tenants. A shard declaring
42
+ * `browse` + `write` can overwrite any document owned by any shard in
43
+ * the current tenant, which is why the install-time consent modal
44
+ * surfaces this as a distinct line item rather than folding it into
45
+ * `browse`.
46
+ */
47
+ export const PERMISSION_DOCUMENTS_WRITE = 'documents:write';
@@ -81,6 +81,9 @@ export async function fetchRegistries(urls) {
81
81
  * @throws If the fetch fails, the server returns a non-OK status, or the integrity check fails.
82
82
  */
83
83
  export async function fetchBundle(version, sourceRegistry) {
84
+ if (!version.bundleUrl || !version.integrity) {
85
+ throw new Error('fetchBundle called on a version with no client bundle');
86
+ }
84
87
  let url = version.bundleUrl;
85
88
  if (sourceRegistry && !/^https?:\/\//i.test(url)) {
86
89
  url = new URL(url, sourceRegistry).href;
@@ -14,6 +14,7 @@
14
14
  * 3. `loadInstalledPackages()` -- called at boot, re-loads all installed
15
15
  * packages from IndexedDB and registers them.
16
16
  */
17
+ import { type LoadedBundle } from './loader';
17
18
  import type { InstalledPackage, InstallResult, PackageMeta } from './types';
18
19
  /**
19
20
  * Install a package from raw bundle bytes and metadata.
@@ -28,7 +29,9 @@ import type { InstalledPackage, InstallResult, PackageMeta } from './types';
28
29
  * @param meta - Provenance metadata for the install record.
29
30
  * @returns Result object indicating success/failure and hot-load status.
30
31
  */
31
- export declare function installPackage(bundle: ArrayBuffer, meta: PackageMeta): Promise<InstallResult>;
32
+ export declare function installPackage(bundle: ArrayBuffer, meta: PackageMeta, options?: {
33
+ loaded?: LoadedBundle;
34
+ }): Promise<InstallResult>;
32
35
  /**
33
36
  * Uninstall a package by id.
34
37
  *
@@ -20,6 +20,7 @@ import { verifyIntegrity } from './integrity';
20
20
  import { deactivateShard } from '../shards/activate.svelte';
21
21
  import { unregisterApp } from '../apps/lifecycle';
22
22
  import { registerLoadedBundle } from './register';
23
+ import { extractBundlePermissions } from './permission-descriptions';
23
24
  /**
24
25
  * Install a package from raw bundle bytes and metadata.
25
26
  *
@@ -33,30 +34,42 @@ import { registerLoadedBundle } from './register';
33
34
  * @param meta - Provenance metadata for the install record.
34
35
  * @returns Result object indicating success/failure and hot-load status.
35
36
  */
36
- export async function installPackage(bundle, meta) {
37
+ export async function installPackage(bundle, meta, options) {
37
38
  // 1. Verify bundle integrity before executing any code.
38
- try {
39
- await verifyIntegrity(bundle, meta.integrity);
40
- }
41
- catch (err) {
39
+ if (!meta.integrity) {
42
40
  return {
43
41
  success: false,
44
42
  hotLoaded: false,
45
- error: `Integrity check failed: ${err instanceof Error ? err.message : String(err)}`,
43
+ error: 'Missing integrity hash refusing to install unverified bundle',
46
44
  };
47
45
  }
48
- // 2. Load the module from verified bytes.
49
- let loaded;
50
46
  try {
51
- loaded = await loadBundleModule(bundle);
47
+ await verifyIntegrity(bundle, meta.integrity);
52
48
  }
53
49
  catch (err) {
54
50
  return {
55
51
  success: false,
56
52
  hotLoaded: false,
57
- error: `Failed to load bundle: ${err instanceof Error ? err.message : String(err)}`,
53
+ error: `Integrity check failed: ${err instanceof Error ? err.message : String(err)}`,
58
54
  };
59
55
  }
56
+ // 2. Load the module from verified bytes (or reuse the caller's copy).
57
+ let loaded;
58
+ if (options === null || options === void 0 ? void 0 : options.loaded) {
59
+ loaded = options.loaded;
60
+ }
61
+ else {
62
+ try {
63
+ loaded = await loadBundleModule(bundle);
64
+ }
65
+ catch (err) {
66
+ return {
67
+ success: false,
68
+ hotLoaded: false,
69
+ error: `Failed to load bundle: ${err instanceof Error ? err.message : String(err)}`,
70
+ };
71
+ }
72
+ }
60
73
  // 3. Verify the bundle contains the declared type.
61
74
  if (meta.type === 'shard' && loaded.shards.length === 0) {
62
75
  return {
@@ -72,7 +85,7 @@ export async function installPackage(bundle, meta) {
72
85
  error: `Package "${meta.id}" declared type "app" but bundle contains no valid app`,
73
86
  };
74
87
  }
75
- // 4. Persist to IndexedDB.
88
+ // 4. Persist to IndexedDB. Permissions captured from manifest(s).
76
89
  const record = {
77
90
  id: meta.id,
78
91
  type: meta.type,
@@ -80,6 +93,7 @@ export async function installPackage(bundle, meta) {
80
93
  sourceRegistry: meta.sourceRegistry,
81
94
  contractVersion: meta.contractVersion,
82
95
  installedAt: new Date().toISOString(),
96
+ permissions: extractBundlePermissions(loaded),
83
97
  };
84
98
  try {
85
99
  await savePackage(meta.id, bundle, record);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Human-friendly descriptions for manifest-declared permissions.
3
+ *
4
+ * The app store's install/update confirmation modal renders each permission
5
+ * via `describePermission`. Unknown permission IDs fall back to showing the
6
+ * raw ID with a generic placeholder so newer packages don't crash older
7
+ * cores.
8
+ */
9
+ import type { LoadedBundle } from './loader';
10
+ export interface PermissionDescription {
11
+ title: string;
12
+ description: string;
13
+ }
14
+ export declare const PERMISSION_DESCRIPTIONS: Record<string, PermissionDescription>;
15
+ export declare function describePermission(id: string): PermissionDescription;
16
+ /**
17
+ * Compute the deduped union of declared permissions across every shard
18
+ * and app exported by a bundle. For a plain shard or app bundle this is
19
+ * just that manifest's permissions; for combos it merges both halves.
20
+ */
21
+ export declare function extractBundlePermissions(loaded: LoadedBundle): string[];