@corbado/observe 1.9.0 → 1.10.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,422 @@
1
+ // src/cross-origin-session.ts
2
+ import { readCookieScope } from "./readers/page.mjs";
3
+ import { cookieJar } from "./readers/storage.mjs";
4
+ var CROSS_ORIGIN_COOKIE_PREFIX = "cbo_xs";
5
+ var CROSS_ORIGIN_COOKIE_VERSION = "1";
6
+ var CROSS_ORIGIN_SEQ_RESERVE = 20;
7
+ var CROSS_ORIGIN_CONTEXT_MAX = 128;
8
+ var CONTEXT_RE = /^[A-Za-z0-9._~-]{1,128}$/;
9
+ var CROSS_ORIGIN_ACTIVITY_WRITE_INTERVAL_MS = 1e3;
10
+ var FUTURE_TOLERANCE_MS = 5 * 60 * 1e3;
11
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ var PROJECT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
13
+ var DOMAIN_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
14
+ var INT_RE = /^\d{1,15}$/;
15
+ var CROSS_ORIGIN_MAX_SEQ_MARK = 1e9;
16
+ var CROSS_ORIGIN_FOREIGN_GAP = 1e3;
17
+ var CROSS_ORIGIN_MAX_SEQ_JUMP = 1e5;
18
+ function isValidCrossOriginContext(value) {
19
+ return typeof value === "string" && CONTEXT_RE.test(value);
20
+ }
21
+ function parseCrossOriginCookie(value) {
22
+ const parts = value.split(".");
23
+ const [version, sessionId, lastActiveAt, seqMark] = parts;
24
+ if (parts.length < 4 || version !== CROSS_ORIGIN_COOKIE_VERSION || !sessionId || !UUID_RE.test(sessionId) || !lastActiveAt || !INT_RE.test(lastActiveAt) || !seqMark || !INT_RE.test(seqMark) || Number(seqMark) > CROSS_ORIGIN_MAX_SEQ_MARK) {
25
+ return void 0;
26
+ }
27
+ const context = parts.length > 4 ? parts.slice(4).join(".") : void 0;
28
+ if (context !== void 0 && !isValidCrossOriginContext(context)) {
29
+ return void 0;
30
+ }
31
+ return {
32
+ sessionId: sessionId.toLowerCase(),
33
+ lastActiveAt: Number(lastActiveAt),
34
+ seqMark: Number(seqMark),
35
+ ...context !== void 0 && { context }
36
+ };
37
+ }
38
+ function formatCrossOriginCookie(record) {
39
+ const parts = [
40
+ CROSS_ORIGIN_COOKIE_VERSION,
41
+ record.sessionId,
42
+ String(Math.floor(record.lastActiveAt)),
43
+ String(Math.floor(record.seqMark))
44
+ ];
45
+ if (record.context !== void 0) {
46
+ parts.push(record.context);
47
+ }
48
+ return parts.join(".");
49
+ }
50
+ function resolveCrossOriginDomain(raw, hostname) {
51
+ const domain = raw.trim().toLowerCase().replace(/^\./, "");
52
+ const host = hostname.toLowerCase();
53
+ if (!DOMAIN_RE.test(domain)) {
54
+ return void 0;
55
+ }
56
+ return host === domain || host.endsWith(`.${domain}`) ? domain : void 0;
57
+ }
58
+ var CrossOriginSession = class _CrossOriginSession {
59
+ constructor(name, domain, jar, contextJar, deps) {
60
+ this.name = name;
61
+ this.domain = domain;
62
+ this.jar = jar;
63
+ this.contextJar = contextJar;
64
+ this.deps = deps;
65
+ }
66
+ enabled = true;
67
+ /** Session the in-memory reservation, highest seen mark and context belong to. */
68
+ reservedFor;
69
+ /** Seq this page may allocate up to (exclusive) without rewriting the cookie. */
70
+ reservedMark = 0;
71
+ /** Highest plausible mark of {@link reservedFor} this page has seen or written. */
72
+ highestSeen = 0;
73
+ lastWriteAt = 0;
74
+ /** Context of {@link reservedFor} as last known to this page. */
75
+ context;
76
+ /**
77
+ * The feature for this page, or `undefined` when it cannot work here (invalid project id or domain, a
78
+ * domain that is not the page's host or a parent of it, a non-HTTPS page, no document). Never throws.
79
+ */
80
+ static create(deps) {
81
+ try {
82
+ if (typeof document === "undefined" || typeof window === "undefined") {
83
+ return void 0;
84
+ }
85
+ if (!PROJECT_ID_RE.test(deps.projectId)) {
86
+ deps.logger.debug("Cross-origin session off: project id is not cookie-safe");
87
+ return void 0;
88
+ }
89
+ const scope = deps.scope ? deps.scope() : readCookieScope({
90
+ key: "page:location.cookie-scope",
91
+ title: "Page host name for the shared session cookie",
92
+ accessNote: "location.hostname and location.protocol, compared with the configured shared cookie domain; not sent",
93
+ group: "Observe SDK state",
94
+ origin: "observe-external",
95
+ fields: [
96
+ {
97
+ path: "hostname",
98
+ label: "Host name of the page",
99
+ exposure: "none",
100
+ transform: { note: "only compared with the configured parent domain; never sent or stored" }
101
+ },
102
+ {
103
+ path: "https",
104
+ label: "Whether the page uses a secure connection",
105
+ exposure: "none",
106
+ transform: { note: "the shared cookie is only used on secure pages; never sent or stored" }
107
+ }
108
+ ]
109
+ });
110
+ if (!scope.https) {
111
+ deps.logger.debug("Cross-origin session off: page is not served over HTTPS");
112
+ return void 0;
113
+ }
114
+ const domain = resolveCrossOriginDomain(deps.cookieDomain, scope.hostname);
115
+ if (!domain) {
116
+ deps.logger.warn(`Cross-origin session off: "${deps.cookieDomain}" is not the page's domain or a parent of it`);
117
+ deps.report("info", "cross-origin session: cookieDomain does not match the page");
118
+ return void 0;
119
+ }
120
+ const jar = deps.jar ?? cookieJar({
121
+ key: "storage:sdk.cross-origin-session",
122
+ title: "Observe session shared across your subdomains",
123
+ accessNote: "document.cookie, Observe's own cbo_xs_ cookie on the configured parent domain (Secure, SameSite Lax, Path /, expires after the session inactivity window); the browser sends it with every request to every subdomain of that domain",
124
+ group: "Observe SDK state",
125
+ origin: "observe-internal",
126
+ fields: [
127
+ {
128
+ path: "sessionId",
129
+ label: "Random Observe session id",
130
+ exposure: "token",
131
+ transform: {
132
+ pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
133
+ max: 36,
134
+ note: "generated by the SDK; sent to Corbado as the session id of every batch; the cookie carries it to every subdomain of the configured domain"
135
+ }
136
+ },
137
+ {
138
+ path: "lastActiveAt",
139
+ label: "Time of the last tracked activity",
140
+ exposure: "none",
141
+ transform: {
142
+ note: "decides whether the session is still active; not sent to Corbado, but the cookie carries it to every subdomain of the configured domain"
143
+ }
144
+ },
145
+ {
146
+ path: "seqMark",
147
+ label: "Event counter position",
148
+ exposure: "none",
149
+ transform: {
150
+ note: "the next page continues event numbering above it; not sent to Corbado, but the cookie carries it to every subdomain of the configured domain"
151
+ }
152
+ }
153
+ ]
154
+ });
155
+ const contextJar = deps.jar ?? cookieJar({
156
+ key: "storage:sdk.cross-origin-context",
157
+ title: "Reference your integration keeps with the shared session",
158
+ accessNote: "document.cookie, the optional context part of Observe's cbo_xs_ cookie, set and read by the integration",
159
+ group: "Observe SDK state",
160
+ origin: "host-config",
161
+ fields: [
162
+ {
163
+ path: "context",
164
+ label: "Optional reference set by your integration",
165
+ exposure: "none",
166
+ transform: {
167
+ note: "opaque string of at most 128 cookie-safe characters chosen by the integration; not sent to Corbado, but the cookie carries it to every subdomain of the configured domain"
168
+ }
169
+ }
170
+ ]
171
+ });
172
+ return new _CrossOriginSession(`${CROSS_ORIGIN_COOKIE_PREFIX}_${deps.projectId}`, domain, jar, contextJar, deps);
173
+ } catch (error) {
174
+ deps.logger.error("Cross-origin session setup failed", error);
175
+ return void 0;
176
+ }
177
+ }
178
+ /** Whether the cookie is in use on this page (false after a failed write check or {@link stop}). */
179
+ isEnabled() {
180
+ return this.enabled;
181
+ }
182
+ /** No cookie access from now on (tracker destroyed). */
183
+ stop() {
184
+ this.enabled = false;
185
+ }
186
+ /**
187
+ * Decide the session of this load before its first event and claim the cookie for it.
188
+ *
189
+ * `local.lastActiveAt` is the local session's last activity BEFORE this load (0 when the local session
190
+ * was created by this load). The cookie's session is adopted when the local one is new or was active
191
+ * earlier than the cookie's; the same session continues at or above the cookie's mark. A local session
192
+ * that was active more recently takes the cookie over. Never throws; `undefined` when the feature is off.
193
+ */
194
+ boot(local, now = Date.now()) {
195
+ if (!this.enabled) {
196
+ return void 0;
197
+ }
198
+ try {
199
+ const cookie = this.read(this.jar, now);
200
+ let sessionId = local.sessionId;
201
+ let adopted = false;
202
+ if (cookie && cookie.sessionId !== sessionId && (!sessionId || local.lastActiveAt <= 0 || cookie.lastActiveAt > local.lastActiveAt)) {
203
+ sessionId = cookie.sessionId;
204
+ adopted = true;
205
+ }
206
+ let seq = adopted ? cookie?.seqMark ?? 0 : local.seq;
207
+ if (sessionId) {
208
+ const written = this.sync(this.jar, cookie, sessionId, seq, now, true, false);
209
+ seq = written.seq;
210
+ if (!this.isStored(written.value)) {
211
+ this.enabled = false;
212
+ this.deps.logger.debug("Cross-origin session off: the cookie could not be stored");
213
+ this.deps.report("info", "cross-origin session: cookie not stored");
214
+ }
215
+ }
216
+ return { sessionId, seq, adopted };
217
+ } catch (error) {
218
+ this.enabled = false;
219
+ this.deps.logger.error("Cross-origin session boot failed", error);
220
+ this.deps.report("error", "cross-origin session: boot failed");
221
+ return void 0;
222
+ }
223
+ }
224
+ /**
225
+ * Called for every seq allocation with the seq about to be used; returns the seq to use instead. Reads
226
+ * the cookie on every call (a string scan).
227
+ *
228
+ * - Cookie of this session: continue above another page's reservation once one exists above this
229
+ * page's, renew the reservation when it is used up, otherwise refresh the activity at most once per
230
+ * {@link CROSS_ORIGIN_ACTIVITY_WRITE_INTERVAL_MS}. The mark is never lowered.
231
+ * - No (valid) cookie: written as for this session.
232
+ * - Cookie of another session: left alone (see the module notes); the seq stays above the highest mark
233
+ * this page saw for its session.
234
+ *
235
+ * Never throws.
236
+ */
237
+ claim(sessionId, seq, now = Date.now()) {
238
+ if (!this.enabled || !sessionId) {
239
+ return seq;
240
+ }
241
+ try {
242
+ const cookie = this.read(this.jar, now);
243
+ this.follow(sessionId);
244
+ const same = cookie?.sessionId === sessionId;
245
+ const mark = same ? this.plausibleMark(cookie.seqMark, seq) : void 0;
246
+ if (mark !== void 0) {
247
+ this.highestSeen = Math.max(this.highestSeen, mark);
248
+ }
249
+ if (cookie && !same) {
250
+ if (seq < this.reservedMark) {
251
+ return seq;
252
+ }
253
+ const next = Math.max(seq, this.highestSeen) + CROSS_ORIGIN_FOREIGN_GAP;
254
+ this.reservedMark = next + CROSS_ORIGIN_FOREIGN_GAP;
255
+ this.highestSeen = this.reservedMark;
256
+ return next;
257
+ }
258
+ const renew = seq >= this.reservedMark || mark !== void 0 && mark > this.reservedMark;
259
+ if (!renew && now - this.lastWriteAt < CROSS_ORIGIN_ACTIVITY_WRITE_INTERVAL_MS) {
260
+ return seq;
261
+ }
262
+ return this.sync(this.jar, cookie, sessionId, seq, now, renew, false).seq;
263
+ } catch (error) {
264
+ this.deps.logger.error("Cross-origin session cookie update failed", error);
265
+ return seq;
266
+ }
267
+ }
268
+ /** A new session of this page (`resetSession`): the cookie starts over with it, without context. */
269
+ reset(sessionId, now = Date.now()) {
270
+ if (!this.enabled || !sessionId) {
271
+ return;
272
+ }
273
+ try {
274
+ this.reservedFor = void 0;
275
+ this.follow(sessionId);
276
+ this.sync(this.jar, this.read(this.jar, now), sessionId, 0, now, true, true);
277
+ } catch (error) {
278
+ this.deps.logger.error("Cross-origin session cookie reset failed", error);
279
+ }
280
+ }
281
+ /**
282
+ * Store (or with `undefined` clear) the integration context of `sessionId`; this explicit call may take
283
+ * the cookie over from another session. `seq` is the page's best known next seq. Returns the next seq the
284
+ * page must continue at (a renewed reservation can move it up), or `undefined` when nothing was written.
285
+ */
286
+ setContext(sessionId, seq, context, now = Date.now()) {
287
+ if (!this.enabled || !sessionId) {
288
+ return void 0;
289
+ }
290
+ try {
291
+ const cookie = this.read(this.contextJar, now);
292
+ this.follow(sessionId);
293
+ this.context = context;
294
+ return this.sync(this.contextJar, cookie, sessionId, seq, now, seq >= this.reservedMark, true).seq;
295
+ } catch (error) {
296
+ this.deps.logger.error("Cross-origin session context update failed", error);
297
+ return void 0;
298
+ }
299
+ }
300
+ /** The integration context of `sessionId` (the cookie's when it holds that session, else this page's). */
301
+ getContext(sessionId, now = Date.now()) {
302
+ if (!this.enabled || !sessionId) {
303
+ return void 0;
304
+ }
305
+ try {
306
+ const cookie = this.read(this.contextJar, now);
307
+ if (cookie?.sessionId === sessionId) {
308
+ if (this.reservedFor === sessionId) {
309
+ this.context = cookie.context;
310
+ }
311
+ return cookie.context;
312
+ }
313
+ } catch (error) {
314
+ this.deps.logger.error("Cross-origin session context read failed", error);
315
+ }
316
+ return this.reservedFor === sessionId ? this.context : void 0;
317
+ }
318
+ /** Switch the in-memory state to `sessionId` (fresh reservation, nothing seen, no context) when it changed. */
319
+ follow(sessionId) {
320
+ if (this.reservedFor !== sessionId) {
321
+ this.reservedFor = sessionId;
322
+ this.reservedMark = 0;
323
+ this.highestSeen = 0;
324
+ this.context = void 0;
325
+ }
326
+ }
327
+ /** `mark` when it is plausible next to the page's own counter `seq`; a jump beyond the cap is reported and ignored. */
328
+ plausibleMark(mark, seq) {
329
+ if (mark - seq > CROSS_ORIGIN_MAX_SEQ_JUMP) {
330
+ this.deps.report("info", "cross-origin session: implausible seq mark ignored");
331
+ return void 0;
332
+ }
333
+ return mark;
334
+ }
335
+ /**
336
+ * Merge the cookie (already read) with this page's state and write it back for `sessionId`.
337
+ *
338
+ * - `renew`: allocate a new reservation from `seq` (jumping above the cookie's plausible mark of the same
339
+ * session and anything seen before). Otherwise the mark only grows.
340
+ * - `ownContext`: this page's context wins (it was just set); otherwise the cookie's context of the same
341
+ * session is taken over.
342
+ */
343
+ sync(jar, cookie, sessionId, seq, now, renew, ownContext) {
344
+ this.follow(sessionId);
345
+ const same = cookie?.sessionId === sessionId;
346
+ const cookieMark = same ? this.plausibleMark(cookie.seqMark, seq) : void 0;
347
+ if (cookieMark !== void 0) {
348
+ this.highestSeen = Math.max(this.highestSeen, cookieMark);
349
+ }
350
+ if (same && !ownContext) {
351
+ this.context = cookie.context;
352
+ }
353
+ let next = seq;
354
+ let mark;
355
+ if (renew) {
356
+ next = Math.max(seq, cookieMark ?? 0, this.highestSeen);
357
+ mark = next + CROSS_ORIGIN_SEQ_RESERVE;
358
+ } else {
359
+ mark = Math.max(this.reservedMark, this.highestSeen, seq);
360
+ }
361
+ const value = formatCrossOriginCookie({
362
+ sessionId,
363
+ lastActiveAt: now,
364
+ seqMark: mark,
365
+ ...this.context !== void 0 && { context: this.context }
366
+ });
367
+ this.write(jar, value);
368
+ if (renew) {
369
+ this.reservedMark = mark;
370
+ }
371
+ this.highestSeen = Math.max(this.highestSeen, mark);
372
+ this.lastWriteAt = now;
373
+ return { seq: next, value };
374
+ }
375
+ maxAgeSeconds() {
376
+ return Math.max(60, Math.ceil(this.deps.inactivityMs() / 1e3));
377
+ }
378
+ write(jar, value) {
379
+ jar.write(
380
+ `${this.name}=${value}; Domain=${this.domain}; Path=/; Max-Age=${this.maxAgeSeconds()}; SameSite=Lax; Secure`
381
+ );
382
+ }
383
+ /** Raw values of every cookie with this name (another path or a host-only copy can shadow it). */
384
+ rawValues(jar) {
385
+ const prefix = `${this.name}=`;
386
+ return jar.read().split(";").map((part) => part.trim()).filter((part) => part.startsWith(prefix)).map((part) => part.substring(prefix.length));
387
+ }
388
+ isStored(value) {
389
+ try {
390
+ return this.rawValues(this.jar).includes(value);
391
+ } catch {
392
+ return false;
393
+ }
394
+ }
395
+ /** The valid, unexpired record with the latest activity, or `undefined`. Throws when the cookie read throws. */
396
+ read(jar, now) {
397
+ const inactivityMs = this.deps.inactivityMs();
398
+ let best;
399
+ for (const raw of this.rawValues(jar)) {
400
+ const record = parseCrossOriginCookie(raw);
401
+ if (record && now - record.lastActiveAt < inactivityMs && record.lastActiveAt <= now + FUTURE_TOLERANCE_MS && (!best || record.lastActiveAt > best.lastActiveAt)) {
402
+ best = record;
403
+ }
404
+ }
405
+ return best;
406
+ }
407
+ };
408
+ export {
409
+ CROSS_ORIGIN_ACTIVITY_WRITE_INTERVAL_MS,
410
+ CROSS_ORIGIN_CONTEXT_MAX,
411
+ CROSS_ORIGIN_COOKIE_PREFIX,
412
+ CROSS_ORIGIN_COOKIE_VERSION,
413
+ CROSS_ORIGIN_FOREIGN_GAP,
414
+ CROSS_ORIGIN_MAX_SEQ_JUMP,
415
+ CROSS_ORIGIN_MAX_SEQ_MARK,
416
+ CROSS_ORIGIN_SEQ_RESERVE,
417
+ CrossOriginSession,
418
+ formatCrossOriginCookie,
419
+ isValidCrossOriginContext,
420
+ parseCrossOriginCookie,
421
+ resolveCrossOriginDomain
422
+ };
@@ -37,6 +37,7 @@ var RequestQueue = class {
37
37
  this.requestConfig = options.requestConfig ?? true;
38
38
  this.onConfigReceived = options.onConfigReceived;
39
39
  this.getDataPolicy = options.getDataPolicy;
40
+ this.getCrossOriginSession = options.getCrossOriginSession;
40
41
  this.fixedData = options.fixedData === true;
41
42
  this.beforeEventBatchSend = options.beforeEventBatchSend;
42
43
  this.standaloneDeviceInfo = options.standaloneDeviceInfo;
@@ -105,6 +106,7 @@ var RequestQueue = class {
105
106
  requestConfig;
106
107
  onConfigReceived;
107
108
  getDataPolicy;
109
+ getCrossOriginSession;
108
110
  fixedData;
109
111
  beforeEventBatchSend;
110
112
  standaloneDeviceInfo;
@@ -487,6 +489,13 @@ var RequestQueue = class {
487
489
  if (this.fixedData) {
488
490
  batch.meta = { ...batch.meta, fixedData: true };
489
491
  }
492
+ try {
493
+ const crossOriginSession = this.getCrossOriginSession?.(batch.sessionId);
494
+ if (crossOriginSession) {
495
+ batch.meta = { ...batch.meta, crossOriginSession };
496
+ }
497
+ } catch {
498
+ }
490
499
  const retryCount = entries.reduce((max, entry) => Math.max(max, entry.attempts), 0);
491
500
  if (retryCount > 0) {
492
501
  batch.meta = { ...batch.meta, retryCount };
@@ -21,8 +21,12 @@ function readSourcePath(_meta, mode, allow) {
21
21
  const search = applySourcePathQuery(window.location.search, mode, allow);
22
22
  return `${window.location.pathname}${search}`;
23
23
  }
24
+ function readCookieScope(_meta) {
25
+ return { hostname: window.location.hostname, https: window.location.protocol === "https:" };
26
+ }
24
27
  export {
25
28
  listenWindow,
29
+ readCookieScope,
26
30
  readDocumentComplete,
27
31
  readDocumentHidden,
28
32
  readPageUrl,
@@ -1,6 +1,6 @@
1
1
  // src/version.ts
2
2
  var SDK_NAME = "@corbado/observe";
3
- var SDK_VERSION = true ? "1.9.0" : "0.0.0-dev";
3
+ var SDK_VERSION = true ? "1.10.0" : "0.0.0-dev";
4
4
  export {
5
5
  SDK_NAME,
6
6
  SDK_VERSION