sh3-core 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/api.d.ts +2 -1
  2. package/dist/api.js +1 -1
  3. package/dist/app/store/InstalledView.svelte +55 -1
  4. package/dist/app/store/PermissionConfirmModal.svelte +232 -0
  5. package/dist/app/store/PermissionConfirmModal.svelte.d.ts +17 -0
  6. package/dist/app/store/StoreView.svelte +119 -5
  7. package/dist/app/store/storeShard.svelte.d.ts +10 -1
  8. package/dist/app/store/storeShard.svelte.js +51 -7
  9. package/dist/app/store/storeShard.svelte.test.d.ts +1 -0
  10. package/dist/app/store/storeShard.svelte.test.js +34 -0
  11. package/dist/contributions/index.d.ts +2 -0
  12. package/dist/contributions/index.js +8 -0
  13. package/dist/contributions/registry.d.ts +21 -0
  14. package/dist/contributions/registry.js +89 -0
  15. package/dist/contributions/registry.test.d.ts +1 -0
  16. package/dist/contributions/registry.test.js +109 -0
  17. package/dist/contributions/types.d.ts +24 -0
  18. package/dist/contributions/types.js +10 -0
  19. package/dist/documents/browse.d.ts +31 -1
  20. package/dist/documents/browse.js +18 -2
  21. package/dist/documents/browse.test.js +81 -0
  22. package/dist/documents/types.d.ts +29 -0
  23. package/dist/documents/types.js +29 -0
  24. package/dist/registry/client.js +3 -0
  25. package/dist/registry/installer.d.ts +4 -1
  26. package/dist/registry/installer.js +25 -11
  27. package/dist/registry/permission-descriptions.d.ts +21 -0
  28. package/dist/registry/permission-descriptions.js +67 -0
  29. package/dist/registry/permission-descriptions.test.d.ts +1 -0
  30. package/dist/registry/permission-descriptions.test.js +86 -0
  31. package/dist/registry/schema.js +19 -6
  32. package/dist/registry/types.d.ts +17 -5
  33. package/dist/shards/activate-browse.test.js +87 -3
  34. package/dist/shards/activate-contributions.test.d.ts +1 -0
  35. package/dist/shards/activate-contributions.test.js +110 -0
  36. package/dist/shards/activate.svelte.js +28 -2
  37. package/dist/shards/types.d.ts +7 -0
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +1 -1
@@ -0,0 +1,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
+ });
@@ -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
- requireString(obj, 'bundleUrl', path);
117
- requireString(obj, 'integrity', path);
118
- // Optional server bundle URL
119
- if ('serverBundleUrl' in obj && obj.serverBundleUrl !== undefined) {
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,
@@ -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: string;
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: string;
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: string;
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 absent', async () => {
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('is defined when documents:browse is declared', async () => {
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.
@@ -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.9.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.9.0';
2
+ export const VERSION = '0.10.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"