@fias/arche-sdk 2.28.0 → 2.30.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.
- package/dist/bridge.d.ts +42 -6
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +93 -2
- package/dist/bridge.js.map +1 -1
- package/dist/bridge.test.js +89 -0
- package/dist/bridge.test.js.map +1 -1
- package/dist/entity-ops.d.ts +27 -1
- package/dist/entity-ops.d.ts.map +1 -1
- package/dist/entity-ops.js +84 -6
- package/dist/entity-ops.js.map +1 -1
- package/dist/generated/permissions.d.ts +1 -1
- package/dist/generated/permissions.d.ts.map +1 -1
- package/dist/generated/permissions.js +10 -0
- package/dist/generated/permissions.js.map +1 -1
- package/dist/generated/surfaces.d.ts +119 -5
- package/dist/generated/surfaces.d.ts.map +1 -1
- package/dist/generated/surfaces.js +14 -6
- package/dist/generated/surfaces.js.map +1 -1
- package/dist/hooks.d.ts +220 -18
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +792 -39
- package/dist/hooks.js.map +1 -1
- package/dist/hooks.test.js +702 -0
- package/dist/hooks.test.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +771 -28
- package/dist/limits.d.ts +72 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +75 -0
- package/dist/limits.js.map +1 -0
- package/dist/types.d.ts +720 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/use-fias-access.render.test.d.ts +12 -0
- package/dist/use-fias-access.render.test.d.ts.map +1 -0
- package/dist/use-fias-access.render.test.js +407 -0
- package/dist/use-fias-access.render.test.js.map +1 -0
- package/package.json +3 -2
- package/templates/default/AGENTS.md +391 -13
- package/templates/default/CLAUDE.md +391 -13
package/dist/hooks.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.useFiasVendoredAssets = useFiasVendoredAssets;
|
|
|
7
7
|
exports.useFiasStorage = useFiasStorage;
|
|
8
8
|
exports.useEntityInvocation = useEntityInvocation;
|
|
9
9
|
exports.useTextModels = useTextModels;
|
|
10
|
+
exports.useFiasHolder = useFiasHolder;
|
|
10
11
|
exports.useImageGeneration = useImageGeneration;
|
|
11
12
|
exports.useImageEntities = useImageEntities;
|
|
12
13
|
exports.useClientEntity = useClientEntity;
|
|
@@ -14,10 +15,13 @@ exports.useAudioGeneration = useAudioGeneration;
|
|
|
14
15
|
exports.useSurface = useSurface;
|
|
15
16
|
exports.useFiasNavigation = useFiasNavigation;
|
|
16
17
|
exports.usePersistentState = usePersistentState;
|
|
18
|
+
exports.useFiasChrome = useFiasChrome;
|
|
17
19
|
exports.useStepNavigation = useStepNavigation;
|
|
18
20
|
exports.useFiasDataStore = useFiasDataStore;
|
|
19
21
|
exports.useFiasWorkspaces = useFiasWorkspaces;
|
|
22
|
+
exports.useFiasAccess = useFiasAccess;
|
|
20
23
|
exports.useFiasStore = useFiasStore;
|
|
24
|
+
exports.clearSdkReadCaches = clearSdkReadCaches;
|
|
21
25
|
exports.fetchVaultDocumentDownloadUrl = fetchVaultDocumentDownloadUrl;
|
|
22
26
|
exports.useVaultDocuments = useVaultDocuments;
|
|
23
27
|
exports.useArcheAssets = useArcheAssets;
|
|
@@ -25,8 +29,14 @@ exports.useArcheAssetBytes = useArcheAssetBytes;
|
|
|
25
29
|
exports.useLocalStore = useLocalStore;
|
|
26
30
|
exports.useFiasContent = useFiasContent;
|
|
27
31
|
exports.useFiasPreviewState = useFiasPreviewState;
|
|
32
|
+
exports.useEntitySubscription = useEntitySubscription;
|
|
28
33
|
exports.useDataSubscription = useDataSubscription;
|
|
29
34
|
exports.useVaultUserDocuments = useVaultUserDocuments;
|
|
35
|
+
exports.useCreativeLibrary = useCreativeLibrary;
|
|
36
|
+
exports.useFiasConnections = useFiasConnections;
|
|
37
|
+
exports.useFiasX = useFiasX;
|
|
38
|
+
exports.useFiasSchedules = useFiasSchedules;
|
|
39
|
+
exports.useFiasLinkedIn = useFiasLinkedIn;
|
|
30
40
|
exports.useCompanyEmail = useCompanyEmail;
|
|
31
41
|
exports.useCommunityAssets = useCommunityAssets;
|
|
32
42
|
exports.useArcheHandoff = useArcheHandoff;
|
|
@@ -292,6 +302,26 @@ function useTextModels() {
|
|
|
292
302
|
}, [bridge]);
|
|
293
303
|
return models;
|
|
294
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* The Fias workspace the plugin was opened in: the user's own (`personal`) or
|
|
307
|
+
* an organization's (`org`), and the user's role there.
|
|
308
|
+
*
|
|
309
|
+
* Requires `user:profile:read` or `data:workspace`. Returns `null` until the
|
|
310
|
+
* host has delivered it (usually immediately), and when the host could not
|
|
311
|
+
* resolve it. The value never changes while the plugin is open — switching
|
|
312
|
+
* workspace reopens the plugin. It carries no id and no name, and the
|
|
313
|
+
* platform applies the workspace to every call itself.
|
|
314
|
+
*/
|
|
315
|
+
function useFiasHolder() {
|
|
316
|
+
const bridge = useBridge();
|
|
317
|
+
const [holder, setHolder] = (0, react_1.useState)(bridge.getHolder());
|
|
318
|
+
(0, react_1.useEffect)(() => {
|
|
319
|
+
// Init may have arrived between the initial render and this effect.
|
|
320
|
+
setHolder(bridge.getHolder());
|
|
321
|
+
return bridge.onHolderReady(setHolder);
|
|
322
|
+
}, [bridge]);
|
|
323
|
+
return holder;
|
|
324
|
+
}
|
|
295
325
|
/**
|
|
296
326
|
* How long `generate` waits for an image. High-quality renders at large sizes
|
|
297
327
|
* run past two minutes, and the platform keeps working on them for up to
|
|
@@ -719,6 +749,94 @@ function usePersistentState(key, initialValue) {
|
|
|
719
749
|
}, [key, writer]);
|
|
720
750
|
return [value, setValue];
|
|
721
751
|
}
|
|
752
|
+
/** What a plugin sees before init, and on a host without the chrome lane. */
|
|
753
|
+
const INERT_CHROME = {
|
|
754
|
+
focusMode: false,
|
|
755
|
+
focusModeAvailable: false,
|
|
756
|
+
sidebarAvailable: false,
|
|
757
|
+
sidebarCollapsed: false,
|
|
758
|
+
hostExpandedBar: true,
|
|
759
|
+
hostExpandButton: true,
|
|
760
|
+
};
|
|
761
|
+
/**
|
|
762
|
+
* The host chrome around your arche — Fias's standard expand/contract (the
|
|
763
|
+
* internal arches' "focus mode"), the suggested-arches sidebar, and the four
|
|
764
|
+
* controls the platform's focus-mode bar carries (Fias home, Worktop, Fias
|
|
765
|
+
* AI, Collapse), so you can place them where your design wants them.
|
|
766
|
+
*
|
|
767
|
+
* Three levels, your choice:
|
|
768
|
+
* 1. Don't call this. The host draws a small expand button over your app;
|
|
769
|
+
* expanding shows the host's thin bar with the standard four controls.
|
|
770
|
+
* 2. Call it and draw your own expand button (`setFocusMode`) and sidebar
|
|
771
|
+
* toggle (`setSidebarCollapsed`); keep the host's bar for the way back.
|
|
772
|
+
* 3. Call it, `setHostExpandedBar(false)`, and draw your own bar from
|
|
773
|
+
* `goToFiasHome` / `goToWorktop` / `openAssistant` / `setFocusMode(false)`.
|
|
774
|
+
*
|
|
775
|
+
* MOUNTING THIS HOOK TAKES THE CHROME OVER: the host's default expand button
|
|
776
|
+
* goes away, because a plugin that reads chrome state is expected to draw its
|
|
777
|
+
* own affordance. Draw one (or set `showExpandButton: false` in the manifest
|
|
778
|
+
* if you want neither). The host decides what is available — `focusModeAvailable`
|
|
779
|
+
* is false outside the normal signed-in layout and `sidebarAvailable` is false
|
|
780
|
+
* where the manifest declined the sidebar — so gate your controls on them.
|
|
781
|
+
*
|
|
782
|
+
* `supported` is false before init and on an older host; the state is then
|
|
783
|
+
* inert and the setters are no-ops, so you can render unconditionally.
|
|
784
|
+
*/
|
|
785
|
+
function useFiasChrome() {
|
|
786
|
+
const bridge = useBridge();
|
|
787
|
+
const [chrome, setChrome] = (0, react_1.useState)(bridge.getChrome());
|
|
788
|
+
const [supported, setSupported] = (0, react_1.useState)(bridge.hasChromeSupport());
|
|
789
|
+
(0, react_1.useEffect)(() => {
|
|
790
|
+
// Same "subscribed after init" race as useFiasNavigation: sync on ready
|
|
791
|
+
// and subscribe for every later change.
|
|
792
|
+
let cancelled = false;
|
|
793
|
+
bridge.waitForInit().then(() => {
|
|
794
|
+
if (cancelled)
|
|
795
|
+
return;
|
|
796
|
+
setChrome(bridge.getChrome());
|
|
797
|
+
setSupported(bridge.hasChromeSupport());
|
|
798
|
+
});
|
|
799
|
+
const unsubscribe = bridge.onChromeUpdate((next) => {
|
|
800
|
+
setChrome(next);
|
|
801
|
+
setSupported(bridge.hasChromeSupport());
|
|
802
|
+
});
|
|
803
|
+
return () => {
|
|
804
|
+
cancelled = true;
|
|
805
|
+
unsubscribe();
|
|
806
|
+
};
|
|
807
|
+
}, [bridge]);
|
|
808
|
+
// The takeover, once per bridge: the host drops its default expand button
|
|
809
|
+
// the moment a plugin reads the chrome. Sent on mount — an older host
|
|
810
|
+
// ignores an unknown message type.
|
|
811
|
+
(0, react_1.useEffect)(() => {
|
|
812
|
+
bridge.setChrome({ hostExpandButton: false });
|
|
813
|
+
}, [bridge]);
|
|
814
|
+
const setFocusMode = (0, react_1.useCallback)((on) => bridge.setChrome({ focusMode: on }), [bridge]);
|
|
815
|
+
const setSidebarCollapsed = (0, react_1.useCallback)((collapsed) => bridge.setChrome({ sidebarCollapsed: collapsed }), [bridge]);
|
|
816
|
+
const setHostExpandedBar = (0, react_1.useCallback)((shown) => bridge.setChrome({ hostExpandedBar: shown }), [bridge]);
|
|
817
|
+
const goToFiasHome = (0, react_1.useCallback)(() => bridge.navigateHost('home'), [bridge]);
|
|
818
|
+
const goToWorktop = (0, react_1.useCallback)(() => bridge.navigateHost('worktop'), [bridge]);
|
|
819
|
+
const openAssistant = (0, react_1.useCallback)(() => bridge.openAssistant(), [bridge]);
|
|
820
|
+
return (0, react_1.useMemo)(() => ({
|
|
821
|
+
...(chrome ?? INERT_CHROME),
|
|
822
|
+
supported: supported && chrome !== null,
|
|
823
|
+
setFocusMode,
|
|
824
|
+
setSidebarCollapsed,
|
|
825
|
+
setHostExpandedBar,
|
|
826
|
+
goToFiasHome,
|
|
827
|
+
goToWorktop,
|
|
828
|
+
openAssistant,
|
|
829
|
+
}), [
|
|
830
|
+
chrome,
|
|
831
|
+
supported,
|
|
832
|
+
setFocusMode,
|
|
833
|
+
setSidebarCollapsed,
|
|
834
|
+
setHostExpandedBar,
|
|
835
|
+
goToFiasHome,
|
|
836
|
+
goToWorktop,
|
|
837
|
+
openAssistant,
|
|
838
|
+
]);
|
|
839
|
+
}
|
|
722
840
|
/**
|
|
723
841
|
* Manage step navigation for multi-node plugins.
|
|
724
842
|
* Listens for step_navigate messages from the host to support
|
|
@@ -841,17 +959,32 @@ function useFiasDataStore() {
|
|
|
841
959
|
const put = (0, react_1.useCallback)(async (collection, key, data, options) => {
|
|
842
960
|
const res = await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'put', {
|
|
843
961
|
collection,
|
|
844
|
-
key,
|
|
962
|
+
// No key: the server makes one (collections with audiences, ADR-034).
|
|
963
|
+
...(key !== null ? { key } : {}),
|
|
845
964
|
data,
|
|
846
965
|
workspaceId: options?.workspaceId,
|
|
966
|
+
...(options?.audience !== undefined ? { audience: options.audience } : {}),
|
|
847
967
|
});
|
|
848
|
-
return {
|
|
968
|
+
return {
|
|
969
|
+
version: res?.version,
|
|
970
|
+
...(res?.key !== undefined ? { key: res.key } : {}),
|
|
971
|
+
...(res?.audience !== undefined ? { audience: res.audience } : {}),
|
|
972
|
+
};
|
|
849
973
|
}, [bridge]);
|
|
974
|
+
const create = (0, react_1.useCallback)(async (collection, data, options) => {
|
|
975
|
+
const result = await put(collection, null, data, options);
|
|
976
|
+
if (result.key === undefined) {
|
|
977
|
+
// A host that predates server-generated keys cannot have made one.
|
|
978
|
+
throw new bridge_1.FiasBridgeError('This Fias does not support creating documents in audiences yet', 'AUDIENCES_UNAVAILABLE');
|
|
979
|
+
}
|
|
980
|
+
return { ...result, key: result.key };
|
|
981
|
+
}, [put]);
|
|
850
982
|
const get = (0, react_1.useCallback)(async (collection, key, options) => {
|
|
851
983
|
const res = await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'get', {
|
|
852
984
|
collection,
|
|
853
985
|
key,
|
|
854
986
|
workspaceId: options?.workspaceId,
|
|
987
|
+
...(options?.audience !== undefined ? { audience: options.audience } : {}),
|
|
855
988
|
});
|
|
856
989
|
return res.data;
|
|
857
990
|
}, [bridge]);
|
|
@@ -860,6 +993,7 @@ function useFiasDataStore() {
|
|
|
860
993
|
collection,
|
|
861
994
|
key,
|
|
862
995
|
workspaceId: options?.workspaceId,
|
|
996
|
+
...(options?.audience !== undefined ? { audience: options.audience } : {}),
|
|
863
997
|
});
|
|
864
998
|
if (res.data === null || res.data === undefined)
|
|
865
999
|
return null;
|
|
@@ -871,6 +1005,7 @@ function useFiasDataStore() {
|
|
|
871
1005
|
...(res.authoredByCollaborator !== undefined
|
|
872
1006
|
? { authoredByCollaborator: res.authoredByCollaborator }
|
|
873
1007
|
: {}),
|
|
1008
|
+
...(res.audience !== undefined ? { audience: res.audience } : {}),
|
|
874
1009
|
};
|
|
875
1010
|
}, [bridge]);
|
|
876
1011
|
const query = (0, react_1.useCallback)(async (collection, options, scope) => {
|
|
@@ -885,6 +1020,7 @@ function useFiasDataStore() {
|
|
|
885
1020
|
// rather than at the call. Take the scope arg when it carries one and
|
|
886
1021
|
// otherwise leave whatever the spread put there.
|
|
887
1022
|
...(scope?.workspaceId !== undefined ? { workspaceId: scope.workspaceId } : {}),
|
|
1023
|
+
...(scope?.audience !== undefined ? { audience: scope.audience } : {}),
|
|
888
1024
|
});
|
|
889
1025
|
}, [bridge]);
|
|
890
1026
|
const search = (0, react_1.useCallback)(async (collection, queryText, options, scope) => {
|
|
@@ -894,6 +1030,7 @@ function useFiasDataStore() {
|
|
|
894
1030
|
...options,
|
|
895
1031
|
// See `query` above — never blank a caller-supplied workspaceId.
|
|
896
1032
|
...(scope?.workspaceId !== undefined ? { workspaceId: scope.workspaceId } : {}),
|
|
1033
|
+
...(scope?.audience !== undefined ? { audience: scope.audience } : {}),
|
|
897
1034
|
});
|
|
898
1035
|
return res.matches;
|
|
899
1036
|
}, [bridge]);
|
|
@@ -916,6 +1053,7 @@ function useFiasDataStore() {
|
|
|
916
1053
|
listCollections,
|
|
917
1054
|
deleteCollection,
|
|
918
1055
|
put,
|
|
1056
|
+
create,
|
|
919
1057
|
get,
|
|
920
1058
|
getDocument,
|
|
921
1059
|
query,
|
|
@@ -927,6 +1065,7 @@ function useFiasDataStore() {
|
|
|
927
1065
|
listCollections,
|
|
928
1066
|
deleteCollection,
|
|
929
1067
|
put,
|
|
1068
|
+
create,
|
|
930
1069
|
get,
|
|
931
1070
|
getDocument,
|
|
932
1071
|
query,
|
|
@@ -949,6 +1088,8 @@ function useFiasDataStore() {
|
|
|
949
1088
|
* const workspaces = useFiasWorkspaces();
|
|
950
1089
|
* const ws = await workspaces.create('Acme Corp');
|
|
951
1090
|
* await workspaces.addMember(ws.workspaceId, otherUserId, 'member');
|
|
1091
|
+
* // or, inside a Fias org workspace, its members' shared space:
|
|
1092
|
+
* const shared = await workspaces.openForWorkspace();
|
|
952
1093
|
* // then, with useFiasDataStore():
|
|
953
1094
|
* await dataStore.put('ledger', 'tx-1', { amount: 100 }, { workspaceId: ws.workspaceId });
|
|
954
1095
|
*
|
|
@@ -960,6 +1101,12 @@ function useFiasWorkspaces() {
|
|
|
960
1101
|
const res = await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.PLUGIN_WORKSPACES_ENTITY_ID, 'create', { displayName });
|
|
961
1102
|
return res.workspace;
|
|
962
1103
|
}, [bridge]);
|
|
1104
|
+
const openForWorkspace = (0, react_1.useCallback)(async () => {
|
|
1105
|
+
// The workspace is the host's acting one, stamped on the call by the host;
|
|
1106
|
+
// the plugin never names it.
|
|
1107
|
+
const res = await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.PLUGIN_WORKSPACES_ENTITY_ID, 'create', { forActingHolder: true });
|
|
1108
|
+
return res.workspace;
|
|
1109
|
+
}, [bridge]);
|
|
963
1110
|
const list = (0, react_1.useCallback)(async () => {
|
|
964
1111
|
const res = await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.PLUGIN_WORKSPACES_ENTITY_ID, 'list');
|
|
965
1112
|
return res.workspaces;
|
|
@@ -994,7 +1141,329 @@ function useFiasWorkspaces() {
|
|
|
994
1141
|
username,
|
|
995
1142
|
});
|
|
996
1143
|
}, [bridge]);
|
|
997
|
-
return (0, react_1.useMemo)(() => ({
|
|
1144
|
+
return (0, react_1.useMemo)(() => ({
|
|
1145
|
+
create,
|
|
1146
|
+
openForWorkspace,
|
|
1147
|
+
list,
|
|
1148
|
+
get,
|
|
1149
|
+
archive,
|
|
1150
|
+
listMembers,
|
|
1151
|
+
addMember,
|
|
1152
|
+
updateMember,
|
|
1153
|
+
removeMember,
|
|
1154
|
+
}), [
|
|
1155
|
+
create,
|
|
1156
|
+
openForWorkspace,
|
|
1157
|
+
list,
|
|
1158
|
+
get,
|
|
1159
|
+
archive,
|
|
1160
|
+
listMembers,
|
|
1161
|
+
addMember,
|
|
1162
|
+
updateMember,
|
|
1163
|
+
removeMember,
|
|
1164
|
+
]);
|
|
1165
|
+
}
|
|
1166
|
+
/** The refusals that mean "no access here" — the workspaces `get` verb's two answers. */
|
|
1167
|
+
const NO_WORKSPACE_ACCESS_CODES = new Set([
|
|
1168
|
+
'NOT_A_MEMBER',
|
|
1169
|
+
'WORKSPACE_NOT_FOUND',
|
|
1170
|
+
]);
|
|
1171
|
+
/**
|
|
1172
|
+
* The current user's OWN access in a workspace (ADR-034): their tier
|
|
1173
|
+
* (owner/admin/member/viewer), when their membership expires, and — for an
|
|
1174
|
+
* app that declares roles, in an org workspace where they are on — the roles
|
|
1175
|
+
* that count now and the permissions they grant.
|
|
1176
|
+
*
|
|
1177
|
+
* **A UI hint, never security.** Use it to show or hide controls — an "Invite"
|
|
1178
|
+
* button for admins, an editor for those who may edit. It protects nothing:
|
|
1179
|
+
* the platform checks every read and write on arrival, whatever your code
|
|
1180
|
+
* shows. Never build a role check of your own on top of it, and never treat a
|
|
1181
|
+
* role picker in your app as access control.
|
|
1182
|
+
*
|
|
1183
|
+
* `access.mode` says which model answered: `'tier'` (no app roles here),
|
|
1184
|
+
* `'legacy'` (your app declares roles, but this workspace has not turned them
|
|
1185
|
+
* on yet — tier rules apply) or `'roles'`. `can(permission)` is true only in
|
|
1186
|
+
* `'roles'` mode. `access.tier` is `null` when the user has no tier there (not
|
|
1187
|
+
* a member, an expired membership, or a workspace not reachable from where the
|
|
1188
|
+
* app is open). Pass `null`/`undefined` for "no workspace yet" — nothing is
|
|
1189
|
+
* fetched.
|
|
1190
|
+
*
|
|
1191
|
+
* Requires the `data:workspace` permission. Answers only for the signed-in
|
|
1192
|
+
* user; it never returns user ids or anything about other members.
|
|
1193
|
+
*
|
|
1194
|
+
* The answer is a snapshot taken when the workspace is first passed: the hook
|
|
1195
|
+
* does not learn of a change made elsewhere while the app stays open (an
|
|
1196
|
+
* admin changing the user's tier or roles, a membership expiring). Call
|
|
1197
|
+
* `refetch()` when the app has reason to think it changed — after your own
|
|
1198
|
+
* invite or role change, or when the platform refuses an action with
|
|
1199
|
+
* `FORBIDDEN` or `NOT_A_MEMBER` — so a stale answer does not linger in the UI.
|
|
1200
|
+
*
|
|
1201
|
+
* Every `useFiasAccess` for one workspace shares one answer and one request,
|
|
1202
|
+
* so calling it in each row of a long list costs one bridge call, and a
|
|
1203
|
+
* `refetch()`, a host `access_update` or a window focus asks once for all of
|
|
1204
|
+
* them.
|
|
1205
|
+
*
|
|
1206
|
+
* @example
|
|
1207
|
+
* const { access, can } = useFiasAccess(workspaceId);
|
|
1208
|
+
* const canApprove =
|
|
1209
|
+
* access?.mode === 'roles' ? can('ledger.approve') : access?.tier === 'admin';
|
|
1210
|
+
* return canApprove ? <ApproveButton /> : null; // the server still decides
|
|
1211
|
+
*/
|
|
1212
|
+
function useFiasAccess(workspaceId) {
|
|
1213
|
+
const bridge = useBridge();
|
|
1214
|
+
// What is shown is read from the workspace's SHARED answer during render
|
|
1215
|
+
// (keyed by workspace, so a previous workspace's answer can never show,
|
|
1216
|
+
// not even on the render where `workspaceId` changes). These copies make
|
|
1217
|
+
// React re-render when it changes, and stand in before this instance has
|
|
1218
|
+
// subscribed.
|
|
1219
|
+
const known = workspaceId ? peekSharedAccess(bridge, workspaceId) : undefined;
|
|
1220
|
+
const [settled, setSettled] = (0, react_1.useState)(known?.settled ?? null);
|
|
1221
|
+
const [fetching, setFetching] = (0, react_1.useState)(known ? known.fetching : !!workspaceId);
|
|
1222
|
+
// One shared ask per workspace (see SharedAccess): mounting joins the
|
|
1223
|
+
// answer, or the ask in flight, that every other instance shares.
|
|
1224
|
+
(0, react_1.useEffect)(() => {
|
|
1225
|
+
if (!workspaceId)
|
|
1226
|
+
return;
|
|
1227
|
+
const shared = sharedAccessFor(bridge, workspaceId);
|
|
1228
|
+
const sync = () => {
|
|
1229
|
+
setSettled(shared.settled);
|
|
1230
|
+
setFetching(shared.fetching);
|
|
1231
|
+
};
|
|
1232
|
+
const unsubscribe = subscribeSharedAccess(shared, sync);
|
|
1233
|
+
askSharedAccess(shared, 'ensure');
|
|
1234
|
+
sync();
|
|
1235
|
+
return unsubscribe;
|
|
1236
|
+
}, [bridge, workspaceId]);
|
|
1237
|
+
const refetch = (0, react_1.useCallback)(() => {
|
|
1238
|
+
if (workspaceId)
|
|
1239
|
+
askSharedAccess(sharedAccessFor(bridge, workspaceId), 'again');
|
|
1240
|
+
}, [bridge, workspaceId]);
|
|
1241
|
+
const openAccessDialog = (0, react_1.useCallback)(async () => {
|
|
1242
|
+
let answer;
|
|
1243
|
+
try {
|
|
1244
|
+
answer = await bridge.request('client_entity_invoke', { entityId: entity_ops_1.APP_ACCESS_DIALOG_ENTITY_ID, input: {} }, ACCESS_SHEET_TIMEOUT_MS);
|
|
1245
|
+
}
|
|
1246
|
+
catch (err) {
|
|
1247
|
+
// A host without the sheet (an older Fias, or an older dev harness) has
|
|
1248
|
+
// no executor for it and answers a generic bridge error naming that —
|
|
1249
|
+
// "no client-side executor registered" / "no client-side executor for …
|
|
1250
|
+
// in the dev harness": the sheet is simply not available. Any other bridge error (a missing permission, a call loop)
|
|
1251
|
+
// is the app's to fix, so it passes through unchanged.
|
|
1252
|
+
if (err instanceof bridge_1.FiasBridgeError &&
|
|
1253
|
+
err.code === 'BRIDGE_ERROR' &&
|
|
1254
|
+
err.message.includes('no client-side executor')) {
|
|
1255
|
+
throw new bridge_1.FiasBridgeError('Managing access is not available here', 'ACCESS_UNAVAILABLE');
|
|
1256
|
+
}
|
|
1257
|
+
throw err;
|
|
1258
|
+
}
|
|
1259
|
+
if (answer && 'error' in answer) {
|
|
1260
|
+
throw new bridge_1.FiasBridgeError(answer.error.message, answer.error.code);
|
|
1261
|
+
}
|
|
1262
|
+
const changed = answer?.changed === true;
|
|
1263
|
+
if (changed)
|
|
1264
|
+
refetch();
|
|
1265
|
+
return { changed };
|
|
1266
|
+
}, [bridge, refetch]);
|
|
1267
|
+
// A refetch of the same workspace keeps its answer while it loads; any
|
|
1268
|
+
// other workspace has no answer until its own arrives.
|
|
1269
|
+
const own = workspaceId && settled?.workspaceId === workspaceId ? settled : null;
|
|
1270
|
+
const current = known ? known.settled : own;
|
|
1271
|
+
const access = current?.access ?? null;
|
|
1272
|
+
const error = current?.error ?? null;
|
|
1273
|
+
const isLoading = !!workspaceId && (current === null || (known ? known.fetching : fetching));
|
|
1274
|
+
const can = (0, react_1.useCallback)((permission) => access?.mode === 'roles' && access.permissions.includes(permission), [access]);
|
|
1275
|
+
// Audiences (ADR-034 phase 2) — UI hints like `can()`.
|
|
1276
|
+
const canReadAudience = (0, react_1.useCallback)((audience) => access?.mode === 'roles' && (access.audiences?.readable.includes(audience) ?? false), [access]);
|
|
1277
|
+
const canDiscloseTo = (0, react_1.useCallback)((audience) => access?.mode === 'roles' && (access.audiences?.disclosable.includes(audience) ?? false), [access]);
|
|
1278
|
+
return (0, react_1.useMemo)(() => ({
|
|
1279
|
+
access,
|
|
1280
|
+
isLoading,
|
|
1281
|
+
error,
|
|
1282
|
+
refetch,
|
|
1283
|
+
can,
|
|
1284
|
+
canReadAudience,
|
|
1285
|
+
canDiscloseTo,
|
|
1286
|
+
openAccessDialog,
|
|
1287
|
+
}), [access, isLoading, error, refetch, can, canReadAudience, canDiscloseTo, openAccessDialog]);
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* The last `my_access` answer per workspace, across every `useFiasAccess`
|
|
1291
|
+
* instance in this plugin — what a changed answer is noticed against.
|
|
1292
|
+
*/
|
|
1293
|
+
const lastAccessAnswers = new Map();
|
|
1294
|
+
/** The least time between two focus-triggered `my_access` asks. */
|
|
1295
|
+
const FOCUS_REFETCH_MIN_MS = 30000;
|
|
1296
|
+
/** How long a person may keep the access sheet open before the call gives up. */
|
|
1297
|
+
const ACCESS_SHEET_TIMEOUT_MS = 30 * 60 * 1000;
|
|
1298
|
+
const sharedAccessByBridge = new WeakMap();
|
|
1299
|
+
function peekSharedAccess(bridge, workspaceId) {
|
|
1300
|
+
return sharedAccessByBridge.get(bridge)?.get(workspaceId);
|
|
1301
|
+
}
|
|
1302
|
+
function sharedAccessFor(bridge, workspaceId) {
|
|
1303
|
+
let byWorkspace = sharedAccessByBridge.get(bridge);
|
|
1304
|
+
if (!byWorkspace) {
|
|
1305
|
+
byWorkspace = new Map();
|
|
1306
|
+
sharedAccessByBridge.set(bridge, byWorkspace);
|
|
1307
|
+
}
|
|
1308
|
+
let shared = byWorkspace.get(workspaceId);
|
|
1309
|
+
if (!shared) {
|
|
1310
|
+
shared = {
|
|
1311
|
+
bridge,
|
|
1312
|
+
workspaceId,
|
|
1313
|
+
settled: null,
|
|
1314
|
+
fetching: false,
|
|
1315
|
+
answered: false,
|
|
1316
|
+
answeredAt: 0,
|
|
1317
|
+
inFlight: null,
|
|
1318
|
+
scheduled: false,
|
|
1319
|
+
again: false,
|
|
1320
|
+
listeners: new Set(),
|
|
1321
|
+
stopListening: null,
|
|
1322
|
+
};
|
|
1323
|
+
byWorkspace.set(workspaceId, shared);
|
|
1324
|
+
}
|
|
1325
|
+
return shared;
|
|
1326
|
+
}
|
|
1327
|
+
/** Drop a workspace's entry once nothing shows it and no ask is out. */
|
|
1328
|
+
function releaseSharedAccess(shared) {
|
|
1329
|
+
if (shared.listeners.size > 0 || shared.inFlight || shared.scheduled)
|
|
1330
|
+
return;
|
|
1331
|
+
const byWorkspace = sharedAccessByBridge.get(shared.bridge);
|
|
1332
|
+
if (byWorkspace?.get(shared.workspaceId) === shared)
|
|
1333
|
+
byWorkspace.delete(shared.workspaceId);
|
|
1334
|
+
}
|
|
1335
|
+
function notifySharedAccess(shared) {
|
|
1336
|
+
for (const listener of Array.from(shared.listeners))
|
|
1337
|
+
listener();
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Show a workspace's shared answer. The first instance starts listening for
|
|
1341
|
+
* the host's `access_update` (always asks again) and window focus (asks at
|
|
1342
|
+
* most once per FOCUS_REFETCH_MIN_MS); the last one to leave stops.
|
|
1343
|
+
*/
|
|
1344
|
+
function subscribeSharedAccess(shared, listener) {
|
|
1345
|
+
shared.listeners.add(listener);
|
|
1346
|
+
if (!shared.stopListening) {
|
|
1347
|
+
// The answer is a snapshot (ADR-034): ask again when the host says the
|
|
1348
|
+
// person's access changed, and when they come back to the window — a
|
|
1349
|
+
// grant or a removal made elsewhere shows up without the app wiring
|
|
1350
|
+
// anything. Focus is frequent (every click back into the frame), so it
|
|
1351
|
+
// is throttled; a host access_update always asks.
|
|
1352
|
+
const unsubscribe = shared.bridge.onAccessUpdate(() => askSharedAccess(shared, 'again'));
|
|
1353
|
+
const onFocus = () => {
|
|
1354
|
+
if (Date.now() - shared.answeredAt >= FOCUS_REFETCH_MIN_MS) {
|
|
1355
|
+
askSharedAccess(shared, 'join');
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
const win = typeof window === 'undefined' ? null : window;
|
|
1359
|
+
win?.addEventListener('focus', onFocus);
|
|
1360
|
+
shared.stopListening = () => {
|
|
1361
|
+
unsubscribe();
|
|
1362
|
+
win?.removeEventListener('focus', onFocus);
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
return () => {
|
|
1366
|
+
shared.listeners.delete(listener);
|
|
1367
|
+
if (shared.listeners.size === 0 && shared.stopListening) {
|
|
1368
|
+
shared.stopListening();
|
|
1369
|
+
shared.stopListening = null;
|
|
1370
|
+
}
|
|
1371
|
+
releaseSharedAccess(shared);
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Ask `my_access` for a workspace, at most once for every instance:
|
|
1376
|
+
* - `ensure` (a mount): only if there is no answer and no ask yet — a first
|
|
1377
|
+
* ask that failed is tried again;
|
|
1378
|
+
* - `join` (focus): unless an ask is already out;
|
|
1379
|
+
* - `again` (access_update, refetch(), a changed access sheet): an ask that
|
|
1380
|
+
* starts after this demand — the one about to start, or one more after
|
|
1381
|
+
* the one out, which may answer from before the change.
|
|
1382
|
+
* Demands made in the same tick share the ask that starts at its end.
|
|
1383
|
+
*/
|
|
1384
|
+
function askSharedAccess(shared, demand) {
|
|
1385
|
+
if (shared.inFlight) {
|
|
1386
|
+
if (demand === 'again')
|
|
1387
|
+
shared.again = true;
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
if (shared.scheduled)
|
|
1391
|
+
return;
|
|
1392
|
+
if (demand === 'ensure' && shared.answered)
|
|
1393
|
+
return;
|
|
1394
|
+
shared.scheduled = true;
|
|
1395
|
+
void Promise.resolve().then(() => {
|
|
1396
|
+
shared.scheduled = false;
|
|
1397
|
+
startSharedAccess(shared);
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Record an answer. The caches it guards are module-wide, so the comparison
|
|
1402
|
+
* is too: against the last answer for this workspace (a remounted hook still
|
|
1403
|
+
* notices a change). No access at all clears them whatever was seen before.
|
|
1404
|
+
*/
|
|
1405
|
+
function settleSharedAccess(shared, access) {
|
|
1406
|
+
const answer = JSON.stringify(access);
|
|
1407
|
+
const before = lastAccessAnswers.get(shared.workspaceId);
|
|
1408
|
+
const noAccess = access.mode === 'tier' && access.tier === null;
|
|
1409
|
+
if (noAccess || (before !== undefined && before !== answer))
|
|
1410
|
+
clearSdkReadCaches();
|
|
1411
|
+
lastAccessAnswers.set(shared.workspaceId, answer);
|
|
1412
|
+
shared.answered = true;
|
|
1413
|
+
shared.answeredAt = Date.now();
|
|
1414
|
+
shared.settled = { workspaceId: shared.workspaceId, access, error: null };
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Ask now. A BACKGROUND ask (an answer is already held) does not show as
|
|
1418
|
+
* loading, and a failure of one keeps the answer rather than blanking the
|
|
1419
|
+
* app's controls; only a first answer reports its error. An answer that comes
|
|
1420
|
+
* back when no instance shows the workspace any more is dropped, as a
|
|
1421
|
+
* superseded one always was.
|
|
1422
|
+
*/
|
|
1423
|
+
function startSharedAccess(shared) {
|
|
1424
|
+
if (shared.listeners.size === 0) {
|
|
1425
|
+
releaseSharedAccess(shared);
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
const { bridge, workspaceId } = shared;
|
|
1429
|
+
const background = shared.answered;
|
|
1430
|
+
if (!background) {
|
|
1431
|
+
shared.fetching = true;
|
|
1432
|
+
notifySharedAccess(shared);
|
|
1433
|
+
}
|
|
1434
|
+
shared.inFlight = (async () => {
|
|
1435
|
+
try {
|
|
1436
|
+
const res = await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.PLUGIN_WORKSPACES_ENTITY_ID, 'my_access', { workspaceId });
|
|
1437
|
+
if (shared.listeners.size > 0)
|
|
1438
|
+
settleSharedAccess(shared, res.access);
|
|
1439
|
+
}
|
|
1440
|
+
catch (err) {
|
|
1441
|
+
if (shared.listeners.size === 0) {
|
|
1442
|
+
// Nobody shows this workspace now; the answer has no one to reach.
|
|
1443
|
+
}
|
|
1444
|
+
else if (err instanceof bridge_1.FiasBridgeError && NO_WORKSPACE_ACCESS_CODES.has(err.code)) {
|
|
1445
|
+
// No access here at all — the biggest narrowing there is.
|
|
1446
|
+
settleSharedAccess(shared, { mode: 'tier', workspaceId, tier: null, expiresAt: null });
|
|
1447
|
+
}
|
|
1448
|
+
else if (!background) {
|
|
1449
|
+
shared.settled = {
|
|
1450
|
+
workspaceId,
|
|
1451
|
+
access: null,
|
|
1452
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
finally {
|
|
1457
|
+
shared.fetching = false;
|
|
1458
|
+
shared.inFlight = null;
|
|
1459
|
+
if (shared.again) {
|
|
1460
|
+
shared.again = false;
|
|
1461
|
+
askSharedAccess(shared, 'again');
|
|
1462
|
+
}
|
|
1463
|
+
notifySharedAccess(shared);
|
|
1464
|
+
releaseSharedAccess(shared);
|
|
1465
|
+
}
|
|
1466
|
+
})();
|
|
998
1467
|
}
|
|
999
1468
|
/**
|
|
1000
1469
|
* Access the in-arche purchase (IAP) store.
|
|
@@ -1100,6 +1569,24 @@ const DOWNLOAD_URL_REFRESH_BUFFER_MS = 60000;
|
|
|
1100
1569
|
*/
|
|
1101
1570
|
const downloadUrlCache = new Map();
|
|
1102
1571
|
const downloadUrlInflight = new Map();
|
|
1572
|
+
/**
|
|
1573
|
+
* Forget every read the SDK caches that the person's access decided — the
|
|
1574
|
+
* presigned Vault download URLs, which stay valid for their window whatever
|
|
1575
|
+
* happens to the access that minted them. Called by `useFiasAccess` when the
|
|
1576
|
+
* person's access answer changes (ADR-034), so a plugin cannot keep serving a
|
|
1577
|
+
* URL minted under access the person no longer has. (Content-pack and
|
|
1578
|
+
* community-asset URLs are not decided by the person's access.)
|
|
1579
|
+
*/
|
|
1580
|
+
function clearSdkReadCaches() {
|
|
1581
|
+
downloadUrlGeneration += 1;
|
|
1582
|
+
downloadUrlCache.clear();
|
|
1583
|
+
downloadUrlInflight.clear();
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Bumped by every clear: a fetch that started before a clear must not put the
|
|
1587
|
+
* URL it brings back into the cache afterwards.
|
|
1588
|
+
*/
|
|
1589
|
+
let downloadUrlGeneration = 0;
|
|
1103
1590
|
/**
|
|
1104
1591
|
* Cache-aware fetcher for a presigned download URL. Used internally by
|
|
1105
1592
|
* `useVaultDocuments().getDownloadUrl()`. Exposed separately so non-React
|
|
@@ -1116,6 +1603,7 @@ async function fetchVaultDocumentDownloadUrl(documentId) {
|
|
|
1116
1603
|
if (inflight)
|
|
1117
1604
|
return inflight;
|
|
1118
1605
|
const bridge = (0, bridge_1.getBridge)();
|
|
1606
|
+
const generation = downloadUrlGeneration;
|
|
1119
1607
|
// The server bridge returns `expiresAt` as an ISO 8601 string; the
|
|
1120
1608
|
// SDK surface uses epoch milliseconds. Normalize here so callers
|
|
1121
1609
|
// don't have to think about the wire shape.
|
|
@@ -1127,15 +1615,19 @@ async function fetchVaultDocumentDownloadUrl(documentId) {
|
|
|
1127
1615
|
expiresAt: new Date(result.expiresAt).getTime(),
|
|
1128
1616
|
contentType: result.contentType,
|
|
1129
1617
|
};
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1618
|
+
if (generation === downloadUrlGeneration) {
|
|
1619
|
+
downloadUrlCache.set(documentId, {
|
|
1620
|
+
url: normalized.url,
|
|
1621
|
+
expiresAt: normalized.expiresAt,
|
|
1622
|
+
contentType: normalized.contentType,
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1135
1625
|
return normalized;
|
|
1136
1626
|
})
|
|
1137
1627
|
.finally(() => {
|
|
1138
|
-
|
|
1628
|
+
// Only our own entry: a newer request may have replaced it.
|
|
1629
|
+
if (downloadUrlInflight.get(documentId) === promise)
|
|
1630
|
+
downloadUrlInflight.delete(documentId);
|
|
1139
1631
|
});
|
|
1140
1632
|
downloadUrlInflight.set(documentId, promise);
|
|
1141
1633
|
return promise;
|
|
@@ -2059,46 +2551,64 @@ function useFiasPreviewState(key, getState, onRestore) {
|
|
|
2059
2551
|
return bridge.registerPreviewState(key, () => getStateRef.current(), (state) => onRestoreRef.current(state));
|
|
2060
2552
|
}, [bridge, key]);
|
|
2061
2553
|
}
|
|
2554
|
+
/** JSON with object keys sorted at every level, so equal content gives equal text. */
|
|
2555
|
+
function stableStringify(value) {
|
|
2556
|
+
return JSON.stringify(value, (_key, v) => v && typeof v === 'object' && !Array.isArray(v)
|
|
2557
|
+
? Object.fromEntries(Object.keys(v)
|
|
2558
|
+
.sort()
|
|
2559
|
+
.map((k) => [k, v[k]]))
|
|
2560
|
+
: v);
|
|
2561
|
+
}
|
|
2062
2562
|
/**
|
|
2063
|
-
* Subscribes to realtime
|
|
2064
|
-
* (ADR-012 Phase M4)
|
|
2065
|
-
*
|
|
2066
|
-
*
|
|
2067
|
-
* component renders from.
|
|
2563
|
+
* Subscribes to realtime invalidation events through any catalog verb of
|
|
2564
|
+
* `kind: 'subscribe'` (ADR-012 Phase M4) — the primitive behind
|
|
2565
|
+
* `useDataSubscription`. Notify-then-refetch: the platform pushes a minimal
|
|
2566
|
+
* "this changed" event and `onInvalidate` decides what to refetch.
|
|
2068
2567
|
*
|
|
2069
|
-
*
|
|
2070
|
-
*
|
|
2568
|
+
* `payload` is the verb's wire payload (`{ op, ...params }`), sent to
|
|
2569
|
+
* `entity_invoke` for `entityId` exactly as given; the hook re-subscribes
|
|
2570
|
+
* when its content changes (key order does not matter). The permission is
|
|
2571
|
+
* the verb's own — a live invalidation grants nothing the matching read
|
|
2572
|
+
* could not retrieve.
|
|
2071
2573
|
*
|
|
2072
2574
|
* Semantics and guarantees:
|
|
2073
2575
|
* - Best-effort, at-most-once delivery: events can be lost or duplicated;
|
|
2074
2576
|
* the initial refetch fires AFTER the subscription is live, so a write
|
|
2075
2577
|
* racing the subscribe is never missed.
|
|
2076
2578
|
* - `onInvalidate` runs single-flight with one trailing call: a burst of
|
|
2077
|
-
* events during an in-flight refetch coalesces into exactly one more.
|
|
2579
|
+
* events during an in-flight refetch coalesces into exactly one more. With
|
|
2580
|
+
* `minRefetchIntervalMs` it also runs at most once per interval (a trailing
|
|
2581
|
+
* call still follows the last event), so a busy resource cannot spend your
|
|
2582
|
+
* per-minute budget on refetches.
|
|
2583
|
+
* - An event says only that the resource changed — not what. Re-read the
|
|
2584
|
+
* visible head of it and merge by id, so edits and deletions show too; a
|
|
2585
|
+
* "newer than X" cursor read would never see them.
|
|
2078
2586
|
* - The host renews authorization periodically; when it definitively ends
|
|
2079
|
-
* (`status: 'ended'`), `endedReason` says why — `collection_deleted`
|
|
2080
|
-
* `authorization_revoked` are terminal,
|
|
2081
|
-
* (unmount/remount or toggle `enabled`).
|
|
2082
|
-
* - Scope-aware: shared collections notify on any user's writes; user-scoped
|
|
2083
|
-
* collections only on yours; workspace-scoped need `workspaceId` and an
|
|
2084
|
-
* active membership.
|
|
2587
|
+
* (`status: 'ended'`), `endedReason` says why — `collection_deleted` (the
|
|
2588
|
+
* observed resource is gone) and `authorization_revoked` are terminal,
|
|
2589
|
+
* others may warrant resubscribing (unmount/remount or toggle `enabled`).
|
|
2085
2590
|
*
|
|
2086
2591
|
* @example
|
|
2087
|
-
*
|
|
2088
|
-
*
|
|
2089
|
-
* const
|
|
2090
|
-
*
|
|
2091
|
-
*
|
|
2092
|
-
*
|
|
2093
|
-
*
|
|
2592
|
+
* // Any catalog verb of kind 'subscribe' (see the capability catalog for the
|
|
2593
|
+
* // entity id and payload); refetch the head of the resource on each event.
|
|
2594
|
+
* const { status } = useEntitySubscription(
|
|
2595
|
+
* ENTITY_ID,
|
|
2596
|
+
* { op: 'subscribe_feed' },
|
|
2597
|
+
* refetchHead,
|
|
2598
|
+
* { enabled: member, minRefetchIntervalMs: 2_000 },
|
|
2599
|
+
* );
|
|
2094
2600
|
*/
|
|
2095
|
-
function
|
|
2601
|
+
function useEntitySubscription(entityId, payload, onInvalidate, options) {
|
|
2096
2602
|
const bridge = useBridge();
|
|
2097
2603
|
const [state, setState] = (0, react_1.useState)({ status: 'subscribing' });
|
|
2098
2604
|
const onInvalidateRef = (0, react_1.useRef)(onInvalidate);
|
|
2099
2605
|
onInvalidateRef.current = onInvalidate;
|
|
2100
2606
|
const enabled = options?.enabled ?? true;
|
|
2101
|
-
const
|
|
2607
|
+
const minRefetchIntervalMs = Math.max(0, options?.minRefetchIntervalMs ?? 0);
|
|
2608
|
+
// The payload is wire JSON, so its content is the identity that matters —
|
|
2609
|
+
// a fresh object literal each render, or the same fields in another order,
|
|
2610
|
+
// must not re-subscribe.
|
|
2611
|
+
const payloadKey = stableStringify(payload);
|
|
2102
2612
|
(0, react_1.useEffect)(() => {
|
|
2103
2613
|
if (!enabled) {
|
|
2104
2614
|
setState({ status: 'ended', endedReason: 'unsubscribed' });
|
|
@@ -2110,14 +2620,31 @@ function useDataSubscription(collection, onInvalidate, options) {
|
|
|
2110
2620
|
let unsubscribeEnded = null;
|
|
2111
2621
|
// Single-flight + exactly one trailing call: events arriving during an
|
|
2112
2622
|
// in-flight refetch coalesce into one follow-up, never a parallel run.
|
|
2623
|
+
// With a minimum interval, a run that would land too soon after the last
|
|
2624
|
+
// one is deferred to the interval's end — still exactly one.
|
|
2113
2625
|
let refetching = false;
|
|
2114
2626
|
let trailing = false;
|
|
2627
|
+
let lastRunAt = -Infinity;
|
|
2628
|
+
let intervalTimer = null;
|
|
2115
2629
|
const runInvalidate = async () => {
|
|
2116
|
-
if (refetching) {
|
|
2630
|
+
if (refetching || intervalTimer) {
|
|
2117
2631
|
trailing = true;
|
|
2118
2632
|
return;
|
|
2119
2633
|
}
|
|
2634
|
+
const wait = lastRunAt + minRefetchIntervalMs - Date.now();
|
|
2635
|
+
if (wait > 0) {
|
|
2636
|
+
trailing = true;
|
|
2637
|
+
intervalTimer = setTimeout(() => {
|
|
2638
|
+
intervalTimer = null;
|
|
2639
|
+
if (trailing && !cancelled) {
|
|
2640
|
+
trailing = false;
|
|
2641
|
+
void runInvalidate();
|
|
2642
|
+
}
|
|
2643
|
+
}, wait);
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2120
2646
|
refetching = true;
|
|
2647
|
+
lastRunAt = Date.now();
|
|
2121
2648
|
try {
|
|
2122
2649
|
await onInvalidateRef.current();
|
|
2123
2650
|
}
|
|
@@ -2135,8 +2662,8 @@ function useDataSubscription(collection, onInvalidate, options) {
|
|
|
2135
2662
|
void (async () => {
|
|
2136
2663
|
try {
|
|
2137
2664
|
const res = await bridge.request('entity_invoke', {
|
|
2138
|
-
entityId
|
|
2139
|
-
payload:
|
|
2665
|
+
entityId,
|
|
2666
|
+
payload: JSON.parse(payloadKey),
|
|
2140
2667
|
});
|
|
2141
2668
|
const subscription = res?.output;
|
|
2142
2669
|
if (!subscription ||
|
|
@@ -2169,14 +2696,55 @@ function useDataSubscription(collection, onInvalidate, options) {
|
|
|
2169
2696
|
})();
|
|
2170
2697
|
return () => {
|
|
2171
2698
|
cancelled = true;
|
|
2699
|
+
if (intervalTimer)
|
|
2700
|
+
clearTimeout(intervalTimer);
|
|
2172
2701
|
unsubscribeEvent?.();
|
|
2173
2702
|
unsubscribeEnded?.();
|
|
2174
2703
|
if (subscriptionId)
|
|
2175
2704
|
bridge.releaseSubscription(subscriptionId);
|
|
2176
2705
|
};
|
|
2177
|
-
}, [bridge,
|
|
2706
|
+
}, [bridge, entityId, payloadKey, enabled, minRefetchIntervalMs]);
|
|
2178
2707
|
return state;
|
|
2179
2708
|
}
|
|
2709
|
+
/**
|
|
2710
|
+
* Subscribes to realtime change events for a datastore collection
|
|
2711
|
+
* (ADR-012 Phase M4) — `useEntitySubscription` bound to the datastore's
|
|
2712
|
+
* `subscribe` verb. Notify-then-refetch: the platform pushes a minimal
|
|
2713
|
+
* "this collection changed" invalidation and `onInvalidate` decides what to
|
|
2714
|
+
* refetch — typically re-running the `useFiasDataStore().query(...)` your
|
|
2715
|
+
* component renders from.
|
|
2716
|
+
*
|
|
2717
|
+
* Requires the `data:store` permission (same as reading the collection —
|
|
2718
|
+
* a live invalidation grants nothing polling couldn't retrieve).
|
|
2719
|
+
*
|
|
2720
|
+
* Semantics and guarantees are `useEntitySubscription`'s. Scope-aware:
|
|
2721
|
+
* shared collections notify on any user's writes; user-scoped collections
|
|
2722
|
+
* only on yours; workspace-scoped need `workspaceId` and an active
|
|
2723
|
+
* membership.
|
|
2724
|
+
*
|
|
2725
|
+
* Audiences (ADR-034): in a collection with audiences, changes are announced
|
|
2726
|
+
* per audience — pass `audience` (one the user reads; see
|
|
2727
|
+
* `useFiasAccess().canReadAudience`), and subscribe once per audience you
|
|
2728
|
+
* show. Without one the subscription ends (`AUDIENCE_REQUIRED`).
|
|
2729
|
+
*
|
|
2730
|
+
* @example
|
|
2731
|
+
* const dataStore = useFiasDataStore();
|
|
2732
|
+
* const [scores, setScores] = useState<DataStoreDocument[]>([]);
|
|
2733
|
+
* const refresh = useCallback(async () => {
|
|
2734
|
+
* const res = await dataStore.query('scores', { limit: 50 });
|
|
2735
|
+
* setScores(res.documents);
|
|
2736
|
+
* }, [dataStore]);
|
|
2737
|
+
* const { status } = useDataSubscription('scores', refresh);
|
|
2738
|
+
*/
|
|
2739
|
+
function useDataSubscription(collection, onInvalidate, options) {
|
|
2740
|
+
const audience = options?.audience;
|
|
2741
|
+
return useEntitySubscription(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, {
|
|
2742
|
+
op: 'subscribe',
|
|
2743
|
+
collection,
|
|
2744
|
+
workspaceId: options?.workspaceId,
|
|
2745
|
+
...(audience !== undefined ? { audience } : {}),
|
|
2746
|
+
}, onInvalidate, { enabled: options?.enabled });
|
|
2747
|
+
}
|
|
2180
2748
|
/**
|
|
2181
2749
|
* Consented access to documents the USER already owns — the only SDK
|
|
2182
2750
|
* surface that reaches data your plugin did not create. Requires the
|
|
@@ -2389,6 +2957,189 @@ async function fetchCommunityAssetUrls(bridge, assetIds) {
|
|
|
2389
2957
|
}
|
|
2390
2958
|
return hits;
|
|
2391
2959
|
}
|
|
2960
|
+
/**
|
|
2961
|
+
* The user's Creative Library (ADR-035): their styles and characters, made in
|
|
2962
|
+
* FiasWorks Studio or any arche they allow, owned by them — or, in a team
|
|
2963
|
+
* workspace that approved this arche, the workspace's. Requires
|
|
2964
|
+
* `vault:creative:read`; `create` / `update` / `remove` / `restore` also need
|
|
2965
|
+
* `vault:creative:write`.
|
|
2966
|
+
*
|
|
2967
|
+
* Generate in a style with `useImageGeneration().generate({ …, creativeStyle:
|
|
2968
|
+
* { styleId } })`: the platform sends the style's pictures and words, so the
|
|
2969
|
+
* arche never has to read them itself.
|
|
2970
|
+
*
|
|
2971
|
+
* @example
|
|
2972
|
+
* const library = useCreativeLibrary();
|
|
2973
|
+
* const styles = await library.list({ kind: 'style' });
|
|
2974
|
+
* await images.generate({ entityId, prompt: 'a red fox', creativeStyle: { styleId: styles[0].itemId } });
|
|
2975
|
+
*/
|
|
2976
|
+
/** How long a person may keep the Creative Library picker open. */
|
|
2977
|
+
const CREATIVE_PICKER_TIMEOUT_MS = 30 * 60 * 1000;
|
|
2978
|
+
function useCreativeLibrary() {
|
|
2979
|
+
const bridge = useBridge();
|
|
2980
|
+
return (0, react_1.useMemo)(() => {
|
|
2981
|
+
const item = async (op, args) => (await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.VAULT_CREATIVE_ENTITY_ID, op, args))
|
|
2982
|
+
.item;
|
|
2983
|
+
return {
|
|
2984
|
+
list: async (options = {}) => (await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.VAULT_CREATIVE_ENTITY_ID, 'list_items', { ...options })).items,
|
|
2985
|
+
get: (itemId, version) => item('get_item', { itemId, ...(version !== undefined ? { version } : {}) }),
|
|
2986
|
+
create: (fields) => item('create_item', { referenceDocumentIds: [], ...fields }),
|
|
2987
|
+
update: (itemId, fields) => item('update_item', { itemId, ...fields }),
|
|
2988
|
+
remove: (itemId) => item('delete_item', { itemId }),
|
|
2989
|
+
restore: (itemId) => item('restore_item', { itemId }),
|
|
2990
|
+
pick: async (options = {}) => {
|
|
2991
|
+
let answer;
|
|
2992
|
+
try {
|
|
2993
|
+
answer = await bridge.request('client_entity_invoke', {
|
|
2994
|
+
entityId: entity_ops_1.CREATIVE_PICKER_ENTITY_ID,
|
|
2995
|
+
input: options.kind ? { kind: options.kind } : {},
|
|
2996
|
+
}, CREATIVE_PICKER_TIMEOUT_MS);
|
|
2997
|
+
}
|
|
2998
|
+
catch (err) {
|
|
2999
|
+
// A host without the picker (an older Fias, the dev harness) has no
|
|
3000
|
+
// executor for it: the picker is simply not available there.
|
|
3001
|
+
if (err instanceof bridge_1.FiasBridgeError &&
|
|
3002
|
+
err.code === 'BRIDGE_ERROR' &&
|
|
3003
|
+
err.message.includes('no client-side executor')) {
|
|
3004
|
+
throw new bridge_1.FiasBridgeError('The Creative Library picker is not available here', 'PICKER_UNAVAILABLE');
|
|
3005
|
+
}
|
|
3006
|
+
throw err;
|
|
3007
|
+
}
|
|
3008
|
+
if (answer && 'error' in answer) {
|
|
3009
|
+
throw new bridge_1.FiasBridgeError(answer.error.message, answer.error.code);
|
|
3010
|
+
}
|
|
3011
|
+
return answer;
|
|
3012
|
+
},
|
|
3013
|
+
};
|
|
3014
|
+
}, [bridge]);
|
|
3015
|
+
}
|
|
3016
|
+
/** Connecting waits on a person (and maybe a provider sign-in in a popup). */
|
|
3017
|
+
const CONNECT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
3018
|
+
/**
|
|
3019
|
+
* Outside accounts (X, LinkedIn, …) the workspace the app is open in lets it
|
|
3020
|
+
* use (ADR-029 §2). Requires `connections:use`.
|
|
3021
|
+
*
|
|
3022
|
+
* The app never sees a credential. `connect(provider)` asks the host to show
|
|
3023
|
+
* its connect screen, where the person connects an account or lets this app
|
|
3024
|
+
* use one already connected; the host makes every grant. Acting as an account
|
|
3025
|
+
* (posting, reading its audience) is a verb on the provider's own entity.
|
|
3026
|
+
*
|
|
3027
|
+
* @example
|
|
3028
|
+
* const connections = useFiasConnections();
|
|
3029
|
+
* const accounts = await connections.list();
|
|
3030
|
+
* const x = accounts.find((a) => a.provider === 'x') ?? (await connections.connect('x'));
|
|
3031
|
+
*/
|
|
3032
|
+
function useFiasConnections() {
|
|
3033
|
+
const bridge = useBridge();
|
|
3034
|
+
const list = (0, react_1.useCallback)(async () => {
|
|
3035
|
+
const res = await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.CONNECTIONS_ENTITY_ID, 'list');
|
|
3036
|
+
return res.connections;
|
|
3037
|
+
}, [bridge]);
|
|
3038
|
+
const connect = (0, react_1.useCallback)(async (provider) => {
|
|
3039
|
+
const result = await bridge.request('client_entity_invoke', { entityId: entity_ops_1.CONNECTIONS_CONNECT_ENTITY_ID, input: { provider } }, CONNECT_TIMEOUT_MS);
|
|
3040
|
+
if (result && 'error' in result) {
|
|
3041
|
+
throw new bridge_1.FiasBridgeError(result.error.message, result.error.code);
|
|
3042
|
+
}
|
|
3043
|
+
return result;
|
|
3044
|
+
}, [bridge]);
|
|
3045
|
+
const release = (0, react_1.useCallback)(async (connectionId) => {
|
|
3046
|
+
await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.CONNECTIONS_ENTITY_ID, 'release', { connectionId });
|
|
3047
|
+
}, [bridge]);
|
|
3048
|
+
return (0, react_1.useMemo)(() => ({ list, connect, release }), [list, connect, release]);
|
|
3049
|
+
}
|
|
3050
|
+
/**
|
|
3051
|
+
* X, as an account the workspace this app is open in let it use (ADR-029 §3).
|
|
3052
|
+
*
|
|
3053
|
+
* Requires `connections:use` in `fias-plugin.json`. Get a `connectionId` from
|
|
3054
|
+
* `useFiasConnections()` (`list`, or `connect('x')`). Each call is charged to
|
|
3055
|
+
* the user as X prices it: a read by what it returns, a post per post — and a
|
|
3056
|
+
* post with a link (any URL or web address) costs more than a plain one. `publishPost` puts a confirm sheet in front of the
|
|
3057
|
+
* person — the platform's own words: the account, the workspace, the post —
|
|
3058
|
+
* and resolves only after they approve. A decline rejects with
|
|
3059
|
+
* `FiasBridgeError` code `USER_DECLINED`. Never available in the dev harness:
|
|
3060
|
+
* a post needs a person in the Fias app.
|
|
3061
|
+
*
|
|
3062
|
+
* @example
|
|
3063
|
+
* ```tsx
|
|
3064
|
+
* const x = useFiasX();
|
|
3065
|
+
* const { posts } = await x.publishPost({ connectionId, posts: [{ text: 'Hello' }] });
|
|
3066
|
+
* ```
|
|
3067
|
+
*/
|
|
3068
|
+
function useFiasX() {
|
|
3069
|
+
const bridge = useBridge();
|
|
3070
|
+
return (0, react_1.useMemo)(() => ({
|
|
3071
|
+
publishPost: (params) => (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.X_ENTITY_ID, 'publish_post', { ...params }),
|
|
3072
|
+
searchUsers: async (connectionId, query, options) => (await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.X_ENTITY_ID, 'search_users', {
|
|
3073
|
+
connectionId,
|
|
3074
|
+
query,
|
|
3075
|
+
...options,
|
|
3076
|
+
})).users,
|
|
3077
|
+
getUserPosts: async (connectionId, options) => (await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.X_ENTITY_ID, 'get_user_posts', {
|
|
3078
|
+
connectionId,
|
|
3079
|
+
...options,
|
|
3080
|
+
})).posts,
|
|
3081
|
+
getPostMetrics: async (connectionId, postIds) => (await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.X_ENTITY_ID, 'get_post_metrics', { connectionId, postIds })).metrics,
|
|
3082
|
+
getAccountStats: (connectionId) => (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.X_ENTITY_ID, 'get_account_stats', {
|
|
3083
|
+
connectionId,
|
|
3084
|
+
}),
|
|
3085
|
+
}), [bridge]);
|
|
3086
|
+
}
|
|
3087
|
+
/**
|
|
3088
|
+
* Schedule one call for later (ADR-029 §5) — e.g. a post at 9 AM tomorrow.
|
|
3089
|
+
*
|
|
3090
|
+
* Requires `schedule:invoke` in `fias-plugin.json`, plus whatever the call
|
|
3091
|
+
* itself needs (`connections:use` to post). For an action outside Fias the
|
|
3092
|
+
* person approves the exact call AND its time in a confirm sheet first; a
|
|
3093
|
+
* decline rejects with `USER_DECLINED`. When it is due every check runs again
|
|
3094
|
+
* — if the account was disconnected or the app lost access, nothing is sent
|
|
3095
|
+
* and `list()` shows it as `refused`. There is no edit: cancel and schedule
|
|
3096
|
+
* again (a new approval).
|
|
3097
|
+
*
|
|
3098
|
+
* @example
|
|
3099
|
+
* ```tsx
|
|
3100
|
+
* const schedules = useFiasSchedules();
|
|
3101
|
+
* await schedules.schedule({
|
|
3102
|
+
* entityId: X_ENTITY_ID,
|
|
3103
|
+
* verb: 'publish_post',
|
|
3104
|
+
* payload: { connectionId, posts: [{ text: 'Launch day' }] },
|
|
3105
|
+
* fireAt: tomorrowAtNine,
|
|
3106
|
+
* });
|
|
3107
|
+
* ```
|
|
3108
|
+
*/
|
|
3109
|
+
function useFiasSchedules() {
|
|
3110
|
+
const bridge = useBridge();
|
|
3111
|
+
return (0, react_1.useMemo)(() => ({
|
|
3112
|
+
schedule: (params) => (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.SCHEDULED_INVOCATIONS_ENTITY_ID, 'schedule', {
|
|
3113
|
+
entityId: params.entityId,
|
|
3114
|
+
verb: params.verb,
|
|
3115
|
+
payload: params.payload,
|
|
3116
|
+
fireAt: params.fireAt instanceof Date ? params.fireAt.toISOString() : params.fireAt,
|
|
3117
|
+
}),
|
|
3118
|
+
list: async (options) => (await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.SCHEDULED_INVOCATIONS_ENTITY_ID, 'list', {
|
|
3119
|
+
...(options?.limit !== undefined ? { limit: options.limit } : {}),
|
|
3120
|
+
...(options?.scheduleIds ? { scheduleIds: options.scheduleIds } : {}),
|
|
3121
|
+
})).schedules,
|
|
3122
|
+
cancel: async (scheduleId) => {
|
|
3123
|
+
await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.SCHEDULED_INVOCATIONS_ENTITY_ID, 'cancel', { scheduleId });
|
|
3124
|
+
},
|
|
3125
|
+
}), [bridge]);
|
|
3126
|
+
}
|
|
3127
|
+
/**
|
|
3128
|
+
* LinkedIn, as a member account the workspace this app is open in let it use
|
|
3129
|
+
* (ADR-029 §3). Requires `connections:use`; get a `connectionId` from
|
|
3130
|
+
* `useFiasConnections()`. Each call is charged to the user. No post-metrics or
|
|
3131
|
+
* audience calls yet: LinkedIn serves them only with analytics scopes the
|
|
3132
|
+
* connection does not ask for. `publishPost` puts
|
|
3133
|
+
* a confirm sheet in front of the person and resolves only after they approve;
|
|
3134
|
+
* a decline rejects with code `USER_DECLINED`.
|
|
3135
|
+
*/
|
|
3136
|
+
function useFiasLinkedIn() {
|
|
3137
|
+
const bridge = useBridge();
|
|
3138
|
+
return (0, react_1.useMemo)(() => ({
|
|
3139
|
+
publishPost: (params) => (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.LINKEDIN_ENTITY_ID, 'publish_post', { ...params }),
|
|
3140
|
+
searchOrganizations: async (connectionId, vanityName) => (await (0, entity_ops_1.invokeEntityOp)(bridge, entity_ops_1.LINKEDIN_ENTITY_ID, 'search_organizations', { connectionId, query: vanityName })).organizations,
|
|
3141
|
+
}), [bridge]);
|
|
3142
|
+
}
|
|
2392
3143
|
/**
|
|
2393
3144
|
* Send email AS the Company this app is being used in (ADR-023), from the
|
|
2394
3145
|
* Company's own verified domain — not from Fias.
|
|
@@ -2604,7 +3355,7 @@ function useDocumentSign() {
|
|
|
2604
3355
|
}
|
|
2605
3356
|
/**
|
|
2606
3357
|
* Declare Fias AI actions at RUNTIME — the dynamic half of the plugin
|
|
2607
|
-
* AI-action plane (`ai:actions` permission; TRUSTED arches only — the host
|
|
3358
|
+
* AI-action plane (`ai:actions` permission for the declaration; TRUSTED arches only — the host
|
|
2608
3359
|
* silently ignores runtime declarations from non-trusted arches, whose lane
|
|
2609
3360
|
* is the manifest `fiasAI` block, fixed at publish and reviewed with the
|
|
2610
3361
|
* submission). Both lanes merge host-side; the manifest wins id collisions.
|
|
@@ -2618,8 +3369,10 @@ function useDocumentSign() {
|
|
|
2618
3369
|
* Returns `{ openAssistant }` — a fire-and-forget request that the host open
|
|
2619
3370
|
* the global Fias AI panel, for CONTEXTUAL affordances (an empty-state "Ask
|
|
2620
3371
|
* Fias AI" button; the host also shows its own "Works with Fias AI" chip).
|
|
2621
|
-
*
|
|
2622
|
-
*
|
|
3372
|
+
* Opening needs NO permission — any plugin may do it, actions or not; the
|
|
3373
|
+
* user can still use the assistant's own tools — and `useFiasChrome` offers
|
|
3374
|
+
* the same call. The host debounces it and honours the user's last dismissal
|
|
3375
|
+
* — never loop or retry, and there is deliberately no way to pass text along.
|
|
2623
3376
|
*/
|
|
2624
3377
|
function useFiasAIActions(config) {
|
|
2625
3378
|
const bridge = useBridge();
|