sh3-core 0.9.0 → 0.9.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 +1 -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/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.svelte.js +5 -2
- 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
|
+
});
|
|
@@ -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[];
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
export const PERMISSION_DESCRIPTIONS = {
|
|
10
|
+
'state:manage': {
|
|
11
|
+
title: 'Manage shell state zones',
|
|
12
|
+
description: 'Read and write state zones belonging to other shards.',
|
|
13
|
+
},
|
|
14
|
+
'documents:browse': {
|
|
15
|
+
title: 'Browse tenant documents',
|
|
16
|
+
description: 'Observe all documents stored by the current tenant.',
|
|
17
|
+
},
|
|
18
|
+
'documents:read': {
|
|
19
|
+
title: 'Read tenant documents',
|
|
20
|
+
description: 'Read the content of any document in the current tenant, across all shards. Required for backup connectors that need to pull document bodies (not just metadata) out of other shards.',
|
|
21
|
+
},
|
|
22
|
+
'documents:write': {
|
|
23
|
+
title: 'Write tenant documents',
|
|
24
|
+
description: 'Create and overwrite documents in any shard\'s namespace within the current tenant. Required for restore-class connectors that re-import documents back into their owning shards.',
|
|
25
|
+
},
|
|
26
|
+
'documents:sync': {
|
|
27
|
+
title: 'Sync documents with peers',
|
|
28
|
+
description: 'Participate in cross-peer document synchronization.',
|
|
29
|
+
},
|
|
30
|
+
'sync:peer': {
|
|
31
|
+
title: 'Act as a sync peer',
|
|
32
|
+
description: 'Exchange document updates with remote peers.',
|
|
33
|
+
},
|
|
34
|
+
'sync:policy': {
|
|
35
|
+
title: 'Define sync policy',
|
|
36
|
+
description: 'Configure which documents sync and with whom.',
|
|
37
|
+
},
|
|
38
|
+
'keys:mint': {
|
|
39
|
+
title: 'Mint API keys',
|
|
40
|
+
description: 'Issue long-lived access tokens for this shell.',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
export function describePermission(id) {
|
|
44
|
+
var _a;
|
|
45
|
+
return (_a = PERMISSION_DESCRIPTIONS[id]) !== null && _a !== void 0 ? _a : {
|
|
46
|
+
title: id,
|
|
47
|
+
description: 'Unknown permission.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Compute the deduped union of declared permissions across every shard
|
|
52
|
+
* and app exported by a bundle. For a plain shard or app bundle this is
|
|
53
|
+
* just that manifest's permissions; for combos it merges both halves.
|
|
54
|
+
*/
|
|
55
|
+
export function extractBundlePermissions(loaded) {
|
|
56
|
+
var _a, _b;
|
|
57
|
+
const perms = new Set();
|
|
58
|
+
for (const s of loaded.shards) {
|
|
59
|
+
for (const p of (_a = s.manifest.permissions) !== null && _a !== void 0 ? _a : [])
|
|
60
|
+
perms.add(p);
|
|
61
|
+
}
|
|
62
|
+
for (const a of loaded.apps) {
|
|
63
|
+
for (const p of (_b = a.manifest.permissions) !== null && _b !== void 0 ? _b : [])
|
|
64
|
+
perms.add(p);
|
|
65
|
+
}
|
|
66
|
+
return [...perms];
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { describePermission, extractBundlePermissions, PERMISSION_DESCRIPTIONS, } from './permission-descriptions';
|
|
3
|
+
describe('describePermission', () => {
|
|
4
|
+
it('returns the registered entry for a known permission id', () => {
|
|
5
|
+
const result = describePermission('state:manage');
|
|
6
|
+
expect(result.title).toBe('Manage shell state zones');
|
|
7
|
+
expect(result.description).toContain('state zones');
|
|
8
|
+
});
|
|
9
|
+
it('covers every permission advertised in the module registry', () => {
|
|
10
|
+
expect(PERMISSION_DESCRIPTIONS['state:manage']).toBeDefined();
|
|
11
|
+
expect(PERMISSION_DESCRIPTIONS['documents:browse']).toBeDefined();
|
|
12
|
+
expect(PERMISSION_DESCRIPTIONS['documents:sync']).toBeDefined();
|
|
13
|
+
expect(PERMISSION_DESCRIPTIONS['sync:peer']).toBeDefined();
|
|
14
|
+
expect(PERMISSION_DESCRIPTIONS['sync:policy']).toBeDefined();
|
|
15
|
+
expect(PERMISSION_DESCRIPTIONS['keys:mint']).toBeDefined();
|
|
16
|
+
expect(PERMISSION_DESCRIPTIONS['documents:read']).toBeDefined();
|
|
17
|
+
expect(PERMISSION_DESCRIPTIONS['documents:write']).toBeDefined();
|
|
18
|
+
});
|
|
19
|
+
it('falls back to the raw id and a generic description for unknown ids', () => {
|
|
20
|
+
const result = describePermission('imaginary:unknown');
|
|
21
|
+
expect(result.title).toBe('imaginary:unknown');
|
|
22
|
+
expect(result.description).toMatch(/unknown/i);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('describePermission — documents:read / documents:write', () => {
|
|
26
|
+
it('returns a read-oriented title and description for documents:read', () => {
|
|
27
|
+
const result = describePermission('documents:read');
|
|
28
|
+
expect(result.title).toMatch(/read/i);
|
|
29
|
+
expect(result.description).toMatch(/document/i);
|
|
30
|
+
expect(result).not.toEqual(describePermission('documents:browse'));
|
|
31
|
+
});
|
|
32
|
+
it('returns a write-oriented title and description for documents:write', () => {
|
|
33
|
+
const result = describePermission('documents:write');
|
|
34
|
+
expect(result.title).toMatch(/write/i);
|
|
35
|
+
expect(result.description).toMatch(/document/i);
|
|
36
|
+
expect(result).not.toEqual(describePermission('documents:browse'));
|
|
37
|
+
expect(result).not.toEqual(describePermission('documents:read'));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
function shardWithPerms(id, perms) {
|
|
41
|
+
return {
|
|
42
|
+
manifest: { id, label: id, version: '0.0.0', views: [], permissions: perms },
|
|
43
|
+
activate: () => { },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function appWithPerms(id, perms) {
|
|
47
|
+
return {
|
|
48
|
+
manifest: {
|
|
49
|
+
id,
|
|
50
|
+
label: id,
|
|
51
|
+
version: '0.0.0',
|
|
52
|
+
initialLayout: { kind: 'leaf', view: '' },
|
|
53
|
+
requiredShards: [],
|
|
54
|
+
layoutVersion: 1,
|
|
55
|
+
permissions: perms,
|
|
56
|
+
},
|
|
57
|
+
activate: () => { },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
describe('extractBundlePermissions', () => {
|
|
61
|
+
it('returns an empty array when no shards or apps declare permissions', () => {
|
|
62
|
+
const bundle = { shards: [shardWithPerms('a')], apps: [] };
|
|
63
|
+
expect(extractBundlePermissions(bundle)).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
it('returns declared permissions for a single shard', () => {
|
|
66
|
+
const bundle = {
|
|
67
|
+
shards: [shardWithPerms('a', ['state:manage', 'documents:browse'])],
|
|
68
|
+
apps: [],
|
|
69
|
+
};
|
|
70
|
+
expect(extractBundlePermissions(bundle).sort()).toEqual(['documents:browse', 'state:manage']);
|
|
71
|
+
});
|
|
72
|
+
it('dedupes the union of shard and app permissions (combo case)', () => {
|
|
73
|
+
const bundle = {
|
|
74
|
+
shards: [shardWithPerms('a', ['state:manage', 'documents:browse'])],
|
|
75
|
+
apps: [appWithPerms('a-app', ['state:manage', 'keys:mint'])],
|
|
76
|
+
};
|
|
77
|
+
expect(extractBundlePermissions(bundle).sort()).toEqual(['documents:browse', 'keys:mint', 'state:manage']);
|
|
78
|
+
});
|
|
79
|
+
it('tolerates manifests with missing permissions arrays', () => {
|
|
80
|
+
const bundle = {
|
|
81
|
+
shards: [shardWithPerms('a', undefined)],
|
|
82
|
+
apps: [appWithPerms('b', ['keys:mint'])],
|
|
83
|
+
};
|
|
84
|
+
expect(extractBundlePermissions(bundle)).toEqual(['keys:mint']);
|
|
85
|
+
});
|
|
86
|
+
});
|
package/dist/registry/schema.js
CHANGED
|
@@ -113,16 +113,29 @@ function validatePackageVersion(data, path) {
|
|
|
113
113
|
const obj = data;
|
|
114
114
|
requireString(obj, 'version', path);
|
|
115
115
|
requireString(obj, 'contractVersion', path);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (
|
|
116
|
+
// Client bundle fields — optional individually, but if either is present both must be.
|
|
117
|
+
const hasBundleUrl = 'bundleUrl' in obj && obj.bundleUrl !== undefined;
|
|
118
|
+
const hasIntegrity = 'integrity' in obj && obj.integrity !== undefined;
|
|
119
|
+
if (hasBundleUrl)
|
|
120
|
+
requireString(obj, 'bundleUrl', path);
|
|
121
|
+
if (hasIntegrity)
|
|
122
|
+
requireString(obj, 'integrity', path);
|
|
123
|
+
if (hasBundleUrl !== hasIntegrity) {
|
|
124
|
+
throw new RegistryValidationError(path, 'bundleUrl and integrity must be provided together');
|
|
125
|
+
}
|
|
126
|
+
// Optional server bundle URL.
|
|
127
|
+
const hasServerBundleUrl = 'serverBundleUrl' in obj && obj.serverBundleUrl !== undefined;
|
|
128
|
+
if (hasServerBundleUrl) {
|
|
120
129
|
requireString(obj, 'serverBundleUrl', path);
|
|
121
130
|
}
|
|
122
131
|
// Optional server bundle integrity hash — provisional, see ADR-015.
|
|
123
132
|
if ('serverIntegrity' in obj && obj.serverIntegrity !== undefined) {
|
|
124
133
|
requireString(obj, 'serverIntegrity', path);
|
|
125
134
|
}
|
|
135
|
+
// A version must ship at least one bundle.
|
|
136
|
+
if (!hasBundleUrl && !hasServerBundleUrl) {
|
|
137
|
+
throw new RegistryValidationError(path, 'expected at least one of bundleUrl+integrity or serverBundleUrl');
|
|
138
|
+
}
|
|
126
139
|
let requires;
|
|
127
140
|
if (obj['requires'] !== undefined) {
|
|
128
141
|
if (!Array.isArray(obj['requires'])) {
|
|
@@ -133,8 +146,8 @@ function validatePackageVersion(data, path) {
|
|
|
133
146
|
return {
|
|
134
147
|
version: obj['version'],
|
|
135
148
|
contractVersion: obj['contractVersion'],
|
|
136
|
-
bundleUrl: obj['bundleUrl'],
|
|
137
|
-
integrity: obj['integrity'],
|
|
149
|
+
bundleUrl: typeof obj['bundleUrl'] === 'string' ? obj['bundleUrl'] : undefined,
|
|
150
|
+
integrity: typeof obj['integrity'] === 'string' ? obj['integrity'] : undefined,
|
|
138
151
|
serverBundleUrl: typeof obj['serverBundleUrl'] === 'string' ? obj['serverBundleUrl'] : undefined,
|
|
139
152
|
serverIntegrity: typeof obj['serverIntegrity'] === 'string' ? obj['serverIntegrity'] : undefined,
|
|
140
153
|
requires,
|
package/dist/registry/types.d.ts
CHANGED
|
@@ -100,16 +100,18 @@ export interface PackageVersion {
|
|
|
100
100
|
/**
|
|
101
101
|
* Absolute or registry-relative URL to the pre-built ESM bundle.
|
|
102
102
|
* The client fetches this URL and verifies the download against `integrity`
|
|
103
|
-
* before executing.
|
|
103
|
+
* before executing. Optional for server-only packages that ship only a
|
|
104
|
+
* `serverBundleUrl` — in that case `integrity` must also be omitted.
|
|
104
105
|
*/
|
|
105
|
-
bundleUrl
|
|
106
|
+
bundleUrl?: string;
|
|
106
107
|
/**
|
|
107
108
|
* SRI integrity hash for the bundle file.
|
|
108
109
|
* Format: `"<algorithm>-<base64digest>"` (e.g. `"sha384-abc123..."`).
|
|
109
110
|
* Algorithms: sha256, sha384 (recommended), sha512.
|
|
110
111
|
* See: https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
|
|
112
|
+
* Omitted when the package has no client bundle.
|
|
111
113
|
*/
|
|
112
|
-
integrity
|
|
114
|
+
integrity?: string;
|
|
113
115
|
/**
|
|
114
116
|
* Optional URL to the server-side bundle for shards that have a backend
|
|
115
117
|
* component. Same resolution rules as `bundleUrl` (absolute or registry-
|
|
@@ -191,6 +193,15 @@ export interface InstalledPackage {
|
|
|
191
193
|
* Example: `"2026-04-06T12:34:56.789Z"`.
|
|
192
194
|
*/
|
|
193
195
|
installedAt: string;
|
|
196
|
+
/**
|
|
197
|
+
* Declared permissions captured from the bundle's manifest(s) at install
|
|
198
|
+
* time. For a shard or app, this is `manifest.permissions ?? []`. For a
|
|
199
|
+
* combo, the deduped union of the shard's and app's permissions.
|
|
200
|
+
*
|
|
201
|
+
* Legacy records written before this field existed may omit it; consumers
|
|
202
|
+
* must treat a missing value as `[]`.
|
|
203
|
+
*/
|
|
204
|
+
permissions: string[];
|
|
194
205
|
}
|
|
195
206
|
/**
|
|
196
207
|
* Result of an install operation.
|
|
@@ -250,9 +261,10 @@ export interface PackageMeta {
|
|
|
250
261
|
sourceRegistry: string;
|
|
251
262
|
/**
|
|
252
263
|
* SRI hash to verify the downloaded bundle against before executing.
|
|
253
|
-
* Must match `PackageVersion.integrity`.
|
|
264
|
+
* Must match `PackageVersion.integrity`. Omitted for server-only packages
|
|
265
|
+
* that ship no client bundle.
|
|
254
266
|
*/
|
|
255
|
-
integrity
|
|
267
|
+
integrity?: string;
|
|
256
268
|
/**
|
|
257
269
|
* Declared shard dependencies. Mirrors `PackageVersion.requires`.
|
|
258
270
|
* Undefined if no dependencies.
|