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.
@@ -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 declare function createBrowseCapability(tenantId: string, backend: DocumentBackend): BrowseCapability;
36
+ export interface BrowseCapabilityOptions {
37
+ /** When true, the returned capability exposes `readFrom`. */
38
+ canRead: boolean;
39
+ /** When true, the returned capability exposes `writeTo`. */
40
+ canWrite: boolean;
41
+ }
42
+ export declare function createBrowseCapability(tenantId: string, backend: DocumentBackend, options?: BrowseCapabilityOptions): BrowseCapability;
@@ -6,8 +6,8 @@
6
6
  * the owning shard's own ctx.documents() handle.
7
7
  */
8
8
  import { documentChanges } from './notifications';
9
- export function createBrowseCapability(tenantId, backend) {
10
- return {
9
+ export function createBrowseCapability(tenantId, backend, options = { canRead: false, canWrite: false }) {
10
+ const capability = {
11
11
  listDocuments: () => backend.listAllDocuments(tenantId),
12
12
  listShards: () => backend.listAllShards(tenantId),
13
13
  watchDocuments: (callback) => documentChanges.subscribe((change) => {
@@ -16,4 +16,20 @@ export function createBrowseCapability(tenantId, backend) {
16
16
  callback(change);
17
17
  }),
18
18
  };
19
+ if (options.canRead) {
20
+ capability.readFrom = (shardId, path) => backend.read(tenantId, shardId, path);
21
+ }
22
+ if (options.canWrite) {
23
+ capability.writeTo = async (shardId, path, content) => {
24
+ const existed = await backend.exists(tenantId, shardId, path);
25
+ await backend.write(tenantId, shardId, path, content);
26
+ documentChanges.emit({
27
+ type: existed ? 'update' : 'create',
28
+ path,
29
+ tenantId,
30
+ shardId,
31
+ });
32
+ };
33
+ }
34
+ return capability;
19
35
  }
@@ -38,4 +38,85 @@ describe('BrowseCapability', () => {
38
38
  documentChanges.emit({ type: 'create', path: 'f.txt', tenantId: 't1', shardId: 's1' });
39
39
  expect(cb).not.toHaveBeenCalled();
40
40
  });
41
+ describe('readFrom (documents:read gate)', () => {
42
+ it('is absent when canRead is false', () => {
43
+ const be = new MemoryDocumentBackend();
44
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
45
+ expect(browse.readFrom).toBeUndefined();
46
+ });
47
+ it('is present when canRead is true', () => {
48
+ const be = new MemoryDocumentBackend();
49
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
50
+ expect(typeof browse.readFrom).toBe('function');
51
+ });
52
+ it('reads content from a foreign shard namespace', async () => {
53
+ const be = new MemoryDocumentBackend();
54
+ await be.write('t1', 'other', 'notes/ideas.md', 'hello');
55
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
56
+ expect(await browse.readFrom('other', 'notes/ideas.md')).toBe('hello');
57
+ });
58
+ it('returns null when the foreign document does not exist', async () => {
59
+ const be = new MemoryDocumentBackend();
60
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
61
+ expect(await browse.readFrom('other', 'nope.txt')).toBeNull();
62
+ });
63
+ it('never crosses tenants: a t1 capability cannot read t2 docs', async () => {
64
+ const be = new MemoryDocumentBackend();
65
+ await be.write('t2', 's', 'secret.txt', 'hidden');
66
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
67
+ expect(await browse.readFrom('s', 'secret.txt')).toBeNull();
68
+ });
69
+ });
70
+ describe('writeTo (documents:write gate)', () => {
71
+ it('is absent when canWrite is false', () => {
72
+ const be = new MemoryDocumentBackend();
73
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
74
+ expect(browse.writeTo).toBeUndefined();
75
+ });
76
+ it('is present when canWrite is true', () => {
77
+ const be = new MemoryDocumentBackend();
78
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
79
+ expect(typeof browse.writeTo).toBe('function');
80
+ });
81
+ it('writes into the target shard namespace and emits a create change', async () => {
82
+ const be = new MemoryDocumentBackend();
83
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
84
+ const events = [];
85
+ const unsub = documentChanges.subscribe((c) => events.push(c));
86
+ await browse.writeTo('target-shard', 'notes/ideas.md', 'hello');
87
+ expect(await be.read('t1', 'target-shard', 'notes/ideas.md')).toBe('hello');
88
+ expect(events).toEqual([
89
+ { type: 'create', path: 'notes/ideas.md', tenantId: 't1', shardId: 'target-shard' },
90
+ ]);
91
+ unsub();
92
+ });
93
+ it('emits update (not create) when overwriting an existing document', async () => {
94
+ const be = new MemoryDocumentBackend();
95
+ await be.write('t1', 'target-shard', 'a.txt', 'v1');
96
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
97
+ const events = [];
98
+ const unsub = documentChanges.subscribe((c) => events.push(c));
99
+ await browse.writeTo('target-shard', 'a.txt', 'v2');
100
+ expect(await be.read('t1', 'target-shard', 'a.txt')).toBe('v2');
101
+ expect(events).toEqual([
102
+ { type: 'update', path: 'a.txt', tenantId: 't1', shardId: 'target-shard' },
103
+ ]);
104
+ unsub();
105
+ });
106
+ it('never crosses tenants: cannot be tricked into writing into tenant b', async () => {
107
+ const be = new MemoryDocumentBackend();
108
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
109
+ await browse.writeTo('s', 'a.txt', 'x');
110
+ expect(await be.read('t1', 's', 'a.txt')).toBe('x');
111
+ expect(await be.read('t2', 's', 'a.txt')).toBeNull();
112
+ });
113
+ });
114
+ describe('read + write combined', () => {
115
+ it('round-trips content through readFrom after writeTo', async () => {
116
+ const be = new MemoryDocumentBackend();
117
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: true });
118
+ await browse.writeTo('shard-x', 'doc.txt', 'roundtrip');
119
+ expect(await browse.readFrom('shard-x', 'doc.txt')).toBe('roundtrip');
120
+ });
121
+ });
41
122
  });
@@ -5,6 +5,35 @@
5
5
  * through the owning shard's own `ctx.documents()` handle.
6
6
  */
7
7
  export declare const PERMISSION_DOCUMENTS_BROWSE = "documents:browse";
8
+ /**
9
+ * Manifest permission string: grants the shard the ability to read
10
+ * document content from any shard's namespace within the active tenant
11
+ * via `ctx.browse.readFrom(shardId, path)`. Requires `documents:browse`
12
+ * in the same manifest — the read surface hangs off `ctx.browse`, which
13
+ * only exists when `browse` is granted.
14
+ *
15
+ * `documents:browse` alone is metadata-only (listDocuments, listShards,
16
+ * watchDocuments). `documents:read` is the separate content-read gate so
17
+ * observer shards granted `browse` today do not silently gain content
18
+ * access.
19
+ *
20
+ * Tenant-scoped: reads cannot cross tenants.
21
+ */
22
+ export declare const PERMISSION_DOCUMENTS_READ = "documents:read";
23
+ /**
24
+ * Manifest permission string: grants the shard the ability to write
25
+ * documents into any shard's namespace within the active tenant via
26
+ * `ctx.browse.writeTo(shardId, path, content)`. Requires
27
+ * `documents:browse` in the same manifest — the write surface hangs off
28
+ * `ctx.browse`, which only exists when `browse` is granted.
29
+ *
30
+ * Tenant-scoped: writes cannot cross tenants. A shard declaring
31
+ * `browse` + `write` can overwrite any document owned by any shard in
32
+ * the current tenant, which is why the install-time consent modal
33
+ * surfaces this as a distinct line item rather than folding it into
34
+ * `browse`.
35
+ */
36
+ export declare const PERMISSION_DOCUMENTS_WRITE = "documents:write";
8
37
  /**
9
38
  * Format hint for document content. Determines whether reads return a string
10
39
  * (`text`) or an `ArrayBuffer` (`binary`).
@@ -16,3 +16,32 @@
16
16
  * through the owning shard's own `ctx.documents()` handle.
17
17
  */
18
18
  export const PERMISSION_DOCUMENTS_BROWSE = 'documents:browse';
19
+ /**
20
+ * Manifest permission string: grants the shard the ability to read
21
+ * document content from any shard's namespace within the active tenant
22
+ * via `ctx.browse.readFrom(shardId, path)`. Requires `documents:browse`
23
+ * in the same manifest — the read surface hangs off `ctx.browse`, which
24
+ * only exists when `browse` is granted.
25
+ *
26
+ * `documents:browse` alone is metadata-only (listDocuments, listShards,
27
+ * watchDocuments). `documents:read` is the separate content-read gate so
28
+ * observer shards granted `browse` today do not silently gain content
29
+ * access.
30
+ *
31
+ * Tenant-scoped: reads cannot cross tenants.
32
+ */
33
+ export const PERMISSION_DOCUMENTS_READ = 'documents:read';
34
+ /**
35
+ * Manifest permission string: grants the shard the ability to write
36
+ * documents into any shard's namespace within the active tenant via
37
+ * `ctx.browse.writeTo(shardId, path, content)`. Requires
38
+ * `documents:browse` in the same manifest — the write surface hangs off
39
+ * `ctx.browse`, which only exists when `browse` is granted.
40
+ *
41
+ * Tenant-scoped: writes cannot cross tenants. A shard declaring
42
+ * `browse` + `write` can overwrite any document owned by any shard in
43
+ * the current tenant, which is why the install-time consent modal
44
+ * surfaces this as a distinct line item rather than folding it into
45
+ * `browse`.
46
+ */
47
+ export const PERMISSION_DOCUMENTS_WRITE = 'documents:write';
@@ -81,6 +81,9 @@ export async function fetchRegistries(urls) {
81
81
  * @throws If the fetch fails, the server returns a non-OK status, or the integrity check fails.
82
82
  */
83
83
  export async function fetchBundle(version, sourceRegistry) {
84
+ if (!version.bundleUrl || !version.integrity) {
85
+ throw new Error('fetchBundle called on a version with no client bundle');
86
+ }
84
87
  let url = version.bundleUrl;
85
88
  if (sourceRegistry && !/^https?:\/\//i.test(url)) {
86
89
  url = new URL(url, sourceRegistry).href;
@@ -14,6 +14,7 @@
14
14
  * 3. `loadInstalledPackages()` -- called at boot, re-loads all installed
15
15
  * packages from IndexedDB and registers them.
16
16
  */
17
+ import { type LoadedBundle } from './loader';
17
18
  import type { InstalledPackage, InstallResult, PackageMeta } from './types';
18
19
  /**
19
20
  * Install a package from raw bundle bytes and metadata.
@@ -28,7 +29,9 @@ import type { InstalledPackage, InstallResult, PackageMeta } from './types';
28
29
  * @param meta - Provenance metadata for the install record.
29
30
  * @returns Result object indicating success/failure and hot-load status.
30
31
  */
31
- export declare function installPackage(bundle: ArrayBuffer, meta: PackageMeta): Promise<InstallResult>;
32
+ export declare function installPackage(bundle: ArrayBuffer, meta: PackageMeta, options?: {
33
+ loaded?: LoadedBundle;
34
+ }): Promise<InstallResult>;
32
35
  /**
33
36
  * Uninstall a package by id.
34
37
  *
@@ -20,6 +20,7 @@ import { verifyIntegrity } from './integrity';
20
20
  import { deactivateShard } from '../shards/activate.svelte';
21
21
  import { unregisterApp } from '../apps/lifecycle';
22
22
  import { registerLoadedBundle } from './register';
23
+ import { extractBundlePermissions } from './permission-descriptions';
23
24
  /**
24
25
  * Install a package from raw bundle bytes and metadata.
25
26
  *
@@ -33,30 +34,42 @@ import { registerLoadedBundle } from './register';
33
34
  * @param meta - Provenance metadata for the install record.
34
35
  * @returns Result object indicating success/failure and hot-load status.
35
36
  */
36
- export async function installPackage(bundle, meta) {
37
+ export async function installPackage(bundle, meta, options) {
37
38
  // 1. Verify bundle integrity before executing any code.
38
- try {
39
- await verifyIntegrity(bundle, meta.integrity);
40
- }
41
- catch (err) {
39
+ if (!meta.integrity) {
42
40
  return {
43
41
  success: false,
44
42
  hotLoaded: false,
45
- error: `Integrity check failed: ${err instanceof Error ? err.message : String(err)}`,
43
+ error: 'Missing integrity hash refusing to install unverified bundle',
46
44
  };
47
45
  }
48
- // 2. Load the module from verified bytes.
49
- let loaded;
50
46
  try {
51
- loaded = await loadBundleModule(bundle);
47
+ await verifyIntegrity(bundle, meta.integrity);
52
48
  }
53
49
  catch (err) {
54
50
  return {
55
51
  success: false,
56
52
  hotLoaded: false,
57
- error: `Failed to load bundle: ${err instanceof Error ? err.message : String(err)}`,
53
+ error: `Integrity check failed: ${err instanceof Error ? err.message : String(err)}`,
58
54
  };
59
55
  }
56
+ // 2. Load the module from verified bytes (or reuse the caller's copy).
57
+ let loaded;
58
+ if (options === null || options === void 0 ? void 0 : options.loaded) {
59
+ loaded = options.loaded;
60
+ }
61
+ else {
62
+ try {
63
+ loaded = await loadBundleModule(bundle);
64
+ }
65
+ catch (err) {
66
+ return {
67
+ success: false,
68
+ hotLoaded: false,
69
+ error: `Failed to load bundle: ${err instanceof Error ? err.message : String(err)}`,
70
+ };
71
+ }
72
+ }
60
73
  // 3. Verify the bundle contains the declared type.
61
74
  if (meta.type === 'shard' && loaded.shards.length === 0) {
62
75
  return {
@@ -72,7 +85,7 @@ export async function installPackage(bundle, meta) {
72
85
  error: `Package "${meta.id}" declared type "app" but bundle contains no valid app`,
73
86
  };
74
87
  }
75
- // 4. Persist to IndexedDB.
88
+ // 4. Persist to IndexedDB. Permissions captured from manifest(s).
76
89
  const record = {
77
90
  id: meta.id,
78
91
  type: meta.type,
@@ -80,6 +93,7 @@ export async function installPackage(bundle, meta) {
80
93
  sourceRegistry: meta.sourceRegistry,
81
94
  contractVersion: meta.contractVersion,
82
95
  installedAt: new Date().toISOString(),
96
+ permissions: extractBundlePermissions(loaded),
83
97
  };
84
98
  try {
85
99
  await savePackage(meta.id, bundle, record);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Human-friendly descriptions for manifest-declared permissions.
3
+ *
4
+ * The app store's install/update confirmation modal renders each permission
5
+ * via `describePermission`. Unknown permission IDs fall back to showing the
6
+ * raw ID with a generic placeholder so newer packages don't crash older
7
+ * cores.
8
+ */
9
+ import type { LoadedBundle } from './loader';
10
+ export interface PermissionDescription {
11
+ title: string;
12
+ description: string;
13
+ }
14
+ export declare const PERMISSION_DESCRIPTIONS: Record<string, PermissionDescription>;
15
+ export declare function describePermission(id: string): PermissionDescription;
16
+ /**
17
+ * Compute the deduped union of declared permissions across every shard
18
+ * and app exported by a bundle. For a plain shard or app bundle this is
19
+ * just that manifest's permissions; for combos it merges both halves.
20
+ */
21
+ export declare function extractBundlePermissions(loaded: LoadedBundle): string[];
@@ -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.