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,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.
|
|
@@ -2,14 +2,14 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|
|
2
2
|
import { MemoryDocumentBackend } from '../documents/backends';
|
|
3
3
|
import { __setDocumentBackend, __setTenantId } from '../documents/config';
|
|
4
4
|
import { registerShard, activateShard, __resetShardRegistryForTest } from './activate.svelte';
|
|
5
|
-
import { PERMISSION_DOCUMENTS_BROWSE } from '../documents/types';
|
|
5
|
+
import { PERMISSION_DOCUMENTS_BROWSE, PERMISSION_DOCUMENTS_READ, PERMISSION_DOCUMENTS_WRITE, } from '../documents/types';
|
|
6
6
|
describe('ctx.browse permission gating', () => {
|
|
7
7
|
beforeEach(() => {
|
|
8
8
|
__resetShardRegistryForTest();
|
|
9
9
|
__setDocumentBackend(new MemoryDocumentBackend());
|
|
10
10
|
__setTenantId('tenant-a');
|
|
11
11
|
});
|
|
12
|
-
it('is undefined when permission is
|
|
12
|
+
it('is undefined when no documents permission is declared', async () => {
|
|
13
13
|
let captured = null;
|
|
14
14
|
registerShard({
|
|
15
15
|
manifest: { id: 'no-browse', label: 'n', version: '0.0.0', views: [] },
|
|
@@ -18,7 +18,7 @@ describe('ctx.browse permission gating', () => {
|
|
|
18
18
|
await activateShard('no-browse');
|
|
19
19
|
expect(captured.browse).toBeUndefined();
|
|
20
20
|
});
|
|
21
|
-
it('
|
|
21
|
+
it('exposes metadata methods when documents:browse is declared', async () => {
|
|
22
22
|
var _a, _b, _c;
|
|
23
23
|
let captured = null;
|
|
24
24
|
registerShard({
|
|
@@ -33,4 +33,88 @@ describe('ctx.browse permission gating', () => {
|
|
|
33
33
|
expect(typeof ((_b = captured.browse) === null || _b === void 0 ? void 0 : _b.watchDocuments)).toBe('function');
|
|
34
34
|
expect(typeof ((_c = captured.browse) === null || _c === void 0 ? void 0 : _c.listShards)).toBe('function');
|
|
35
35
|
});
|
|
36
|
+
it('omits readFrom and writeTo when only documents:browse is declared', async () => {
|
|
37
|
+
var _a, _b;
|
|
38
|
+
let captured = null;
|
|
39
|
+
registerShard({
|
|
40
|
+
manifest: {
|
|
41
|
+
id: 'browse-only', label: 'b', version: '0.0.0', views: [],
|
|
42
|
+
permissions: [PERMISSION_DOCUMENTS_BROWSE],
|
|
43
|
+
},
|
|
44
|
+
activate(ctx) { captured = ctx; },
|
|
45
|
+
});
|
|
46
|
+
await activateShard('browse-only');
|
|
47
|
+
expect((_a = captured.browse) === null || _a === void 0 ? void 0 : _a.readFrom).toBeUndefined();
|
|
48
|
+
expect((_b = captured.browse) === null || _b === void 0 ? void 0 : _b.writeTo).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
it('exposes readFrom when documents:browse + documents:read are declared', async () => {
|
|
51
|
+
var _a, _b;
|
|
52
|
+
let captured = null;
|
|
53
|
+
registerShard({
|
|
54
|
+
manifest: {
|
|
55
|
+
id: 'browse-and-read', label: 'br', version: '0.0.0', views: [],
|
|
56
|
+
permissions: [PERMISSION_DOCUMENTS_BROWSE, PERMISSION_DOCUMENTS_READ],
|
|
57
|
+
},
|
|
58
|
+
activate(ctx) { captured = ctx; },
|
|
59
|
+
});
|
|
60
|
+
await activateShard('browse-and-read');
|
|
61
|
+
expect(typeof ((_a = captured.browse) === null || _a === void 0 ? void 0 : _a.readFrom)).toBe('function');
|
|
62
|
+
expect((_b = captured.browse) === null || _b === void 0 ? void 0 : _b.writeTo).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
it('exposes writeTo when documents:browse + documents:write are declared', async () => {
|
|
65
|
+
var _a, _b;
|
|
66
|
+
let captured = null;
|
|
67
|
+
registerShard({
|
|
68
|
+
manifest: {
|
|
69
|
+
id: 'browse-and-write', label: 'bw', version: '0.0.0', views: [],
|
|
70
|
+
permissions: [PERMISSION_DOCUMENTS_BROWSE, PERMISSION_DOCUMENTS_WRITE],
|
|
71
|
+
},
|
|
72
|
+
activate(ctx) { captured = ctx; },
|
|
73
|
+
});
|
|
74
|
+
await activateShard('browse-and-write');
|
|
75
|
+
expect((_a = captured.browse) === null || _a === void 0 ? void 0 : _a.readFrom).toBeUndefined();
|
|
76
|
+
expect(typeof ((_b = captured.browse) === null || _b === void 0 ? void 0 : _b.writeTo)).toBe('function');
|
|
77
|
+
});
|
|
78
|
+
it('exposes both readFrom and writeTo when all three permissions are declared', async () => {
|
|
79
|
+
var _a, _b;
|
|
80
|
+
let captured = null;
|
|
81
|
+
registerShard({
|
|
82
|
+
manifest: {
|
|
83
|
+
id: 'full-access', label: 'full', version: '0.0.0', views: [],
|
|
84
|
+
permissions: [
|
|
85
|
+
PERMISSION_DOCUMENTS_BROWSE,
|
|
86
|
+
PERMISSION_DOCUMENTS_READ,
|
|
87
|
+
PERMISSION_DOCUMENTS_WRITE,
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
activate(ctx) { captured = ctx; },
|
|
91
|
+
});
|
|
92
|
+
await activateShard('full-access');
|
|
93
|
+
expect(typeof ((_a = captured.browse) === null || _a === void 0 ? void 0 : _a.readFrom)).toBe('function');
|
|
94
|
+
expect(typeof ((_b = captured.browse) === null || _b === void 0 ? void 0 : _b.writeTo)).toBe('function');
|
|
95
|
+
});
|
|
96
|
+
it('yields no ctx.browse when documents:read is declared without documents:browse', async () => {
|
|
97
|
+
let captured = null;
|
|
98
|
+
registerShard({
|
|
99
|
+
manifest: {
|
|
100
|
+
id: 'read-only', label: 'r', version: '0.0.0', views: [],
|
|
101
|
+
permissions: [PERMISSION_DOCUMENTS_READ],
|
|
102
|
+
},
|
|
103
|
+
activate(ctx) { captured = ctx; },
|
|
104
|
+
});
|
|
105
|
+
await activateShard('read-only');
|
|
106
|
+
expect(captured.browse).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
it('yields no ctx.browse when documents:write is declared without documents:browse', async () => {
|
|
109
|
+
let captured = null;
|
|
110
|
+
registerShard({
|
|
111
|
+
manifest: {
|
|
112
|
+
id: 'write-only', label: 'w', version: '0.0.0', views: [],
|
|
113
|
+
permissions: [PERMISSION_DOCUMENTS_WRITE],
|
|
114
|
+
},
|
|
115
|
+
activate(ctx) { captured = ctx; },
|
|
116
|
+
});
|
|
117
|
+
await activateShard('write-only');
|
|
118
|
+
expect(captured.browse).toBeUndefined();
|
|
119
|
+
});
|
|
36
120
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { MemoryDocumentBackend } from '../documents/backends';
|
|
3
|
+
import { __setDocumentBackend, __setTenantId } from '../documents/config';
|
|
4
|
+
import { registerShard, activateShard, deactivateShard, __resetShardRegistryForTest, } from './activate.svelte';
|
|
5
|
+
import { __resetContributionsForTest, list, listPoints } from '../contributions';
|
|
6
|
+
describe('ctx.contributions', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
__resetShardRegistryForTest();
|
|
9
|
+
__resetContributionsForTest();
|
|
10
|
+
__setDocumentBackend(new MemoryDocumentBackend());
|
|
11
|
+
__setTenantId('tenant-a');
|
|
12
|
+
});
|
|
13
|
+
it('is always present on ShardContext (no permission required)', async () => {
|
|
14
|
+
let captured = null;
|
|
15
|
+
registerShard({
|
|
16
|
+
manifest: { id: 'a', label: 'A', version: '0.0.0', views: [] },
|
|
17
|
+
activate(ctx) { captured = ctx; },
|
|
18
|
+
});
|
|
19
|
+
await activateShard('a');
|
|
20
|
+
expect(typeof captured.contributions.register).toBe('function');
|
|
21
|
+
expect(typeof captured.contributions.list).toBe('function');
|
|
22
|
+
expect(typeof captured.contributions.listPoints).toBe('function');
|
|
23
|
+
expect(typeof captured.contributions.onChange).toBe('function');
|
|
24
|
+
});
|
|
25
|
+
it('register() makes the descriptor visible via the global list()', async () => {
|
|
26
|
+
registerShard({
|
|
27
|
+
manifest: { id: 'provider', label: 'P', version: '0.0.0', views: [] },
|
|
28
|
+
activate(ctx) {
|
|
29
|
+
ctx.contributions.register('my.point', { id: 'alpha' });
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
await activateShard('provider');
|
|
33
|
+
expect(list('my.point')).toEqual([{ id: 'alpha' }]);
|
|
34
|
+
});
|
|
35
|
+
it('deactivate auto-unregisters every descriptor the shard registered', async () => {
|
|
36
|
+
registerShard({
|
|
37
|
+
manifest: { id: 'provider', label: 'P', version: '0.0.0', views: [] },
|
|
38
|
+
activate(ctx) {
|
|
39
|
+
ctx.contributions.register('p1', { id: 'a' });
|
|
40
|
+
ctx.contributions.register('p1', { id: 'b' });
|
|
41
|
+
ctx.contributions.register('p2', { id: 'c' });
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
await activateShard('provider');
|
|
45
|
+
expect(list('p1')).toHaveLength(2);
|
|
46
|
+
expect(list('p2')).toHaveLength(1);
|
|
47
|
+
deactivateShard('provider');
|
|
48
|
+
expect(list('p1')).toEqual([]);
|
|
49
|
+
expect(list('p2')).toEqual([]);
|
|
50
|
+
expect(listPoints()).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
it('deactivate auto-unsubscribes every onChange listener the shard registered', async () => {
|
|
53
|
+
const cb = vi.fn();
|
|
54
|
+
registerShard({
|
|
55
|
+
manifest: { id: 'watcher', label: 'W', version: '0.0.0', views: [] },
|
|
56
|
+
activate(ctx) {
|
|
57
|
+
ctx.contributions.onChange('p', cb);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
await activateShard('watcher');
|
|
61
|
+
registerShard({
|
|
62
|
+
manifest: { id: 'provider', label: 'P', version: '0.0.0', views: [] },
|
|
63
|
+
activate(ctx) {
|
|
64
|
+
ctx.contributions.register('p', { id: 'x' });
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
await activateShard('provider');
|
|
68
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
69
|
+
deactivateShard('watcher');
|
|
70
|
+
// Further registrations must not reach the watcher's callback.
|
|
71
|
+
registerShard({
|
|
72
|
+
manifest: { id: 'provider-2', label: 'P2', version: '0.0.0', views: [] },
|
|
73
|
+
activate(ctx) {
|
|
74
|
+
ctx.contributions.register('p', { id: 'y' });
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
await activateShard('provider-2');
|
|
78
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
it('two shards can contribute to the same point independently', async () => {
|
|
81
|
+
registerShard({
|
|
82
|
+
manifest: { id: 'a', label: 'A', version: '0.0.0', views: [] },
|
|
83
|
+
activate(ctx) { ctx.contributions.register('shared', { from: 'a' }); },
|
|
84
|
+
});
|
|
85
|
+
registerShard({
|
|
86
|
+
manifest: { id: 'b', label: 'B', version: '0.0.0', views: [] },
|
|
87
|
+
activate(ctx) { ctx.contributions.register('shared', { from: 'b' }); },
|
|
88
|
+
});
|
|
89
|
+
await activateShard('a');
|
|
90
|
+
await activateShard('b');
|
|
91
|
+
expect(list('shared')).toEqual([{ from: 'a' }, { from: 'b' }]);
|
|
92
|
+
deactivateShard('a');
|
|
93
|
+
expect(list('shared')).toEqual([{ from: 'b' }]);
|
|
94
|
+
});
|
|
95
|
+
it('explicit unregister returned by register() is safe alongside auto-cleanup', async () => {
|
|
96
|
+
let earlyUnregister;
|
|
97
|
+
registerShard({
|
|
98
|
+
manifest: { id: 'p', label: 'P', version: '0.0.0', views: [] },
|
|
99
|
+
activate(ctx) {
|
|
100
|
+
earlyUnregister = ctx.contributions.register('p', { id: 'a' });
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
await activateShard('p');
|
|
104
|
+
expect(list('p')).toHaveLength(1);
|
|
105
|
+
earlyUnregister();
|
|
106
|
+
expect(list('p')).toEqual([]);
|
|
107
|
+
// Deactivate must not throw when the entry is already gone.
|
|
108
|
+
expect(() => deactivateShard('p')).not.toThrow();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -23,11 +23,12 @@ import { fetchEnvState, putEnvState } from '../env/client';
|
|
|
23
23
|
import { isAdmin as checkIsAdmin } from '../auth/index';
|
|
24
24
|
import { createZoneManager } from '../state/manage';
|
|
25
25
|
import { PERMISSION_STATE_MANAGE } from '../state/types';
|
|
26
|
-
import { PERMISSION_DOCUMENTS_BROWSE } from '../documents/types';
|
|
26
|
+
import { PERMISSION_DOCUMENTS_BROWSE, PERMISSION_DOCUMENTS_READ, PERMISSION_DOCUMENTS_WRITE, } from '../documents/types';
|
|
27
27
|
import { createBrowseCapability } from '../documents/browse';
|
|
28
28
|
import { createShardKeysApi } from '../keys/client';
|
|
29
29
|
import { PERMISSION_KEYS_MINT } from '../keys/types';
|
|
30
30
|
import { subscribe } from '../keys/revocation-bus.svelte';
|
|
31
|
+
import { register as contributionsRegister, list as contributionsList, listPoints as contributionsListPoints, onChange as contributionsOnChange, } from '../contributions';
|
|
31
32
|
/**
|
|
32
33
|
* Reactive registry of every shard known to the host. Keys are shard ids.
|
|
33
34
|
* Populated once at boot by the glob-discovery loop in main.ts (through
|
|
@@ -87,6 +88,27 @@ export async function activateShard(id) {
|
|
|
87
88
|
proxy: null,
|
|
88
89
|
defaults: null,
|
|
89
90
|
});
|
|
91
|
+
// Per-shard wrapper: every register/onChange call goes into the
|
|
92
|
+
// global registry, and its disposer is pushed into entry.cleanupFns
|
|
93
|
+
// so deactivate auto-unregisters.
|
|
94
|
+
const contributions = {
|
|
95
|
+
register(pointId, descriptor) {
|
|
96
|
+
const dispose = contributionsRegister(pointId, descriptor);
|
|
97
|
+
entry.cleanupFns.push(async () => dispose());
|
|
98
|
+
return dispose;
|
|
99
|
+
},
|
|
100
|
+
list(pointId) {
|
|
101
|
+
return contributionsList(pointId);
|
|
102
|
+
},
|
|
103
|
+
listPoints() {
|
|
104
|
+
return contributionsListPoints();
|
|
105
|
+
},
|
|
106
|
+
onChange(pointId, cb) {
|
|
107
|
+
const off = contributionsOnChange(pointId, cb);
|
|
108
|
+
entry.cleanupFns.push(async () => off());
|
|
109
|
+
return off;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
90
112
|
const ctx = {
|
|
91
113
|
state: (schema) => shell.state(id, schema),
|
|
92
114
|
registerView: (viewId, factory) => {
|
|
@@ -137,7 +159,10 @@ export async function activateShard(id) {
|
|
|
137
159
|
? createZoneManager()
|
|
138
160
|
: undefined,
|
|
139
161
|
browse: ((_b = shard.manifest.permissions) === null || _b === void 0 ? void 0 : _b.includes(PERMISSION_DOCUMENTS_BROWSE))
|
|
140
|
-
? createBrowseCapability(getTenantId(), getDocumentBackend()
|
|
162
|
+
? createBrowseCapability(getTenantId(), getDocumentBackend(), {
|
|
163
|
+
canRead: shard.manifest.permissions.includes(PERMISSION_DOCUMENTS_READ),
|
|
164
|
+
canWrite: shard.manifest.permissions.includes(PERMISSION_DOCUMENTS_WRITE),
|
|
165
|
+
})
|
|
141
166
|
: undefined,
|
|
142
167
|
keys: ((_c = shard.manifest.permissions) === null || _c === void 0 ? void 0 : _c.includes(PERMISSION_KEYS_MINT))
|
|
143
168
|
? createShardKeysApi({
|
|
@@ -145,6 +170,7 @@ export async function activateShard(id) {
|
|
|
145
170
|
shardPermissions: (_d = shard.manifest.permissions) !== null && _d !== void 0 ? _d : [],
|
|
146
171
|
})
|
|
147
172
|
: undefined,
|
|
173
|
+
contributions,
|
|
148
174
|
};
|
|
149
175
|
entry.ctx = ctx;
|
|
150
176
|
// Wire onKeyRevoked hook: subscribe to the revocation bus for this shard.
|
package/dist/shards/types.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { BrowseCapability } from '../documents/browse';
|
|
|
5
5
|
import type { EnvState } from '../env/types';
|
|
6
6
|
import type { Verb } from '../verbs/types';
|
|
7
7
|
import type { ShardContextKeys } from '../keys/types';
|
|
8
|
+
import type { ContributionsApi } from '../contributions/types';
|
|
8
9
|
export { PERMISSION_KEYS_MINT, type ShardContextKeys, type ApiKeyPublic, type MintOpts, ScopeEscalationError, ConsentDeniedError } from '../keys/types';
|
|
9
10
|
/**
|
|
10
11
|
* The object returned by `ViewFactory.mount`. The framework calls
|
|
@@ -225,6 +226,12 @@ export interface ShardContext {
|
|
|
225
226
|
* manifest declares the `keys:mint` permission.
|
|
226
227
|
*/
|
|
227
228
|
keys?: ShardContextKeys;
|
|
229
|
+
/**
|
|
230
|
+
* Runtime registry for inter-shard contribution points. Every
|
|
231
|
+
* shard receives this — no permission gate in v1. See
|
|
232
|
+
* docs/sh3-rfcs/2026-04-20-shard-contribution-points.md.
|
|
233
|
+
*/
|
|
234
|
+
contributions: ContributionsApi;
|
|
228
235
|
}
|
|
229
236
|
/**
|
|
230
237
|
* A shard module. Shards are the fundamental unit of contribution in SH3.
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.10.1";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export const VERSION = '0.
|
|
2
|
+
export const VERSION = '0.10.1';
|