@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.
Files changed (42) hide show
  1. package/dist/bridge.d.ts +42 -6
  2. package/dist/bridge.d.ts.map +1 -1
  3. package/dist/bridge.js +93 -2
  4. package/dist/bridge.js.map +1 -1
  5. package/dist/bridge.test.js +89 -0
  6. package/dist/bridge.test.js.map +1 -1
  7. package/dist/entity-ops.d.ts +27 -1
  8. package/dist/entity-ops.d.ts.map +1 -1
  9. package/dist/entity-ops.js +84 -6
  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 +10 -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 +220 -18
  20. package/dist/hooks.d.ts.map +1 -1
  21. package/dist/hooks.js +792 -39
  22. package/dist/hooks.js.map +1 -1
  23. package/dist/hooks.test.js +702 -0
  24. package/dist/hooks.test.js.map +1 -1
  25. package/dist/index.d.ts +4 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +24 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +771 -28
  30. package/dist/limits.d.ts +72 -0
  31. package/dist/limits.d.ts.map +1 -0
  32. package/dist/limits.js +75 -0
  33. package/dist/limits.js.map +1 -0
  34. package/dist/types.d.ts +720 -10
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/use-fias-access.render.test.d.ts +12 -0
  37. package/dist/use-fias-access.render.test.d.ts.map +1 -0
  38. package/dist/use-fias-access.render.test.js +407 -0
  39. package/dist/use-fias-access.render.test.js.map +1 -0
  40. package/package.json +3 -2
  41. package/templates/default/AGENTS.md +391 -13
  42. package/templates/default/CLAUDE.md +391 -13
@@ -68,8 +68,16 @@ function createMockBridge() {
68
68
  getCurrentPath: jest.fn().mockReturnValue('/'),
69
69
  getTextModels: jest.fn().mockReturnValue(null),
70
70
  onTextModelsUpdate: jest.fn().mockReturnValue(() => { }),
71
+ getHolder: jest.fn().mockReturnValue(null),
72
+ onHolderReady: jest.fn().mockReturnValue(() => { }),
71
73
  onThemeUpdate: jest.fn().mockReturnValue(() => { }),
74
+ onAccessUpdate: jest.fn().mockReturnValue(() => { }),
72
75
  onNavigationUpdate: jest.fn().mockReturnValue(() => { }),
76
+ getChrome: jest.fn().mockReturnValue(null),
77
+ hasChromeSupport: jest.fn().mockReturnValue(false),
78
+ onChromeUpdate: jest.fn().mockReturnValue(() => { }),
79
+ setChrome: jest.fn(),
80
+ navigateHost: jest.fn(),
73
81
  onStepNavigationUpdate: jest.fn().mockReturnValue(() => { }),
74
82
  registerPreviewState: jest.fn().mockReturnValue(() => { }),
75
83
  onSubscriptionEvent: jest.fn().mockReturnValue(() => { }),
@@ -242,6 +250,66 @@ describe('SDK hooks', () => {
242
250
  });
243
251
  });
244
252
  });
253
+ describe('useFiasChrome', () => {
254
+ const HOST_STATE = {
255
+ focusMode: true,
256
+ focusModeAvailable: true,
257
+ sidebarAvailable: true,
258
+ sidebarCollapsed: false,
259
+ hostExpandedBar: true,
260
+ hostExpandButton: false,
261
+ };
262
+ it('is inert and unsupported before init / on an older host, and still renders', () => {
263
+ const { useFiasChrome } = require('./hooks');
264
+ const chrome = useFiasChrome();
265
+ expect(chrome.supported).toBe(false);
266
+ expect(chrome.focusMode).toBe(false);
267
+ expect(chrome.focusModeAvailable).toBe(false);
268
+ expect(chrome.sidebarAvailable).toBe(false);
269
+ expect(chrome.hostExpandedBar).toBe(true);
270
+ expect(mockBridge.onChromeUpdate).toHaveBeenCalled();
271
+ });
272
+ it('spreads the host state and reports supported once delivered', () => {
273
+ mockBridge.getChrome.mockReturnValue(HOST_STATE);
274
+ mockBridge.hasChromeSupport.mockReturnValue(true);
275
+ const { useFiasChrome } = require('./hooks');
276
+ const chrome = useFiasChrome();
277
+ expect(chrome.supported).toBe(true);
278
+ expect(chrome.focusMode).toBe(true);
279
+ expect(chrome.hostExpandButton).toBe(false);
280
+ });
281
+ it('TAKES THE CHROME OVER on mount — the host drops its default expand button', () => {
282
+ const { useFiasChrome } = require('./hooks');
283
+ useFiasChrome();
284
+ expect(mockBridge.setChrome).toHaveBeenCalledWith({ hostExpandButton: false });
285
+ });
286
+ it('setters send one-field chrome_set patches; navigation uses the closed enum', () => {
287
+ const { useFiasChrome } = require('./hooks');
288
+ const chrome = useFiasChrome();
289
+ chrome.setFocusMode(true);
290
+ expect(mockBridge.setChrome).toHaveBeenCalledWith({ focusMode: true });
291
+ chrome.setSidebarCollapsed(true);
292
+ expect(mockBridge.setChrome).toHaveBeenCalledWith({ sidebarCollapsed: true });
293
+ chrome.setHostExpandedBar(false);
294
+ expect(mockBridge.setChrome).toHaveBeenCalledWith({ hostExpandedBar: false });
295
+ chrome.goToFiasHome();
296
+ expect(mockBridge.navigateHost).toHaveBeenCalledWith('home');
297
+ chrome.goToWorktop();
298
+ expect(mockBridge.navigateHost).toHaveBeenCalledWith('worktop');
299
+ chrome.openAssistant();
300
+ expect(mockBridge.openAssistant).toHaveBeenCalled();
301
+ });
302
+ it('cleanup unsubscribes from chrome updates', () => {
303
+ const unsubscribe = jest.fn();
304
+ mockBridge.onChromeUpdate.mockReturnValue(unsubscribe);
305
+ const { useFiasChrome } = require('./hooks');
306
+ useFiasChrome();
307
+ // The LAST captured cleanup is the takeover effect's (none) or the
308
+ // subscription's — walk the captured one if present.
309
+ capturedEffectCleanup?.();
310
+ expect(unsubscribe).toHaveBeenCalled();
311
+ });
312
+ });
245
313
  describe('hook return identity', () => {
246
314
  // These two returns MUST be memoized. The natural way to consume them is
247
315
  // `useEffect(() => { void storage.readFile(...) }, [storage])`, and a
@@ -763,6 +831,366 @@ describe('SDK hooks', () => {
763
831
  expect(useTextModels()).toEqual([]);
764
832
  });
765
833
  });
834
+ describe('useFiasHolder', () => {
835
+ it('returns the init-payload workspace and subscribes for its arrival', () => {
836
+ const { useFiasHolder } = require('./hooks');
837
+ const holder = { type: 'org', role: 'member' };
838
+ mockBridge.getHolder.mockReturnValue(holder);
839
+ expect(useFiasHolder()).toEqual(holder);
840
+ expect(mockBridge.onHolderReady).toHaveBeenCalled();
841
+ });
842
+ it('returns null before init', () => {
843
+ const { useFiasHolder } = require('./hooks');
844
+ mockBridge.getHolder.mockReturnValue(null);
845
+ expect(useFiasHolder()).toBeNull();
846
+ });
847
+ });
848
+ describe('useFiasWorkspaces().openForWorkspace', () => {
849
+ it('asks for the acting workspace by flag, never by id', async () => {
850
+ const { useFiasWorkspaces } = require('./hooks');
851
+ const ws = { workspaceId: 'ws_1', displayName: 'Acme', role: 'member', holderBacked: true };
852
+ mockBridge.request.mockResolvedValue(output({ workspace: ws }));
853
+ await expect(useFiasWorkspaces().openForWorkspace()).resolves.toEqual(ws);
854
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_WORKSPACES_ENTITY_ID, 'create', { forActingHolder: true }));
855
+ });
856
+ });
857
+ // ADR-034 phase 0b. The real-render cases (stale answers across workspace
858
+ // switches, the render before effects run, refetch) live in
859
+ // use-fias-access.render.test.tsx, which CI runs through the package's
860
+ // `test:render` script; these mock React and run under the root config.
861
+ describe('useFiasAccess', () => {
862
+ const settle = () => new Promise((r) => setTimeout(r, 0));
863
+ const setCalls = () => mockSetState.mock.calls.map((c) => c[0]);
864
+ it('asks the workspaces entity for my_access with the workspace id', async () => {
865
+ const { useFiasAccess } = require('./hooks');
866
+ const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
867
+ mockBridge.request.mockResolvedValue(output({ access }));
868
+ useFiasAccess('ws_1');
869
+ await settle();
870
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_WORKSPACES_ENTITY_ID, 'my_access', { workspaceId: 'ws_1' }));
871
+ expect(setCalls()).toContainEqual({ workspaceId: 'ws_1', access, error: null });
872
+ });
873
+ it.each(['NOT_A_MEMBER', 'WORKSPACE_NOT_FOUND'])('%s becomes tier: null', async (code) => {
874
+ const { useFiasAccess } = require('./hooks');
875
+ const { FiasBridgeError } = jest.requireActual('./bridge');
876
+ mockBridge.request.mockRejectedValue(new FiasBridgeError('refused', code));
877
+ useFiasAccess('ws_1');
878
+ await settle();
879
+ expect(setCalls()).toContainEqual({
880
+ workspaceId: 'ws_1',
881
+ access: { mode: 'tier', workspaceId: 'ws_1', tier: null, expiresAt: null },
882
+ error: null,
883
+ });
884
+ });
885
+ it('surfaces any other failure as an error', async () => {
886
+ const { useFiasAccess } = require('./hooks');
887
+ const { FiasBridgeError } = jest.requireActual('./bridge');
888
+ mockBridge.request.mockRejectedValue(new FiasBridgeError('slow down', 'RATE_LIMIT'));
889
+ useFiasAccess('ws_1');
890
+ await settle();
891
+ expect(setCalls()).toContainEqual({
892
+ workspaceId: 'ws_1',
893
+ access: null,
894
+ error: expect.objectContaining({ code: 'RATE_LIMIT' }),
895
+ });
896
+ });
897
+ it('asks nothing without a workspace', async () => {
898
+ const { useFiasAccess } = require('./hooks');
899
+ const state = useFiasAccess(null);
900
+ await settle();
901
+ expect(mockBridge.request).not.toHaveBeenCalled();
902
+ expect(state).toMatchObject({ access: null, isLoading: false, error: null });
903
+ });
904
+ it('asks again when the host says the person’s access changed (ADR-034)', async () => {
905
+ const { useFiasAccess } = require('./hooks');
906
+ const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
907
+ mockBridge.request.mockResolvedValue(output({ access }));
908
+ useFiasAccess('ws_1');
909
+ await settle();
910
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
911
+ const listener = mockBridge.onAccessUpdate.mock.calls[0][0];
912
+ listener();
913
+ await settle();
914
+ expect(mockBridge.request).toHaveBeenCalledTimes(2);
915
+ });
916
+ it('a failed background refetch keeps the answer it had', async () => {
917
+ const { useFiasAccess } = require('./hooks');
918
+ const { FiasBridgeError } = jest.requireActual('./bridge');
919
+ const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
920
+ mockBridge.request.mockResolvedValueOnce(output({ access }));
921
+ useFiasAccess('ws_1');
922
+ await settle();
923
+ mockSetState.mockClear();
924
+ mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('slow down', 'RATE_LIMIT'));
925
+ const subscriptions = mockBridge.onAccessUpdate.mock.calls;
926
+ subscriptions[subscriptions.length - 1][0]();
927
+ await settle();
928
+ // No blanking answer was written, and nothing flipped to loading.
929
+ expect(setCalls()).not.toContainEqual(expect.objectContaining({ access: null }));
930
+ expect(setCalls()).not.toContain(true);
931
+ });
932
+ it('window focus asks again at most once per 30 s', async () => {
933
+ const listeners = {};
934
+ const win = {
935
+ addEventListener: jest.fn((type, fn) => (listeners[type] = fn)),
936
+ removeEventListener: jest.fn(),
937
+ };
938
+ globalThis.window = win;
939
+ try {
940
+ const { useFiasAccess } = require('./hooks');
941
+ const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
942
+ mockBridge.request.mockResolvedValue(output({ access }));
943
+ useFiasAccess('ws_1');
944
+ await settle();
945
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
946
+ listeners.focus();
947
+ await settle();
948
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
949
+ const now = Date.now();
950
+ jest.spyOn(Date, 'now').mockReturnValue(now + 31000);
951
+ listeners.focus();
952
+ await settle();
953
+ expect(mockBridge.request).toHaveBeenCalledTimes(2);
954
+ }
955
+ finally {
956
+ delete globalThis.window;
957
+ jest.restoreAllMocks();
958
+ }
959
+ });
960
+ it('opens the host’s access sheet with no input, and asks again only if it changed', async () => {
961
+ const { useFiasAccess } = require('./hooks');
962
+ const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
963
+ mockBridge.request.mockResolvedValue(output({ access }));
964
+ const state = useFiasAccess('ws_1');
965
+ await settle();
966
+ mockBridge.request.mockClear();
967
+ mockBridge.request.mockResolvedValueOnce({ changed: false });
968
+ await expect(state.openAccessDialog()).resolves.toEqual({ changed: false });
969
+ expect(mockBridge.request).toHaveBeenCalledWith('client_entity_invoke', { entityId: 'ent_intg_app_access_dialog_00000', input: {} }, expect.any(Number));
970
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
971
+ mockBridge.request.mockResolvedValueOnce({ changed: true });
972
+ mockBridge.request.mockResolvedValue(output({ access }));
973
+ await expect(state.openAccessDialog()).resolves.toEqual({ changed: true });
974
+ await settle();
975
+ expect(mockBridge.request).toHaveBeenCalledTimes(3);
976
+ });
977
+ it('a host without the sheet (an older Fias) is ACCESS_UNAVAILABLE, not a bare bridge error', async () => {
978
+ const { useFiasAccess } = require('./hooks');
979
+ const { FiasBridgeError } = jest.requireActual('./bridge');
980
+ mockBridge.request.mockResolvedValue(output({ access: null }));
981
+ const state = useFiasAccess('ws_1');
982
+ await settle();
983
+ mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('no client-side executor registered', 'BRIDGE_ERROR'));
984
+ await expect(state.openAccessDialog()).rejects.toMatchObject({ code: 'ACCESS_UNAVAILABLE' });
985
+ });
986
+ it('an older dev harness without the sheet is ACCESS_UNAVAILABLE too', async () => {
987
+ const { useFiasAccess } = require('./hooks');
988
+ const { FiasBridgeError } = jest.requireActual('./bridge');
989
+ mockBridge.request.mockResolvedValue(output({ access: null }));
990
+ const state = useFiasAccess('ws_1');
991
+ await settle();
992
+ mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('client_entity_invoke: no client-side executor for ent_intg_app_access_dialog_00000 in the dev harness', 'BRIDGE_ERROR'));
993
+ await expect(state.openAccessDialog()).rejects.toMatchObject({ code: 'ACCESS_UNAVAILABLE' });
994
+ });
995
+ it('any other bridge error (e.g. a missing permission) passes through unchanged', async () => {
996
+ const { useFiasAccess } = require('./hooks');
997
+ const { FiasBridgeError } = jest.requireActual('./bridge');
998
+ mockBridge.request.mockResolvedValue(output({ access: null }));
999
+ const state = useFiasAccess('ws_1');
1000
+ await settle();
1001
+ mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('Permission denied: data:workspace not granted', 'BRIDGE_ERROR'));
1002
+ await expect(state.openAccessDialog()).rejects.toMatchObject({
1003
+ code: 'BRIDGE_ERROR',
1004
+ message: 'Permission denied: data:workspace not granted',
1005
+ });
1006
+ });
1007
+ it('the sheet’s refusal is a typed error', async () => {
1008
+ const { useFiasAccess } = require('./hooks');
1009
+ mockBridge.request.mockResolvedValue(output({ access: null }));
1010
+ const state = useFiasAccess('ws_1');
1011
+ await settle();
1012
+ mockBridge.request.mockResolvedValueOnce({
1013
+ error: { code: 'NOT_IN_WORKSPACE', message: 'Personal' },
1014
+ });
1015
+ await expect(state.openAccessDialog()).rejects.toMatchObject({ code: 'NOT_IN_WORKSPACE' });
1016
+ });
1017
+ });
1018
+ describe('useCreativeLibrary().pick (ADR-035 D8)', () => {
1019
+ const PICKED = {
1020
+ item: { itemId: 'vci_' + 'a'.repeat(32), kind: 'style', name: 'Ink', version: 2 },
1021
+ };
1022
+ it('asks the host’s picker for a kind, waiting on the person', async () => {
1023
+ const { useCreativeLibrary } = require('./hooks');
1024
+ mockBridge.request.mockResolvedValue(PICKED);
1025
+ await expect(useCreativeLibrary().pick({ kind: 'style' })).resolves.toEqual(PICKED);
1026
+ const [op, payload, timeout] = mockBridge.request.mock.calls[0];
1027
+ expect(op).toBe('client_entity_invoke');
1028
+ expect(payload).toEqual({ entityId: entity_ops_1.CREATIVE_PICKER_ENTITY_ID, input: { kind: 'style' } });
1029
+ expect(timeout).toBeGreaterThanOrEqual(5 * 60 * 1000);
1030
+ });
1031
+ it('sends nothing when no kind is asked, and passes a closed sheet through', async () => {
1032
+ const { useCreativeLibrary } = require('./hooks');
1033
+ mockBridge.request.mockResolvedValue({ canceled: true });
1034
+ await expect(useCreativeLibrary().pick()).resolves.toEqual({ canceled: true });
1035
+ expect(mockBridge.request.mock.calls[0][1]).toEqual({
1036
+ entityId: entity_ops_1.CREATIVE_PICKER_ENTITY_ID,
1037
+ input: {},
1038
+ });
1039
+ });
1040
+ it('turns a refusal result into a typed error', async () => {
1041
+ const { useCreativeLibrary } = require('./hooks');
1042
+ mockBridge.request.mockResolvedValue({
1043
+ error: { code: 'SHEET_BUSY', message: 'Another request is waiting' },
1044
+ });
1045
+ await expect(useCreativeLibrary().pick()).rejects.toMatchObject({ code: 'SHEET_BUSY' });
1046
+ });
1047
+ it('a host with no picker answers PICKER_UNAVAILABLE', async () => {
1048
+ const { useCreativeLibrary } = require('./hooks');
1049
+ const { FiasBridgeError } = jest.requireActual('./bridge');
1050
+ mockBridge.request.mockRejectedValue(new FiasBridgeError('no client-side executor registered for x', 'BRIDGE_ERROR'));
1051
+ await expect(useCreativeLibrary().pick()).rejects.toMatchObject({
1052
+ code: 'PICKER_UNAVAILABLE',
1053
+ });
1054
+ });
1055
+ });
1056
+ describe('useFiasConnections', () => {
1057
+ it('lists through the connections entity', async () => {
1058
+ const { useFiasConnections } = require('./hooks');
1059
+ const accounts = [{ connectionId: 'conn_1', provider: 'x', status: 'active' }];
1060
+ mockBridge.request.mockResolvedValue(output({ connections: accounts }));
1061
+ await expect(useFiasConnections().list()).resolves.toEqual(accounts);
1062
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.CONNECTIONS_ENTITY_ID, 'list'));
1063
+ });
1064
+ it('connects through the client-side entity, waiting on the person', async () => {
1065
+ const { useFiasConnections } = require('./hooks');
1066
+ mockBridge.request.mockResolvedValue({ connectionId: 'conn_1', provider: 'x' });
1067
+ await expect(useFiasConnections().connect('x')).resolves.toEqual({
1068
+ connectionId: 'conn_1',
1069
+ provider: 'x',
1070
+ });
1071
+ const [op, payload, timeout] = mockBridge.request.mock.calls[0];
1072
+ expect(op).toBe('client_entity_invoke');
1073
+ expect(payload).toEqual({
1074
+ entityId: entity_ops_1.CONNECTIONS_CONNECT_ENTITY_ID,
1075
+ input: { provider: 'x' },
1076
+ });
1077
+ expect(timeout).toBeGreaterThanOrEqual(5 * 60 * 1000);
1078
+ });
1079
+ it('turns a refusal result into a typed error', async () => {
1080
+ const { useFiasConnections } = require('./hooks');
1081
+ mockBridge.request.mockResolvedValue({
1082
+ error: { code: 'PERMISSION_DENIED', message: 'no connections:use' },
1083
+ });
1084
+ await expect(useFiasConnections().connect('x')).rejects.toMatchObject({
1085
+ code: 'PERMISSION_DENIED',
1086
+ });
1087
+ });
1088
+ it('releases its own access', async () => {
1089
+ const { useFiasConnections } = require('./hooks');
1090
+ mockBridge.request.mockResolvedValue(output({ released: true }));
1091
+ await useFiasConnections().release('conn_1');
1092
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.CONNECTIONS_ENTITY_ID, 'release', { connectionId: 'conn_1' }));
1093
+ });
1094
+ });
1095
+ describe('the outward guard round-trip (ADR-029 §4)', () => {
1096
+ const PACT = `pact_${'a'.repeat(32)}`;
1097
+ const publish = { connectionId: 'conn_1', posts: [{ text: 'Hi' }] };
1098
+ it('a confirmation answer asks the host, then re-sends the identical call naming the approval', async () => {
1099
+ const { useFiasX } = require('./hooks');
1100
+ mockBridge.request
1101
+ .mockResolvedValueOnce({ output: null, confirmationRequired: { pendingActionId: PACT } })
1102
+ .mockResolvedValueOnce({ approved: true })
1103
+ .mockResolvedValueOnce(output({ posts: [{ postId: '1', url: 'u' }], complete: true }));
1104
+ await expect(useFiasX().publishPost(publish)).resolves.toEqual({
1105
+ posts: [{ postId: '1', url: 'u' }],
1106
+ complete: true,
1107
+ });
1108
+ const [first, confirm, resend] = mockBridge.request.mock.calls;
1109
+ expect(first).toEqual(entityOp(entity_ops_1.X_ENTITY_ID, 'publish_post', publish));
1110
+ expect(confirm[0]).toBe('client_entity_invoke');
1111
+ expect(confirm[1]).toEqual({
1112
+ entityId: entity_ops_1.ACTION_CONFIRMATION_ENTITY_ID,
1113
+ input: { pendingActionId: PACT },
1114
+ });
1115
+ expect(confirm[2]).toBeGreaterThanOrEqual(5 * 60 * 1000);
1116
+ expect(resend[0]).toBe('entity_invoke');
1117
+ expect(resend[1]).toEqual({
1118
+ entityId: entity_ops_1.X_ENTITY_ID,
1119
+ payload: { op: 'publish_post', ...publish },
1120
+ pendingActionId: PACT,
1121
+ });
1122
+ // The approved call acts outside Fias and may take a while; it is not cut at the default.
1123
+ expect(resend[2]).toBeGreaterThanOrEqual(60 * 1000);
1124
+ });
1125
+ it('a decline rejects with USER_DECLINED and sends nothing more', async () => {
1126
+ const { useFiasX } = require('./hooks');
1127
+ mockBridge.request
1128
+ .mockResolvedValueOnce({ output: null, confirmationRequired: { pendingActionId: PACT } })
1129
+ .mockResolvedValueOnce({ approved: false });
1130
+ await expect(useFiasX().publishPost(publish)).rejects.toMatchObject({
1131
+ code: 'USER_DECLINED',
1132
+ });
1133
+ expect(mockBridge.request).toHaveBeenCalledTimes(2);
1134
+ });
1135
+ it("the host's refusal is a typed error", async () => {
1136
+ const { useFiasLinkedIn } = require('./hooks');
1137
+ mockBridge.request
1138
+ .mockResolvedValueOnce({ output: null, confirmationRequired: { pendingActionId: PACT } })
1139
+ .mockResolvedValueOnce({ error: { code: 'CONFIRM_UNAVAILABLE', message: 'no host' } });
1140
+ await expect(useFiasLinkedIn().publishPost({ connectionId: 'conn_1', text: 'Hi' })).rejects.toMatchObject({ code: 'CONFIRM_UNAVAILABLE' });
1141
+ });
1142
+ });
1143
+ describe('useFiasSchedules (ADR-029 §5)', () => {
1144
+ it('schedules one call, approved first, and lists and cancels its own', async () => {
1145
+ const { useFiasSchedules } = require('./hooks');
1146
+ const PACT = `pact_${'b'.repeat(32)}`;
1147
+ const fireAt = new Date('2030-01-01T09:00:00Z');
1148
+ const payload = { connectionId: 'conn_1', posts: [{ text: 'Later' }] };
1149
+ mockBridge.request
1150
+ .mockResolvedValueOnce({ output: null, confirmationRequired: { pendingActionId: PACT } })
1151
+ .mockResolvedValueOnce({ approved: true })
1152
+ .mockResolvedValueOnce(output({ scheduleId: 's1', fireAt: fireAt.toISOString() }));
1153
+ await expect(useFiasSchedules().schedule({
1154
+ entityId: entity_ops_1.X_ENTITY_ID,
1155
+ verb: 'publish_post',
1156
+ payload,
1157
+ fireAt,
1158
+ })).resolves.toEqual({ scheduleId: 's1', fireAt: fireAt.toISOString() });
1159
+ // The Date is sent as ISO, and the re-send names the approval.
1160
+ expect(mockBridge.request.mock.calls[0]).toEqual(entityOp(entity_ops_1.SCHEDULED_INVOCATIONS_ENTITY_ID, 'schedule', {
1161
+ entityId: entity_ops_1.X_ENTITY_ID,
1162
+ verb: 'publish_post',
1163
+ payload,
1164
+ fireAt: fireAt.toISOString(),
1165
+ }));
1166
+ expect(mockBridge.request.mock.calls[2][1]).toMatchObject({ pendingActionId: PACT });
1167
+ mockBridge.request.mockResolvedValueOnce(output({ schedules: [{ scheduleId: 's1' }] }));
1168
+ await expect(useFiasSchedules().list()).resolves.toEqual([{ scheduleId: 's1' }]);
1169
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.SCHEDULED_INVOCATIONS_ENTITY_ID, 'list', {}));
1170
+ mockBridge.request.mockResolvedValueOnce(output({ schedules: [] }));
1171
+ await useFiasSchedules().list({ limit: 100, scheduleIds: ['s1'] });
1172
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.SCHEDULED_INVOCATIONS_ENTITY_ID, 'list', { limit: 100, scheduleIds: ['s1'] }));
1173
+ mockBridge.request.mockResolvedValueOnce(output({ cancelled: true }));
1174
+ await useFiasSchedules().cancel('s1');
1175
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.SCHEDULED_INVOCATIONS_ENTITY_ID, 'cancel', { scheduleId: 's1' }));
1176
+ });
1177
+ });
1178
+ describe('useFiasX / useFiasLinkedIn reads', () => {
1179
+ it('unwrap each read through its entity verb', async () => {
1180
+ const { useFiasX, useFiasLinkedIn } = require('./hooks');
1181
+ mockBridge.request.mockResolvedValueOnce(output({ users: [{ id: '1' }] }));
1182
+ await expect(useFiasX().searchUsers('conn_1', 'acme')).resolves.toEqual([{ id: '1' }]);
1183
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.X_ENTITY_ID, 'search_users', { connectionId: 'conn_1', query: 'acme' }));
1184
+ mockBridge.request.mockResolvedValueOnce(output({ organizations: [{ id: '2' }] }));
1185
+ await expect(useFiasLinkedIn().searchOrganizations('conn_1', 'acme')).resolves.toEqual([
1186
+ { id: '2' },
1187
+ ]);
1188
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.LINKEDIN_ENTITY_ID, 'search_organizations', {
1189
+ connectionId: 'conn_1',
1190
+ query: 'acme',
1191
+ }));
1192
+ });
1193
+ });
766
1194
  describe('NLLB translation entity', () => {
767
1195
  it('exposes the NLLB_LANGUAGES picker list keyed by FLORES code', () => {
768
1196
  const { NLLB_LANGUAGES } = require('./hooks');
@@ -1049,6 +1477,7 @@ describe('SDK hooks', () => {
1049
1477
  __esModule: true,
1050
1478
  getBridge: () => mockSingletonBridge,
1051
1479
  FiasBridge: jest.fn(),
1480
+ FiasBridgeError: jest.requireActual('./bridge').FiasBridgeError,
1052
1481
  resetBridge: jest.fn(),
1053
1482
  }));
1054
1483
  jest.doMock('react', () => require('react'));
@@ -1174,6 +1603,136 @@ describe('SDK hooks', () => {
1174
1603
  expect(second.url).toBe('https://example.com/new');
1175
1604
  expect(mockBridge.request).toHaveBeenCalledTimes(2);
1176
1605
  });
1606
+ it('a changed access answer forgets cached download URLs (ADR-034)', async () => {
1607
+ const { useVaultDocuments, useFiasAccess } = freshHooks();
1608
+ const api = useVaultDocuments();
1609
+ mockBridge.request.mockResolvedValueOnce({
1610
+ url: 'https://example.com/minted-under-old-access',
1611
+ expiresAt: Date.now() + 10 * 60000,
1612
+ });
1613
+ await api.getDownloadUrl('file_c');
1614
+ const answer = (permissions) => ({
1615
+ output: {
1616
+ access: {
1617
+ mode: 'roles',
1618
+ workspaceId: 'ws_1',
1619
+ tier: 'member',
1620
+ expiresAt: null,
1621
+ roles: [],
1622
+ permissions,
1623
+ },
1624
+ },
1625
+ });
1626
+ const settle = () => new Promise((r) => setTimeout(r, 0));
1627
+ mockBridge.request.mockResolvedValueOnce(answer(['ledger.read']));
1628
+ useFiasAccess('ws_1');
1629
+ await settle();
1630
+ // The same answer again keeps the cache…
1631
+ mockBridge.request.mockResolvedValueOnce(answer(['ledger.read']));
1632
+ const subscriptions = mockBridge.onAccessUpdate.mock.calls;
1633
+ const listener = subscriptions[subscriptions.length - 1][0];
1634
+ listener();
1635
+ await settle();
1636
+ mockBridge.request.mockClear();
1637
+ await api.getDownloadUrl('file_c');
1638
+ expect(mockBridge.request).not.toHaveBeenCalled();
1639
+ // …a different one does not.
1640
+ mockBridge.request.mockResolvedValueOnce(answer([]));
1641
+ listener();
1642
+ await settle();
1643
+ mockBridge.request.mockClear();
1644
+ mockBridge.request.mockResolvedValueOnce({
1645
+ url: 'https://example.com/fresh',
1646
+ expiresAt: Date.now() + 10 * 60000,
1647
+ });
1648
+ await expect(api.getDownloadUrl('file_c')).resolves.toMatchObject({
1649
+ url: 'https://example.com/fresh',
1650
+ });
1651
+ });
1652
+ it('losing the workspace (NOT_A_MEMBER) forgets cached download URLs too (ADR-034)', async () => {
1653
+ const { useVaultDocuments, useFiasAccess } = freshHooks();
1654
+ const { FiasBridgeError } = jest.requireActual('./bridge');
1655
+ const api = useVaultDocuments();
1656
+ mockBridge.request.mockResolvedValueOnce({
1657
+ url: 'https://example.com/old',
1658
+ expiresAt: Date.now() + 10 * 60000,
1659
+ });
1660
+ await api.getDownloadUrl('file_d');
1661
+ const settle = () => new Promise((r) => setTimeout(r, 0));
1662
+ mockBridge.request.mockResolvedValueOnce({
1663
+ output: { access: { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null } },
1664
+ });
1665
+ useFiasAccess('ws_1');
1666
+ await settle();
1667
+ mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('gone', 'NOT_A_MEMBER'));
1668
+ const subscriptions = mockBridge.onAccessUpdate.mock.calls;
1669
+ subscriptions[subscriptions.length - 1][0]();
1670
+ await settle();
1671
+ mockBridge.request.mockClear();
1672
+ mockBridge.request.mockResolvedValueOnce({
1673
+ url: 'https://example.com/new',
1674
+ expiresAt: Date.now() + 10 * 60000,
1675
+ });
1676
+ await expect(api.getDownloadUrl('file_d')).resolves.toMatchObject({
1677
+ url: 'https://example.com/new',
1678
+ });
1679
+ });
1680
+ it('a remounted access hook still forgets the caches — on no access, and on a changed answer', async () => {
1681
+ const { useVaultDocuments, useFiasAccess } = freshHooks();
1682
+ const { FiasBridgeError } = jest.requireActual('./bridge');
1683
+ const api = useVaultDocuments();
1684
+ const settle = () => new Promise((r) => setTimeout(r, 0));
1685
+ const cacheUrl = async (documentId, url) => {
1686
+ mockBridge.request.mockResolvedValueOnce({ url, expiresAt: Date.now() + 600000 });
1687
+ await api.getDownloadUrl(documentId);
1688
+ };
1689
+ const fresh = async (documentId, url) => {
1690
+ mockBridge.request.mockClear();
1691
+ mockBridge.request.mockResolvedValueOnce({ url, expiresAt: Date.now() + 600000 });
1692
+ await expect(api.getDownloadUrl(documentId)).resolves.toMatchObject({ url });
1693
+ };
1694
+ // A brand-new hook whose FIRST answer is "no access" (the old one unmounted).
1695
+ await cacheUrl('file_f', 'https://example.com/old');
1696
+ mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('gone', 'NOT_A_MEMBER'));
1697
+ useFiasAccess('ws_9');
1698
+ await settle();
1699
+ await fresh('file_f', 'https://example.com/new');
1700
+ // One hook sees an answer; a later, new hook sees a different one.
1701
+ const answer = (tier) => ({
1702
+ output: { access: { mode: 'tier', workspaceId: 'ws_8', tier, expiresAt: null } },
1703
+ });
1704
+ mockBridge.request.mockResolvedValueOnce(answer('admin'));
1705
+ useFiasAccess('ws_8');
1706
+ await settle();
1707
+ await cacheUrl('file_g', 'https://example.com/old-g');
1708
+ // The first hook unmounts (instances of one workspace share an answer
1709
+ // while any is mounted), then a new one mounts.
1710
+ capturedEffectCleanup?.();
1711
+ mockBridge.request.mockResolvedValueOnce(answer('viewer'));
1712
+ useFiasAccess('ws_8');
1713
+ await settle();
1714
+ await fresh('file_g', 'https://example.com/new-g');
1715
+ });
1716
+ it('a fetch already in flight when the caches are forgotten does not refill them', async () => {
1717
+ const hooks = freshHooks();
1718
+ const api = hooks.useVaultDocuments();
1719
+ let answer;
1720
+ mockBridge.request.mockImplementationOnce(() => new Promise((r) => (answer = r)));
1721
+ const pending = api.getDownloadUrl('file_e');
1722
+ hooks.clearSdkReadCaches();
1723
+ answer({
1724
+ url: 'https://example.com/minted-before',
1725
+ expiresAt: new Date(Date.now() + 600000).toISOString(),
1726
+ });
1727
+ await pending;
1728
+ mockBridge.request.mockResolvedValueOnce({
1729
+ url: 'https://example.com/after',
1730
+ expiresAt: new Date(Date.now() + 600000).toISOString(),
1731
+ });
1732
+ await expect(api.getDownloadUrl('file_e')).resolves.toMatchObject({
1733
+ url: 'https://example.com/after',
1734
+ });
1735
+ });
1177
1736
  it('getDownloadUrl dedups concurrent in-flight calls', async () => {
1178
1737
  const { useVaultDocuments } = freshHooks();
1179
1738
  const api = useVaultDocuments();
@@ -1535,6 +2094,47 @@ describe('SDK hooks', () => {
1535
2094
  data: { score: 100 },
1536
2095
  }));
1537
2096
  });
2097
+ it('put with a null key creates: no key is sent, the audience is, and the server’s key comes back (ADR-034 phase 2)', async () => {
2098
+ const { useFiasDataStore } = require('./hooks');
2099
+ const ds = useFiasDataStore();
2100
+ mockBridge.request.mockResolvedValueOnce(output({ key: 'rec_0123456789abcdef0123456789abcdef', version: 1, audience: 'board' }));
2101
+ const result = await ds.put('papers', null, { t: 1 }, { workspaceId: 'ws_1', audience: 'board' });
2102
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'put', {
2103
+ collection: 'papers',
2104
+ data: { t: 1 },
2105
+ workspaceId: 'ws_1',
2106
+ audience: 'board',
2107
+ }));
2108
+ expect(result).toEqual({
2109
+ key: 'rec_0123456789abcdef0123456789abcdef',
2110
+ version: 1,
2111
+ audience: 'board',
2112
+ });
2113
+ });
2114
+ it('create is a keyless put; a host that predates server keys is refused, never guessed at', async () => {
2115
+ const { useFiasDataStore } = require('./hooks');
2116
+ const ds = useFiasDataStore();
2117
+ mockBridge.request.mockResolvedValueOnce(output({ key: 'rec_1', version: 1, audience: 'staff' }));
2118
+ await expect(ds.create('papers', { t: 1 }, { workspaceId: 'ws_1', audience: 'staff' })).resolves.toMatchObject({ key: 'rec_1', audience: 'staff' });
2119
+ mockBridge.request.mockResolvedValueOnce(output({ version: 1 }));
2120
+ await expect(ds.create('papers', { t: 1 }, { workspaceId: 'ws_1', audience: 'staff' })).rejects.toMatchObject({ code: 'AUDIENCES_UNAVAILABLE' });
2121
+ });
2122
+ it('get, query and search forward an audience only when one is named', async () => {
2123
+ const { useFiasDataStore } = require('./hooks');
2124
+ const ds = useFiasDataStore();
2125
+ mockBridge.request.mockResolvedValue(output({ data: null, documents: [], matches: [] }));
2126
+ await ds.get('papers', 'k', { workspaceId: 'ws_1', audience: 'staff' });
2127
+ await ds.query('papers', {}, { workspaceId: 'ws_1', audience: 'staff' });
2128
+ await ds.search('papers', 'q', {}, { workspaceId: 'ws_1', audience: 'staff' });
2129
+ await ds.get('papers', 'k', { workspaceId: 'ws_1' });
2130
+ const payloads = mockBridge.request.mock.calls.map((c) => c[1].payload);
2131
+ expect(payloads.slice(0, 3).map((p) => p.audience)).toEqual([
2132
+ 'staff',
2133
+ 'staff',
2134
+ 'staff',
2135
+ ]);
2136
+ expect(payloads[3]).not.toHaveProperty('audience');
2137
+ });
1538
2138
  it('get unwraps { data } from the bridge envelope', async () => {
1539
2139
  const { useFiasDataStore } = require('./hooks');
1540
2140
  const ds = useFiasDataStore();
@@ -2311,6 +2911,19 @@ describe('useDataSubscription (ADR-012 Phase M4)', () => {
2311
2911
  expect(onInvalidate).toHaveBeenCalledTimes(1);
2312
2912
  expect(mockSetState).toHaveBeenCalledWith({ status: 'active' });
2313
2913
  });
2914
+ it('names the audience to watch, where a collection has audiences (ADR-034 phase 2)', async () => {
2915
+ subscribed();
2916
+ const { useDataSubscription } = require('./hooks');
2917
+ useDataSubscription('papers', jest.fn().mockResolvedValue(undefined), {
2918
+ workspaceId: 'ws_1',
2919
+ audience: 'board',
2920
+ });
2921
+ await flush();
2922
+ expect(mockBridge.request).toHaveBeenCalledWith('entity_invoke', {
2923
+ entityId: 'ent_intg_plugin_datastore_000000',
2924
+ payload: { op: 'subscribe', collection: 'papers', workspaceId: 'ws_1', audience: 'board' },
2925
+ });
2926
+ });
2314
2927
  it('runs onInvalidate single-flight with exactly one trailing call', async () => {
2315
2928
  subscribed();
2316
2929
  let resolveFirst = () => undefined;
@@ -2358,6 +2971,95 @@ describe('useDataSubscription (ADR-012 Phase M4)', () => {
2358
2971
  expect(onInvalidate).not.toHaveBeenCalled();
2359
2972
  });
2360
2973
  });
2974
+ describe('useEntitySubscription (ADR-012 Phase M4 — any subscribe-kind verb)', () => {
2975
+ const flush = async () => {
2976
+ await Promise.resolve();
2977
+ await Promise.resolve();
2978
+ await Promise.resolve();
2979
+ };
2980
+ let mockBridge;
2981
+ beforeEach(() => {
2982
+ mockBridge = createMockBridge();
2983
+ mockContextValue = mockBridge;
2984
+ mockSingletonBridge = mockBridge;
2985
+ });
2986
+ it('forwards the entity id and the verb payload verbatim, then refetches once live', async () => {
2987
+ mockBridge.request.mockResolvedValue({
2988
+ output: { kind: 'subscription', subscriptionId: 'lsub_chat' },
2989
+ });
2990
+ const onInvalidate = jest.fn().mockResolvedValue(undefined);
2991
+ const { useEntitySubscription } = require('./hooks');
2992
+ useEntitySubscription('ent_intg_litcraft_player_0000000', { op: 'subscribe_guild_chat' }, onInvalidate);
2993
+ await flush();
2994
+ expect(mockBridge.request).toHaveBeenCalledWith('entity_invoke', {
2995
+ entityId: 'ent_intg_litcraft_player_0000000',
2996
+ payload: { op: 'subscribe_guild_chat' },
2997
+ });
2998
+ expect(mockBridge.onSubscriptionEvent).toHaveBeenCalledWith('lsub_chat', expect.any(Function));
2999
+ expect(onInvalidate).toHaveBeenCalledTimes(1);
3000
+ expect(mockSetState).toHaveBeenCalledWith({ status: 'active' });
3001
+ });
3002
+ it('enabled: false subscribes to nothing and reports unsubscribed', async () => {
3003
+ const onInvalidate = jest.fn();
3004
+ const { useEntitySubscription } = require('./hooks');
3005
+ useEntitySubscription('ent_x', { op: 'subscribe_x' }, onInvalidate, { enabled: false });
3006
+ await flush();
3007
+ expect(mockBridge.request).not.toHaveBeenCalled();
3008
+ expect(onInvalidate).not.toHaveBeenCalled();
3009
+ expect(mockSetState).toHaveBeenCalledWith({ status: 'ended', endedReason: 'unsubscribed' });
3010
+ });
3011
+ it('sends the payload exactly as given (key order is identity-neutral, not rewritten)', async () => {
3012
+ mockBridge.request.mockResolvedValue({
3013
+ output: { kind: 'subscription', subscriptionId: 'lsub_1' },
3014
+ });
3015
+ const { useEntitySubscription } = require('./hooks');
3016
+ useEntitySubscription('ent_x', { workspaceId: 'ws_1', op: 'subscribe_x' }, jest.fn());
3017
+ await flush();
3018
+ expect(mockBridge.request).toHaveBeenCalledWith('entity_invoke', {
3019
+ entityId: 'ent_x',
3020
+ payload: { op: 'subscribe_x', workspaceId: 'ws_1' },
3021
+ });
3022
+ });
3023
+ it('minRefetchIntervalMs caps refetches to one per interval with one trailing run', async () => {
3024
+ jest.useFakeTimers();
3025
+ try {
3026
+ mockBridge.request.mockResolvedValue({
3027
+ output: { kind: 'subscription', subscriptionId: 'lsub_1' },
3028
+ });
3029
+ const onInvalidate = jest.fn().mockResolvedValue(undefined);
3030
+ const { useEntitySubscription } = require('./hooks');
3031
+ useEntitySubscription('ent_x', { op: 'subscribe_x' }, onInvalidate, {
3032
+ minRefetchIntervalMs: 2000,
3033
+ });
3034
+ await flush();
3035
+ expect(onInvalidate).toHaveBeenCalledTimes(1); // the subscribe-then-fetch run
3036
+ const eventCb = mockBridge.onSubscriptionEvent.mock.calls[0][1];
3037
+ eventCb();
3038
+ eventCb();
3039
+ eventCb();
3040
+ await flush();
3041
+ expect(onInvalidate).toHaveBeenCalledTimes(1); // inside the interval: deferred, not dropped
3042
+ await jest.advanceTimersByTimeAsync(2000);
3043
+ expect(onInvalidate).toHaveBeenCalledTimes(2); // exactly one trailing run at the interval's end
3044
+ capturedEffectCleanup?.();
3045
+ }
3046
+ finally {
3047
+ jest.useRealTimers();
3048
+ }
3049
+ });
3050
+ it('a rejected handshake ends with transport_error and never refetches', async () => {
3051
+ mockBridge.request.mockRejectedValue(new Error('NOT_A_MEMBER'));
3052
+ const onInvalidate = jest.fn();
3053
+ const { useEntitySubscription } = require('./hooks');
3054
+ useEntitySubscription('ent_x', { op: 'subscribe_x' }, onInvalidate);
3055
+ await flush();
3056
+ expect(mockSetState).toHaveBeenCalledWith({
3057
+ status: 'ended',
3058
+ endedReason: 'transport_error',
3059
+ });
3060
+ expect(onInvalidate).not.toHaveBeenCalled();
3061
+ });
3062
+ });
2361
3063
  describe('useFiasAIActions', () => {
2362
3064
  let mockBridge;
2363
3065
  beforeEach(() => {