@graffiti-garden/wrapper-data-guard 0.2.6 → 0.2.8

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/src/index.ts CHANGED
@@ -1,10 +1,14 @@
1
- import { Graffiti, type GraffitiSession } from "@graffiti-garden/api";
1
+ import {
2
+ Graffiti,
3
+ type GraffitiSession,
4
+ } from "@graffiti-garden/api";
2
5
  import { GraffitiRpcClient } from "@graffiti-garden/wrapper-iframe-rpc/client";
3
6
 
4
7
  declare const DATA_GUARD_DEFAULT_HOST_URL: string;
5
8
 
6
9
  let instance: GraffitiGuarded | undefined;
7
10
  const defaultConnectionTimeout = 10_000;
11
+ const pendingLoginKey = "graffiti-guard-login:";
8
12
 
9
13
  export interface GraffitiGuardedOptions {
10
14
  hostUrl?: string | URL;
@@ -24,13 +28,16 @@ export type GraffitiGuardSession = GraffitiSession & {
24
28
  /** A Graffiti implementation backed by a sandboxed guard iframe. */
25
29
  // @ts-ignore Graffiti methods are delegated through the constructor proxy.
26
30
  export class GraffitiGuarded extends Graffiti {
27
- readonly sessionEvents!: EventTarget;
31
+ readonly sessionEvents!: Graffiti["sessionEvents"];
28
32
  private readonly hostUrl!: URL;
29
33
  private readonly iframe!: HTMLIFrameElement;
30
34
  private readonly rpc!: GraffitiRpcClient;
31
35
  private readonly onMessage!: (event: MessageEvent) => void;
32
36
  private readonly connectionTimeout!: number;
33
37
  private destroyed = false;
38
+ private returnFocus: HTMLElement | null = null;
39
+ private readonly backgroundInert = new Map<HTMLElement, boolean>();
40
+ private backgroundObserver: MutationObserver | undefined;
34
41
 
35
42
  constructor(options: GraffitiGuardedOptions = {}) {
36
43
  super();
@@ -40,6 +47,8 @@ export class GraffitiGuarded extends Graffiti {
40
47
  options.hostUrl?.toString() ?? DATA_GUARD_DEFAULT_HOST_URL,
41
48
  document.baseURI,
42
49
  );
50
+ const loginKey = pendingLoginKey + hostUrl.origin;
51
+ const loginNonce = sessionStorage.getItem(loginKey);
43
52
  const iframe = document.createElement("iframe");
44
53
  iframe.title = "Graffiti Guard";
45
54
  iframe.allow = "storage-access";
@@ -58,7 +67,7 @@ export class GraffitiGuarded extends Graffiti {
58
67
  background: "transparent",
59
68
  border: "0",
60
69
  display: "none",
61
- // Prompts scroll inside this frame, so size it for visible browser controls.
70
+ // Keep the overlay within the visible viewport when browser controls move.
62
71
  height: CSS.supports("height", "100svh") ? "100svh" : "100vh",
63
72
  inset: "0",
64
73
  position: "fixed",
@@ -74,6 +83,7 @@ export class GraffitiGuarded extends Graffiti {
74
83
  {
75
84
  type: "graffiti-guard:connect",
76
85
  pageUrl: window.location.href,
86
+ ...(loginNonce ? { loginNonce } : {}),
77
87
  },
78
88
  hostUrl.origin,
79
89
  );
@@ -102,6 +112,10 @@ export class GraffitiGuarded extends Graffiti {
102
112
  channel: options.channel,
103
113
  timeout: options.timeout,
104
114
  });
115
+ this.sessionEvents = rpc.sessionEvents;
116
+ this.sessionEvents.addEventListener("initialized", () => {
117
+ if (sessionStorage.getItem(loginKey) === loginNonce) sessionStorage.removeItem(loginKey);
118
+ }, { once: true });
105
119
  const connectionTimeout = window.setTimeout(
106
120
  showConnectionError,
107
121
  options.timeout ?? defaultConnectionTimeout,
@@ -128,10 +142,8 @@ export class GraffitiGuarded extends Graffiti {
128
142
  event.data.type === "graffiti-guard:set-visible" &&
129
143
  typeof event.data.visible === "boolean"
130
144
  ) {
131
- iframe.style.display = event.data.visible ? "block" : "none";
132
- iframe.setAttribute("aria-hidden", String(!event.data.visible));
145
+ this.setGuardVisible(event.data.visible);
133
146
  if (event.data.visible) {
134
- iframe.focus();
135
147
  remoteWindow.postMessage(
136
148
  { type: "graffiti-guard:shown" },
137
149
  hostUrl.origin,
@@ -166,7 +178,6 @@ export class GraffitiGuarded extends Graffiti {
166
178
  this.hostUrl = hostUrl;
167
179
  this.iframe = iframe;
168
180
  this.rpc = rpc;
169
- this.sessionEvents = rpc.sessionEvents;
170
181
  this.onMessage = onMessage;
171
182
  this.connectionTimeout = connectionTimeout;
172
183
 
@@ -182,7 +193,7 @@ export class GraffitiGuarded extends Graffiti {
182
193
  return instance;
183
194
  }
184
195
 
185
- login: Graffiti["login"] = (actor) => {
196
+ login: Graffiti["login"] = (options) => {
186
197
  const loginUrl = new URL(this.hostUrl);
187
198
  // The guard reads this state in the browser; keep it out of the HTTP request.
188
199
  // This allows for very long redirectUrls
@@ -190,7 +201,11 @@ export class GraffitiGuarded extends Graffiti {
190
201
  guardLogin: "1",
191
202
  redirectUrl: window.location.href,
192
203
  });
193
- if (actor) params.set("suggestedActor", actor);
204
+ if (options?.actor) params.set("suggestedActor", options.actor);
205
+ const nonce = crypto.randomUUID();
206
+ sessionStorage.setItem(pendingLoginKey + this.hostUrl.origin, nonce);
207
+ params.set("loginNonce", nonce);
208
+ if (options?.state !== undefined) params.set("state", options.state);
194
209
  loginUrl.hash = params.toString();
195
210
  window.location.assign(loginUrl.href);
196
211
  return Promise.resolve();
@@ -211,11 +226,62 @@ export class GraffitiGuarded extends Graffiti {
211
226
  return auditUrl.href;
212
227
  }
213
228
 
229
+ private setGuardVisible(visible: boolean) {
230
+ if (visible) {
231
+ if (this.iframe.style.display === "block") return;
232
+ const focused = document.activeElement;
233
+ this.returnFocus =
234
+ focused instanceof HTMLElement && focused !== this.iframe
235
+ ? focused
236
+ : null;
237
+ this.inertBackground();
238
+ this.backgroundObserver = new MutationObserver(() => this.inertBackground());
239
+ this.backgroundObserver.observe(document.body, { childList: true });
240
+ this.iframe.style.display = "block";
241
+ this.iframe.setAttribute("aria-hidden", "false");
242
+ this.iframe.focus();
243
+ return;
244
+ }
245
+
246
+ if (this.iframe.style.display === "none") return;
247
+
248
+ this.iframe.style.display = "none";
249
+ this.iframe.setAttribute("aria-hidden", "true");
250
+ this.backgroundObserver?.disconnect();
251
+ this.backgroundObserver = undefined;
252
+ for (const [element, wasInert] of this.backgroundInert) {
253
+ element.inert = wasInert;
254
+ }
255
+ this.backgroundInert.clear();
256
+ const target = this.returnFocus;
257
+ this.returnFocus = null;
258
+ if (target?.isConnected && !target.inert && target !== document.body) {
259
+ target.focus({ preventScroll: true });
260
+ } else {
261
+ const previousTabindex = document.body.getAttribute("tabindex");
262
+ document.body.tabIndex = -1;
263
+ document.body.focus({ preventScroll: true });
264
+ if (previousTabindex === null) document.body.removeAttribute("tabindex");
265
+ else document.body.setAttribute("tabindex", previousTabindex);
266
+ }
267
+ }
268
+
269
+ private inertBackground() {
270
+ for (const child of Array.from(document.body.children)) {
271
+ if (child === this.iframe || !(child instanceof HTMLElement)) continue;
272
+ if (!this.backgroundInert.has(child)) {
273
+ this.backgroundInert.set(child, child.inert);
274
+ }
275
+ child.inert = true;
276
+ }
277
+ }
278
+
214
279
  destroy() {
215
280
  if (this.destroyed) return;
216
281
  this.destroyed = true;
217
282
  window.clearTimeout(this.connectionTimeout);
218
283
  window.removeEventListener("message", this.onMessage);
284
+ if (this.iframe.style.display === "block") this.setGuardVisible(false);
219
285
  this.rpc.destroy();
220
286
  this.iframe.remove();
221
287
  if (instance === this) instance = undefined;