@fias/arche-sdk 2.29.0 → 2.31.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 (38) hide show
  1. package/dist/bridge.d.ts +32 -6
  2. package/dist/bridge.d.ts.map +1 -1
  3. package/dist/bridge.js +73 -2
  4. package/dist/bridge.js.map +1 -1
  5. package/dist/bridge.test.js +71 -0
  6. package/dist/bridge.test.js.map +1 -1
  7. package/dist/entity-ops.d.ts +12 -0
  8. package/dist/entity-ops.d.ts.map +1 -1
  9. package/dist/entity-ops.js +13 -1
  10. package/dist/entity-ops.js.map +1 -1
  11. package/dist/generated/permissions.d.ts +1 -1
  12. package/dist/generated/permissions.d.ts.map +1 -1
  13. package/dist/generated/permissions.js +8 -0
  14. package/dist/generated/permissions.js.map +1 -1
  15. package/dist/generated/surfaces.d.ts +119 -5
  16. package/dist/generated/surfaces.d.ts.map +1 -1
  17. package/dist/generated/surfaces.js +14 -6
  18. package/dist/generated/surfaces.js.map +1 -1
  19. package/dist/hooks.d.ts +161 -18
  20. package/dist/hooks.d.ts.map +1 -1
  21. package/dist/hooks.js +682 -38
  22. package/dist/hooks.js.map +1 -1
  23. package/dist/hooks.test.js +613 -0
  24. package/dist/hooks.test.js.map +1 -1
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +8 -3
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +555 -21
  30. package/dist/types.d.ts +489 -13
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/use-fias-access.render.test.d.ts +12 -0
  33. package/dist/use-fias-access.render.test.d.ts.map +1 -0
  34. package/dist/use-fias-access.render.test.js +407 -0
  35. package/dist/use-fias-access.render.test.js.map +1 -0
  36. package/package.json +3 -2
  37. package/templates/default/AGENTS.md +360 -27
  38. package/templates/default/CLAUDE.md +360 -27
package/dist/hooks.js CHANGED
@@ -15,10 +15,14 @@ exports.useAudioGeneration = useAudioGeneration;
15
15
  exports.useSurface = useSurface;
16
16
  exports.useFiasNavigation = useFiasNavigation;
17
17
  exports.usePersistentState = usePersistentState;
18
+ exports.useFiasChrome = useFiasChrome;
19
+ exports.useOpener = useOpener;
18
20
  exports.useStepNavigation = useStepNavigation;
19
21
  exports.useFiasDataStore = useFiasDataStore;
20
22
  exports.useFiasWorkspaces = useFiasWorkspaces;
23
+ exports.useFiasAccess = useFiasAccess;
21
24
  exports.useFiasStore = useFiasStore;
25
+ exports.clearSdkReadCaches = clearSdkReadCaches;
22
26
  exports.fetchVaultDocumentDownloadUrl = fetchVaultDocumentDownloadUrl;
23
27
  exports.useVaultDocuments = useVaultDocuments;
24
28
  exports.useArcheAssets = useArcheAssets;
@@ -26,8 +30,10 @@ exports.useArcheAssetBytes = useArcheAssetBytes;
26
30
  exports.useLocalStore = useLocalStore;
27
31
  exports.useFiasContent = useFiasContent;
28
32
  exports.useFiasPreviewState = useFiasPreviewState;
33
+ exports.useEntitySubscription = useEntitySubscription;
29
34
  exports.useDataSubscription = useDataSubscription;
30
35
  exports.useVaultUserDocuments = useVaultUserDocuments;
36
+ exports.useCreativeLibrary = useCreativeLibrary;
31
37
  exports.useFiasConnections = useFiasConnections;
32
38
  exports.useFiasX = useFiasX;
33
39
  exports.useFiasSchedules = useFiasSchedules;
@@ -744,6 +750,164 @@ function usePersistentState(key, initialValue) {
744
750
  }, [key, writer]);
745
751
  return [value, setValue];
746
752
  }
753
+ /** What a plugin sees before init, and on a host without the chrome lane. */
754
+ const INERT_CHROME = {
755
+ focusMode: false,
756
+ focusModeAvailable: false,
757
+ sidebarAvailable: false,
758
+ sidebarCollapsed: false,
759
+ hostExpandedBar: true,
760
+ hostExpandButton: true,
761
+ opener: null,
762
+ };
763
+ /**
764
+ * The host chrome around your arche — Fias's standard expand/contract (the
765
+ * internal arches' "focus mode"), the suggested-arches sidebar, and the four
766
+ * controls the platform's focus-mode bar carries (Fias home, Worktop, Fias
767
+ * AI, Collapse), so you can place them where your design wants them.
768
+ *
769
+ * Three levels, your choice:
770
+ * 1. Don't call this. The host draws a small expand button over your app;
771
+ * expanding shows the host's thin bar with the standard four controls.
772
+ * 2. Call it and draw your own expand button (`setFocusMode`) and sidebar
773
+ * toggle (`setSidebarCollapsed`); keep the host's bar for the way back.
774
+ * 3. Call it, `setHostExpandedBar(false)`, and draw your own bar from
775
+ * `goToFiasHome` / `goToWorktop` / `openAssistant` / `setFocusMode(false)`.
776
+ *
777
+ * MOUNTING THIS HOOK TAKES THE CHROME OVER: the host's default expand button
778
+ * goes away, because a plugin that reads chrome state is expected to draw its
779
+ * own affordance. Draw one (or set `showExpandButton: false` in the manifest
780
+ * if you want neither). The host decides what is available — `focusModeAvailable`
781
+ * is false outside the normal signed-in layout and `sidebarAvailable` is false
782
+ * where the manifest declined the sidebar — so gate your controls on them.
783
+ *
784
+ * `supported` is false before init and on an older host; the state is then
785
+ * inert and the setters are no-ops, so you can render unconditionally.
786
+ */
787
+ function useFiasChrome() {
788
+ const bridge = useBridge();
789
+ const [chrome, setChrome] = (0, react_1.useState)(bridge.getChrome());
790
+ const [supported, setSupported] = (0, react_1.useState)(bridge.hasChromeSupport());
791
+ (0, react_1.useEffect)(() => {
792
+ // Same "subscribed after init" race as useFiasNavigation: sync on ready
793
+ // and subscribe for every later change.
794
+ let cancelled = false;
795
+ bridge.waitForInit().then(() => {
796
+ if (cancelled)
797
+ return;
798
+ setChrome(bridge.getChrome());
799
+ setSupported(bridge.hasChromeSupport());
800
+ });
801
+ const unsubscribe = bridge.onChromeUpdate((next) => {
802
+ setChrome(next);
803
+ setSupported(bridge.hasChromeSupport());
804
+ });
805
+ return () => {
806
+ cancelled = true;
807
+ unsubscribe();
808
+ };
809
+ }, [bridge]);
810
+ // The takeover, once per bridge: the host drops its default expand button
811
+ // the moment a plugin reads the chrome. Sent on mount — an older host
812
+ // ignores an unknown message type.
813
+ (0, react_1.useEffect)(() => {
814
+ bridge.setChrome({ hostExpandButton: false });
815
+ }, [bridge]);
816
+ const setFocusMode = (0, react_1.useCallback)((on) => bridge.setChrome({ focusMode: on }), [bridge]);
817
+ const setSidebarCollapsed = (0, react_1.useCallback)((collapsed) => bridge.setChrome({ sidebarCollapsed: collapsed }), [bridge]);
818
+ const setHostExpandedBar = (0, react_1.useCallback)((shown) => bridge.setChrome({ hostExpandedBar: shown }), [bridge]);
819
+ const goToFiasHome = (0, react_1.useCallback)(() => bridge.navigateHost('home'), [bridge]);
820
+ const goToWorktop = (0, react_1.useCallback)(() => bridge.navigateHost('worktop'), [bridge]);
821
+ const openAssistant = (0, react_1.useCallback)(() => bridge.openAssistant(), [bridge]);
822
+ return (0, react_1.useMemo)(() => ({
823
+ ...(chrome ?? INERT_CHROME),
824
+ opener: chrome?.opener ?? null,
825
+ supported: supported && chrome !== null,
826
+ setFocusMode,
827
+ setSidebarCollapsed,
828
+ setHostExpandedBar,
829
+ goToFiasHome,
830
+ goToWorktop,
831
+ openAssistant,
832
+ }), [
833
+ chrome,
834
+ supported,
835
+ setFocusMode,
836
+ setSidebarCollapsed,
837
+ setHostExpandedBar,
838
+ goToFiasHome,
839
+ goToWorktop,
840
+ openAssistant,
841
+ ]);
842
+ }
843
+ /** The person answers the send sheet; it waits on them, like the picker. */
844
+ const SEND_TO_OPENER_TIMEOUT_MS = 30 * 60 * 1000;
845
+ /**
846
+ * Where the person came from, when a Fias tool opened your arche — the forge
847
+ * page's "Open FiasWorks Studio", the Arche Builder's (ADR-037 D8) — and the
848
+ * two things you can do about it:
849
+ *
850
+ * - `returnTo()` takes the person back there.
851
+ * - `send(file)` offers a Vault file YOUR arche created ("Use in {name}"):
852
+ * Fias shows the person a sheet naming the file and the destination, and
853
+ * their Send moves it — into a forge's `public/`, or an Arche Builder
854
+ * arche's asset library. Requires `opener:send`.
855
+ *
856
+ * `opener` is null when nothing opened your arche; show neither then. Reading
857
+ * it does not take the host chrome over (unlike `useFiasChrome`).
858
+ *
859
+ * @example
860
+ * const { opener, send, returnTo } = useOpener();
861
+ * {opener && <button onClick={returnTo}>Return to {opener.name}</button>}
862
+ * {opener && <button onClick={() => send({ fileId, name: 'pumpkin.png' })}>
863
+ * Use in {opener.name}</button>}
864
+ */
865
+ function useOpener() {
866
+ const bridge = useBridge();
867
+ const [opener, setOpener] = (0, react_1.useState)(bridge.getChrome()?.opener ?? null);
868
+ (0, react_1.useEffect)(() => {
869
+ let cancelled = false;
870
+ bridge.waitForInit().then(() => {
871
+ if (!cancelled)
872
+ setOpener(bridge.getChrome()?.opener ?? null);
873
+ });
874
+ const unsubscribe = bridge.onChromeUpdate((next) => setOpener(next?.opener ?? null));
875
+ return () => {
876
+ cancelled = true;
877
+ unsubscribe();
878
+ };
879
+ }, [bridge]);
880
+ const returnTo = (0, react_1.useCallback)(() => {
881
+ if (bridge.getChrome()?.opener)
882
+ bridge.navigateHost('opener');
883
+ }, [bridge]);
884
+ const send = (0, react_1.useCallback)(async (file) => {
885
+ if (!bridge.getChrome()?.opener) {
886
+ throw new bridge_1.FiasBridgeError('Nothing opened this arche, so there is nowhere to send', 'NO_OPENER');
887
+ }
888
+ let answer;
889
+ try {
890
+ answer = await bridge.request('client_entity_invoke', {
891
+ entityId: entity_ops_1.SEND_TO_OPENER_ENTITY_ID,
892
+ input: { fileId: file.fileId, ...(file.name ? { name: file.name } : {}) },
893
+ }, SEND_TO_OPENER_TIMEOUT_MS);
894
+ }
895
+ catch (err) {
896
+ // A host without it (an older Fias, the dev harness).
897
+ if (err instanceof bridge_1.FiasBridgeError &&
898
+ err.code === 'BRIDGE_ERROR' &&
899
+ err.message.includes('no client-side executor')) {
900
+ throw new bridge_1.FiasBridgeError('Sending to another arche is not available here', 'SEND_UNAVAILABLE');
901
+ }
902
+ throw err;
903
+ }
904
+ if (answer && 'error' in answer) {
905
+ throw new bridge_1.FiasBridgeError(answer.error.message, answer.error.code);
906
+ }
907
+ return answer;
908
+ }, [bridge]);
909
+ return (0, react_1.useMemo)(() => ({ opener, returnTo, send }), [opener, returnTo, send]);
910
+ }
747
911
  /**
748
912
  * Manage step navigation for multi-node plugins.
749
913
  * Listens for step_navigate messages from the host to support
@@ -866,17 +1030,32 @@ function useFiasDataStore() {
866
1030
  const put = (0, react_1.useCallback)(async (collection, key, data, options) => {
867
1031
  const res = await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'put', {
868
1032
  collection,
869
- key,
1033
+ // No key: the server makes one (collections with audiences, ADR-034).
1034
+ ...(key !== null ? { key } : {}),
870
1035
  data,
871
1036
  workspaceId: options?.workspaceId,
1037
+ ...(options?.audience !== undefined ? { audience: options.audience } : {}),
872
1038
  });
873
- return { version: res?.version };
1039
+ return {
1040
+ version: res?.version,
1041
+ ...(res?.key !== undefined ? { key: res.key } : {}),
1042
+ ...(res?.audience !== undefined ? { audience: res.audience } : {}),
1043
+ };
874
1044
  }, [bridge]);
1045
+ const create = (0, react_1.useCallback)(async (collection, data, options) => {
1046
+ const result = await put(collection, null, data, options);
1047
+ if (result.key === undefined) {
1048
+ // A host that predates server-generated keys cannot have made one.
1049
+ throw new bridge_1.FiasBridgeError('This Fias does not support creating documents in audiences yet', 'AUDIENCES_UNAVAILABLE');
1050
+ }
1051
+ return { ...result, key: result.key };
1052
+ }, [put]);
875
1053
  const get = (0, react_1.useCallback)(async (collection, key, options) => {
876
1054
  const res = await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'get', {
877
1055
  collection,
878
1056
  key,
879
1057
  workspaceId: options?.workspaceId,
1058
+ ...(options?.audience !== undefined ? { audience: options.audience } : {}),
880
1059
  });
881
1060
  return res.data;
882
1061
  }, [bridge]);
@@ -885,6 +1064,7 @@ function useFiasDataStore() {
885
1064
  collection,
886
1065
  key,
887
1066
  workspaceId: options?.workspaceId,
1067
+ ...(options?.audience !== undefined ? { audience: options.audience } : {}),
888
1068
  });
889
1069
  if (res.data === null || res.data === undefined)
890
1070
  return null;
@@ -896,6 +1076,7 @@ function useFiasDataStore() {
896
1076
  ...(res.authoredByCollaborator !== undefined
897
1077
  ? { authoredByCollaborator: res.authoredByCollaborator }
898
1078
  : {}),
1079
+ ...(res.audience !== undefined ? { audience: res.audience } : {}),
899
1080
  };
900
1081
  }, [bridge]);
901
1082
  const query = (0, react_1.useCallback)(async (collection, options, scope) => {
@@ -910,6 +1091,7 @@ function useFiasDataStore() {
910
1091
  // rather than at the call. Take the scope arg when it carries one and
911
1092
  // otherwise leave whatever the spread put there.
912
1093
  ...(scope?.workspaceId !== undefined ? { workspaceId: scope.workspaceId } : {}),
1094
+ ...(scope?.audience !== undefined ? { audience: scope.audience } : {}),
913
1095
  });
914
1096
  }, [bridge]);
915
1097
  const search = (0, react_1.useCallback)(async (collection, queryText, options, scope) => {
@@ -919,6 +1101,7 @@ function useFiasDataStore() {
919
1101
  ...options,
920
1102
  // See `query` above — never blank a caller-supplied workspaceId.
921
1103
  ...(scope?.workspaceId !== undefined ? { workspaceId: scope.workspaceId } : {}),
1104
+ ...(scope?.audience !== undefined ? { audience: scope.audience } : {}),
922
1105
  });
923
1106
  return res.matches;
924
1107
  }, [bridge]);
@@ -941,6 +1124,7 @@ function useFiasDataStore() {
941
1124
  listCollections,
942
1125
  deleteCollection,
943
1126
  put,
1127
+ create,
944
1128
  get,
945
1129
  getDocument,
946
1130
  query,
@@ -952,6 +1136,7 @@ function useFiasDataStore() {
952
1136
  listCollections,
953
1137
  deleteCollection,
954
1138
  put,
1139
+ create,
955
1140
  get,
956
1141
  getDocument,
957
1142
  query,
@@ -1049,6 +1234,308 @@ function useFiasWorkspaces() {
1049
1234
  removeMember,
1050
1235
  ]);
1051
1236
  }
1237
+ /** The refusals that mean "no access here" — the workspaces `get` verb's two answers. */
1238
+ const NO_WORKSPACE_ACCESS_CODES = new Set([
1239
+ 'NOT_A_MEMBER',
1240
+ 'WORKSPACE_NOT_FOUND',
1241
+ ]);
1242
+ /**
1243
+ * The current user's OWN access in a workspace (ADR-034): their tier
1244
+ * (owner/admin/member/viewer), when their membership expires, and — for an
1245
+ * app that declares roles, in an org workspace where they are on — the roles
1246
+ * that count now and the permissions they grant.
1247
+ *
1248
+ * **A UI hint, never security.** Use it to show or hide controls — an "Invite"
1249
+ * button for admins, an editor for those who may edit. It protects nothing:
1250
+ * the platform checks every read and write on arrival, whatever your code
1251
+ * shows. Never build a role check of your own on top of it, and never treat a
1252
+ * role picker in your app as access control.
1253
+ *
1254
+ * `access.mode` says which model answered: `'tier'` (no app roles here),
1255
+ * `'legacy'` (your app declares roles, but this workspace has not turned them
1256
+ * on yet — tier rules apply) or `'roles'`. `can(permission)` is true only in
1257
+ * `'roles'` mode. `access.tier` is `null` when the user has no tier there (not
1258
+ * a member, an expired membership, or a workspace not reachable from where the
1259
+ * app is open). Pass `null`/`undefined` for "no workspace yet" — nothing is
1260
+ * fetched.
1261
+ *
1262
+ * Requires the `data:workspace` permission. Answers only for the signed-in
1263
+ * user; it never returns user ids or anything about other members.
1264
+ *
1265
+ * The answer is a snapshot taken when the workspace is first passed: the hook
1266
+ * does not learn of a change made elsewhere while the app stays open (an
1267
+ * admin changing the user's tier or roles, a membership expiring). Call
1268
+ * `refetch()` when the app has reason to think it changed — after your own
1269
+ * invite or role change, or when the platform refuses an action with
1270
+ * `FORBIDDEN` or `NOT_A_MEMBER` — so a stale answer does not linger in the UI.
1271
+ *
1272
+ * Every `useFiasAccess` for one workspace shares one answer and one request,
1273
+ * so calling it in each row of a long list costs one bridge call, and a
1274
+ * `refetch()`, a host `access_update` or a window focus asks once for all of
1275
+ * them.
1276
+ *
1277
+ * @example
1278
+ * const { access, can } = useFiasAccess(workspaceId);
1279
+ * const canApprove =
1280
+ * access?.mode === 'roles' ? can('ledger.approve') : access?.tier === 'admin';
1281
+ * return canApprove ? <ApproveButton /> : null; // the server still decides
1282
+ */
1283
+ function useFiasAccess(workspaceId) {
1284
+ const bridge = useBridge();
1285
+ // What is shown is read from the workspace's SHARED answer during render
1286
+ // (keyed by workspace, so a previous workspace's answer can never show,
1287
+ // not even on the render where `workspaceId` changes). These copies make
1288
+ // React re-render when it changes, and stand in before this instance has
1289
+ // subscribed.
1290
+ const known = workspaceId ? peekSharedAccess(bridge, workspaceId) : undefined;
1291
+ const [settled, setSettled] = (0, react_1.useState)(known?.settled ?? null);
1292
+ const [fetching, setFetching] = (0, react_1.useState)(known ? known.fetching : !!workspaceId);
1293
+ // One shared ask per workspace (see SharedAccess): mounting joins the
1294
+ // answer, or the ask in flight, that every other instance shares.
1295
+ (0, react_1.useEffect)(() => {
1296
+ if (!workspaceId)
1297
+ return;
1298
+ const shared = sharedAccessFor(bridge, workspaceId);
1299
+ const sync = () => {
1300
+ setSettled(shared.settled);
1301
+ setFetching(shared.fetching);
1302
+ };
1303
+ const unsubscribe = subscribeSharedAccess(shared, sync);
1304
+ askSharedAccess(shared, 'ensure');
1305
+ sync();
1306
+ return unsubscribe;
1307
+ }, [bridge, workspaceId]);
1308
+ const refetch = (0, react_1.useCallback)(() => {
1309
+ if (workspaceId)
1310
+ askSharedAccess(sharedAccessFor(bridge, workspaceId), 'again');
1311
+ }, [bridge, workspaceId]);
1312
+ const openAccessDialog = (0, react_1.useCallback)(async () => {
1313
+ let answer;
1314
+ try {
1315
+ answer = await bridge.request('client_entity_invoke', { entityId: entity_ops_1.APP_ACCESS_DIALOG_ENTITY_ID, input: {} }, ACCESS_SHEET_TIMEOUT_MS);
1316
+ }
1317
+ catch (err) {
1318
+ // A host without the sheet (an older Fias, or an older dev harness) has
1319
+ // no executor for it and answers a generic bridge error naming that —
1320
+ // "no client-side executor registered" / "no client-side executor for …
1321
+ // in the dev harness": the sheet is simply not available. Any other bridge error (a missing permission, a call loop)
1322
+ // is the app's to fix, so it passes through unchanged.
1323
+ if (err instanceof bridge_1.FiasBridgeError &&
1324
+ err.code === 'BRIDGE_ERROR' &&
1325
+ err.message.includes('no client-side executor')) {
1326
+ throw new bridge_1.FiasBridgeError('Managing access is not available here', 'ACCESS_UNAVAILABLE');
1327
+ }
1328
+ throw err;
1329
+ }
1330
+ if (answer && 'error' in answer) {
1331
+ throw new bridge_1.FiasBridgeError(answer.error.message, answer.error.code);
1332
+ }
1333
+ const changed = answer?.changed === true;
1334
+ if (changed)
1335
+ refetch();
1336
+ return { changed };
1337
+ }, [bridge, refetch]);
1338
+ // A refetch of the same workspace keeps its answer while it loads; any
1339
+ // other workspace has no answer until its own arrives.
1340
+ const own = workspaceId && settled?.workspaceId === workspaceId ? settled : null;
1341
+ const current = known ? known.settled : own;
1342
+ const access = current?.access ?? null;
1343
+ const error = current?.error ?? null;
1344
+ const isLoading = !!workspaceId && (current === null || (known ? known.fetching : fetching));
1345
+ const can = (0, react_1.useCallback)((permission) => access?.mode === 'roles' && access.permissions.includes(permission), [access]);
1346
+ // Audiences (ADR-034 phase 2) — UI hints like `can()`.
1347
+ const canReadAudience = (0, react_1.useCallback)((audience) => access?.mode === 'roles' && (access.audiences?.readable.includes(audience) ?? false), [access]);
1348
+ const canDiscloseTo = (0, react_1.useCallback)((audience) => access?.mode === 'roles' && (access.audiences?.disclosable.includes(audience) ?? false), [access]);
1349
+ return (0, react_1.useMemo)(() => ({
1350
+ access,
1351
+ isLoading,
1352
+ error,
1353
+ refetch,
1354
+ can,
1355
+ canReadAudience,
1356
+ canDiscloseTo,
1357
+ openAccessDialog,
1358
+ }), [access, isLoading, error, refetch, can, canReadAudience, canDiscloseTo, openAccessDialog]);
1359
+ }
1360
+ /**
1361
+ * The last `my_access` answer per workspace, across every `useFiasAccess`
1362
+ * instance in this plugin — what a changed answer is noticed against.
1363
+ */
1364
+ const lastAccessAnswers = new Map();
1365
+ /** The least time between two focus-triggered `my_access` asks. */
1366
+ const FOCUS_REFETCH_MIN_MS = 30000;
1367
+ /** How long a person may keep the access sheet open before the call gives up. */
1368
+ const ACCESS_SHEET_TIMEOUT_MS = 30 * 60 * 1000;
1369
+ const sharedAccessByBridge = new WeakMap();
1370
+ function peekSharedAccess(bridge, workspaceId) {
1371
+ return sharedAccessByBridge.get(bridge)?.get(workspaceId);
1372
+ }
1373
+ function sharedAccessFor(bridge, workspaceId) {
1374
+ let byWorkspace = sharedAccessByBridge.get(bridge);
1375
+ if (!byWorkspace) {
1376
+ byWorkspace = new Map();
1377
+ sharedAccessByBridge.set(bridge, byWorkspace);
1378
+ }
1379
+ let shared = byWorkspace.get(workspaceId);
1380
+ if (!shared) {
1381
+ shared = {
1382
+ bridge,
1383
+ workspaceId,
1384
+ settled: null,
1385
+ fetching: false,
1386
+ answered: false,
1387
+ answeredAt: 0,
1388
+ inFlight: null,
1389
+ scheduled: false,
1390
+ again: false,
1391
+ listeners: new Set(),
1392
+ stopListening: null,
1393
+ };
1394
+ byWorkspace.set(workspaceId, shared);
1395
+ }
1396
+ return shared;
1397
+ }
1398
+ /** Drop a workspace's entry once nothing shows it and no ask is out. */
1399
+ function releaseSharedAccess(shared) {
1400
+ if (shared.listeners.size > 0 || shared.inFlight || shared.scheduled)
1401
+ return;
1402
+ const byWorkspace = sharedAccessByBridge.get(shared.bridge);
1403
+ if (byWorkspace?.get(shared.workspaceId) === shared)
1404
+ byWorkspace.delete(shared.workspaceId);
1405
+ }
1406
+ function notifySharedAccess(shared) {
1407
+ for (const listener of Array.from(shared.listeners))
1408
+ listener();
1409
+ }
1410
+ /**
1411
+ * Show a workspace's shared answer. The first instance starts listening for
1412
+ * the host's `access_update` (always asks again) and window focus (asks at
1413
+ * most once per FOCUS_REFETCH_MIN_MS); the last one to leave stops.
1414
+ */
1415
+ function subscribeSharedAccess(shared, listener) {
1416
+ shared.listeners.add(listener);
1417
+ if (!shared.stopListening) {
1418
+ // The answer is a snapshot (ADR-034): ask again when the host says the
1419
+ // person's access changed, and when they come back to the window — a
1420
+ // grant or a removal made elsewhere shows up without the app wiring
1421
+ // anything. Focus is frequent (every click back into the frame), so it
1422
+ // is throttled; a host access_update always asks.
1423
+ const unsubscribe = shared.bridge.onAccessUpdate(() => askSharedAccess(shared, 'again'));
1424
+ const onFocus = () => {
1425
+ if (Date.now() - shared.answeredAt >= FOCUS_REFETCH_MIN_MS) {
1426
+ askSharedAccess(shared, 'join');
1427
+ }
1428
+ };
1429
+ const win = typeof window === 'undefined' ? null : window;
1430
+ win?.addEventListener('focus', onFocus);
1431
+ shared.stopListening = () => {
1432
+ unsubscribe();
1433
+ win?.removeEventListener('focus', onFocus);
1434
+ };
1435
+ }
1436
+ return () => {
1437
+ shared.listeners.delete(listener);
1438
+ if (shared.listeners.size === 0 && shared.stopListening) {
1439
+ shared.stopListening();
1440
+ shared.stopListening = null;
1441
+ }
1442
+ releaseSharedAccess(shared);
1443
+ };
1444
+ }
1445
+ /**
1446
+ * Ask `my_access` for a workspace, at most once for every instance:
1447
+ * - `ensure` (a mount): only if there is no answer and no ask yet — a first
1448
+ * ask that failed is tried again;
1449
+ * - `join` (focus): unless an ask is already out;
1450
+ * - `again` (access_update, refetch(), a changed access sheet): an ask that
1451
+ * starts after this demand — the one about to start, or one more after
1452
+ * the one out, which may answer from before the change.
1453
+ * Demands made in the same tick share the ask that starts at its end.
1454
+ */
1455
+ function askSharedAccess(shared, demand) {
1456
+ if (shared.inFlight) {
1457
+ if (demand === 'again')
1458
+ shared.again = true;
1459
+ return;
1460
+ }
1461
+ if (shared.scheduled)
1462
+ return;
1463
+ if (demand === 'ensure' && shared.answered)
1464
+ return;
1465
+ shared.scheduled = true;
1466
+ void Promise.resolve().then(() => {
1467
+ shared.scheduled = false;
1468
+ startSharedAccess(shared);
1469
+ });
1470
+ }
1471
+ /**
1472
+ * Record an answer. The caches it guards are module-wide, so the comparison
1473
+ * is too: against the last answer for this workspace (a remounted hook still
1474
+ * notices a change). No access at all clears them whatever was seen before.
1475
+ */
1476
+ function settleSharedAccess(shared, access) {
1477
+ const answer = JSON.stringify(access);
1478
+ const before = lastAccessAnswers.get(shared.workspaceId);
1479
+ const noAccess = access.mode === 'tier' && access.tier === null;
1480
+ if (noAccess || (before !== undefined && before !== answer))
1481
+ clearSdkReadCaches();
1482
+ lastAccessAnswers.set(shared.workspaceId, answer);
1483
+ shared.answered = true;
1484
+ shared.answeredAt = Date.now();
1485
+ shared.settled = { workspaceId: shared.workspaceId, access, error: null };
1486
+ }
1487
+ /**
1488
+ * Ask now. A BACKGROUND ask (an answer is already held) does not show as
1489
+ * loading, and a failure of one keeps the answer rather than blanking the
1490
+ * app's controls; only a first answer reports its error. An answer that comes
1491
+ * back when no instance shows the workspace any more is dropped, as a
1492
+ * superseded one always was.
1493
+ */
1494
+ function startSharedAccess(shared) {
1495
+ if (shared.listeners.size === 0) {
1496
+ releaseSharedAccess(shared);
1497
+ return;
1498
+ }
1499
+ const { bridge, workspaceId } = shared;
1500
+ const background = shared.answered;
1501
+ if (!background) {
1502
+ shared.fetching = true;
1503
+ notifySharedAccess(shared);
1504
+ }
1505
+ shared.inFlight = (async () => {
1506
+ try {
1507
+ const res = await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.PLUGIN_WORKSPACES_ENTITY_ID, 'my_access', { workspaceId });
1508
+ if (shared.listeners.size > 0)
1509
+ settleSharedAccess(shared, res.access);
1510
+ }
1511
+ catch (err) {
1512
+ if (shared.listeners.size === 0) {
1513
+ // Nobody shows this workspace now; the answer has no one to reach.
1514
+ }
1515
+ else if (err instanceof bridge_1.FiasBridgeError && NO_WORKSPACE_ACCESS_CODES.has(err.code)) {
1516
+ // No access here at all — the biggest narrowing there is.
1517
+ settleSharedAccess(shared, { mode: 'tier', workspaceId, tier: null, expiresAt: null });
1518
+ }
1519
+ else if (!background) {
1520
+ shared.settled = {
1521
+ workspaceId,
1522
+ access: null,
1523
+ error: err instanceof Error ? err : new Error(String(err)),
1524
+ };
1525
+ }
1526
+ }
1527
+ finally {
1528
+ shared.fetching = false;
1529
+ shared.inFlight = null;
1530
+ if (shared.again) {
1531
+ shared.again = false;
1532
+ askSharedAccess(shared, 'again');
1533
+ }
1534
+ notifySharedAccess(shared);
1535
+ releaseSharedAccess(shared);
1536
+ }
1537
+ })();
1538
+ }
1052
1539
  /**
1053
1540
  * Access the in-arche purchase (IAP) store.
1054
1541
  * Provides product listing, purchase initiation, entitlement checking,
@@ -1153,6 +1640,24 @@ const DOWNLOAD_URL_REFRESH_BUFFER_MS = 60000;
1153
1640
  */
1154
1641
  const downloadUrlCache = new Map();
1155
1642
  const downloadUrlInflight = new Map();
1643
+ /**
1644
+ * Forget every read the SDK caches that the person's access decided — the
1645
+ * presigned Vault download URLs, which stay valid for their window whatever
1646
+ * happens to the access that minted them. Called by `useFiasAccess` when the
1647
+ * person's access answer changes (ADR-034), so a plugin cannot keep serving a
1648
+ * URL minted under access the person no longer has. (Content-pack and
1649
+ * community-asset URLs are not decided by the person's access.)
1650
+ */
1651
+ function clearSdkReadCaches() {
1652
+ downloadUrlGeneration += 1;
1653
+ downloadUrlCache.clear();
1654
+ downloadUrlInflight.clear();
1655
+ }
1656
+ /**
1657
+ * Bumped by every clear: a fetch that started before a clear must not put the
1658
+ * URL it brings back into the cache afterwards.
1659
+ */
1660
+ let downloadUrlGeneration = 0;
1156
1661
  /**
1157
1662
  * Cache-aware fetcher for a presigned download URL. Used internally by
1158
1663
  * `useVaultDocuments().getDownloadUrl()`. Exposed separately so non-React
@@ -1169,6 +1674,7 @@ async function fetchVaultDocumentDownloadUrl(documentId) {
1169
1674
  if (inflight)
1170
1675
  return inflight;
1171
1676
  const bridge = (0, bridge_1.getBridge)();
1677
+ const generation = downloadUrlGeneration;
1172
1678
  // The server bridge returns `expiresAt` as an ISO 8601 string; the
1173
1679
  // SDK surface uses epoch milliseconds. Normalize here so callers
1174
1680
  // don't have to think about the wire shape.
@@ -1180,15 +1686,19 @@ async function fetchVaultDocumentDownloadUrl(documentId) {
1180
1686
  expiresAt: new Date(result.expiresAt).getTime(),
1181
1687
  contentType: result.contentType,
1182
1688
  };
1183
- downloadUrlCache.set(documentId, {
1184
- url: normalized.url,
1185
- expiresAt: normalized.expiresAt,
1186
- contentType: normalized.contentType,
1187
- });
1689
+ if (generation === downloadUrlGeneration) {
1690
+ downloadUrlCache.set(documentId, {
1691
+ url: normalized.url,
1692
+ expiresAt: normalized.expiresAt,
1693
+ contentType: normalized.contentType,
1694
+ });
1695
+ }
1188
1696
  return normalized;
1189
1697
  })
1190
1698
  .finally(() => {
1191
- downloadUrlInflight.delete(documentId);
1699
+ // Only our own entry: a newer request may have replaced it.
1700
+ if (downloadUrlInflight.get(documentId) === promise)
1701
+ downloadUrlInflight.delete(documentId);
1192
1702
  });
1193
1703
  downloadUrlInflight.set(documentId, promise);
1194
1704
  return promise;
@@ -2112,46 +2622,64 @@ function useFiasPreviewState(key, getState, onRestore) {
2112
2622
  return bridge.registerPreviewState(key, () => getStateRef.current(), (state) => onRestoreRef.current(state));
2113
2623
  }, [bridge, key]);
2114
2624
  }
2625
+ /** JSON with object keys sorted at every level, so equal content gives equal text. */
2626
+ function stableStringify(value) {
2627
+ return JSON.stringify(value, (_key, v) => v && typeof v === 'object' && !Array.isArray(v)
2628
+ ? Object.fromEntries(Object.keys(v)
2629
+ .sort()
2630
+ .map((k) => [k, v[k]]))
2631
+ : v);
2632
+ }
2115
2633
  /**
2116
- * Subscribes to realtime change events for a datastore collection
2117
- * (ADR-012 Phase M4). Notify-then-refetch: the platform pushes a minimal
2118
- * "this collection changed" invalidation and `onInvalidate` decides what to
2119
- * refetch — typically re-running the `useFiasDataStore().query(...)` your
2120
- * component renders from.
2634
+ * Subscribes to realtime invalidation events through any catalog verb of
2635
+ * `kind: 'subscribe'` (ADR-012 Phase M4) — the primitive behind
2636
+ * `useDataSubscription`. Notify-then-refetch: the platform pushes a minimal
2637
+ * "this changed" event and `onInvalidate` decides what to refetch.
2121
2638
  *
2122
- * Requires the `data:store` permission (same as reading the collection —
2123
- * a live invalidation grants nothing polling couldn't retrieve).
2639
+ * `payload` is the verb's wire payload (`{ op, ...params }`), sent to
2640
+ * `entity_invoke` for `entityId` exactly as given; the hook re-subscribes
2641
+ * when its content changes (key order does not matter). The permission is
2642
+ * the verb's own — a live invalidation grants nothing the matching read
2643
+ * could not retrieve.
2124
2644
  *
2125
2645
  * Semantics and guarantees:
2126
2646
  * - Best-effort, at-most-once delivery: events can be lost or duplicated;
2127
2647
  * the initial refetch fires AFTER the subscription is live, so a write
2128
2648
  * racing the subscribe is never missed.
2129
2649
  * - `onInvalidate` runs single-flight with one trailing call: a burst of
2130
- * events during an in-flight refetch coalesces into exactly one more.
2650
+ * events during an in-flight refetch coalesces into exactly one more. With
2651
+ * `minRefetchIntervalMs` it also runs at most once per interval (a trailing
2652
+ * call still follows the last event), so a busy resource cannot spend your
2653
+ * per-minute budget on refetches.
2654
+ * - An event says only that the resource changed — not what. Re-read the
2655
+ * visible head of it and merge by id, so edits and deletions show too; a
2656
+ * "newer than X" cursor read would never see them.
2131
2657
  * - The host renews authorization periodically; when it definitively ends
2132
- * (`status: 'ended'`), `endedReason` says why — `collection_deleted` and
2133
- * `authorization_revoked` are terminal, others may warrant resubscribing
2134
- * (unmount/remount or toggle `enabled`).
2135
- * - Scope-aware: shared collections notify on any user's writes; user-scoped
2136
- * collections only on yours; workspace-scoped need `workspaceId` and an
2137
- * active membership.
2658
+ * (`status: 'ended'`), `endedReason` says why — `collection_deleted` (the
2659
+ * observed resource is gone) and `authorization_revoked` are terminal,
2660
+ * others may warrant resubscribing (unmount/remount or toggle `enabled`).
2138
2661
  *
2139
2662
  * @example
2140
- * const dataStore = useFiasDataStore();
2141
- * const [scores, setScores] = useState<DataStoreDocument[]>([]);
2142
- * const refresh = useCallback(async () => {
2143
- * const res = await dataStore.query('scores', { limit: 50 });
2144
- * setScores(res.documents);
2145
- * }, [dataStore]);
2146
- * const { status } = useDataSubscription('scores', refresh);
2663
+ * // Any catalog verb of kind 'subscribe' (see the capability catalog for the
2664
+ * // entity id and payload); refetch the head of the resource on each event.
2665
+ * const { status } = useEntitySubscription(
2666
+ * ENTITY_ID,
2667
+ * { op: 'subscribe_feed' },
2668
+ * refetchHead,
2669
+ * { enabled: member, minRefetchIntervalMs: 2_000 },
2670
+ * );
2147
2671
  */
2148
- function useDataSubscription(collection, onInvalidate, options) {
2672
+ function useEntitySubscription(entityId, payload, onInvalidate, options) {
2149
2673
  const bridge = useBridge();
2150
2674
  const [state, setState] = (0, react_1.useState)({ status: 'subscribing' });
2151
2675
  const onInvalidateRef = (0, react_1.useRef)(onInvalidate);
2152
2676
  onInvalidateRef.current = onInvalidate;
2153
2677
  const enabled = options?.enabled ?? true;
2154
- const workspaceId = options?.workspaceId;
2678
+ const minRefetchIntervalMs = Math.max(0, options?.minRefetchIntervalMs ?? 0);
2679
+ // The payload is wire JSON, so its content is the identity that matters —
2680
+ // a fresh object literal each render, or the same fields in another order,
2681
+ // must not re-subscribe.
2682
+ const payloadKey = stableStringify(payload);
2155
2683
  (0, react_1.useEffect)(() => {
2156
2684
  if (!enabled) {
2157
2685
  setState({ status: 'ended', endedReason: 'unsubscribed' });
@@ -2163,14 +2691,31 @@ function useDataSubscription(collection, onInvalidate, options) {
2163
2691
  let unsubscribeEnded = null;
2164
2692
  // Single-flight + exactly one trailing call: events arriving during an
2165
2693
  // in-flight refetch coalesce into one follow-up, never a parallel run.
2694
+ // With a minimum interval, a run that would land too soon after the last
2695
+ // one is deferred to the interval's end — still exactly one.
2166
2696
  let refetching = false;
2167
2697
  let trailing = false;
2698
+ let lastRunAt = -Infinity;
2699
+ let intervalTimer = null;
2168
2700
  const runInvalidate = async () => {
2169
- if (refetching) {
2701
+ if (refetching || intervalTimer) {
2702
+ trailing = true;
2703
+ return;
2704
+ }
2705
+ const wait = lastRunAt + minRefetchIntervalMs - Date.now();
2706
+ if (wait > 0) {
2170
2707
  trailing = true;
2708
+ intervalTimer = setTimeout(() => {
2709
+ intervalTimer = null;
2710
+ if (trailing && !cancelled) {
2711
+ trailing = false;
2712
+ void runInvalidate();
2713
+ }
2714
+ }, wait);
2171
2715
  return;
2172
2716
  }
2173
2717
  refetching = true;
2718
+ lastRunAt = Date.now();
2174
2719
  try {
2175
2720
  await onInvalidateRef.current();
2176
2721
  }
@@ -2188,8 +2733,8 @@ function useDataSubscription(collection, onInvalidate, options) {
2188
2733
  void (async () => {
2189
2734
  try {
2190
2735
  const res = await bridge.request('entity_invoke', {
2191
- entityId: entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID,
2192
- payload: { op: 'subscribe', collection, workspaceId },
2736
+ entityId,
2737
+ payload: JSON.parse(payloadKey),
2193
2738
  });
2194
2739
  const subscription = res?.output;
2195
2740
  if (!subscription ||
@@ -2222,14 +2767,55 @@ function useDataSubscription(collection, onInvalidate, options) {
2222
2767
  })();
2223
2768
  return () => {
2224
2769
  cancelled = true;
2770
+ if (intervalTimer)
2771
+ clearTimeout(intervalTimer);
2225
2772
  unsubscribeEvent?.();
2226
2773
  unsubscribeEnded?.();
2227
2774
  if (subscriptionId)
2228
2775
  bridge.releaseSubscription(subscriptionId);
2229
2776
  };
2230
- }, [bridge, collection, workspaceId, enabled]);
2777
+ }, [bridge, entityId, payloadKey, enabled, minRefetchIntervalMs]);
2231
2778
  return state;
2232
2779
  }
2780
+ /**
2781
+ * Subscribes to realtime change events for a datastore collection
2782
+ * (ADR-012 Phase M4) — `useEntitySubscription` bound to the datastore's
2783
+ * `subscribe` verb. Notify-then-refetch: the platform pushes a minimal
2784
+ * "this collection changed" invalidation and `onInvalidate` decides what to
2785
+ * refetch — typically re-running the `useFiasDataStore().query(...)` your
2786
+ * component renders from.
2787
+ *
2788
+ * Requires the `data:store` permission (same as reading the collection —
2789
+ * a live invalidation grants nothing polling couldn't retrieve).
2790
+ *
2791
+ * Semantics and guarantees are `useEntitySubscription`'s. Scope-aware:
2792
+ * shared collections notify on any user's writes; user-scoped collections
2793
+ * only on yours; workspace-scoped need `workspaceId` and an active
2794
+ * membership.
2795
+ *
2796
+ * Audiences (ADR-034): in a collection with audiences, changes are announced
2797
+ * per audience — pass `audience` (one the user reads; see
2798
+ * `useFiasAccess().canReadAudience`), and subscribe once per audience you
2799
+ * show. Without one the subscription ends (`AUDIENCE_REQUIRED`).
2800
+ *
2801
+ * @example
2802
+ * const dataStore = useFiasDataStore();
2803
+ * const [scores, setScores] = useState<DataStoreDocument[]>([]);
2804
+ * const refresh = useCallback(async () => {
2805
+ * const res = await dataStore.query('scores', { limit: 50 });
2806
+ * setScores(res.documents);
2807
+ * }, [dataStore]);
2808
+ * const { status } = useDataSubscription('scores', refresh);
2809
+ */
2810
+ function useDataSubscription(collection, onInvalidate, options) {
2811
+ const audience = options?.audience;
2812
+ return useEntitySubscription(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, {
2813
+ op: 'subscribe',
2814
+ collection,
2815
+ workspaceId: options?.workspaceId,
2816
+ ...(audience !== undefined ? { audience } : {}),
2817
+ }, onInvalidate, { enabled: options?.enabled });
2818
+ }
2233
2819
  /**
2234
2820
  * Consented access to documents the USER already owns — the only SDK
2235
2821
  * surface that reaches data your plugin did not create. Requires the
@@ -2442,6 +3028,62 @@ async function fetchCommunityAssetUrls(bridge, assetIds) {
2442
3028
  }
2443
3029
  return hits;
2444
3030
  }
3031
+ /**
3032
+ * The user's Creative Library (ADR-037): their styles and characters, made in
3033
+ * FiasWorks Studio or any arche they allow, owned by them — or, in a team
3034
+ * workspace that approved this arche, the workspace's. Requires
3035
+ * `vault:creative:read`; `create` / `update` / `remove` / `restore` also need
3036
+ * `vault:creative:write`.
3037
+ *
3038
+ * Generate in a style with `useImageGeneration().generate({ …, creativeStyle:
3039
+ * { styleId } })`: the platform sends the style's pictures and words, so the
3040
+ * arche never has to read them itself.
3041
+ *
3042
+ * @example
3043
+ * const library = useCreativeLibrary();
3044
+ * const styles = await library.list({ kind: 'style' });
3045
+ * await images.generate({ entityId, prompt: 'a red fox', creativeStyle: { styleId: styles[0].itemId } });
3046
+ */
3047
+ /** How long a person may keep the Creative Library picker open. */
3048
+ const CREATIVE_PICKER_TIMEOUT_MS = 30 * 60 * 1000;
3049
+ function useCreativeLibrary() {
3050
+ const bridge = useBridge();
3051
+ return (0, react_1.useMemo)(() => {
3052
+ const item = async (op, args) => (await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.VAULT_CREATIVE_ENTITY_ID, op, args))
3053
+ .item;
3054
+ return {
3055
+ list: async (options = {}) => (await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.VAULT_CREATIVE_ENTITY_ID, 'list_items', { ...options })).items,
3056
+ get: (itemId, version) => item('get_item', { itemId, ...(version !== undefined ? { version } : {}) }),
3057
+ create: (fields) => item('create_item', { referenceDocumentIds: [], ...fields }),
3058
+ update: (itemId, fields) => item('update_item', { itemId, ...fields }),
3059
+ remove: (itemId) => item('delete_item', { itemId }),
3060
+ restore: (itemId) => item('restore_item', { itemId }),
3061
+ pick: async (options = {}) => {
3062
+ let answer;
3063
+ try {
3064
+ answer = await bridge.request('client_entity_invoke', {
3065
+ entityId: entity_ops_1.CREATIVE_PICKER_ENTITY_ID,
3066
+ input: options.kind ? { kind: options.kind } : {},
3067
+ }, CREATIVE_PICKER_TIMEOUT_MS);
3068
+ }
3069
+ catch (err) {
3070
+ // A host without the picker (an older Fias, the dev harness) has no
3071
+ // executor for it: the picker is simply not available there.
3072
+ if (err instanceof bridge_1.FiasBridgeError &&
3073
+ err.code === 'BRIDGE_ERROR' &&
3074
+ err.message.includes('no client-side executor')) {
3075
+ throw new bridge_1.FiasBridgeError('The Creative Library picker is not available here', 'PICKER_UNAVAILABLE');
3076
+ }
3077
+ throw err;
3078
+ }
3079
+ if (answer && 'error' in answer) {
3080
+ throw new bridge_1.FiasBridgeError(answer.error.message, answer.error.code);
3081
+ }
3082
+ return answer;
3083
+ },
3084
+ };
3085
+ }, [bridge]);
3086
+ }
2445
3087
  /** Connecting waits on a person (and maybe a provider sign-in in a popup). */
2446
3088
  const CONNECT_TIMEOUT_MS = 10 * 60 * 1000;
2447
3089
  /**
@@ -2784,7 +3426,7 @@ function useDocumentSign() {
2784
3426
  }
2785
3427
  /**
2786
3428
  * Declare Fias AI actions at RUNTIME — the dynamic half of the plugin
2787
- * AI-action plane (`ai:actions` permission; TRUSTED arches only — the host
3429
+ * AI-action plane (`ai:actions` permission for the declaration; TRUSTED arches only — the host
2788
3430
  * silently ignores runtime declarations from non-trusted arches, whose lane
2789
3431
  * is the manifest `fiasAI` block, fixed at publish and reviewed with the
2790
3432
  * submission). Both lanes merge host-side; the manifest wins id collisions.
@@ -2798,8 +3440,10 @@ function useDocumentSign() {
2798
3440
  * Returns `{ openAssistant }` — a fire-and-forget request that the host open
2799
3441
  * the global Fias AI panel, for CONTEXTUAL affordances (an empty-state "Ask
2800
3442
  * Fias AI" button; the host also shows its own "Works with Fias AI" chip).
2801
- * The host debounces it and honours the user's last dismissal — never loop
2802
- * or retry, and there is deliberately no way to pass text along.
3443
+ * Opening needs NO permission — any plugin may do it, actions or not; the
3444
+ * user can still use the assistant's own tools — and `useFiasChrome` offers
3445
+ * the same call. The host debounces it and honours the user's last dismissal
3446
+ * — never loop or retry, and there is deliberately no way to pass text along.
2803
3447
  */
2804
3448
  function useFiasAIActions(config) {
2805
3449
  const bridge = useBridge();