@ateam-ai-mobile/plugin-runtime 0.1.7 → 0.1.8

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.
@@ -6,7 +6,7 @@
6
6
  * the BundleStorage adapter (in-memory by default; @ateam-ai-mobile/expo provides disk).
7
7
  *
8
8
  * Usage:
9
- * const component = await bundleManager.load(pluginId, bundleUrl, version, hash);
9
+ * const component = await bundleManager.load(pluginId, bundleUrl, version, hash, grants);
10
10
  * // component = { id, type, Component, ... } (RegisteredUIPlugin)
11
11
  */
12
12
  import React from 'react';
@@ -25,8 +25,9 @@ import SurfaceDispatcher from '../registry/SurfaceDispatcher';
25
25
  import { resolveBundleModule } from '../host';
26
26
  import { sha256Hex } from '../utils/sha256';
27
27
  import { assertBundleIntegrity } from './integrity';
28
- import { getBundleStorage, type BundleCacheEntry } from './storage';
28
+ import { getBundleStorage, type BundleCacheEntry, type TrustedToolDeclaration } from './storage';
29
29
  import { registerDeviceTools, unregisterDeviceTools, listDeviceTools } from '../tools/registry';
30
+ import type { PluginGrants } from '../pluginRuntime';
30
31
  import type { RegisteredUIPlugin } from '../sdk/types';
31
32
 
32
33
  // ── Require shim ─────────────────────────────────────────────
@@ -90,6 +91,12 @@ const requireShim = (name: string) => {
90
91
 
91
92
  const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
92
93
 
94
+ // What findPluginExport looks for: a renderable plugin, or one with a `tools` section.
95
+ const hasComponent = (val: any): boolean =>
96
+ !!val && typeof val === 'object' && typeof val.Component === 'function';
97
+ const hasTools = (val: any): boolean =>
98
+ !!val && typeof val === 'object' && Array.isArray(val.tools) && val.tools.length > 0;
99
+
93
100
  // ── BundleManager singleton ──────────────────────────────────
94
101
 
95
102
  /** Where the bytes currently running came from. */
@@ -148,6 +155,10 @@ class BundleManager {
148
155
  */
149
156
  private registeredToolPluginIds = new Set<string>();
150
157
 
158
+ /** bundleUrl → the trusted declaration last recorded on its disk-cache entry
159
+ * this session, so a memory hit with unchanged grants skips the index I/O. */
160
+ private recordedDeclarations = new Map<string, string>();
161
+
151
162
  /**
152
163
  * Enforce the in-memory tenant boundary. Call at the top of every public load
153
164
  * path, BEFORE any cache read.
@@ -193,6 +204,7 @@ class BundleManager {
193
204
  try { unregisterDeviceTools(pluginId); } catch { /* ignore */ }
194
205
  }
195
206
  this.registeredToolPluginIds.clear();
207
+ this.recordedDeclarations.clear();
196
208
  this.modules.clear();
197
209
  this.provenance.clear();
198
210
  this.inflight.clear();
@@ -208,13 +220,15 @@ class BundleManager {
208
220
  return this.epoch !== epoch || getTenant() !== tenant;
209
221
  }
210
222
 
211
- /** Flip an existing provenance record to source:'memory' — a later serve of an
212
- * already-evaluated bundle came from the in-memory cache, not a fresh
213
- * download/disk read. No-op if nothing was recorded yet. */
214
- private markMemoryHit(bundleUrl: string): void {
223
+ /** Record a memory serve: the running bytes are the already-evaluated module,
224
+ * and `expected` is refreshed from the manifest hash the caller JUST passed —
225
+ * so after a redeploy the record reads hash !== expected (STALE) instead of
226
+ * the old in-sync pair. The ONE place both memory paths (load()'s early return
227
+ * and loadModule's) record reuse. No-op if nothing was recorded yet. */
228
+ private markMemoryHit(bundleUrl: string, expectedHash?: string): void {
215
229
  const prev = this.provenance.get(bundleUrl);
216
- if (prev && prev.source !== 'memory') {
217
- this.provenance.set(bundleUrl, { ...prev, source: 'memory' });
230
+ if (prev) {
231
+ this.provenance.set(bundleUrl, { ...prev, source: 'memory', expected: expectedHash?.toLowerCase() ?? prev.expected });
218
232
  }
219
233
  }
220
234
 
@@ -228,12 +242,20 @@ class BundleManager {
228
242
  * provided for any bundle the user has not previously verified — without
229
243
  * it, a tampered bundle is `eval`'d as JS with full RN runtime + every
230
244
  * native capability the manifest declares.
245
+ *
246
+ * `grants` is what the MANIFEST grants this plugin (resolvePluginGrants) — the
247
+ * only authority its device tools are registered under. Omitted (e.g. a
248
+ * prewarm from the slim plugin list) → the bundle is loaded but its tools are
249
+ * NOT registered: without the manifest there is nothing trusted to gate them
250
+ * on, and registering with nothing would downgrade a registration the
251
+ * manifest-driven tool loader already made.
231
252
  */
232
253
  async load(
233
254
  pluginId: string,
234
255
  bundleUrl: string,
235
256
  version: string,
236
257
  expectedHash?: string,
258
+ grants?: PluginGrants,
237
259
  ): Promise<RegisteredUIPlugin> {
238
260
  // Tenant boundary FIRST — fail closed without a tenant, drop the previous
239
261
  // tenant's modules on a switch, and CAPTURE the tenant + generation at entry
@@ -248,7 +270,8 @@ class BundleManager {
248
270
  // this the provenance would freeze on the first download/disk source and
249
271
  // getLoadedBundles() would never report a memory serve. (loadModule's
250
272
  // own memory branch covers the loadTools path, which has no early return.)
251
- this.markMemoryHit(bundleUrl);
273
+ this.markMemoryHit(bundleUrl, expectedHash);
274
+ if (grants) await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
252
275
  return cached;
253
276
  }
254
277
 
@@ -277,6 +300,10 @@ class BundleManager {
277
300
  throw new Error('[BundleManager] tenant changed while loading — discarding (fail closed)');
278
301
  }
279
302
 
303
+ // Tools register at LOAD, not at mount — and for a UI bundle too, so a hybrid
304
+ // plugin's tools are callable whether or not its component is found.
305
+ if (grants) await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
306
+
280
307
  // 3. Extract component for this specific pluginId
281
308
  const component = this.getComponentFromMemory(pluginId, bundleUrl);
282
309
  if (!component) {
@@ -295,13 +322,20 @@ class BundleManager {
295
322
  * Same download, same integrity check, same evaluation as `load()`; the only
296
323
  * difference is that a bundle exporting tools and no `Component` is a success
297
324
  * here rather than "no component found". Returns the tool names registered.
325
+ *
326
+ * `grants` is REQUIRED: the tools are registered under `pluginId` with exactly
327
+ * what the manifest grants (see load()), never what the bundle claims.
298
328
  */
299
329
  async loadTools(
300
330
  pluginId: string,
301
331
  bundleUrl: string,
302
332
  version: string,
303
- expectedHash?: string,
333
+ expectedHash: string | undefined,
334
+ grants: PluginGrants,
304
335
  ): Promise<string[]> {
336
+ if (!grants) {
337
+ throw new Error(`[BundleManager] loadTools(${pluginId}) needs the manifest's grants (resolvePluginGrants) — refusing to register tools on the bundle's own claim`);
338
+ }
305
339
  // Tenant boundary FIRST — same fail-closed / drop-on-switch as load(), and
306
340
  // capture the tenant + generation for the stale check after the async load.
307
341
  const tenant = this.requireTenant();
@@ -322,6 +356,7 @@ class BundleManager {
322
356
  if (this.isStale(epoch, tenant)) {
323
357
  throw new Error('[BundleManager] tenant changed while loading tools — discarding (fail closed)');
324
358
  }
359
+ await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
325
360
 
326
361
  const names = listDeviceTools()
327
362
  .filter(t => t.pluginId === pluginId)
@@ -347,6 +382,8 @@ class BundleManager {
347
382
  bundleUrl: string;
348
383
  version: string;
349
384
  bundleHash?: string;
385
+ /** The manifest's grants, when the caller has the manifest (see load()). */
386
+ grants?: PluginGrants;
350
387
  }>): Promise<void> {
351
388
  const queue = [...items];
352
389
  const concurrency = Math.min(3, queue.length);
@@ -359,7 +396,7 @@ class BundleManager {
359
396
  const it = queue.shift();
360
397
  if (!it) return;
361
398
  try {
362
- await this.load(it.pluginId, it.bundleUrl, it.version, it.bundleHash);
399
+ await this.load(it.pluginId, it.bundleUrl, it.version, it.bundleHash, it.grants);
363
400
  okCount++;
364
401
  } catch (err) {
365
402
  failCount++;
@@ -438,8 +475,7 @@ class BundleManager {
438
475
  // Already in memory? Then the running bytes are whatever this session
439
476
  // already evaluated — record the reuse so provenance never goes silent.
440
477
  if (this.modules.has(bundleUrl)) {
441
- const prev = this.provenance.get(bundleUrl);
442
- if (prev) this.provenance.set(bundleUrl, { ...prev, source: 'memory', expected: expectedHash?.toLowerCase() ?? prev.expected });
478
+ this.markMemoryHit(bundleUrl, expectedHash);
443
479
  return;
444
480
  }
445
481
 
@@ -551,37 +587,112 @@ class BundleManager {
551
587
  `[BundleManager] RUNNING ${bundleUrl} v${version} sha=${(verifiedHash || 'none').slice(0, 12)} ` +
552
588
  `(${servedFrom}${expectedLc && verifiedHash && expectedLc !== verifiedHash ? `, EXPECTED ${expectedLc.slice(0, 12)} — STALE` : ''})`,
553
589
  );
590
+ }
554
591
 
555
- // Tools register at LOAD, not at mount — that is what makes a tool callable
556
- // with nothing of the plugin on screen.
557
- this.registerToolsFromModule(mod, bundleUrl);
592
+ /**
593
+ * Register THIS plugin's device tools under the TRUSTED declaration, then
594
+ * record that declaration on the disk-cache entry. Runs on every trusted load
595
+ * — memory hit included — so the grants in force are always the latest the
596
+ * manifest gave (a revoked grant stops applying on the next load, not the next
597
+ * app start). Callers run it only after their stale check.
598
+ */
599
+ private async commitTools(
600
+ pluginId: string,
601
+ bundleUrl: string,
602
+ grants: PluginGrants,
603
+ tenant: string,
604
+ epoch: number,
605
+ ): Promise<void> {
606
+ const declaration: TrustedToolDeclaration = {
607
+ pluginId,
608
+ capabilities: grants.capabilities,
609
+ stateDomains: grants.stateDomains,
610
+ };
611
+ this.registerToolsFromModule(this.modules.get(bundleUrl), declaration);
612
+ await this.recordTrustedDeclaration(bundleUrl, declaration, tenant, epoch);
558
613
  }
559
614
 
560
615
  /**
561
- * Pick up every `tools` section in an evaluated bundle and put it in the
562
- * device tool registry. Runs for UI, hybrid and tools-only bundles alike: a
563
- * plugin that ships both gets its tools registered without its UI mounting.
616
+ * Put the plugin's own `tools` section in the device tool registry — under the
617
+ * pluginId and grants the MANIFEST resolved, never the bundle's own `id`,
618
+ * `capabilities` or `stateDomains`. A bundle is code the manifest authorised
619
+ * by hash; what it may touch is the manifest's to say, same as its rendered
620
+ * surface (resolvePluginRuntimeProps). Its own `id` would also let it register
621
+ * as another plugin — including the host's platform tools, which a same-id
622
+ * re-registration replaces.
623
+ *
624
+ * Only this plugin's export (the same lookup the component uses) is
625
+ * registered. Another export's tools in the same bundle belong to another
626
+ * plugin and register when THAT plugin's manifest loads it.
564
627
  */
565
- private registerToolsFromModule(mod: any, bundleUrl: string): void {
628
+ private registerToolsFromModule(mod: any, { pluginId, capabilities, stateDomains }: TrustedToolDeclaration): void {
566
629
  if (!mod) return;
567
630
 
568
- for (const key of Object.keys(mod)) {
569
- const val = mod[key];
570
- if (!val || typeof val !== 'object' || !Array.isArray(val.tools) || val.tools.length === 0) {
571
- continue;
572
- }
573
- const pluginId = typeof val.id === 'string' ? val.id : `${bundleUrl}#${key}`;
574
- try {
575
- registerDeviceTools(pluginId, val.tools, val.capabilities, val.stateDomains);
576
- // Remember it so a tenant switch (or clearAll) can unregister the OLD
577
- // tenant's tools before the incoming tenant re-registers.
578
- this.registeredToolPluginIds.add(pluginId);
579
- } catch (err) {
580
- console.warn(
581
- `[BundleManager] tool registration failed for ${pluginId}:`,
582
- err instanceof Error ? err.message : String(err),
583
- );
631
+ const own = this.findPluginExport(mod, pluginId, hasTools);
632
+ const others = Object.keys(mod).filter((k) => hasTools(mod[k]) && mod[k] !== own);
633
+ if (others.length > 0) {
634
+ console.warn(
635
+ `[BundleManager] ${pluginId}: not registering tools from export(s) ${others.join(', ')} — ` +
636
+ `they are not this plugin's, and only their own manifest can grant them`,
637
+ );
638
+ }
639
+ if (!own) return;
640
+
641
+ // The bundle's own declaration grants nothing, but a gap between it and the
642
+ // manifest is the author's bug — name it, or the tool just reads "not declared".
643
+ const ungranted = [
644
+ ...(Array.isArray(own.stateDomains) ? own.stateDomains : []).filter((d: any) => !stateDomains?.includes(d)),
645
+ ...Object.keys(own.capabilities || {}).filter((k) => own.capabilities[k] === true && !(capabilities as any)?.[k]),
646
+ ];
647
+ if (ungranted.length > 0) {
648
+ console.warn(
649
+ `[BundleManager] ${pluginId}: bundle declares ${ungranted.join(', ')} but the manifest does not grant it — denied`,
650
+ );
651
+ }
652
+
653
+ try {
654
+ registerDeviceTools(pluginId, own.tools, capabilities, stateDomains);
655
+ // Remember it so a tenant switch (or clearAll) can unregister the OLD
656
+ // tenant's tools before the incoming tenant re-registers.
657
+ this.registeredToolPluginIds.add(pluginId);
658
+ } catch (err) {
659
+ console.warn(
660
+ `[BundleManager] tool registration failed for ${pluginId}:`,
661
+ err instanceof Error ? err.message : String(err),
662
+ );
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Record the trusted declaration on this tenant's disk-cache entry for the
668
+ * bytes RUNNING — the recovery path (tools/loader loadFromDiskCache) has no
669
+ * manifest and re-registers with exactly this. Only onto an entry this tenant
670
+ * owns whose hash is the running hash: the grant was issued for these bytes,
671
+ * and those are the bytes recovery will evaluate. Best-effort — a lost write
672
+ * leaves no `trusted`, which recovery treats as fail closed.
673
+ */
674
+ private async recordTrustedDeclaration(
675
+ bundleUrl: string,
676
+ declaration: TrustedToolDeclaration,
677
+ tenant: string,
678
+ epoch: number,
679
+ ): Promise<void> {
680
+ const key = JSON.stringify(declaration);
681
+ if (this.recordedDeclarations.get(bundleUrl) === key) return;
682
+ try {
683
+ const storage = getBundleStorage();
684
+ const index = await storage.loadIndex();
685
+ const entry = index[bundleUrl];
686
+ const running = this.provenance.get(bundleUrl)?.hash;
687
+ if (!entry || entry.tenant !== tenant || !running || entry.bundleHash?.toLowerCase() !== running) return;
688
+ if (this.isStale(epoch, tenant)) return;
689
+ if (JSON.stringify(entry.trusted) !== key) {
690
+ index[bundleUrl] = { ...entry, trusted: declaration };
691
+ await storage.saveIndex(index);
584
692
  }
693
+ this.recordedDeclarations.set(bundleUrl, key);
694
+ } catch (err) {
695
+ console.warn('[BundleManager] could not record trusted declaration:', err);
585
696
  }
586
697
  }
587
698
 
@@ -691,44 +802,37 @@ class BundleManager {
691
802
 
692
803
  // ── Private: component extraction ───────────────────────────
693
804
 
805
+ /** Find a plugin component in a loaded module. */
806
+ private getComponentFromMemory(pluginId: string, bundleUrl: string): RegisteredUIPlugin | null {
807
+ return this.findPluginExport(this.modules.get(bundleUrl), pluginId, hasComponent);
808
+ }
809
+
694
810
  /**
695
- * Find a plugin component in a loaded module.
696
- * Handles: default export, direct key match, multi-plugin .id match.
811
+ * Find THIS plugin's export in a loaded module — the one lookup both the
812
+ * component and the device tools use, so a plugin's UI and its tools always
813
+ * come from the same export. `accept` says what the caller needs from it.
814
+ * Handles: direct key match, PascalCase key, default export, multi-plugin .id match.
697
815
  */
698
- private getComponentFromMemory(pluginId: string, bundleUrl: string): RegisteredUIPlugin | null {
699
- const mod = this.modules.get(bundleUrl);
816
+ private findPluginExport(mod: any, pluginId: string, accept: (val: any) => boolean): any | null {
700
817
  if (!mod) return null;
701
818
 
702
819
  const shortName = pluginId.includes(':') ? pluginId.split(':').pop()! : pluginId;
703
820
 
704
821
  // 1. Direct key match (full pluginId as key)
705
- if (mod[pluginId] && typeof mod[pluginId].Component === 'function') {
706
- return mod[pluginId] as RegisteredUIPlugin;
707
- }
822
+ if (accept(mod[pluginId])) return mod[pluginId];
708
823
 
709
824
  // 2. PascalCase match from shortName (most reliable for combined bundles)
710
825
  // e.g., "memories-panel" → "MemoriesPanel"
711
826
  const pascal = shortName.replace(/(^|-)([a-z])/g, (_: string, __: string, c: string) => c.toUpperCase());
712
- if (mod[pascal] && typeof mod[pascal] === 'object' && typeof mod[pascal].Component === 'function') {
713
- return mod[pascal] as RegisteredUIPlugin;
714
- }
827
+ if (accept(mod[pascal])) return mod[pascal];
715
828
 
716
829
  // 3. Default export (single-plugin bundles)
717
- if (mod.default && typeof mod.default === 'object' && typeof mod.default.Component === 'function') {
718
- return mod.default as RegisteredUIPlugin;
719
- }
830
+ if (accept(mod.default)) return mod.default;
720
831
 
721
832
  // 4. Match by .id field (last resort — can be unreliable in combined bundles)
722
833
  for (const key of Object.keys(mod)) {
723
834
  const val = mod[key];
724
- if (
725
- val &&
726
- typeof val === 'object' &&
727
- val.id === shortName &&
728
- typeof val.Component === 'function'
729
- ) {
730
- return val as RegisteredUIPlugin;
731
- }
835
+ if (val && (val.id === pluginId || val.id === shortName) && accept(val)) return val;
732
836
  }
733
837
 
734
838
  return null;
@@ -5,6 +5,7 @@
5
5
  * default is in-memory (bundles re-download per app session — correct,
6
6
  * just slower on cold start).
7
7
  */
8
+ import type { PluginGrants } from '../pluginRuntime';
8
9
 
9
10
  export interface BundleCacheEntry {
10
11
  localPath: string;
@@ -27,6 +28,24 @@ export interface BundleCacheEntry {
27
28
  * untrusted. Never read a missing tenant as "mine".
28
29
  */
29
30
  tenant?: string;
31
+ /**
32
+ * The trusted declaration these bytes' device tools were last registered
33
+ * under: the pluginId and grants resolved from the server manifest that named
34
+ * this bundle (resolvePluginGrants). Recorded so the disk-cache recovery path —
35
+ * which runs exactly when there is NO manifest — re-registers with the same
36
+ * authority instead of the bundle's own claim.
37
+ *
38
+ * Absent → no trusted declaration is known for these bytes, so recovery skips
39
+ * the entry (fail closed) and the catalog path registers it once the owning
40
+ * connector answers again.
41
+ */
42
+ trusted?: TrustedToolDeclaration;
43
+ }
44
+
45
+ /** Who a bundle's device tools belong to and what they are granted — always
46
+ * from the manifest, never from the bundle. */
47
+ export interface TrustedToolDeclaration extends PluginGrants {
48
+ pluginId: string;
30
49
  }
31
50
 
32
51
  /**
@@ -191,6 +191,31 @@ export function gateDeviceState(declared?: readonly DeviceBridgeDomain[]): Devic
191
191
  return out as DeviceState;
192
192
  }
193
193
 
194
+ /**
195
+ * The `deviceState` a device TOOL call receives: the same domains, but
196
+ * `subscribe` refuses.
197
+ *
198
+ * A declared domain IS the engine, and the engines live as long as the app. A
199
+ * UI plugin has a mount to unsubscribe in; a tool call has nothing — a listener
200
+ * `run()` adds outlives it and runs on every later event, once per call that
201
+ * added one. Refused loudly rather than quietly scoped to the call: a
202
+ * subscription that stops hearing when `run` returns reads as "nothing has
203
+ * changed". Everything else passes through untouched.
204
+ */
205
+ export function withoutSubscribe(state: DeviceState): DeviceState {
206
+ if (!state) return state;
207
+ const out: any = {};
208
+ for (const domain of ALL_DOMAINS) {
209
+ const d = (state as any)[domain];
210
+ out[domain] = d && Object.assign(Object.create(d), {
211
+ subscribe() {
212
+ throw denial(domain, 'a device tool call has no lifetime to own a subscription. Subscribe from the plugin UI, or read getSnapshot() within the call.');
213
+ },
214
+ });
215
+ }
216
+ return out as DeviceState;
217
+ }
218
+
194
219
  /** Which domains this build can actually answer — for diagnostics. */
195
220
  export function implementedStateDomains(): DeviceBridgeDomain[] {
196
221
  return ALL_DOMAINS.filter(d => !!(_provider as any)[d]);
@@ -13,13 +13,18 @@
13
13
  import { useState, useEffect } from 'react';
14
14
  import { bundleManager } from '../bundles/bundleManager';
15
15
  import { nativePluginRegistry } from '../host';
16
- import type { RegisteredUIPlugin } from '../sdk/types';
16
+ import { resolvePluginGrants } from '../pluginRuntime';
17
+ import type { NativeCapabilities, RegisteredUIPlugin } from '../sdk/types';
18
+ import type { DeviceBridgeDomain } from '../sdk/device-state';
17
19
 
18
20
  interface PluginManifestLike {
19
21
  version?: string;
20
22
  name?: string;
21
23
  render?: any;
22
24
  _connectorId?: string;
25
+ capabilities?: Record<string, unknown>;
26
+ native?: NativeCapabilities;
27
+ stateDomains?: DeviceBridgeDomain[];
23
28
  }
24
29
 
25
30
  interface UsePluginResult {
@@ -102,6 +107,11 @@ export function usePlugin(pluginId: string, manifest: PluginManifestLike): UsePl
102
107
  const bundleHash = extractBundleHash(manifest);
103
108
  const componentId = extractComponentId(manifest);
104
109
  const lookupId = componentId || pluginId;
110
+ // The manifest's grants — the authority for the bundle's device tools, the
111
+ // same one the renderer gates the component with. Keyed by value so a fresh
112
+ // manifest object with the same grants does not reload the plugin.
113
+ const grants = resolvePluginGrants({ pluginId, manifest });
114
+ const grantsKey = JSON.stringify(grants);
105
115
 
106
116
  if (ignoredUrl) {
107
117
  console.warn(`[usePlugin] ${pluginId}: IGNORED connector-injected bundleUrl="${ignoredUrl}". The host derives bundle URLs from pluginId via the canonical route.`);
@@ -132,7 +142,7 @@ export function usePlugin(pluginId: string, manifest: PluginManifestLike): UsePl
132
142
  // Path 2: Remote bundle (via global BundleManager)
133
143
  if (bundleUrl) {
134
144
  try {
135
- const component = await bundleManager.load(pluginId, bundleUrl, version, bundleHash);
145
+ const component = await bundleManager.load(pluginId, bundleUrl, version, bundleHash, grants);
136
146
  if (cancelled) return;
137
147
  setPlugin(component);
138
148
  setLoading(false);
@@ -179,7 +189,7 @@ export function usePlugin(pluginId: string, manifest: PluginManifestLike): UsePl
179
189
  })();
180
190
 
181
191
  return () => { cancelled = true; };
182
- }, [pluginId, lookupId, bundleUrl, bundleHash, version]);
192
+ }, [pluginId, lookupId, bundleUrl, bundleHash, version, grantsKey]);
183
193
 
184
194
  return { plugin, loading, error, bundleMissing };
185
195
  }
package/src/host.ts CHANGED
@@ -50,6 +50,7 @@ const noopNative: PluginNative = {
50
50
  nfc: unavailable('nfc'),
51
51
  bluetooth: unavailable('bluetooth'),
52
52
  vision: unavailable('vision'),
53
+ maps: unavailable('maps'),
53
54
  };
54
55
 
55
56
  let _pluginNative: PluginNative = noopNative;
@@ -129,6 +130,7 @@ export function gatePluginNative(
129
130
  // used to be, which crashed with "cannot read properties of undefined".
130
131
  bluetooth: caps.bluetooth ? native.bluetooth : blocked('bluetooth'),
131
132
  vision: caps.vision ? native.vision : blocked('vision'),
133
+ maps: caps.maps ? native.maps : blocked('maps'),
132
134
  // What this plugin DECLARED, carried on the object it already receives.
133
135
  //
134
136
  // It is how a COMPONENT can be capability-gated: <CameraPreview> is handed
package/src/index.ts CHANGED
@@ -35,6 +35,11 @@ export type {
35
35
  SpatialSnapshot,
36
36
  SpatialPoint,
37
37
  SpatialIntent,
38
+ SpatialEvent,
39
+ SpatialStateEvent,
40
+ StateEvent,
41
+ StateListener,
42
+ Subscription,
38
43
  ActivitySnapshot,
39
44
  EnvironmentSnapshot,
40
45
  InteractionSnapshot,
@@ -86,11 +91,11 @@ export {
86
91
  registerBundleModules,
87
92
  } from './host';
88
93
 
89
- // The ONE runtime contract every renderer uses to gate a plugin's device surface
90
- // (device/native + deviceState) — so a plugin gets identical access on every
91
- // surface. See pluginRuntime.ts for why this is centralized.
92
- export { resolvePluginRuntimeProps } from './pluginRuntime';
93
- export type { PluginRuntimeProps, PluginRuntimeSource } from './pluginRuntime';
94
+ // The ONE runtime contract that gates a plugin's device surface (device/native +
95
+ // deviceState) — every renderer AND its device tools resolve grants through it,
96
+ // so a plugin gets identical access on every surface and in its tools.
97
+ export { resolvePluginRuntimeProps, resolvePluginGrants } from './pluginRuntime';
98
+ export type { PluginRuntimeProps, PluginRuntimeSource, PluginGrants } from './pluginRuntime';
94
99
 
95
100
  // Device tools (plugin `tools/` sections — registered at bundle load)
96
101
  export {
@@ -121,7 +126,7 @@ export {
121
126
  export { bundleManager } from './bundles/bundleManager';
122
127
  export type { LoadedBundleInfo, LoadedBundleSource } from './bundles/bundleManager';
123
128
  export { setBundleStorage, MemoryBundleStorage } from './bundles/storage';
124
- export type { BundleStorage, BundleCacheEntry } from './bundles/storage';
129
+ export type { BundleStorage, BundleCacheEntry, TrustedToolDeclaration } from './bundles/storage';
125
130
 
126
131
  // Bridge
127
132
  export { useNativeBridge, createNativeBridge } from './bridge/native';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The ONE runtime contract that turns a plugin's declarations into its gated
3
3
  * device surface — `props.device` / `props.native` (L2) and `props.deviceState`
4
- * (L3) — for EVERY renderer, on EVERY surface.
4
+ * (L3) — for EVERY renderer, on EVERY surface, and for its device tools.
5
5
  *
6
6
  * WHY THIS EXISTS. Each renderer used to resolve this itself: the embedded route
7
7
  * gated both `native` and `deviceState`; the drawer/fullscreen renderer gated
@@ -29,11 +29,6 @@ export interface PluginRuntimeProps {
29
29
  voice: VoiceAPI;
30
30
  }
31
31
 
32
- /**
33
- * Where a plugin's declarations come from. TRUSTED source resolution lives here,
34
- * not in each caller: the server `manifest` wins, the loaded bundle's own default
35
- * export is the fallback.
36
- */
37
32
  /** One declaration source — either the server manifest or the registry entry. */
38
33
  interface PluginDeclaration {
39
34
  capabilities?: NativeCapabilities | Record<string, unknown>;
@@ -68,9 +63,18 @@ function mergeCaps(decl: PluginDeclaration | null | undefined): NativeCapabiliti
68
63
  return { ...(decl.native as any), ...(decl.capabilities as any) } as NativeCapabilities;
69
64
  }
70
65
 
66
+ /** What a plugin is GRANTED — resolved from its one trusted source. The same
67
+ * value gates its rendered surface and its device tools (the tool registry
68
+ * receives it through bundleManager.load/loadTools). */
69
+ export interface PluginGrants {
70
+ capabilities?: NativeCapabilities;
71
+ stateDomains?: DeviceBridgeDomain[];
72
+ }
73
+
71
74
  /**
72
- * Turn a plugin's declarations into the gated { device, native, deviceState } it
73
- * receives — the SINGLE place this happens, so every surface is identical.
75
+ * Resolve a plugin's grants — the SINGLE place this happens, for every rendered
76
+ * surface AND for the device tools its bundle registers, so a plugin gets
77
+ * identical access through either.
74
78
  *
75
79
  * TRUST BOUNDARY. Exactly ONE source is authoritative: the server `manifest` for
76
80
  * a remote plugin, the registry `plugin` for a host-owned one. They are NEVER
@@ -80,10 +84,17 @@ function mergeCaps(decl: PluginDeclaration | null | undefined): NativeCapabiliti
80
84
  * not carry `stateDomains` for a remote plugin, the fix is Core passing it, not
81
85
  * trusting the bundle: Core already holds the solution's declaration.)
82
86
  */
83
- export function resolvePluginRuntimeProps(src: PluginRuntimeSource): PluginRuntimeProps {
87
+ export function resolvePluginGrants(src: PluginRuntimeSource): PluginGrants {
84
88
  const trusted = src.manifest ?? src.plugin ?? null;
85
- const capabilities = mergeCaps(trusted);
86
- const stateDomains = trusted?.stateDomains;
89
+ // Copied, so the grant cannot change after the fact by mutating the source.
90
+ const stateDomains = trusted?.stateDomains ? [...trusted.stateDomains] : undefined;
91
+ return { capabilities: mergeCaps(trusted), stateDomains };
92
+ }
93
+
94
+ /** Turn a plugin's grants into the gated { device, native, deviceState, voice } it
95
+ * receives on every rendered surface. */
96
+ export function resolvePluginRuntimeProps(src: PluginRuntimeSource): PluginRuntimeProps {
97
+ const { capabilities, stateDomains } = resolvePluginGrants(src);
87
98
  const native = gatePluginNative(getPluginNative(), capabilities, src.pluginId);
88
99
  const deviceState = gateDeviceState(stateDomains);
89
100
  // Voice is a capability flag, gated the same way — the TRUSTED declaration