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.
- package/dist/api.d.ts +2 -1
- package/dist/api.js +1 -1
- package/dist/app/store/InstalledView.svelte +55 -1
- package/dist/app/store/PermissionConfirmModal.svelte +232 -0
- package/dist/app/store/PermissionConfirmModal.svelte.d.ts +17 -0
- package/dist/app/store/StoreView.svelte +119 -5
- package/dist/app/store/storeShard.svelte.d.ts +10 -1
- package/dist/app/store/storeShard.svelte.js +51 -7
- package/dist/app/store/storeShard.svelte.test.d.ts +1 -0
- package/dist/app/store/storeShard.svelte.test.js +34 -0
- package/dist/contributions/index.d.ts +2 -0
- package/dist/contributions/index.js +8 -0
- package/dist/contributions/registry.d.ts +21 -0
- package/dist/contributions/registry.js +89 -0
- package/dist/contributions/registry.test.d.ts +1 -0
- package/dist/contributions/registry.test.js +109 -0
- package/dist/contributions/types.d.ts +24 -0
- package/dist/contributions/types.js +10 -0
- package/dist/documents/browse.d.ts +31 -1
- package/dist/documents/browse.js +18 -2
- package/dist/documents/browse.test.js +81 -0
- package/dist/documents/types.d.ts +29 -0
- package/dist/documents/types.js +29 -0
- package/dist/registry/client.js +3 -0
- package/dist/registry/installer.d.ts +4 -1
- package/dist/registry/installer.js +25 -11
- package/dist/registry/permission-descriptions.d.ts +21 -0
- package/dist/registry/permission-descriptions.js +67 -0
- package/dist/registry/permission-descriptions.test.d.ts +1 -0
- package/dist/registry/permission-descriptions.test.js +86 -0
- package/dist/registry/schema.js +19 -6
- package/dist/registry/types.d.ts +17 -5
- package/dist/shards/activate-browse.test.js +87 -3
- package/dist/shards/activate-contributions.test.d.ts +1 -0
- package/dist/shards/activate-contributions.test.js +110 -0
- package/dist/shards/activate.svelte.js +28 -2
- package/dist/shards/types.d.ts +7 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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,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
|
|
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;
|
package/dist/documents/browse.js
CHANGED
|
@@ -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
|
-
|
|
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`).
|
package/dist/documents/types.js
CHANGED
|
@@ -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';
|
package/dist/registry/client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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: `
|
|
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[];
|