@genex-ai/embed-sdk 0.2.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.
@@ -0,0 +1,397 @@
1
+ // src/index.ts
2
+ var PROTOCOL_VERSION = 1;
3
+ var RETRY_FLAG = "genex:embed:retry";
4
+ var config = null;
5
+ var state = "pending";
6
+ var user = null;
7
+ var embedToken;
8
+ var colyseusUrl;
9
+ var parentOrigin = null;
10
+ var initialized = false;
11
+ var redeeming = false;
12
+ var handshakeTimeoutMs = 1e4;
13
+ var refreshDelayMs = 10 * 6e4;
14
+ var refreshRetryMs = 6e4;
15
+ var handshakeTimer;
16
+ var refreshTimer;
17
+ var messageHandler;
18
+ var listeners = /* @__PURE__ */ new Map();
19
+ var authWaiters = [];
20
+ var overlayEl = null;
21
+ function win() {
22
+ return globalThis.window;
23
+ }
24
+ function doc() {
25
+ return globalThis.document;
26
+ }
27
+ function doFetch(input, init) {
28
+ return globalThis.fetch(input, init);
29
+ }
30
+ function readRetryFlag() {
31
+ try {
32
+ return win()?.sessionStorage?.getItem(RETRY_FLAG) === "1";
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+ function writeRetryFlag() {
38
+ try {
39
+ win()?.sessionStorage?.setItem(RETRY_FLAG, "1");
40
+ } catch {
41
+ }
42
+ }
43
+ function clearRetryFlag() {
44
+ try {
45
+ win()?.sessionStorage?.removeItem(RETRY_FLAG);
46
+ } catch {
47
+ }
48
+ }
49
+ function initEmbed(cfg) {
50
+ const w = win();
51
+ if (!w) return;
52
+ if (initialized) return;
53
+ initialized = true;
54
+ config = {
55
+ slug: cfg.slug,
56
+ apiUrl: cfg.apiUrl.replace(/\/$/, ""),
57
+ dashboardOrigins: [...cfg.dashboardOrigins]
58
+ };
59
+ state = "pending";
60
+ showOverlay("connecting");
61
+ if (isEmbedded()) {
62
+ startEmbeddedHandshake(w);
63
+ } else {
64
+ void startStandaloneFlow(w);
65
+ }
66
+ }
67
+ function isEmbedded() {
68
+ const w = win();
69
+ if (!w) return false;
70
+ try {
71
+ return w.self !== w.top;
72
+ } catch {
73
+ return true;
74
+ }
75
+ }
76
+ function getAuthState() {
77
+ return state;
78
+ }
79
+ function getUser() {
80
+ return user;
81
+ }
82
+ function getEmbedToken() {
83
+ return embedToken;
84
+ }
85
+ function getColyseusAuth() {
86
+ return embedToken ? { embedToken } : void 0;
87
+ }
88
+ function waitForAuth() {
89
+ if (state === "authenticated" && user) return Promise.resolve({ user });
90
+ if (state === "blocked") return Promise.reject(new Error("genex embed auth: blocked"));
91
+ return new Promise((resolve, reject) => {
92
+ authWaiters.push({ resolve, reject });
93
+ });
94
+ }
95
+ function on(event, cb) {
96
+ let set = listeners.get(event);
97
+ if (!set) {
98
+ set = /* @__PURE__ */ new Set();
99
+ listeners.set(event, set);
100
+ }
101
+ set.add(cb);
102
+ return () => {
103
+ set.delete(cb);
104
+ };
105
+ }
106
+ function emit(event, ctx) {
107
+ const set = listeners.get(event);
108
+ if (!set) return;
109
+ for (const cb of [...set]) {
110
+ try {
111
+ cb(ctx);
112
+ } catch {
113
+ }
114
+ }
115
+ }
116
+ function startEmbeddedHandshake(w) {
117
+ messageHandler = (event) => {
118
+ void handleParentMessage(event);
119
+ };
120
+ w.addEventListener("message", messageHandler);
121
+ w.parent.postMessage({ type: "genex:embed:ready", v: PROTOCOL_VERSION }, "*");
122
+ handshakeTimer = setTimeout(() => {
123
+ enterBlocked();
124
+ }, handshakeTimeoutMs);
125
+ }
126
+ async function handleParentMessage(event) {
127
+ if (!config || state !== "pending" || redeeming) return;
128
+ const data = event.data;
129
+ if (!data || data.type !== "genex:embed:ticket" || data.v !== PROTOCOL_VERSION) return;
130
+ if (typeof data.ticket !== "string" || !data.ticket) return;
131
+ if (!config.dashboardOrigins.includes(event.origin)) {
132
+ const fresh = await fetchDashboardOrigins();
133
+ if (state !== "pending" || redeeming) return;
134
+ if (!fresh.includes(event.origin)) return;
135
+ }
136
+ if (handshakeTimer !== void 0) clearTimeout(handshakeTimer);
137
+ parentOrigin = event.origin;
138
+ await redeemTicket(data.ticket);
139
+ }
140
+ function postToParent(message, targetOrigin) {
141
+ const w = win();
142
+ if (!w || !isEmbedded()) return;
143
+ try {
144
+ w.parent.postMessage(message, targetOrigin);
145
+ } catch {
146
+ }
147
+ }
148
+ async function startStandaloneFlow(w) {
149
+ const ticket = readTicketFromFragment(w);
150
+ if (ticket) {
151
+ try {
152
+ w.history.replaceState(null, "", w.location.pathname + w.location.search);
153
+ } catch {
154
+ }
155
+ await redeemTicket(ticket);
156
+ return;
157
+ }
158
+ showOverlay("redirecting");
159
+ await redirectToAuthorize(w);
160
+ }
161
+ var stashedTicketHash = null;
162
+ function _stashTicketFromUrl() {
163
+ const w = win();
164
+ if (!w) return;
165
+ const hash = w.location.hash;
166
+ if (!hash || !hash.includes("genex_ticket")) return;
167
+ stashedTicketHash = hash;
168
+ try {
169
+ w.history.replaceState(null, "", w.location.pathname + w.location.search);
170
+ } catch {
171
+ }
172
+ }
173
+ function readTicketFromFragment(w) {
174
+ const hash = stashedTicketHash ?? w.location.hash;
175
+ stashedTicketHash = null;
176
+ if (!hash || hash.length < 2) return null;
177
+ try {
178
+ return new URLSearchParams(hash.slice(1)).get("genex_ticket");
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+ async function redirectToAuthorize(w) {
184
+ if (!config) return;
185
+ const origins = await fetchDashboardOrigins();
186
+ const origin = origins[0] ?? config.dashboardOrigins[0];
187
+ if (!origin) {
188
+ enterBlocked();
189
+ return;
190
+ }
191
+ const returnTo = w.location.origin + w.location.pathname;
192
+ const url = `${origin}/play/authorize?returnTo=${encodeURIComponent(returnTo)}&slug=${encodeURIComponent(config.slug)}`;
193
+ w.location.replace(url);
194
+ }
195
+ async function fetchDashboardOrigins() {
196
+ if (!config) return [];
197
+ try {
198
+ const res = await doFetch(`${config.apiUrl}/api/embed/dashboard-origins`);
199
+ if (!res.ok) return config.dashboardOrigins;
200
+ const body = await res.json();
201
+ if (Array.isArray(body.origins) && body.origins.every((o) => typeof o === "string")) {
202
+ return body.origins;
203
+ }
204
+ } catch {
205
+ }
206
+ return config.dashboardOrigins;
207
+ }
208
+ async function redeemTicket(ticket) {
209
+ if (!config || redeeming || state !== "pending") return;
210
+ redeeming = true;
211
+ try {
212
+ const res = await doFetch(`${config.apiUrl}/api/embed/session`, {
213
+ method: "POST",
214
+ headers: { "Content-Type": "application/json" },
215
+ body: JSON.stringify({ ticket })
216
+ });
217
+ if (!res.ok) {
218
+ emit("error", { error: new Error(`ticket redemption failed (${res.status})`) });
219
+ await handleRedeemFailure();
220
+ return;
221
+ }
222
+ const body = await res.json();
223
+ embedToken = body.embedToken;
224
+ user = body.user;
225
+ colyseusUrl = body.colyseus?.url;
226
+ void colyseusUrl;
227
+ state = "authenticated";
228
+ clearRetryFlag();
229
+ removeOverlay();
230
+ scheduleRefresh();
231
+ if (isEmbedded() && parentOrigin) {
232
+ postToParent({ type: "genex:embed:authenticated", v: PROTOCOL_VERSION }, parentOrigin);
233
+ }
234
+ const ctx = { user };
235
+ for (const waiter of authWaiters.splice(0)) waiter.resolve(ctx);
236
+ emit("authenticated", ctx);
237
+ } catch (error) {
238
+ emit("error", { error });
239
+ await handleRedeemFailure();
240
+ } finally {
241
+ redeeming = false;
242
+ }
243
+ }
244
+ async function handleRedeemFailure() {
245
+ const w = win();
246
+ if (w && !isEmbedded() && !readRetryFlag()) {
247
+ writeRetryFlag();
248
+ showOverlay("redirecting");
249
+ await redirectToAuthorize(w);
250
+ return;
251
+ }
252
+ enterBlocked();
253
+ }
254
+ function scheduleRefresh() {
255
+ if (refreshTimer !== void 0) clearTimeout(refreshTimer);
256
+ refreshTimer = setTimeout(() => {
257
+ void refreshToken();
258
+ }, refreshDelayMs);
259
+ }
260
+ async function refreshToken() {
261
+ if (!config || state !== "authenticated" || !embedToken) return;
262
+ let status;
263
+ try {
264
+ const res = await doFetch(`${config.apiUrl}/api/embed/session/refresh`, {
265
+ method: "POST",
266
+ headers: { Authorization: `Bearer ${embedToken}` },
267
+ // Best-effort completion if the tab starts unloading mid-refresh; zero
268
+ // security consequence either way (worst case the token just expires).
269
+ keepalive: true
270
+ });
271
+ status = res.status;
272
+ if (res.ok) {
273
+ const body = await res.json();
274
+ embedToken = body.embedToken;
275
+ scheduleRefresh();
276
+ return;
277
+ }
278
+ } catch {
279
+ status = void 0;
280
+ }
281
+ if (status === 401) {
282
+ emit("error", { error: new Error("embed session expired") });
283
+ enterBlocked();
284
+ return;
285
+ }
286
+ if (refreshTimer !== void 0) clearTimeout(refreshTimer);
287
+ refreshTimer = setTimeout(() => {
288
+ void refreshToken();
289
+ }, refreshRetryMs);
290
+ }
291
+ function enterBlocked() {
292
+ if (state === "blocked") return;
293
+ state = "blocked";
294
+ user = null;
295
+ embedToken = void 0;
296
+ if (handshakeTimer !== void 0) clearTimeout(handshakeTimer);
297
+ if (refreshTimer !== void 0) clearTimeout(refreshTimer);
298
+ showOverlay(isEmbedded() ? "blocked-embedded" : "blocked-standalone");
299
+ if (isEmbedded()) {
300
+ postToParent({ type: "genex:embed:blocked", v: PROTOCOL_VERSION }, parentOrigin ?? "*");
301
+ }
302
+ const err = new Error("genex embed auth: blocked");
303
+ for (const waiter of authWaiters.splice(0)) waiter.reject(err);
304
+ emit("blocked");
305
+ }
306
+ var OVERLAY_TEXT = {
307
+ connecting: { title: "Connecting to Genex\u2026" },
308
+ redirecting: { title: "Signing you in\u2026" },
309
+ "blocked-standalone": { title: "Sign in to play", button: "Sign in" },
310
+ "blocked-embedded": { title: "Sign in on the Genex dashboard to continue" }
311
+ };
312
+ function showOverlay(kind) {
313
+ const d = doc();
314
+ if (!d?.createElement) return;
315
+ try {
316
+ if (!overlayEl) overlayEl = buildOverlay(d);
317
+ overlayEl.setContent(kind);
318
+ } catch {
319
+ }
320
+ }
321
+ function removeOverlay() {
322
+ try {
323
+ const root = overlayEl?.root;
324
+ root?.remove?.();
325
+ } catch {
326
+ }
327
+ overlayEl = null;
328
+ }
329
+ function buildOverlay(d) {
330
+ const root = d.createElement("div");
331
+ root.setAttribute("data-genex-embed-overlay", "");
332
+ root.style.cssText = "position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px;background:rgba(8,10,20,0.92);color:#fff;font-family:system-ui,-apple-system,sans-serif;text-align:center;pointer-events:auto;user-select:none";
333
+ const title = d.createElement("div");
334
+ title.style.cssText = "font-size:18px;font-weight:600;padding:0 24px";
335
+ root.appendChild(title);
336
+ const button = d.createElement("button");
337
+ button.style.cssText = "font-size:15px;font-weight:600;padding:10px 28px;border-radius:8px;border:none;cursor:pointer;background:#fff;color:#111;display:none";
338
+ button.addEventListener("click", () => {
339
+ const w = win();
340
+ if (!w) return;
341
+ button.disabled = true;
342
+ clearRetryFlag();
343
+ void redirectToAuthorize(w);
344
+ });
345
+ root.appendChild(button);
346
+ const mount = () => {
347
+ if (d.body && !root.isConnected) d.body.appendChild(root);
348
+ };
349
+ if (d.body) mount();
350
+ else d.addEventListener?.("DOMContentLoaded", mount);
351
+ return {
352
+ root,
353
+ setContent(kind) {
354
+ const text = OVERLAY_TEXT[kind];
355
+ title.textContent = text.title;
356
+ button.style.display = text.button ? "inline-block" : "none";
357
+ if (text.button) button.textContent = text.button;
358
+ mount();
359
+ }
360
+ };
361
+ }
362
+ function __resetForTests(overrides) {
363
+ const w = win();
364
+ if (w && messageHandler) w.removeEventListener("message", messageHandler);
365
+ messageHandler = void 0;
366
+ if (handshakeTimer !== void 0) clearTimeout(handshakeTimer);
367
+ if (refreshTimer !== void 0) clearTimeout(refreshTimer);
368
+ handshakeTimer = void 0;
369
+ refreshTimer = void 0;
370
+ removeOverlay();
371
+ config = null;
372
+ state = "pending";
373
+ user = null;
374
+ embedToken = void 0;
375
+ colyseusUrl = void 0;
376
+ parentOrigin = null;
377
+ initialized = false;
378
+ redeeming = false;
379
+ listeners.clear();
380
+ authWaiters = [];
381
+ handshakeTimeoutMs = overrides?.handshakeTimeoutMs ?? 1e4;
382
+ refreshDelayMs = overrides?.refreshDelayMs ?? 10 * 6e4;
383
+ refreshRetryMs = overrides?.refreshRetryMs ?? 6e4;
384
+ }
385
+
386
+ export {
387
+ initEmbed,
388
+ isEmbedded,
389
+ getAuthState,
390
+ getUser,
391
+ getEmbedToken,
392
+ getColyseusAuth,
393
+ waitForAuth,
394
+ on,
395
+ _stashTicketFromUrl,
396
+ __resetForTests
397
+ };
@@ -0,0 +1,84 @@
1
+ interface EmbedConfig {
2
+ /** This game's own slug (GENEX.slug) — identifies the project to /play/authorize. */
3
+ slug: string;
4
+ /** The genex API base URL (GENEX.apiUrl) — for redeem/refresh/origin fetches. */
5
+ apiUrl: string;
6
+ /**
7
+ * EXACT-match allowlist of dashboard origins this game accepts a ticket
8
+ * postMessage from (GENEX.dashboardOrigins). Never matched by substring/
9
+ * suffix/regex. Also the fallback standalone redirect target.
10
+ */
11
+ dashboardOrigins: string[];
12
+ }
13
+ interface EmbedUser {
14
+ id: string;
15
+ name: string;
16
+ image?: string;
17
+ }
18
+ type AuthState = 'pending' | 'authenticated' | 'blocked';
19
+ type EmbedEvent = 'authenticated' | 'blocked' | 'error';
20
+ interface EventContext {
21
+ user?: EmbedUser;
22
+ error?: unknown;
23
+ }
24
+ /**
25
+ * Initialize embed auth. Call ONCE, first thing in the boot sequence — before
26
+ * connect() and before any /state call. Scene/asset boot may proceed
27
+ * immediately (the gate overlay blocks interaction, not execution); await
28
+ * waitForAuth() before anything identity-dependent.
29
+ *
30
+ * Standalone (not in an iframe) with no return-trip ticket present, this
31
+ * navigates away to the dashboard sign-in — by design, there is no anonymous
32
+ * play.
33
+ */
34
+ declare function initEmbed(cfg: EmbedConfig): void;
35
+ /**
36
+ * Structural "is in a frame" check — deliberately a different question from
37
+ * "is authenticated". True even before the handshake resolves.
38
+ */
39
+ declare function isEmbedded(): boolean;
40
+ /** Current auth state. Synchronous; starts 'pending', ends 'authenticated' or 'blocked'. */
41
+ declare function getAuthState(): AuthState;
42
+ /** The signed-in user — non-null only once getAuthState() === 'authenticated'. */
43
+ declare function getUser(): EmbedUser | null;
44
+ /**
45
+ * The raw scoped embedToken, for Authorization: Bearer calls the game makes
46
+ * itself (GET/PUT /api/projects/:slug/state — which REQUIRES it; there is no
47
+ * anonymous save path). undefined unless authenticated.
48
+ *
49
+ * NEVER log this value or pass it to crash-reporting/analytics breadcrumb
50
+ * capture. It is bounded (15 minutes, one project, one scope), but third-party
51
+ * logging-service retention can outlive that.
52
+ */
53
+ declare function getEmbedToken(): string | undefined;
54
+ /**
55
+ * The credential for multiplayer: pass as `connect({ ..., auth: getColyseusAuth() })`
56
+ * AFTER `await waitForAuth()` — the relay rejects tokenless joins. Read it
57
+ * fresh at every connect() call (tokens rotate ~every 10 minutes).
58
+ * NEVER log this value.
59
+ */
60
+ declare function getColyseusAuth(): {
61
+ embedToken: string;
62
+ } | undefined;
63
+ /**
64
+ * Resolves with the signed-in user once authenticated; rejects if the session
65
+ * ends up blocked. THE gate for connect() and /state calls.
66
+ */
67
+ declare function waitForAuth(): Promise<{
68
+ user: EmbedUser;
69
+ }>;
70
+ /** Subscribe to auth lifecycle events. Returns an unsubscribe function. */
71
+ declare function on(event: EmbedEvent, cb: (ctx?: EventContext) => void): () => void;
72
+ /**
73
+ * @internal — for ./sentry.ts ONLY, not public API (games never call this).
74
+ * If the URL fragment carries a genex ticket, move it out of the URL (so no
75
+ * recorder/logger ever sees it) into a stash the standalone flow reads.
76
+ */
77
+ declare function _stashTicketFromUrl(): void;
78
+ declare function __resetForTests(overrides?: {
79
+ handshakeTimeoutMs?: number;
80
+ refreshDelayMs?: number;
81
+ refreshRetryMs?: number;
82
+ }): void;
83
+
84
+ export { type AuthState, type EmbedConfig, type EmbedEvent, type EmbedUser, __resetForTests, _stashTicketFromUrl, getAuthState, getColyseusAuth, getEmbedToken, getUser, initEmbed, isEmbedded, on, waitForAuth };
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ import {
2
+ __resetForTests,
3
+ _stashTicketFromUrl,
4
+ getAuthState,
5
+ getColyseusAuth,
6
+ getEmbedToken,
7
+ getUser,
8
+ initEmbed,
9
+ isEmbedded,
10
+ on,
11
+ waitForAuth
12
+ } from "./chunk-KXUDNW6P.js";
13
+ export {
14
+ __resetForTests,
15
+ _stashTicketFromUrl,
16
+ getAuthState,
17
+ getColyseusAuth,
18
+ getEmbedToken,
19
+ getUser,
20
+ initEmbed,
21
+ isEmbedded,
22
+ on,
23
+ waitForAuth
24
+ };
@@ -0,0 +1,34 @@
1
+ interface GameSentryOptions {
2
+ /** The game's slug — use GENEX.slug. Becomes the game_slug tag. */
3
+ slug: string;
4
+ /** Override the shared genex-games project DSN. */
5
+ dsn?: string;
6
+ /** Sentry environment. Published bundles are 'production' (the default). */
7
+ environment?: string;
8
+ }
9
+ /**
10
+ * Initialize crash reporting + session replay for a game. Call ONCE, BEFORE
11
+ * initEmbed() — so even auth-boot failures are reported. Safe in any context
12
+ * the game runs in: dashboard iframe, standalone tab, local dev preview.
13
+ *
14
+ * Replay records the DOM; the game's WebGL/WebGPU canvas needs explicit
15
+ * snapshots — call sentryCanvasSnapshot(renderer.domElement) in the render
16
+ * loop (see below).
17
+ */
18
+ declare function initGameSentry(opts: GameSentryOptions): void;
19
+ /**
20
+ * Renderer-agnostic canvas capture for session replay: call once per frame at
21
+ * the END of the render loop, right after renderer.render(scene, camera),
22
+ * passing renderer.domElement. Throttled internally (default: one capture per
23
+ * 500ms), so calling at 60fps is fine.
24
+ *
25
+ * skipRequestAnimationFrame captures synchronously in the SAME task as the
26
+ * draw — REQUIRED for WebGPU (canvas textures expire when the task completes)
27
+ * and safe for WebGL (the buffer is still valid within the task, so no
28
+ * preserveDrawingBuffer needed). One code path for both renderers.
29
+ */
30
+ declare function sentryCanvasSnapshot(canvas: HTMLCanvasElement, opts?: {
31
+ intervalMs?: number;
32
+ }): void;
33
+
34
+ export { type GameSentryOptions, initGameSentry, sentryCanvasSnapshot };
package/dist/sentry.js ADDED
@@ -0,0 +1,113 @@
1
+ import {
2
+ _stashTicketFromUrl,
3
+ getUser,
4
+ on
5
+ } from "./chunk-KXUDNW6P.js";
6
+
7
+ // src/sentry.ts
8
+ import * as Sentry from "@sentry/browser";
9
+
10
+ // src/sentry-scrub.ts
11
+ var TOKENISH = /((?:genex_ticket|ticket|embedToken|token)=)[^&#\s"']+/gi;
12
+ function scrubTokens(s) {
13
+ return s.replace(TOKENISH, "$1[redacted]");
14
+ }
15
+
16
+ // src/sentry.ts
17
+ var DEFAULT_GAMES_DSN = "https://d1414cfcdc09bf1c1d124e86f8087251@o4511115493900288.ingest.us.sentry.io/4511662569160704";
18
+ var initialized = false;
19
+ function initGameSentry(opts) {
20
+ if (typeof window === "undefined") return;
21
+ if (initialized) return;
22
+ initialized = true;
23
+ _stashTicketFromUrl();
24
+ Sentry.init({
25
+ dsn: opts.dsn ?? DEFAULT_GAMES_DSN,
26
+ environment: opts.environment ?? "production",
27
+ tracesSampleRate: 1,
28
+ // low-traffic demo; first knobs to lower: replays, then this
29
+ replaysSessionSampleRate: 1,
30
+ // record EVERY session (demo decision 2026-07-02)
31
+ replaysOnErrorSampleRate: 1,
32
+ dataCollection: { userInfo: true },
33
+ initialScope: { tags: { game_slug: opts.slug } },
34
+ integrations: [
35
+ Sentry.browserTracingIntegration(),
36
+ // Unmasked (demo decision 2026-07-02): game UI is the product. Replay
37
+ // network capture stays at its default (URLs only, never bodies or
38
+ // headers) — do NOT add networkDetailAllowUrls for the genex API, or
39
+ // ticket/embedToken response bodies would be recorded.
40
+ Sentry.replayIntegration({
41
+ maskAllText: false,
42
+ blockAllMedia: false,
43
+ // Scrub token-ish URLs from replay CUSTOM recording events (navigation
44
+ // breadcrumbs, resource spans). rrweb's own DOM events can't be
45
+ // modified here — that's what _stashTicketFromUrl above is for.
46
+ beforeAddRecordingEvent: (event) => {
47
+ try {
48
+ const payload = event.data?.payload;
49
+ if (payload) {
50
+ if (typeof payload.description === "string") {
51
+ payload.description = scrubTokens(payload.description);
52
+ }
53
+ const d = payload.data;
54
+ if (d) {
55
+ for (const k of ["url", "to", "from"]) {
56
+ if (typeof d[k] === "string") d[k] = scrubTokens(d[k]);
57
+ }
58
+ }
59
+ }
60
+ } catch {
61
+ }
62
+ return event;
63
+ }
64
+ }),
65
+ // Canvas frames are captured manually from the render loop — automatic
66
+ // capture would need preserveDrawingBuffer (WebGL perf hit) and misses
67
+ // WebGPU entirely. See sentryCanvasSnapshot.
68
+ Sentry.replayCanvasIntegration({ enableManualSnapshot: true })
69
+ ],
70
+ // The auth ticket travels in the URL fragment on the standalone flow —
71
+ // scrub token-ish values from everything URL- or message-shaped.
72
+ beforeSend(event) {
73
+ if (event.request?.url) event.request.url = scrubTokens(event.request.url);
74
+ if (event.message) event.message = scrubTokens(event.message);
75
+ return event;
76
+ },
77
+ beforeBreadcrumb(crumb) {
78
+ if (typeof crumb.data?.url === "string") crumb.data.url = scrubTokens(crumb.data.url);
79
+ if (typeof crumb.data?.to === "string") crumb.data.to = scrubTokens(crumb.data.to);
80
+ if (typeof crumb.data?.from === "string") crumb.data.from = scrubTokens(crumb.data.from);
81
+ if (typeof crumb.message === "string") crumb.message = scrubTokens(crumb.message);
82
+ return crumb;
83
+ }
84
+ });
85
+ Sentry.addEventProcessor((event) => {
86
+ const e = event;
87
+ if (Array.isArray(e.urls)) {
88
+ e.urls = e.urls.map((u) => typeof u === "string" ? scrubTokens(u) : u);
89
+ }
90
+ if (e.request?.url) e.request.url = scrubTokens(e.request.url);
91
+ return event;
92
+ });
93
+ on("authenticated", (ctx) => {
94
+ const u = ctx?.user ?? getUser();
95
+ if (u) Sentry.setUser({ id: u.id, username: u.name });
96
+ });
97
+ on("blocked", () => Sentry.setUser(null));
98
+ on("error", (ctx) => {
99
+ if (ctx?.error) Sentry.captureException(ctx.error);
100
+ });
101
+ }
102
+ var lastSnapshot = 0;
103
+ function sentryCanvasSnapshot(canvas, opts) {
104
+ const now = Date.now();
105
+ if (now - lastSnapshot < (opts?.intervalMs ?? 500)) return;
106
+ lastSnapshot = now;
107
+ const replayCanvas = Sentry.getClient()?.getIntegrationByName("ReplayCanvas");
108
+ void replayCanvas?.snapshot(canvas, { skipRequestAnimationFrame: true });
109
+ }
110
+ export {
111
+ initGameSentry,
112
+ sentryCanvasSnapshot
113
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@genex-ai/embed-sdk",
3
+ "version": "0.2.0",
4
+ "description": "Signed-in identity for genex games — embedded (dashboard iframe postMessage handshake) or standalone (sign-in redirect). No anonymous play.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "@genex-ai/source": "./src/index.ts",
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./sentry": {
15
+ "@genex-ai/source": "./src/sentry.ts",
16
+ "types": "./dist/sentry.d.ts",
17
+ "import": "./dist/sentry.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "peerDependencies": {
27
+ "@sentry/browser": "^10.0.0"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "@sentry/browser": {
31
+ "optional": true
32
+ }
33
+ },
34
+ "devDependencies": {
35
+ "@arethetypeswrong/cli": "^0.18.0",
36
+ "@sentry/browser": "^10.63.0",
37
+ "publint": "^0.3.0",
38
+ "tsup": "^8.0.0"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/me-ai-org/genex-demo.git",
43
+ "directory": "packages/embed-sdk"
44
+ },
45
+ "license": "MIT",
46
+ "scripts": {
47
+ "build": "tsup",
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "node --test test/*.test.ts",
50
+ "check": "pnpm build && publint && attw --pack . --profile esm-only"
51
+ }
52
+ }