sh3-core 0.8.1 → 0.9.0

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 (98) hide show
  1. package/dist/Shell.svelte +19 -0
  2. package/dist/api.d.ts +6 -6
  3. package/dist/api.js +6 -3
  4. package/dist/app/admin/ApiKeysView.svelte +16 -27
  5. package/dist/apps/types.d.ts +3 -5
  6. package/dist/documents/backends.d.ts +2 -0
  7. package/dist/documents/backends.js +6 -0
  8. package/dist/documents/handle.js +13 -5
  9. package/dist/documents/handle.test.js +55 -0
  10. package/dist/documents/http-backend.d.ts +11 -4
  11. package/dist/documents/http-backend.js +37 -11
  12. package/dist/documents/index.d.ts +2 -1
  13. package/dist/documents/index.js +1 -1
  14. package/dist/documents/sync-types.d.ts +45 -0
  15. package/dist/documents/sync-types.js +11 -0
  16. package/dist/documents/types.d.ts +40 -2
  17. package/dist/documents/types.js +3 -2
  18. package/dist/keys/ConsentDialog.svelte +176 -0
  19. package/dist/keys/ConsentDialog.svelte.d.ts +3 -0
  20. package/dist/keys/client.d.ts +13 -0
  21. package/dist/keys/client.js +65 -0
  22. package/dist/keys/client.test.js +44 -0
  23. package/dist/keys/consent.svelte.d.ts +16 -0
  24. package/dist/keys/consent.svelte.js +29 -0
  25. package/dist/keys/consent.test.js +54 -0
  26. package/dist/keys/revocation-bus.svelte.d.ts +35 -0
  27. package/dist/keys/revocation-bus.svelte.js +92 -0
  28. package/dist/keys/revocation-bus.test.js +95 -0
  29. package/dist/keys/types.d.ts +34 -0
  30. package/dist/keys/types.js +13 -0
  31. package/dist/server-shard/types.d.ts +68 -2
  32. package/dist/sh3core-shard/ShellHome.svelte +140 -63
  33. package/dist/sh3core-shard/sh3coreShard.svelte.js +12 -1
  34. package/dist/shards/activate-on-key-revoked.test.js +60 -0
  35. package/dist/shards/activate.svelte.js +21 -24
  36. package/dist/shards/types.d.ts +7 -13
  37. package/dist/shards/types.js +1 -1
  38. package/dist/shell/views/KeysAndPeers.svelte +110 -0
  39. package/dist/shell/views/KeysAndPeers.svelte.d.ts +3 -0
  40. package/dist/shell-shard/Terminal.svelte +0 -11
  41. package/dist/shell-shard/toolbar/Toolbar.svelte +11 -32
  42. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +0 -2
  43. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +29 -62
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/package.json +1 -1
  47. package/dist/documents/journal-hook.d.ts +0 -6
  48. package/dist/documents/journal-hook.js +0 -16
  49. package/dist/documents/sync/activate-integration.test.js +0 -37
  50. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +0 -99
  51. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +0 -15
  52. package/dist/documents/sync/components/SyncGrantPicker.svelte +0 -70
  53. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +0 -12
  54. package/dist/documents/sync/conflicts.d.ts +0 -30
  55. package/dist/documents/sync/conflicts.js +0 -77
  56. package/dist/documents/sync/conflicts.test.js +0 -71
  57. package/dist/documents/sync/engine.d.ts +0 -19
  58. package/dist/documents/sync/engine.js +0 -188
  59. package/dist/documents/sync/engine.test.js +0 -169
  60. package/dist/documents/sync/handle.d.ts +0 -11
  61. package/dist/documents/sync/handle.js +0 -79
  62. package/dist/documents/sync/handle.test.js +0 -56
  63. package/dist/documents/sync/hash.d.ts +0 -1
  64. package/dist/documents/sync/hash.js +0 -13
  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/shards/activate-sync-registry.test.d.ts +0 -1
  93. package/dist/shards/activate-sync-registry.test.js +0 -42
  94. /package/dist/documents/{sync/handle.test.d.ts → handle.test.d.ts} +0 -0
  95. /package/dist/{documents/sync/activate-integration.test.d.ts → keys/client.test.d.ts} +0 -0
  96. /package/dist/{documents/sync/conflicts.test.d.ts → keys/consent.test.d.ts} +0 -0
  97. /package/dist/{documents/sync/engine.test.d.ts → keys/revocation-bus.test.d.ts} +0 -0
  98. /package/dist/{documents/sync/hash.test.d.ts → shards/activate-on-key-revoked.test.d.ts} +0 -0
@@ -10,6 +10,51 @@
10
10
  * The server bundle is a separate ESM file whose default export conforms
11
11
  * to the `ServerShard` interface.
12
12
  */
13
+ import type { DocumentMeta } from '../documents/types';
14
+ import type { SyncPolicy, ConflictFile } from '../documents/sync-types';
15
+ /**
16
+ * Per-tenant document API exposed to server shards via
17
+ * `ServerShardContext.documents(tenantId)`. Every method is
18
+ * permission-checked by the host at call time.
19
+ */
20
+ export interface TenantDocumentAPI {
21
+ read(shardId: string, path: string): Promise<string | null>;
22
+ exists(shardId: string, path: string): Promise<boolean>;
23
+ list(shardId: string): Promise<DocumentMeta[]>;
24
+ listAll(): Promise<Array<DocumentMeta & {
25
+ shardId: string;
26
+ }>>;
27
+ write(shardId: string, path: string, content: string | Uint8Array, metadata?: Record<string, unknown>): Promise<{
28
+ version: number;
29
+ syncState: 'synced' | 'pending';
30
+ }>;
31
+ delete(shardId: string, path: string): Promise<void>;
32
+ applyFromPeer(input: {
33
+ shardId: string;
34
+ path: string;
35
+ content: string | Uint8Array;
36
+ incomingVersion: number;
37
+ expectedLocalVersion: number;
38
+ origin: string;
39
+ deleted?: boolean;
40
+ metadata?: Record<string, unknown>;
41
+ }): Promise<{
42
+ applied: true;
43
+ version: number;
44
+ } | {
45
+ applied: false;
46
+ reason: 'stale' | 'conflict' | 'conflict-extended';
47
+ }>;
48
+ getTick(): Promise<number>;
49
+ readPolicy(): Promise<SyncPolicy | null>;
50
+ writePolicy(policy: SyncPolicy): Promise<void>;
51
+ listConflicts(): Promise<Array<{
52
+ shardId: string;
53
+ path: string;
54
+ }>>;
55
+ readConflict(shardId: string, path: string): Promise<ConflictFile | null>;
56
+ resolveConflict(shardId: string, path: string, choice: 'local' | string | Uint8Array): Promise<void>;
57
+ }
13
58
  /**
14
59
  * Context provided by sh3-server when mounting a server shard's routes.
15
60
  */
@@ -22,14 +67,33 @@ export interface ServerShardContext {
22
67
  * Path: `<dataDir>/shards/<shard-id>/`
23
68
  */
24
69
  dataDir: string;
70
+ /** Permission strings declared in the paired client manifest, empty if no manifest. */
71
+ permissions: string[];
25
72
  /**
26
73
  * Hono middleware that rejects non-admin callers.
27
- * Apply per-route to protect mutation endpoints. Public read routes
28
- * should not use this.
74
+ * Backwards-compatible alias for scopeRequired('admin:*'). Prefer the generic form.
29
75
  *
30
76
  * Usage: `router.post('/publish', ctx.adminOnly, handler)`
31
77
  */
32
78
  adminOnly: MiddlewareHandler;
79
+ /** 403 unless caller has the named scope (or admin:*). */
80
+ scopeRequired: (scope: string) => MiddlewareHandler;
81
+ /** 401 unless caller has a non-null tenantId. */
82
+ tenantRequired: MiddlewareHandler;
83
+ /**
84
+ * WebSocket upgrade registration — unchanged from 0.8.1.
85
+ */
86
+ wsRegister?: (onConnect: (ws: any, c: any) => void) => any;
87
+ /** Tenant ids that currently have doc content on this server. */
88
+ tenants(): string[];
89
+ /** Per-tenant document API, permission-checked per operation. */
90
+ documents(tenant: string): TenantDocumentAPI;
91
+ /**
92
+ * Declare the server's role for a tenant. Called by shards with
93
+ * `sync:peer` permission. No-op unless the caller holds it.
94
+ * Absent => 'primary' behavior at the store.
95
+ */
96
+ setPeerRole(tenant: string, role: 'primary' | 'replica'): void;
33
97
  }
34
98
  /**
35
99
  * The interface a server shard bundle must default-export.
@@ -45,6 +109,8 @@ export interface ServerShard {
45
109
  * May be async if the shard needs to initialise resources before serving.
46
110
  */
47
111
  routes: (router: HonoLike, context: ServerShardContext) => void | Promise<void>;
112
+ /** Optional shutdown hook. Called once on server SIGTERM/SIGINT. */
113
+ teardown?(): void | Promise<void>;
48
114
  }
49
115
  /**
50
116
  * Hono MiddlewareHandler type — duplicated here to avoid importing hono
@@ -1,17 +1,29 @@
1
1
  <script lang="ts">
2
2
  /*
3
- * Shell home — the view shown when no app is active. Sections:
4
- * 1. User apps — always visible
5
- * 2. Admin apps visible when user has admin role
3
+ * Shell home — the view shown when no app is active.
4
+ *
5
+ * Layout: a title header, a filter bar, then one grid per visible
6
+ * section (User apps always, Admin apps when elevated). Each app is
7
+ * rendered as a square card; the whole card is the launch action.
6
8
  */
7
9
 
8
10
  import { listRegisteredApps, launchApp, isAdmin, VERSION } from '../api';
9
11
  import ShellTitle from './ShellTitle.svelte';
10
12
 
13
+ let filter = $state('');
14
+
11
15
  const apps = $derived(listRegisteredApps());
12
- const userApps = $derived(apps.filter(m => !m.admin));
13
- const adminApps = $derived(apps.filter(m => m.admin));
14
16
  const elevated = $derived(isAdmin());
17
+
18
+ function matches(m: { id: string; label: string }, q: string): boolean {
19
+ if (!q) return true;
20
+ const needle = q.toLowerCase();
21
+ return m.label.toLowerCase().includes(needle) || m.id.toLowerCase().includes(needle);
22
+ }
23
+
24
+ const userApps = $derived(apps.filter((m) => !m.admin && matches(m, filter)));
25
+ const adminApps = $derived(apps.filter((m) => m.admin && matches(m, filter)));
26
+ const totalVisible = $derived(userApps.length + (elevated ? adminApps.length : 0));
15
27
  </script>
16
28
 
17
29
  <div class="shell-home">
@@ -26,54 +38,68 @@
26
38
  </div>
27
39
  </header>
28
40
 
41
+ <div class="shell-home-filter">
42
+ <input
43
+ type="search"
44
+ placeholder="Filter apps…"
45
+ bind:value={filter}
46
+ aria-label="Filter apps by name"
47
+ class="shell-home-filter-input"
48
+ />
49
+ </div>
50
+
29
51
  {#if userApps.length > 0}
30
52
  <section class="shell-home-section">
31
53
  <h2 class="shell-home-section-title">Apps</h2>
32
- <ul class="shell-home-list">
54
+ <div class="shell-home-grid">
33
55
  {#each userApps as manifest (manifest.id)}
34
- <li class="shell-home-entry">
35
- <div class="shell-home-entry-label">{manifest.label}</div>
36
- <div class="shell-home-entry-meta">
37
- {manifest.id} · v{manifest.version}
56
+ <button
57
+ type="button"
58
+ class="shell-home-card"
59
+ onclick={() => launchApp(manifest.id)}
60
+ title="Launch {manifest.label}"
61
+ >
62
+ <div class="shell-home-card-label">{manifest.label}</div>
63
+ <div class="shell-home-card-meta">
64
+ <span class="shell-home-card-id">{manifest.id}</span>
65
+ <span class="shell-home-card-version">v{manifest.version}</span>
38
66
  </div>
39
- <button
40
- type="button"
41
- class="shell-home-launch"
42
- onclick={() => launchApp(manifest.id)}
43
- >
44
- Launch
45
- </button>
46
- </li>
67
+ </button>
47
68
  {/each}
48
- </ul>
69
+ </div>
49
70
  </section>
50
71
  {/if}
51
72
 
52
73
  {#if elevated && adminApps.length > 0}
53
74
  <section class="shell-home-section">
54
75
  <h2 class="shell-home-section-title">Admin</h2>
55
- <ul class="shell-home-list">
76
+ <div class="shell-home-grid">
56
77
  {#each adminApps as manifest (manifest.id)}
57
- <li class="shell-home-entry">
58
- <div class="shell-home-entry-label">{manifest.label}</div>
59
- <div class="shell-home-entry-meta">
60
- {manifest.id} · v{manifest.version}
78
+ <button
79
+ type="button"
80
+ class="shell-home-card"
81
+ onclick={() => launchApp(manifest.id)}
82
+ title="Launch {manifest.label}"
83
+ >
84
+ <div class="shell-home-card-label">{manifest.label}</div>
85
+ <div class="shell-home-card-meta">
86
+ <span class="shell-home-card-id">{manifest.id}</span>
87
+ <span class="shell-home-card-version">v{manifest.version}</span>
61
88
  </div>
62
- <button
63
- type="button"
64
- class="shell-home-launch"
65
- onclick={() => launchApp(manifest.id)}
66
- >
67
- Launch
68
- </button>
69
- </li>
89
+ </button>
70
90
  {/each}
71
- </ul>
91
+ </div>
72
92
  </section>
73
93
  {/if}
74
94
 
75
- {#if userApps.length === 0 && (!elevated || adminApps.length === 0)}
76
- <p class="shell-home-empty">No apps registered.</p>
95
+ {#if totalVisible === 0}
96
+ <p class="shell-home-empty">
97
+ {#if apps.length === 0}
98
+ No apps registered.
99
+ {:else}
100
+ No apps match “{filter}”.
101
+ {/if}
102
+ </p>
77
103
  {/if}
78
104
  </div>
79
105
 
@@ -93,7 +119,7 @@
93
119
  }
94
120
  .shell-home-header {
95
121
  text-align: center;
96
- margin-bottom: 32px;
122
+ margin-bottom: 24px;
97
123
  display: flex;
98
124
  flex-direction: column;
99
125
  align-items: center;
@@ -136,14 +162,38 @@
136
162
  position: relative;
137
163
  top: -1px;
138
164
  }
165
+ .shell-home-filter {
166
+ width: 100%;
167
+ max-width: 720px;
168
+ margin-bottom: 24px;
169
+ }
170
+ .shell-home-filter-input {
171
+ width: 100%;
172
+ padding: 10px 14px;
173
+ font: inherit;
174
+ font-size: 14px;
175
+ color: var(--shell-fg);
176
+ background: var(--shell-bg-elevated);
177
+ border: 1px solid var(--shell-border);
178
+ border-radius: var(--shell-radius-md);
179
+ outline: none;
180
+ transition: border-color 120ms ease, box-shadow 120ms ease;
181
+ }
182
+ .shell-home-filter-input::placeholder {
183
+ color: var(--shell-fg-muted);
184
+ }
185
+ .shell-home-filter-input:focus {
186
+ border-color: var(--shell-accent);
187
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--shell-accent) 25%, transparent);
188
+ }
139
189
  .shell-home-empty {
140
190
  color: var(--shell-fg-muted);
141
191
  font-style: italic;
142
192
  }
143
193
  .shell-home-section {
144
194
  width: 100%;
145
- max-width: 440px;
146
- margin-bottom: 24px;
195
+ max-width: 720px;
196
+ margin-bottom: 28px;
147
197
  }
148
198
  .shell-home-section-title {
149
199
  font-size: 13px;
@@ -153,40 +203,67 @@
153
203
  color: var(--shell-fg-subtle);
154
204
  margin: 0 0 12px;
155
205
  }
156
- .shell-home-list {
157
- list-style: none;
158
- margin: 0;
159
- padding: 0;
206
+ .shell-home-grid {
207
+ display: grid;
208
+ grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
209
+ gap: 10px;
210
+ }
211
+ .shell-home-card {
212
+ aspect-ratio: 1 / 1;
160
213
  display: flex;
161
214
  flex-direction: column;
162
- gap: 12px;
163
- }
164
- .shell-home-entry {
165
- display: grid;
166
- grid-template-columns: 1fr auto;
167
- grid-template-rows: auto auto;
168
- gap: 4px 16px;
169
- align-items: center;
170
- padding: 14px 18px;
215
+ justify-content: space-between;
216
+ text-align: left;
217
+ padding: 10px;
171
218
  background: var(--shell-grad-bg-elevated, var(--shell-bg-elevated));
172
219
  border: 1px solid var(--shell-border);
173
220
  border-radius: var(--shell-radius-md);
221
+ color: inherit;
222
+ font: inherit;
223
+ cursor: pointer;
224
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.15);
225
+ transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
226
+ }
227
+ .shell-home-card:hover {
228
+ border-color: var(--shell-accent);
229
+ transform: translateY(-1px);
230
+ box-shadow:
231
+ 0 6px 14px rgba(0, 0, 0, 0.3),
232
+ 0 0 0 1px color-mix(in srgb, var(--shell-accent) 35%, transparent),
233
+ 0 4px 12px color-mix(in srgb, var(--shell-accent) 18%, transparent);
174
234
  }
175
- .shell-home-entry-label {
176
- grid-column: 1;
177
- grid-row: 1;
235
+ .shell-home-card:focus-visible {
236
+ outline: none;
237
+ border-color: var(--shell-accent);
238
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--shell-accent) 40%, transparent);
239
+ }
240
+ .shell-home-card:active {
241
+ transform: translateY(0);
242
+ }
243
+ .shell-home-card-label {
178
244
  font-weight: 600;
245
+ font-size: 12px;
246
+ line-height: 1.2;
247
+ overflow: hidden;
248
+ display: -webkit-box;
249
+ -webkit-box-orient: vertical;
250
+ -webkit-line-clamp: 2;
251
+ line-clamp: 2;
179
252
  }
180
- .shell-home-entry-meta {
181
- grid-column: 1;
182
- grid-row: 2;
183
- font-size: 11px;
253
+ .shell-home-card-meta {
254
+ display: flex;
255
+ flex-direction: column;
256
+ gap: 1px;
257
+ font-size: 9px;
184
258
  color: var(--shell-fg-subtle);
259
+ min-width: 0;
185
260
  }
186
- .shell-home-launch {
187
- grid-column: 2;
188
- grid-row: 1 / span 2;
189
- padding: 8px 16px;
190
- font-weight: 600;
261
+ .shell-home-card-id {
262
+ overflow: hidden;
263
+ text-overflow: ellipsis;
264
+ white-space: nowrap;
265
+ }
266
+ .shell-home-card-version {
267
+ color: var(--shell-fg-muted);
191
268
  }
192
269
  </style>
@@ -23,13 +23,17 @@
23
23
  */
24
24
  import { mount, unmount } from 'svelte';
25
25
  import ShellHome from './ShellHome.svelte';
26
+ import KeysAndPeers from '../shell/views/KeysAndPeers.svelte';
26
27
  import { VERSION } from '../version';
27
28
  export const sh3coreShard = {
28
29
  manifest: {
29
30
  id: '__sh3core__',
30
31
  label: 'SH3 Core',
31
32
  version: VERSION,
32
- views: [{ id: 'sh3core:home', label: 'Home' }],
33
+ views: [
34
+ { id: 'sh3core:home', label: 'Home' },
35
+ { id: 'shell:keys-and-peers', label: 'Keys & Peers' },
36
+ ],
33
37
  },
34
38
  activate(ctx) {
35
39
  const factory = {
@@ -43,7 +47,14 @@ export const sh3coreShard = {
43
47
  };
44
48
  },
45
49
  };
50
+ const keysFactory = {
51
+ mount(container, _context) {
52
+ const instance = mount(KeysAndPeers, { target: container });
53
+ return { unmount() { unmount(instance); } };
54
+ },
55
+ };
46
56
  ctx.registerView('sh3core:home', factory);
57
+ ctx.registerView('shell:keys-and-peers', keysFactory);
47
58
  },
48
59
  autostart() {
49
60
  // Intentionally empty. Defining this field is what puts the sh3core
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect, beforeEach } 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 { emit } from '../keys/revocation-bus.svelte';
6
+ describe('onKeyRevoked hook wiring', () => {
7
+ beforeEach(() => {
8
+ __resetShardRegistryForTest();
9
+ __setDocumentBackend(new MemoryDocumentBackend());
10
+ __setTenantId('tenant-a');
11
+ });
12
+ it('fires onKeyRevoked when the bus emits for the shard', async () => {
13
+ const received = [];
14
+ registerShard({
15
+ manifest: { id: 'hook-shard', label: 'h', version: '0.0.0', views: [] },
16
+ activate() { },
17
+ onKeyRevoked(id) { received.push(id); },
18
+ });
19
+ await activateShard('hook-shard');
20
+ emit('hook-shard', 'key-abc');
21
+ // Handler is async; give microtasks a chance to flush.
22
+ await Promise.resolve();
23
+ expect(received).toEqual(['key-abc']);
24
+ });
25
+ it('does not fire for a different shardId', async () => {
26
+ const received = [];
27
+ registerShard({
28
+ manifest: { id: 'shard-x', label: 'x', version: '0.0.0', views: [] },
29
+ activate() { },
30
+ onKeyRevoked(id) { received.push(id); },
31
+ });
32
+ await activateShard('shard-x');
33
+ emit('shard-y', 'key-other');
34
+ await Promise.resolve();
35
+ expect(received).toHaveLength(0);
36
+ });
37
+ it('does not fire after deactivation', async () => {
38
+ const received = [];
39
+ registerShard({
40
+ manifest: { id: 'shard-deact', label: 'd', version: '0.0.0', views: [] },
41
+ activate() { },
42
+ onKeyRevoked(id) { received.push(id); },
43
+ });
44
+ await activateShard('shard-deact');
45
+ deactivateShard('shard-deact');
46
+ emit('shard-deact', 'key-gone');
47
+ await Promise.resolve();
48
+ expect(received).toHaveLength(0);
49
+ });
50
+ it('does not subscribe when onKeyRevoked is absent', async () => {
51
+ // Should not throw — just silently skips subscribing.
52
+ registerShard({
53
+ manifest: { id: 'no-hook', label: 'n', version: '0.0.0', views: [] },
54
+ activate() { },
55
+ });
56
+ await expect(activateShard('no-hook')).resolves.toBeUndefined();
57
+ // Emitting for a shard with no listener is a no-op.
58
+ expect(() => emit('no-hook', 'k')).not.toThrow();
59
+ });
60
+ });
@@ -23,12 +23,11 @@ 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_SYNC } from '../documents/sync/types';
27
26
  import { PERMISSION_DOCUMENTS_BROWSE } from '../documents/types';
28
27
  import { createBrowseCapability } from '../documents/browse';
29
- import { getSyncBundle } from '../documents/sync/singleton';
30
- import { createSyncHandle } from '../documents/sync/handle';
31
- import { createSyncRegistryAccessor } from '../documents/sync/observer';
28
+ import { createShardKeysApi } from '../keys/client';
29
+ import { PERMISSION_KEYS_MINT } from '../keys/types';
30
+ import { subscribe } from '../keys/revocation-bus.svelte';
32
31
  /**
33
32
  * Reactive registry of every shard known to the host. Keys are shard ids.
34
33
  * Populated once at boot by the glob-discovery loop in main.ts (through
@@ -140,29 +139,27 @@ export async function activateShard(id) {
140
139
  browse: ((_b = shard.manifest.permissions) === null || _b === void 0 ? void 0 : _b.includes(PERMISSION_DOCUMENTS_BROWSE))
141
140
  ? createBrowseCapability(getTenantId(), getDocumentBackend())
142
141
  : undefined,
143
- syncRegistry: ((_c = shard.manifest.permissions) === null || _c === void 0 ? void 0 : _c.includes(PERMISSION_DOCUMENTS_BROWSE))
144
- ? createSyncRegistryAccessor(getDocumentBackend(), getTenantId())
145
- : undefined,
146
- sync: ((_d = shard.manifest.permissions) === null || _d === void 0 ? void 0 : _d.includes(PERMISSION_DOCUMENTS_SYNC))
147
- ? () => {
148
- const backend = getDocumentBackend();
149
- const tenantId = getTenantId();
150
- const bundlePromise = getSyncBundle(backend, tenantId);
151
- const handlePromise = bundlePromise.then(({ engine, registry }) => createSyncHandle({ tenantId, connectorId: id, engine, registry }));
152
- return {
153
- connectorId: id,
154
- grantedScopes: async () => (await handlePromise).grantedScopes(),
155
- getManifest: async (scope) => (await handlePromise).getManifest(scope),
156
- changesSince: async (scope, cursor) => (await handlePromise).changesSince(scope, cursor),
157
- ack: async (scope, cursor) => (await handlePromise).ack(scope, cursor),
158
- apply: async (scope, entry, opts) => (await handlePromise).apply(scope, entry, opts),
159
- applyBatch: async (scope, manifest, opts) => (await handlePromise).applyBatch(scope, manifest, opts),
160
- forget: async (scope, path) => (await handlePromise).forget(scope, path),
161
- };
162
- }
142
+ keys: ((_c = shard.manifest.permissions) === null || _c === void 0 ? void 0 : _c.includes(PERMISSION_KEYS_MINT))
143
+ ? createShardKeysApi({
144
+ shardId: id,
145
+ shardPermissions: (_d = shard.manifest.permissions) !== null && _d !== void 0 ? _d : [],
146
+ })
163
147
  : undefined,
164
148
  };
165
149
  entry.ctx = ctx;
150
+ // Wire onKeyRevoked hook: subscribe to the revocation bus for this shard.
151
+ // Only shards that declare the hook incur the subscription overhead.
152
+ if (shard.onKeyRevoked) {
153
+ const off = subscribe(id, async (keyId) => {
154
+ try {
155
+ await shard.onKeyRevoked(keyId);
156
+ }
157
+ catch (err) {
158
+ console.error(`[sh3] onKeyRevoked failed in "${id}":`, err);
159
+ }
160
+ });
161
+ entry.cleanupFns.push(async () => off());
162
+ }
166
163
  active.set(id, entry);
167
164
  activeShards.set(id, shard);
168
165
  await shard.activate(ctx);
@@ -2,10 +2,10 @@ import type { StateZones } from '../state/zones.svelte';
2
2
  import type { ZoneSchema, ZoneManager } from '../state/types';
3
3
  import type { DocumentHandle, DocumentHandleOptions } from '../documents/types';
4
4
  import type { BrowseCapability } from '../documents/browse';
5
- import type { SyncHandle } from '../documents/sync/types';
6
- import type { SyncRegistry } from '../documents/sync/registry';
7
5
  import type { EnvState } from '../env/types';
8
6
  import type { Verb } from '../verbs/types';
7
+ import type { ShardContextKeys } from '../keys/types';
8
+ export { PERMISSION_KEYS_MINT, type ShardContextKeys, type ApiKeyPublic, type MintOpts, ScopeEscalationError, ConsentDeniedError } from '../keys/types';
9
9
  /**
10
10
  * The object returned by `ViewFactory.mount`. The framework calls
11
11
  * `unmount()` when the slot goes away, and `onResize(w, h)` whenever the
@@ -212,12 +212,6 @@ export interface ShardContext {
212
212
  * `if (ctx.zones)` before use.
213
213
  */
214
214
  zones?: ZoneManager;
215
- /**
216
- * Cross-shard document sync API. Only present when the shard's
217
- * manifest declares the `'documents:sync'` permission. Check with
218
- * `if (ctx.sync)` before use.
219
- */
220
- sync?: () => SyncHandle;
221
215
  /**
222
216
  * Tenant-wide document browse API. Read-only enumeration and change
223
217
  * subscription across every shard's documents for the active tenant.
@@ -227,12 +221,10 @@ export interface ShardContext {
227
221
  */
228
222
  browse?: BrowseCapability;
229
223
  /**
230
- * Sync registry observer. Read-only list/revoke/conflict enumeration
231
- * for explorer-class shards. Only present when the shard declares
232
- * `'documents:browse'`. Granting still happens exclusively via
233
- * `<SyncGrantPicker />`.
224
+ * Mint/list/revoke keys minted by this shard. Only available when the
225
+ * manifest declares the `keys:mint` permission.
234
226
  */
235
- syncRegistry?: () => SyncRegistry;
227
+ keys?: ShardContextKeys;
236
228
  }
237
229
  /**
238
230
  * A shard module. Shards are the fundamental unit of contribution in SH3.
@@ -274,6 +266,8 @@ export interface Shard {
274
266
  * `ShardContext` that `activate` received.
275
267
  */
276
268
  resume?(ctx: ShardContext): void | Promise<void>;
269
+ /** Fires when a key minted by this shard is revoked from any source. */
270
+ onKeyRevoked?(id: string): void | Promise<void>;
277
271
  }
278
272
  /**
279
273
  * Source-level shape of a shard as written by external package authors.
@@ -17,4 +17,4 @@
17
17
  * activation events. They'll slot into `ShardContext` as new `register*`
18
18
  * methods without disturbing the phase-4 shape.
19
19
  */
20
- export {};
20
+ export { PERMISSION_KEYS_MINT, ScopeEscalationError, ConsentDeniedError } from '../keys/types';