@verentis/sdk 0.1.1 → 0.2.3

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/README.md CHANGED
@@ -10,6 +10,53 @@ npm install @verentis/sdk
10
10
 
11
11
  ## Quick Start
12
12
 
13
+ ### Hosted header actions (SDK 0.2)
14
+
15
+ Applications register buttons after `await client.whenReady()`. The workspace owns their
16
+ presentation; handlers execute inside the application with its existing delegated token.
17
+
18
+ ```ts
19
+ const save = client.bridge!.registerAction({
20
+ id: 'document.save',
21
+ label: 'Save',
22
+ icon: 'lucide:save',
23
+ variant: 'default',
24
+ scopes: ['node.file.read', 'node.node.create', 'node.node.update', 'node.journal.create'],
25
+ enabled: false,
26
+ }, async () => {
27
+ await persistDocument()
28
+ })
29
+
30
+ // Pass the complete descriptor when document state changes.
31
+ save.update({ id: 'document.save', label: 'Save', scopes: [
32
+ 'node.file.read', 'node.node.create', 'node.node.update', 'node.journal.create',
33
+ ], enabled: true })
34
+ save.dispose()
35
+ ```
36
+
37
+ Use stable app-local IDs, up to 16 actions, Lucide icon identifiers, and explicit `scopes`
38
+ (`[]` for client-only operations). `enabled`, `visible`, `busy`, and `disabledReason` communicate
39
+ state without serializing handlers. The host checks required scopes against the delegated grant;
40
+ registration never expands permissions. Throw on failure so the host can display it.
41
+
42
+ For clipboard actions, set `kind: 'clipboard'` and return `{ clipboardText: source }`. The host starts
43
+ the clipboard write in the user's click gesture while awaiting the text, preserving browser
44
+ user-activation requirements. For operations that navigate after completion, return
45
+ `{ navigate: { path: '/applications', newTab: false } }` instead of navigating before the result.
46
+
47
+ The protocol uses session-bound `verentis:actions:set`, `verentis:action:invoke`, and
48
+ `verentis:action:result` messages. Reload/navigation disposes pending work; an unanswered invocation
49
+ times out as **outcome unknown**, never an automatic retry. App handlers must not report successful
50
+ completion before their mutation finishes.
51
+
52
+ Migrated actions are hosted only: there is no legacy-host or standalone-toolbar fallback.
53
+ Fullscreen apps can hide the host header only when they register no actions. Keep specialized
54
+ controls (zoom, editing modes, contextual forms) within the app.
55
+
56
+ Release the 0.2 SDK before deploying apps that require `^0.2.0`, together with the matching host.
57
+ Local sibling-repository development can link the built SDK; published applications must resolve
58
+ the released SDK from npm.
59
+
13
60
  ### Standalone mode (local development)
14
61
 
15
62
  ```ts
package/dist/index.cjs CHANGED
@@ -30,6 +30,14 @@ var BridgeAuth = class {
30
30
  _refreshTimer = null;
31
31
  _pendingRefresh = null;
32
32
  _messageHandler;
33
+ _requestId = null;
34
+ _refreshReject = null;
35
+ _requestTimer = null;
36
+ _destroyed = false;
37
+ setHostOrigin(origin) {
38
+ if (!origin || origin === "*" || origin === "null") throw new Error("A trusted host origin is required.");
39
+ this.hostOrigin = origin;
40
+ }
33
41
  /** Set the initial token received from the host's `verentis:init` message */
34
42
  setToken(info) {
35
43
  this._token = info.accessToken;
@@ -44,7 +52,9 @@ var BridgeAuth = class {
44
52
  return `Bearer ${this._token}`;
45
53
  }
46
54
  destroy() {
55
+ this._destroyed = true;
47
56
  if (this._refreshTimer) clearTimeout(this._refreshTimer);
57
+ this.failRefresh("Authentication has been destroyed.");
48
58
  if (typeof window !== "undefined") {
49
59
  window.removeEventListener("message", this._messageHandler);
50
60
  }
@@ -54,11 +64,16 @@ var BridgeAuth = class {
54
64
  }
55
65
  _refreshResolve = null;
56
66
  requestRefresh() {
67
+ if (this._destroyed || this.hostOrigin === "*")
68
+ return Promise.reject(new Error("Authentication is not initialized."));
57
69
  if (this._pendingRefresh) return this._pendingRefresh;
58
70
  const requestId = crypto.randomUUID();
59
- this._pendingRefresh = new Promise((resolve) => {
71
+ this._requestId = requestId;
72
+ this._pendingRefresh = new Promise((resolve, reject) => {
60
73
  this._refreshResolve = resolve;
74
+ this._refreshReject = reject;
61
75
  });
76
+ this._requestTimer = setTimeout(() => this.failRefresh("Token refresh timed out."), 2e4);
62
77
  const message = {
63
78
  type: "verentis:token:refresh",
64
79
  version: 1,
@@ -68,21 +83,35 @@ var BridgeAuth = class {
68
83
  return this._pendingRefresh;
69
84
  }
70
85
  handleMessage(event) {
71
- if (event.origin !== this.hostOrigin) return;
86
+ if (this._destroyed || event.origin !== this.hostOrigin || event.source !== window.parent) return;
72
87
  const data = event.data;
73
- if (data?.type !== "verentis:token:refreshed") return;
88
+ if (data?.version !== 1 || data?.type !== "verentis:token:refreshed" || !this._requestId || data.requestId !== this._requestId || typeof data.token?.accessToken !== "string" || !Number.isFinite(Date.parse(data.token.expiresAt)) || Date.parse(data.token.expiresAt) <= Date.now() + SKEW_MS) return;
89
+ if (this._requestTimer) clearTimeout(this._requestTimer);
90
+ this._requestTimer = null;
91
+ this._requestId = null;
74
92
  this._token = data.token.accessToken;
75
93
  this._expiresAt = new Date(data.token.expiresAt).getTime();
76
94
  this._pendingRefresh = null;
77
95
  this._refreshResolve?.();
78
96
  this._refreshResolve = null;
97
+ this._refreshReject = null;
79
98
  this.scheduleRefresh();
80
99
  }
81
100
  scheduleRefresh() {
82
101
  if (this._refreshTimer) clearTimeout(this._refreshTimer);
83
102
  if (this._expiresAt === 0) return;
84
103
  const delay2 = Math.max(0, this._expiresAt - Date.now() - SKEW_MS);
85
- this._refreshTimer = setTimeout(() => void this.requestRefresh(), delay2);
104
+ this._refreshTimer = setTimeout(() => void this.requestRefresh().catch(() => {
105
+ }), delay2);
106
+ }
107
+ failRefresh(message) {
108
+ if (this._requestTimer) clearTimeout(this._requestTimer);
109
+ this._requestTimer = null;
110
+ this._requestId = null;
111
+ this._pendingRefresh = null;
112
+ this._refreshReject?.(new Error(message));
113
+ this._refreshReject = null;
114
+ this._refreshResolve = null;
86
115
  }
87
116
  };
88
117
 
@@ -745,23 +774,41 @@ var SettingsModule = class {
745
774
  }
746
775
  };
747
776
 
777
+ // src/transport/header-actions.ts
778
+ function isHeaderAction(value) {
779
+ if (!value || typeof value !== "object") return false;
780
+ const a = value;
781
+ return typeof a.id === "string" && /^[a-z][a-z0-9.-]{0,63}$/.test(a.id) && typeof a.label === "string" && a.label.trim().length > 0 && a.label.length <= 100 && Array.isArray(a.scopes) && a.scopes.length <= 32 && a.scopes.every((s) => typeof s === "string" && /^[a-zA-Z0-9._:-]{1,128}$/.test(s)) && (a.icon === void 0 || typeof a.icon === "string" && /^lucide:[a-z0-9-]{1,64}$/.test(a.icon)) && (a.variant === void 0 || ["default", "outline", "destructive"].includes(String(a.variant))) && ["enabled", "visible", "busy"].every((key) => a[key] === void 0 || typeof a[key] === "boolean") && (a.disabledReason === void 0 || typeof a.disabledReason === "string" && a.disabledReason.length <= 200) && (a.kind === void 0 || a.kind === "clipboard");
782
+ }
783
+
748
784
  // src/transport/bridge.transport.ts
749
785
  var Bridge = class {
750
- listeners = /* @__PURE__ */ new Map();
751
- _initResolve;
752
- _initPromise;
753
- _hostOrigin = null;
754
- _messageHandler;
755
- _destroyed = false;
756
- constructor() {
757
- this._initPromise = new Promise((resolve) => {
786
+ constructor(expectedHostOrigin) {
787
+ this.expectedHostOrigin = expectedHostOrigin;
788
+ this._initPromise = new Promise((resolve, reject) => {
758
789
  this._initResolve = resolve;
790
+ this._initReject = reject;
791
+ });
792
+ void this._initPromise.catch(() => {
759
793
  });
760
794
  this._messageHandler = this.handleMessage.bind(this);
761
795
  if (typeof window !== "undefined") {
762
796
  window.addEventListener("message", this._messageHandler);
763
797
  }
764
798
  }
799
+ expectedHostOrigin;
800
+ listeners = /* @__PURE__ */ new Map();
801
+ _initResolve;
802
+ _initReject;
803
+ _initTimer;
804
+ _initPromise;
805
+ _hostOrigin = null;
806
+ _messageHandler;
807
+ _destroyed = false;
808
+ sessionId = null;
809
+ actions = /* @__PURE__ */ new Map();
810
+ requests = /* @__PURE__ */ new Set();
811
+ backendRequests = /* @__PURE__ */ new Map();
765
812
  /** Post `verentis:ready` to the host to start the handshake */
766
813
  sendReady(capabilities) {
767
814
  console.info("[Verentis SDK] Sending verentis:ready to host");
@@ -774,6 +821,12 @@ var Bridge = class {
774
821
  }
775
822
  /** Wait for the host to send `verentis:init` with context and token */
776
823
  waitForInit() {
824
+ if (!this._hostOrigin && !this._destroyed && !this._initTimer) {
825
+ this._initTimer = setTimeout(() => {
826
+ this._initReject(new Error("Workspace handshake timed out."));
827
+ this.destroy();
828
+ }, 2e4);
829
+ }
777
830
  return this._initPromise;
778
831
  }
779
832
  /** Get the host origin (available after init) */
@@ -782,6 +835,7 @@ var Bridge = class {
782
835
  }
783
836
  /** Send a message to the host */
784
837
  postToHost(message) {
838
+ if (this._destroyed) throw new Error("Bridge has been destroyed.");
785
839
  if (!this._hostOrigin) {
786
840
  throw new Error("Bridge not initialized: host origin unknown. Wait for init first.");
787
841
  }
@@ -816,6 +870,91 @@ var Bridge = class {
816
870
  setDirty(isDirty) {
817
871
  this.postToHost({ type: "verentis:dirty", version: 1, isDirty });
818
872
  }
873
+ /** The host, not the app, selects the installation, file, branch and permission ceiling. */
874
+ requestBackendCredential() {
875
+ if (this._destroyed || !this.sessionId || !this._hostOrigin)
876
+ return Promise.reject(new Error("An initialized workspace host is required."));
877
+ if (this.backendRequests.size >= 4)
878
+ return Promise.reject(new Error("A backend credential request is already pending."));
879
+ const requestId = crypto.randomUUID();
880
+ return new Promise((resolve, reject) => {
881
+ const timer = setTimeout(() => {
882
+ this.backendRequests.delete(requestId);
883
+ reject(new Error("Backend authorization timed out."));
884
+ }, 2e4);
885
+ this.backendRequests.set(requestId, { resolve, reject, timer });
886
+ try {
887
+ this.postToHost({ type: "verentis:backend:request", version: 1, requestId, sessionId: this.sessionId });
888
+ } catch {
889
+ clearTimeout(timer);
890
+ this.backendRequests.delete(requestId);
891
+ reject(new Error("The workspace host is unavailable."));
892
+ }
893
+ });
894
+ }
895
+ registerAction(descriptor, handler) {
896
+ if (!this.sessionId || this._destroyed) throw new Error("Header actions require an initialized workspace host.");
897
+ if (!isHeaderAction(descriptor)) throw new Error("Invalid header action.");
898
+ if (this.actions.has(descriptor.id)) throw new Error(`Action '${descriptor.id}' is already registered.`);
899
+ if (this.actions.size >= 16) throw new Error("At most 16 header actions may be registered.");
900
+ const action = { descriptor: { ...descriptor, scopes: [...descriptor.scopes] }, handler, busy: false };
901
+ this.actions.set(descriptor.id, action);
902
+ this.publishActions();
903
+ return {
904
+ update: (next) => {
905
+ if (this.actions.get(descriptor.id) !== action) throw new Error("Action has been disposed.");
906
+ if (!isHeaderAction(next) || next.id !== descriptor.id) throw new Error("Invalid header action update.");
907
+ action.descriptor = { ...next, scopes: [...next.scopes] };
908
+ this.publishActions();
909
+ },
910
+ dispose: () => {
911
+ if (this.actions.get(descriptor.id) !== action) return;
912
+ this.actions.delete(descriptor.id);
913
+ this.publishActions();
914
+ }
915
+ };
916
+ }
917
+ publishActions() {
918
+ if (!this.sessionId || this._destroyed) return;
919
+ this.postToHost({
920
+ type: "verentis:actions:set",
921
+ version: 1,
922
+ sessionId: this.sessionId,
923
+ actions: [...this.actions.values()].map((a) => ({ ...a.descriptor, busy: a.busy || a.descriptor.busy }))
924
+ });
925
+ }
926
+ async invokeAction(message) {
927
+ if (message.sessionId !== this.sessionId || typeof message.requestId !== "string" || message.requestId.length > 128 || !message.requestId || this.requests.has(message.requestId)) return;
928
+ this.requests.add(message.requestId);
929
+ if (this.requests.size > 1e3) this.requests.delete(this.requests.values().next().value);
930
+ const action = this.actions.get(message.actionId);
931
+ const reply = {
932
+ type: "verentis:action:result",
933
+ version: 1,
934
+ sessionId: message.sessionId,
935
+ requestId: message.requestId,
936
+ actionId: message.actionId
937
+ };
938
+ if (!action || action.busy || action.descriptor.busy || action.descriptor.enabled === false || action.descriptor.visible === false) {
939
+ this.postToHost({ ...reply, error: "Action is unavailable." });
940
+ return;
941
+ }
942
+ action.busy = true;
943
+ this.publishActions();
944
+ try {
945
+ const result = await action.handler();
946
+ if (!this._destroyed && this.sessionId === message.sessionId) {
947
+ this.postToHost({ ...reply, result: result || void 0 });
948
+ }
949
+ } catch (error) {
950
+ if (!this._destroyed && this.sessionId === message.sessionId) {
951
+ this.postToHost({ ...reply, error: (error instanceof Error ? error.message : "Action failed.").slice(0, 500) });
952
+ }
953
+ } finally {
954
+ action.busy = false;
955
+ this.publishActions();
956
+ }
957
+ }
819
958
  /** Subscribe to a specific message type from the host */
820
959
  on(type, listener) {
821
960
  if (!this.listeners.has(type)) {
@@ -829,7 +968,17 @@ var Bridge = class {
829
968
  /** Clean up event listeners */
830
969
  destroy() {
831
970
  this._destroyed = true;
971
+ clearTimeout(this._initTimer);
972
+ this._initTimer = void 0;
973
+ if (!this._hostOrigin) this._initReject(new Error("Bridge has been destroyed."));
974
+ this.actions.clear();
975
+ this.requests.clear();
832
976
  this.listeners.clear();
977
+ for (const pending of this.backendRequests.values()) {
978
+ clearTimeout(pending.timer);
979
+ pending.reject(new Error("Bridge has been destroyed."));
980
+ }
981
+ this.backendRequests.clear();
833
982
  if (typeof window !== "undefined") {
834
983
  window.removeEventListener("message", this._messageHandler);
835
984
  }
@@ -837,9 +986,16 @@ var Bridge = class {
837
986
  handleMessage(event) {
838
987
  if (this._destroyed) return;
839
988
  const data = event.data;
840
- if (!data?.type?.startsWith("verentis:")) return;
989
+ if (event.source !== window.parent || data?.version !== 1 || typeof data?.type !== "string" || !data.type.startsWith("verentis:")) return;
990
+ if (this._hostOrigin && event.origin !== this._hostOrigin) return;
991
+ if (this.expectedHostOrigin && event.origin !== this.expectedHostOrigin) return;
841
992
  if (data.type === "verentis:init") {
993
+ if (this._hostOrigin) return;
994
+ if (!data.context?.workspace?.id || !data.token || event.origin === "null") return;
995
+ clearTimeout(this._initTimer);
996
+ this._initTimer = void 0;
842
997
  this._hostOrigin = event.origin;
998
+ this.sessionId = typeof data.sessionId === "string" ? data.sessionId : null;
843
999
  console.info("[Verentis SDK] Received verentis:init from host", {
844
1000
  origin: event.origin,
845
1001
  workspaceId: data.context.workspace.id,
@@ -848,6 +1004,22 @@ var Bridge = class {
848
1004
  const initData = data;
849
1005
  this._initResolve({ context: initData.context, token: initData.token });
850
1006
  }
1007
+ if (data.type === "verentis:backend:response") {
1008
+ if (!this._hostOrigin || data.sessionId !== this.sessionId) return;
1009
+ const pending = this.backendRequests.get(data.requestId);
1010
+ if (!pending) return;
1011
+ clearTimeout(pending.timer);
1012
+ this.backendRequests.delete(data.requestId);
1013
+ const value = data.credential;
1014
+ if (data.error || !value || typeof value.launchCredential !== "string" || value.launchCredential.length > 512 || !value.launchCredential || typeof value.delegationId !== "string" || typeof value.backendClientId !== "string" || !Number.isFinite(Date.parse(value.expiresAt)) || Date.parse(value.expiresAt) <= Date.now())
1015
+ pending.reject(new Error("Backend authorization was denied or is not configured."));
1016
+ else pending.resolve(value);
1017
+ return;
1018
+ }
1019
+ if (data.type === "verentis:action:invoke") {
1020
+ void this.invokeAction(data);
1021
+ return;
1022
+ }
851
1023
  const typeListeners = this.listeners.get(data.type);
852
1024
  if (typeListeners) {
853
1025
  for (const listener of typeListeners) {
@@ -1029,7 +1201,7 @@ var VerentisClient = class {
1029
1201
  this.bridge.sendReady();
1030
1202
  const { context, token } = await this.bridge.waitForInit();
1031
1203
  const hostOrigin = this.bridge.hostOrigin;
1032
- Object.assign(bridgeAuth, new BridgeAuth(hostOrigin));
1204
+ bridgeAuth.setHostOrigin(hostOrigin);
1033
1205
  bridgeAuth.setToken(token);
1034
1206
  this.context.setFromAppContext(context);
1035
1207
  const http = new HttpTransport(
@@ -1116,6 +1288,7 @@ exports.TokenAuth = TokenAuth;
1116
1288
  exports.VerentisClient = VerentisClient;
1117
1289
  exports.createVerentisClient = createVerentisClient;
1118
1290
  exports.findBestMatch = findBestMatch;
1291
+ exports.isHeaderAction = isHeaderAction;
1119
1292
  exports.isTerminalStatus = isTerminalStatus;
1120
1293
  exports.matchMimeType = matchMimeType;
1121
1294
  //# sourceMappingURL=index.cjs.map