@verentis/sdk 0.2.2 → 0.2.4

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
@@ -57,6 +57,30 @@ Release the 0.2 SDK before deploying apps that require `^0.2.0`, together with t
57
57
  Local sibling-repository development can link the built SDK; published applications must resolve
58
58
  the released SDK from npm.
59
59
 
60
+ ### WOPI editor launch
61
+
62
+ Signed WOPI applications use `Bridge.requestWopiLaunch()` after `waitForInit()`.
63
+ The authenticated workspace parent chooses the installed editor, document,
64
+ branch and action; the child cannot request arbitrary document identities.
65
+ The returned token is scoped to WOPI and is not a normal workspace API token.
66
+ Submit it using a form POST to the validated discovery action, never a URL.
67
+
68
+ `requestWopiSave(generation)`, `getWopiStatus()` and `closeWopiSession()` use the
69
+ same exact-parent, origin/session/request-correlated bridge. A matching durable
70
+ save receipt—not a CODE postMessage alone—is required before clearing dirty state.
71
+ When status reports `continuationAvailable`, `requestWopiContinuation()` asks
72
+ the parent to bind the target of a completed server-owned Save As operation.
73
+ It accepts no document or acquisition IDs. Its `WopiContinuation` result contains
74
+ only document metadata and the persisted target `accessTokenTtl`, not a token,
75
+ action URL or private control credential. The parent obtains the exact existing
76
+ control IDs from authenticated `/derived`; it does not reconstruct a launch,
77
+ including after signing-key rotation. Keep the initial launch/form separate from
78
+ active document metadata, retain CODE's iframe and existing token, and preserve
79
+ unverified edits until a matching target save receipt arrives.
80
+ These methods do not replace `requestBackendCredential()` or ordinary OAuth
81
+ authentication for apps that need workspace APIs. WOPI-only packages need no
82
+ per-editor OAuth client secret.
83
+
60
84
  ### Standalone mode (local development)
61
85
 
62
86
  ```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
 
@@ -754,8 +783,24 @@ function isHeaderAction(value) {
754
783
 
755
784
  // src/transport/bridge.transport.ts
756
785
  var Bridge = class {
786
+ constructor(expectedHostOrigin) {
787
+ this.expectedHostOrigin = expectedHostOrigin;
788
+ this._initPromise = new Promise((resolve, reject) => {
789
+ this._initResolve = resolve;
790
+ this._initReject = reject;
791
+ });
792
+ void this._initPromise.catch(() => {
793
+ });
794
+ this._messageHandler = this.handleMessage.bind(this);
795
+ if (typeof window !== "undefined") {
796
+ window.addEventListener("message", this._messageHandler);
797
+ }
798
+ }
799
+ expectedHostOrigin;
757
800
  listeners = /* @__PURE__ */ new Map();
758
801
  _initResolve;
802
+ _initReject;
803
+ _initTimer;
759
804
  _initPromise;
760
805
  _hostOrigin = null;
761
806
  _messageHandler;
@@ -763,15 +808,8 @@ var Bridge = class {
763
808
  sessionId = null;
764
809
  actions = /* @__PURE__ */ new Map();
765
810
  requests = /* @__PURE__ */ new Set();
766
- constructor() {
767
- this._initPromise = new Promise((resolve) => {
768
- this._initResolve = resolve;
769
- });
770
- this._messageHandler = this.handleMessage.bind(this);
771
- if (typeof window !== "undefined") {
772
- window.addEventListener("message", this._messageHandler);
773
- }
774
- }
811
+ backendRequests = /* @__PURE__ */ new Map();
812
+ wopiRequests = /* @__PURE__ */ new Map();
775
813
  /** Post `verentis:ready` to the host to start the handshake */
776
814
  sendReady(capabilities) {
777
815
  console.info("[Verentis SDK] Sending verentis:ready to host");
@@ -780,10 +818,16 @@ var Bridge = class {
780
818
  version: 1,
781
819
  capabilities
782
820
  };
783
- window.parent.postMessage(message, "*");
821
+ window.parent.postMessage(message, this.expectedHostOrigin ?? "*");
784
822
  }
785
823
  /** Wait for the host to send `verentis:init` with context and token */
786
824
  waitForInit() {
825
+ if (!this._hostOrigin && !this._destroyed && !this._initTimer) {
826
+ this._initTimer = setTimeout(() => {
827
+ this._initReject(new Error("Workspace handshake timed out."));
828
+ this.destroy();
829
+ }, 2e4);
830
+ }
787
831
  return this._initPromise;
788
832
  }
789
833
  /** Get the host origin (available after init) */
@@ -827,6 +871,75 @@ var Bridge = class {
827
871
  setDirty(isDirty) {
828
872
  this.postToHost({ type: "verentis:dirty", version: 1, isDirty });
829
873
  }
874
+ /** The host, not the app, selects the installation, file, branch and permission ceiling. */
875
+ requestBackendCredential() {
876
+ if (this._destroyed || !this.sessionId || !this._hostOrigin)
877
+ return Promise.reject(new Error("An initialized workspace host is required."));
878
+ if (this.backendRequests.size >= 4)
879
+ return Promise.reject(new Error("A backend credential request is already pending."));
880
+ const requestId = crypto.randomUUID();
881
+ return new Promise((resolve, reject) => {
882
+ const timer = setTimeout(() => {
883
+ this.backendRequests.delete(requestId);
884
+ reject(new Error("Backend authorization timed out."));
885
+ }, 2e4);
886
+ this.backendRequests.set(requestId, { resolve, reject, timer });
887
+ try {
888
+ this.postToHost({ type: "verentis:backend:request", version: 1, requestId, sessionId: this.sessionId });
889
+ } catch {
890
+ clearTimeout(timer);
891
+ this.backendRequests.delete(requestId);
892
+ reject(new Error("The workspace host is unavailable."));
893
+ }
894
+ });
895
+ }
896
+ /** The trusted parent selects the approved installation, document and action. */
897
+ requestWopiLaunch() {
898
+ return this.requestWopi("launch");
899
+ }
900
+ /** Recover only the target selected by a completed server-owned derived operation. */
901
+ requestWopiContinuation() {
902
+ return this.requestWopi("continue");
903
+ }
904
+ getWopiStatus() {
905
+ return this.requestWopi("status");
906
+ }
907
+ requestWopiSave(generation) {
908
+ if (!Number.isSafeInteger(generation) || generation < 0)
909
+ return Promise.reject(new Error("Invalid edit generation."));
910
+ return this.requestWopi("save", generation);
911
+ }
912
+ closeWopiSession() {
913
+ return this.requestWopi("close");
914
+ }
915
+ requestWopi(operation, generation) {
916
+ if (this._destroyed || !this.sessionId || !this._hostOrigin)
917
+ return Promise.reject(new Error("An initialized workspace host is required."));
918
+ if (this.wopiRequests.size >= 4)
919
+ return Promise.reject(new Error("Too many collaboration requests."));
920
+ const requestId = crypto.randomUUID();
921
+ return new Promise((resolve, reject) => {
922
+ const timer = setTimeout(() => {
923
+ this.wopiRequests.delete(requestId);
924
+ reject(new Error("Collaboration authorization timed out."));
925
+ }, 3e4);
926
+ this.wopiRequests.set(requestId, { resolve, reject, timer });
927
+ try {
928
+ this.postToHost({
929
+ type: "verentis:wopi:request",
930
+ version: 1,
931
+ requestId,
932
+ sessionId: this.sessionId,
933
+ operation,
934
+ ...generation === void 0 ? {} : { generation }
935
+ });
936
+ } catch {
937
+ clearTimeout(timer);
938
+ this.wopiRequests.delete(requestId);
939
+ reject(new Error("The workspace host is unavailable."));
940
+ }
941
+ });
942
+ }
830
943
  registerAction(descriptor, handler) {
831
944
  if (!this.sessionId || this._destroyed) throw new Error("Header actions require an initialized workspace host.");
832
945
  if (!isHeaderAction(descriptor)) throw new Error("Invalid header action.");
@@ -903,9 +1016,22 @@ var Bridge = class {
903
1016
  /** Clean up event listeners */
904
1017
  destroy() {
905
1018
  this._destroyed = true;
1019
+ clearTimeout(this._initTimer);
1020
+ this._initTimer = void 0;
1021
+ if (!this._hostOrigin) this._initReject(new Error("Bridge has been destroyed."));
906
1022
  this.actions.clear();
907
1023
  this.requests.clear();
908
1024
  this.listeners.clear();
1025
+ for (const pending of this.backendRequests.values()) {
1026
+ clearTimeout(pending.timer);
1027
+ pending.reject(new Error("Bridge has been destroyed."));
1028
+ }
1029
+ this.backendRequests.clear();
1030
+ for (const pending of this.wopiRequests.values()) {
1031
+ clearTimeout(pending.timer);
1032
+ pending.reject(new Error("Bridge has been destroyed."));
1033
+ }
1034
+ this.wopiRequests.clear();
909
1035
  if (typeof window !== "undefined") {
910
1036
  window.removeEventListener("message", this._messageHandler);
911
1037
  }
@@ -915,8 +1041,12 @@ var Bridge = class {
915
1041
  const data = event.data;
916
1042
  if (event.source !== window.parent || data?.version !== 1 || typeof data?.type !== "string" || !data.type.startsWith("verentis:")) return;
917
1043
  if (this._hostOrigin && event.origin !== this._hostOrigin) return;
1044
+ if (this.expectedHostOrigin && event.origin !== this.expectedHostOrigin) return;
918
1045
  if (data.type === "verentis:init") {
919
1046
  if (this._hostOrigin) return;
1047
+ if (!data.context?.workspace?.id || !data.token || event.origin === "null") return;
1048
+ clearTimeout(this._initTimer);
1049
+ this._initTimer = void 0;
920
1050
  this._hostOrigin = event.origin;
921
1051
  this.sessionId = typeof data.sessionId === "string" ? data.sessionId : null;
922
1052
  console.info("[Verentis SDK] Received verentis:init from host", {
@@ -927,6 +1057,29 @@ var Bridge = class {
927
1057
  const initData = data;
928
1058
  this._initResolve({ context: initData.context, token: initData.token });
929
1059
  }
1060
+ if (data.type === "verentis:backend:response") {
1061
+ if (!this._hostOrigin || data.sessionId !== this.sessionId) return;
1062
+ const pending = this.backendRequests.get(data.requestId);
1063
+ if (!pending) return;
1064
+ clearTimeout(pending.timer);
1065
+ this.backendRequests.delete(data.requestId);
1066
+ const value = data.credential;
1067
+ 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())
1068
+ pending.reject(new Error("Backend authorization was denied or is not configured."));
1069
+ else pending.resolve(value);
1070
+ return;
1071
+ }
1072
+ if (data.type === "verentis:wopi:response") {
1073
+ if (!this._hostOrigin || data.sessionId !== this.sessionId) return;
1074
+ const pending = this.wopiRequests.get(data.requestId);
1075
+ if (!pending) return;
1076
+ clearTimeout(pending.timer);
1077
+ this.wopiRequests.delete(data.requestId);
1078
+ if (data.error || !Object.hasOwn(data, "result"))
1079
+ pending.reject(new Error("Collaboration authorization is unavailable."));
1080
+ else pending.resolve(data.result);
1081
+ return;
1082
+ }
930
1083
  if (data.type === "verentis:action:invoke") {
931
1084
  void this.invokeAction(data);
932
1085
  return;
@@ -1112,7 +1265,7 @@ var VerentisClient = class {
1112
1265
  this.bridge.sendReady();
1113
1266
  const { context, token } = await this.bridge.waitForInit();
1114
1267
  const hostOrigin = this.bridge.hostOrigin;
1115
- Object.assign(bridgeAuth, new BridgeAuth(hostOrigin));
1268
+ bridgeAuth.setHostOrigin(hostOrigin);
1116
1269
  bridgeAuth.setToken(token);
1117
1270
  this.context.setFromAppContext(context);
1118
1271
  const http = new HttpTransport(