sh3-core 0.8.2 → 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.
Files changed (102) hide show
  1. package/dist/api.d.ts +4 -7
  2. package/dist/api.js +2 -4
  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.js +34 -0
  10. package/dist/apps/types.d.ts +3 -5
  11. package/dist/documents/backends.d.ts +2 -0
  12. package/dist/documents/backends.js +6 -0
  13. package/dist/documents/browse.d.ts +31 -1
  14. package/dist/documents/browse.js +18 -2
  15. package/dist/documents/browse.test.js +81 -0
  16. package/dist/documents/handle.js +13 -5
  17. package/dist/documents/handle.test.js +55 -0
  18. package/dist/documents/http-backend.d.ts +11 -4
  19. package/dist/documents/http-backend.js +37 -11
  20. package/dist/documents/index.d.ts +2 -1
  21. package/dist/documents/index.js +1 -1
  22. package/dist/documents/sync-types.d.ts +45 -0
  23. package/dist/documents/sync-types.js +11 -0
  24. package/dist/documents/types.d.ts +69 -2
  25. package/dist/documents/types.js +32 -2
  26. package/dist/keys/ConsentDialog.svelte +4 -4
  27. package/dist/keys/consent.test.js +4 -3
  28. package/dist/keys/types.d.ts +4 -2
  29. package/dist/registry/client.js +3 -0
  30. package/dist/registry/installer.d.ts +4 -1
  31. package/dist/registry/installer.js +25 -11
  32. package/dist/registry/permission-descriptions.d.ts +21 -0
  33. package/dist/registry/permission-descriptions.js +67 -0
  34. package/dist/registry/permission-descriptions.test.js +86 -0
  35. package/dist/registry/schema.js +19 -6
  36. package/dist/registry/types.d.ts +17 -5
  37. package/dist/server-shard/types.d.ts +55 -8
  38. package/dist/shards/activate-browse.test.js +87 -3
  39. package/dist/shards/activate.svelte.js +9 -31
  40. package/dist/shards/types.d.ts +0 -15
  41. package/dist/shell/views/KeysAndPeers.svelte +1 -1
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/package.json +2 -10
  45. package/dist/documents/journal-hook.d.ts +0 -6
  46. package/dist/documents/journal-hook.js +0 -16
  47. package/dist/documents/sync/activate-integration.test.js +0 -37
  48. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +0 -99
  49. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +0 -15
  50. package/dist/documents/sync/components/SyncGrantPicker.svelte +0 -70
  51. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +0 -12
  52. package/dist/documents/sync/conflicts.d.ts +0 -30
  53. package/dist/documents/sync/conflicts.js +0 -77
  54. package/dist/documents/sync/conflicts.test.js +0 -71
  55. package/dist/documents/sync/engine.d.ts +0 -19
  56. package/dist/documents/sync/engine.js +0 -188
  57. package/dist/documents/sync/engine.test.d.ts +0 -1
  58. package/dist/documents/sync/engine.test.js +0 -169
  59. package/dist/documents/sync/handle.d.ts +0 -11
  60. package/dist/documents/sync/handle.js +0 -79
  61. package/dist/documents/sync/handle.test.js +0 -56
  62. package/dist/documents/sync/hash.d.ts +0 -1
  63. package/dist/documents/sync/hash.js +0 -13
  64. package/dist/documents/sync/hash.test.d.ts +0 -1
  65. package/dist/documents/sync/hash.test.js +0 -20
  66. package/dist/documents/sync/index.d.ts +0 -5
  67. package/dist/documents/sync/index.js +0 -10
  68. package/dist/documents/sync/journal.d.ts +0 -30
  69. package/dist/documents/sync/journal.js +0 -179
  70. package/dist/documents/sync/journal.test.d.ts +0 -1
  71. package/dist/documents/sync/journal.test.js +0 -87
  72. package/dist/documents/sync/observer.d.ts +0 -3
  73. package/dist/documents/sync/observer.js +0 -45
  74. package/dist/documents/sync/registry.d.ts +0 -13
  75. package/dist/documents/sync/registry.js +0 -73
  76. package/dist/documents/sync/registry.test.d.ts +0 -1
  77. package/dist/documents/sync/registry.test.js +0 -53
  78. package/dist/documents/sync/serialization.d.ts +0 -5
  79. package/dist/documents/sync/serialization.js +0 -24
  80. package/dist/documents/sync/serialization.test.d.ts +0 -1
  81. package/dist/documents/sync/serialization.test.js +0 -26
  82. package/dist/documents/sync/singleton.d.ts +0 -11
  83. package/dist/documents/sync/singleton.js +0 -26
  84. package/dist/documents/sync/tombstones.d.ts +0 -19
  85. package/dist/documents/sync/tombstones.js +0 -58
  86. package/dist/documents/sync/tombstones.test.d.ts +0 -1
  87. package/dist/documents/sync/tombstones.test.js +0 -37
  88. package/dist/documents/sync/types.d.ts +0 -116
  89. package/dist/documents/sync/types.js +0 -27
  90. package/dist/documents/sync/write-hook.test.d.ts +0 -1
  91. package/dist/documents/sync/write-hook.test.js +0 -36
  92. package/dist/server-sync.d.ts +0 -6
  93. package/dist/server-sync.js +0 -634
  94. package/dist/server-sync.js.map +0 -7
  95. package/dist/shards/activate-sync-registry.test.d.ts +0 -1
  96. package/dist/shards/activate-sync-registry.test.js +0 -42
  97. package/dist/testing.d.ts +0 -3
  98. package/dist/testing.js +0 -77
  99. package/dist/testing.js.map +0 -7
  100. /package/dist/{documents/sync/activate-integration.test.d.ts → app/store/storeShard.svelte.test.d.ts} +0 -0
  101. /package/dist/documents/{sync/handle.test.d.ts → handle.test.d.ts} +0 -0
  102. /package/dist/{documents/sync/conflicts.test.d.ts → registry/permission-descriptions.test.d.ts} +0 -0
@@ -1,9 +1,39 @@
1
1
  /**
2
2
  * Manifest permission string: grants tenant-wide document observation
3
- * (`ctx.browse`) and sync registry visibility (`ctx.syncRegistry`).
4
- * Read-only; writes still flow through the shard's own `ctx.documents()`.
3
+ * via `ctx.browse` (read-only enumeration and change subscription across
4
+ * every shard's documents for the active tenant). Writes still flow
5
+ * through the owning shard's own `ctx.documents()` handle.
5
6
  */
6
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";
7
37
  /**
8
38
  * Format hint for document content. Determines whether reads return a string
9
39
  * (`text`) or an `ArrayBuffer` (`binary`).
@@ -25,6 +55,20 @@ export interface DocumentMeta {
25
55
  size: number;
26
56
  /** Last modified timestamp in epoch milliseconds. */
27
57
  lastModified: number;
58
+ /** Monotonic per-document version; primary-authoritative. */
59
+ version?: number;
60
+ /** 'sync' or 'local-only', cached from policy.json at write time. */
61
+ syncMode?: 'sync' | 'local-only';
62
+ /** 'synced' | 'pending' | 'conflict'. */
63
+ syncState?: 'synced' | 'pending' | 'conflict';
64
+ /** Replica's view of primary's version at last confirmed sync. */
65
+ lastKnownVersion?: number;
66
+ /** Epoch ms; when replica/primary last agreed on content. */
67
+ lastSyncedAt?: number;
68
+ /** Peer id that produced this content (set by Mode B). */
69
+ origin?: string;
70
+ /** Tombstone marker for deleted docs. */
71
+ deleted?: boolean;
28
72
  }
29
73
  /** Change notification payload delivered to watch callbacks. */
30
74
  export interface DocumentChange {
@@ -33,6 +77,7 @@ export interface DocumentChange {
33
77
  tenantId: string;
34
78
  shardId: string;
35
79
  }
80
+ import type { DocStatus } from './sync-types';
36
81
  /**
37
82
  * File-oriented backend for the document zone.
38
83
  *
@@ -68,6 +113,19 @@ export interface DocumentBackend {
68
113
  listAllDocuments(tenantId: string): Promise<Array<DocumentMeta & {
69
114
  shardId: string;
70
115
  }>>;
116
+ /**
117
+ * Read sync-state metadata for a doc without fetching content.
118
+ * Returns null if the doc does not exist. Optional because only
119
+ * sync-aware backends (HttpDocumentBackend) support it.
120
+ */
121
+ readMeta?(tenantId: string, shardId: string, path: string): Promise<DocStatus | null>;
122
+ /**
123
+ * Resolve a conflict by picking a branch or supplying fresh content.
124
+ * Optional; only supported by HttpDocumentBackend in v1.
125
+ */
126
+ resolve?(tenantId: string, shardId: string, path: string, choice: 'local' | 'remote' | {
127
+ origin: string;
128
+ } | string): Promise<void>;
71
129
  }
72
130
  /**
73
131
  * Shard-facing document handle returned by `ctx.documents()`. Binds
@@ -84,6 +142,15 @@ export interface DocumentHandle {
84
142
  delete(path: string): Promise<void>;
85
143
  /** Check existence without reading content. */
86
144
  exists(path: string): Promise<boolean>;
145
+ /** Fetch sync-state metadata for a path. Null if the doc does not exist. */
146
+ status(path: string): Promise<DocStatus | null>;
147
+ /**
148
+ * Resolve a conflict. `'local'` keeps the local branch; any other
149
+ * origin string picks the named branch from the conflict bucket.
150
+ */
151
+ resolveConflict(path: string, choice: 'local' | 'remote' | {
152
+ origin: string;
153
+ } | string): Promise<void>;
87
154
  /**
88
155
  * Subscribe to change notifications within this handle's scope.
89
156
  * Returns an unsubscribe function.
@@ -11,7 +11,37 @@
11
11
  */
12
12
  /**
13
13
  * Manifest permission string: grants tenant-wide document observation
14
- * (`ctx.browse`) and sync registry visibility (`ctx.syncRegistry`).
15
- * Read-only; writes still flow through the shard's own `ctx.documents()`.
14
+ * via `ctx.browse` (read-only enumeration and change subscription across
15
+ * every shard's documents for the active tenant). Writes still flow
16
+ * through the owning shard's own `ctx.documents()` handle.
16
17
  */
17
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';
@@ -4,7 +4,7 @@
4
4
 
5
5
  Mounted once by the shell at boot; never by shards.
6
6
 
7
- Security: all shard-provided strings (shardId, label, scope, connectorId)
7
+ Security: all shard-provided strings (shardId, label, scope, peerId)
8
8
  are rendered as plain text via Svelte's default interpolation — no @html.
9
9
  -->
10
10
  <script lang="ts">
@@ -45,9 +45,9 @@
45
45
  <code class="sh3-consent-scope">{s}</code>
46
46
  {/each}
47
47
  </dd>
48
- {#if current.connectorId}
49
- <dt>Connector</dt>
50
- <dd>{current.connectorId}</dd>
48
+ {#if current.peerRole || current.peerId}
49
+ <dt>Peer</dt>
50
+ <dd>{current.peerRole ?? '—'}{current.peerId ? ` · ${current.peerId}` : ''}</dd>
51
51
  {/if}
52
52
  {#if current.expiresIn}
53
53
  <dt>Expires in</dt>
@@ -14,13 +14,14 @@ describe('consent runtime', () => {
14
14
  queueMicrotask(() => resolveConsent(req.requestId, true));
15
15
  });
16
16
  cleanups.push(off);
17
- await requestConsent('shard-a', { label: 'My key', scopes: ['documents:sync'], connectorId: 'peer-x' });
17
+ await requestConsent('shard-a', { label: 'My key', scopes: ['sync:peer'], peerRole: 'replica', peerId: 'peer-x' });
18
18
  expect(received).toHaveLength(1);
19
19
  const req = received[0];
20
20
  expect(req.shardId).toBe('shard-a');
21
21
  expect(req.label).toBe('My key');
22
- expect(req.scopes).toEqual(['documents:sync']);
23
- expect(req.connectorId).toBe('peer-x');
22
+ expect(req.scopes).toEqual(['sync:peer']);
23
+ expect(req.peerRole).toBe('replica');
24
+ expect(req.peerId).toBe('peer-x');
24
25
  expect(typeof req.requestId).toBe('string');
25
26
  });
26
27
  it('resolves true when approved', async () => {
@@ -6,14 +6,16 @@ export interface ApiKeyPublic {
6
6
  ownerUserId: string | null;
7
7
  mintedByShardId: string | null;
8
8
  scopes: string[];
9
- connectorId?: string;
9
+ peerRole?: 'primary' | 'replica';
10
+ peerId?: string;
10
11
  createdAt: string;
11
12
  expiresAt?: string;
12
13
  }
13
14
  export interface MintOpts {
14
15
  label: string;
15
16
  scopes: string[];
16
- connectorId?: string;
17
+ peerRole?: 'primary' | 'replica';
18
+ peerId?: string;
17
19
  expiresIn?: number;
18
20
  }
19
21
  export interface ShardContextKeys {
@@ -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,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.