@funnelsgrove/runtime 0.7.25 → 0.7.27

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
@@ -55,6 +55,9 @@ documented in [`docs/product-specs/funnel-template-runtime.md`](../../docs/produ
55
55
  - Do not import `@funnelsgrove/payments` or `@funnelsgrove/analytics` here for checkout or transport orchestration.
56
56
  - Runtime UI must be generic and copy-injectable.
57
57
  - Browser-only values must resolve after mount when SSR hydration can be affected.
58
+ - Subscription handoff app and universal links receive the known funnel-user email even when the
59
+ configured template omits `{email}`. Store links never receive email, including case-insensitive
60
+ `email` keys present in configured URLs or first-touch attribution.
58
61
  - Funnel code must not copy `useSyncExternalStore`/`popstate` query-string hooks. Use
59
62
  `useBrowserLocationSearch`; the shared controller also notifies it after
60
63
  `history.pushState` and `history.replaceState` navigation.
@@ -13,6 +13,7 @@ var __rest = (this && this.__rest) || function (s, e) {
13
13
  import { jsx as _jsx } from "react/jsx-runtime";
14
14
  import { createContext, useContext, useEffect, useMemo, useState, } from 'react';
15
15
  import { loadFunnelRuntimeConfig, resolvePublishedFunnelRuntimeConfig, } from '../services/funnel-runtime-config.js';
16
+ import { isPreviewFrameRuntime } from '../services/preview-frame.service.js';
16
17
  const FunnelRuntimeConfigContext = createContext({
17
18
  status: 'fallback',
18
19
  config: null,
@@ -32,6 +33,12 @@ function ActiveFunnelRuntimeConfigBoundary({ funnelId, funnelVersionId, children
32
33
  error: null,
33
34
  });
34
35
  useEffect(() => {
36
+ // The server snapshot cannot see the browser query string, so a preview
37
+ // frame can briefly mount this active boundary during hydration. Avoid a
38
+ // same-origin config request before the parent switches to disabled mode.
39
+ if (isPreviewFrameRuntime()) {
40
+ return undefined;
41
+ }
35
42
  let active = true;
36
43
  let refreshTimer = null;
37
44
  const refresh = () => loadFunnelRuntimeConfig(funnelId, { versionId: funnelVersionId })
@@ -1,3 +1,5 @@
1
+ const LINK_RESOLUTION_BASE = 'https://subscription-handoff.invalid/';
2
+ const ABSOLUTE_URL_PATTERN = /^[a-z][a-z\d+.-]*:/i;
1
3
  const asTrimmedString = (value) => {
2
4
  if (typeof value !== 'string') {
3
5
  return null;
@@ -16,33 +18,86 @@ const resolvePlatform = (userAgent) => {
16
18
  }
17
19
  return 'desktop';
18
20
  };
19
- const applyLinkTemplates = (value, variables) => {
21
+ const normalizedQueryKey = (value) => value.trim().toLowerCase();
22
+ const hasQueryKey = (url, key) => {
23
+ return [...url.searchParams.keys()].some((candidate) => normalizedQueryKey(candidate) === key);
24
+ };
25
+ const removeQueryKey = (url, key) => {
26
+ for (const candidate of [...url.searchParams.keys()]) {
27
+ if (normalizedQueryKey(candidate) === key) {
28
+ url.searchParams.delete(candidate);
29
+ }
30
+ }
31
+ };
32
+ const applyLinkTemplates = (value, variables, includeEmail) => {
20
33
  return value
21
34
  .replace(/\{user_id\}/g, encodeURIComponent(variables.userId))
22
- .replace(/\{email\}/g, variables.email ? encodeURIComponent(variables.email) : '');
35
+ .replace(/\{email\}/g, includeEmail && variables.email ? encodeURIComponent(variables.email) : '');
36
+ };
37
+ const hasRelativeDotSegment = (source) => {
38
+ var _a, _b;
39
+ if (ABSOLUTE_URL_PATTERN.test(source) || source.startsWith('/')) {
40
+ return false;
41
+ }
42
+ const path = (_b = (_a = source.match(/^[^?#]*/)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : '';
43
+ return path.split(/[\\/]/).some((segment) => {
44
+ try {
45
+ return /^\.{1,2}$/.test(decodeURIComponent(segment));
46
+ }
47
+ catch (_a) {
48
+ return false;
49
+ }
50
+ });
51
+ };
52
+ const hasAmbiguousPathBackslash = (source) => {
53
+ var _a, _b;
54
+ return ((_b = (_a = source.match(/^[^?#]*/)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : '').includes('\\');
55
+ };
56
+ const serializeResolvedUrl = (url, source) => {
57
+ if (ABSOLUTE_URL_PATTERN.test(source)) {
58
+ return url.toString();
59
+ }
60
+ if (source.startsWith('//')) {
61
+ return url.toString().slice(url.protocol.length);
62
+ }
63
+ const relativeUrl = `${url.pathname}${url.search}${url.hash}`;
64
+ return source.startsWith('/') ? relativeUrl : relativeUrl.slice(1);
23
65
  };
24
- const resolveUrl = (value, variables, firstTouch) => {
66
+ const resolveUrl = (value, variables, firstTouch, includeEmail = false) => {
25
67
  const normalized = asTrimmedString(value);
26
68
  if (!normalized) {
27
69
  return null;
28
70
  }
71
+ const templated = applyLinkTemplates(normalized, variables, includeEmail);
29
72
  try {
30
- const url = new URL(applyLinkTemplates(normalized, variables));
73
+ if (hasAmbiguousPathBackslash(templated) || hasRelativeDotSegment(templated)) {
74
+ return null;
75
+ }
76
+ const url = new URL(templated, LINK_RESOLUTION_BASE);
31
77
  if (!url.searchParams.has('user_id')) {
32
78
  url.searchParams.set('user_id', variables.userId);
33
79
  }
80
+ if (!includeEmail) {
81
+ removeQueryKey(url, 'email');
82
+ }
83
+ else if (variables.email && !hasQueryKey(url, 'email')) {
84
+ url.searchParams.set('email', variables.email);
85
+ }
34
86
  for (const [key, rawValue] of Object.entries(firstTouch)) {
35
87
  const nextKey = key.trim();
36
88
  const nextValue = rawValue.trim();
37
- if (!nextKey || !nextValue || url.searchParams.has(nextKey)) {
89
+ if (!nextKey
90
+ || !nextValue
91
+ || normalizedQueryKey(nextKey) === 'email'
92
+ || url.searchParams.has(nextKey)) {
38
93
  continue;
39
94
  }
40
95
  url.searchParams.set(nextKey, nextValue);
41
96
  }
42
- return url.toString();
97
+ return serializeResolvedUrl(url, templated);
43
98
  }
44
99
  catch (_a) {
45
- return applyLinkTemplates(normalized, variables);
100
+ return null;
46
101
  }
47
102
  };
48
103
  const buildQrConfirmationUrl = (confirmationUrl, userId) => {
@@ -87,9 +142,9 @@ export const resolveSubscriptionHandoff = (input) => {
87
142
  const variables = { userId, email };
88
143
  const iosStoreUrl = resolveUrl(input.config.iosAppStoreUrl, variables, firstTouch);
89
144
  const androidStoreUrl = resolveUrl(input.config.androidPlayStoreUrl, variables, firstTouch);
90
- const iosDeepLink = resolveUrl(input.config.iosDeepLink, variables, firstTouch);
91
- const androidDeepLink = resolveUrl(input.config.androidDeepLink, variables, firstTouch);
92
- const universalLink = resolveUrl(input.config.universalLink, variables, firstTouch);
145
+ const iosDeepLink = resolveUrl(input.config.iosDeepLink, variables, firstTouch, true);
146
+ const androidDeepLink = resolveUrl(input.config.androidDeepLink, variables, firstTouch, true);
147
+ const universalLink = resolveUrl(input.config.universalLink, variables, firstTouch, true);
93
148
  const openAppUrl = platform === 'ios'
94
149
  ? iosDeepLink || universalLink || iosStoreUrl
95
150
  : platform === 'android'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.7.25",
3
+ "version": "0.7.27",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/The-Solid-Grove/funnelsgrove.git",