@learncard/partner-connect 0.2.16 → 0.3.1

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/dist/index.d.ts CHANGED
@@ -10,44 +10,73 @@ export { AppEvent, AppEventResponse, CheckCredentialEvent, SendCredentialEvent }
10
10
  */
11
11
  interface PartnerConnectOptions {
12
12
  /**
13
- * The origin(s) of the LearnCard host
14
- *
15
- * This can be a single string or an array of strings to serve as a whitelist for
16
- * the `lc_host_override` query parameter.
17
- *
18
- * **Origin Configuration Hierarchy:**
19
- * 1. **Hardcoded Default**: `https://learncard.app` (security anchor)
20
- * 2. **Query Parameter Override**: `?lc_host_override=https://staging.learncard.app`
21
- * - Checked against whitelist if `hostOrigin` is provided
22
- * - Used for staging/testing environments
23
- * 3. **Configured Origin**: First value in array or single string value
24
- *
25
- * **Security Model:**
26
- * - The SDK enforces STRICT origin validation
27
- * - Incoming messages must EXACTLY match the active host origin
28
- * - Prevents origin spoofing: even if malicious query param is added,
29
- * messages from unauthorized origins are rejected
13
+ * The origin(s) of the LearnCard host.
14
+ *
15
+ * Each entry may be either an **exact origin** (`https://learncard.app`) or a
16
+ * **wildcard pattern** where `*` stands in for a single DNS label portion in
17
+ * the host portion of the origin (e.g. `https://*.learncard.app`,
18
+ * `https://*.vetpass.app`). Wildcards are **only** allowed in the host and
19
+ * only as label(s); the protocol and port must always match exactly.
20
+ *
21
+ * Wildcard patterns match any non-empty chain of labels. So
22
+ * `https://*.learncard.app` matches both `https://staging.learncard.app` and
23
+ * `https://pr-123.preview.learncard.app`, but **not** `https://learncard.app`
24
+ * itself (include the bare origin explicitly if you need it) and **not**
25
+ * `https://evil.learncard.app.attacker.com` (the suffix must match).
26
+ *
27
+ * **Origin Configuration Hierarchy at runtime:**
28
+ * 1. `window.location.ancestorOrigins[0]` (when available) — the real parent
29
+ * origin as reported by the browser, validated against the effective
30
+ * whitelist. This source cannot be spoofed by a malicious query param.
31
+ * 2. `?lc_host_override=<origin>` query parameter — validated against the
32
+ * whitelist. Used by the LearnCard host to tell the SDK which origin it
33
+ * is loading from.
34
+ * 3. `sessionStorage['lc_host_override']` — a previously-validated override
35
+ * persisted across in-iframe navigation.
36
+ * 4. First value in the configured `hostOrigin` array / single string.
37
+ * 5. `PartnerConnect.DEFAULT_HOST_ORIGIN` (`https://learncard.app`).
38
+ *
39
+ * The partner app's configured whitelist is combined with a small built-in
40
+ * list of LearnCard tenant domains (see `disableDefaultTenants` to opt out).
41
+ * This lets a partner app work out-of-the-box inside any current or future
42
+ * `*.learncard.app`, `*.learncard.ai`, or `*.vetpass.app` tenant without a
43
+ * re-deploy.
30
44
  *
31
45
  * **Examples:**
32
46
  *
33
- * Single origin (production):
47
+ * Single origin (production only):
34
48
  * ```typescript
35
49
  * hostOrigin: 'https://learncard.app'
36
- * // Uses: https://learncard.app
37
- * // Override: ?lc_host_override=https://staging.learncard.app (not validated)
38
50
  * ```
39
51
  *
40
- * Multiple origins (whitelist for staging):
52
+ * Wildcard whitelist (covers staging + preview):
53
+ * ```typescript
54
+ * hostOrigin: ['https://learncard.app', 'https://*.learncard.app']
55
+ * ```
56
+ *
57
+ * Custom tenant alongside the built-in LearnCard defaults:
41
58
  * ```typescript
42
- * hostOrigin: ['https://learncard.app', 'https://staging.learncard.app']
43
- * // Default: https://learncard.app
44
- * // Override: ?lc_host_override=https://staging.learncard.app (validated)
45
- * // Invalid: ?lc_host_override=https://evil.com (rejected)
59
+ * hostOrigin: ['https://partner.example.com']
60
+ * // learncard.app / *.learncard.app / *.learncard.ai / vetpass.app / *.vetpass.app
61
+ * // are ALSO trusted because disableDefaultTenants is false.
46
62
  * ```
47
63
  *
48
64
  * @default 'https://learncard.app'
49
65
  */
50
66
  hostOrigin?: string | string[];
67
+ /**
68
+ * Opt out of the built-in LearnCard tenant whitelist.
69
+ *
70
+ * By default, the SDK merges `hostOrigin` with a curated list of LearnCard
71
+ * and tenant domains (see `PartnerConnect.DEFAULT_TRUSTED_TENANTS`) so that
72
+ * partner apps work on any LearnCard-managed tenant without reconfiguration.
73
+ *
74
+ * Set to `true` if you want the partner app to **only** trust the origins
75
+ * you pass in `hostOrigin`.
76
+ *
77
+ * @default false
78
+ */
79
+ disableDefaultTenants?: boolean;
51
80
  /**
52
81
  * Whether to allow native app origins (default: true)
53
82
  *
@@ -325,11 +354,14 @@ interface SummaryCredentialSkill {
325
354
  }
326
355
  /**
327
356
  * Next step item in summary credential data
357
+ *
358
+ * `keywords` is optional. Omit it entirely (or pass `undefined`) when you do not
359
+ * have taxonomy data — you no longer need to pass a struct of `null` fields.
328
360
  */
329
361
  interface SummaryCredentialNextStep {
330
362
  title: string;
331
363
  description: string;
332
- keywords: SummaryCredentialKeyword;
364
+ keywords?: SummaryCredentialKeyword;
333
365
  }
334
366
  /**
335
367
  * Reflection item in summary credential data
@@ -431,12 +463,55 @@ interface GetCountersResponse {
431
463
  */
432
464
  type ErrorCode = 'LC_TIMEOUT' | 'LC_UNAUTHENTICATED' | 'CREDENTIAL_NOT_FOUND' | 'USER_REJECTED' | 'UNAUTHORIZED' | 'TEMPLATE_NOT_FOUND' | 'BOOST_NOT_FOUND' | 'INSUFFICIENT_PERMISSIONS' | string;
433
465
  /**
434
- * Error object returned when a request fails
466
+ * Error object returned when a request fails.
467
+ *
468
+ * Historically the SDK rejected with a plain `{ code, message }` object. As of
469
+ * v0.3.0 we reject with a {@link PartnerConnectError} instance instead, which
470
+ * still satisfies this interface (it has both `code` and `message` fields), so
471
+ * existing consumers that do `if (err.code === '...')` continue to work
472
+ * unchanged.
435
473
  */
436
474
  interface LearnCardError {
437
475
  code: ErrorCode;
438
476
  message: string;
439
477
  }
478
+ /**
479
+ * Typed error class for all Partner Connect SDK rejections.
480
+ *
481
+ * Use `instanceof PartnerConnectError` to narrow caught errors and unlock
482
+ * exhaustive `switch` checks on `code`. Both `code` and `message` are present
483
+ * (so the legacy `LearnCardError` object shape is preserved), and `name` is
484
+ * always `'PartnerConnectError'`.
485
+ *
486
+ * @example
487
+ * ```typescript
488
+ * try {
489
+ * await learnCard.requestLearnerContext();
490
+ * } catch (err) {
491
+ * if (err instanceof PartnerConnectError) {
492
+ * switch (err.code) {
493
+ * case 'LC_UNAUTHENTICATED': showLogin(); break;
494
+ * case 'USER_REJECTED': showPrivacyNotice(); break;
495
+ * case 'UNAUTHORIZED': showPermissionsError(); break;
496
+ * default: console.error(err);
497
+ * }
498
+ * }
499
+ * }
500
+ * ```
501
+ */
502
+ declare class PartnerConnectError extends Error implements LearnCardError {
503
+ readonly code: ErrorCode;
504
+ constructor(code: ErrorCode, message: string);
505
+ /**
506
+ * Wrap any incoming `LearnCardError`-shaped value into a `PartnerConnectError`.
507
+ * Returns the value unchanged if it is already an instance.
508
+ *
509
+ * Used internally at every reject site so callers always receive a typed
510
+ * `PartnerConnectError`, regardless of whether the failure originated from
511
+ * the host (over postMessage), an SDK timeout, or `destroy()`.
512
+ */
513
+ static from(input: LearnCardError | unknown): PartnerConnectError;
514
+ }
440
515
  /**
441
516
  * Internal message structure sent via postMessage
442
517
  */
@@ -491,6 +566,19 @@ interface PendingRequest {
491
566
  declare class PartnerConnect {
492
567
  /** Default host origin (security anchor) */
493
568
  static readonly DEFAULT_HOST_ORIGIN = "https://learncard.app";
569
+ /**
570
+ * Built-in list of LearnCard-managed tenant origins.
571
+ *
572
+ * These are merged with the partner app's configured `hostOrigin` whitelist
573
+ * unless `disableDefaultTenants: true` is passed. This lets a partner app
574
+ * run inside any current or future LearnCard tenant (staging, preview,
575
+ * VetPass, etc.) without needing a re-deploy each time a new tenant is
576
+ * onboarded.
577
+ *
578
+ * Patterns follow the same rules as user-supplied `hostOrigin` entries:
579
+ * `*` is a wildcard for one or more DNS labels in the host portion.
580
+ */
581
+ static readonly DEFAULT_TRUSTED_TENANTS: readonly string[];
494
582
  private hostOrigins;
495
583
  private activeHostOrigin;
496
584
  private allowNativeAppOrigins;
@@ -502,22 +590,60 @@ declare class PartnerConnect {
502
590
  constructor(options?: PartnerConnectOptions);
503
591
  /**
504
592
  * Configure the active host origin using the following hierarchy:
505
- * 1. Check for `lc_host_override` query parameter (for staging/testing)
506
- * 2. Check sessionStorage for a previously stored override (survives in-app navigation)
507
- * 3. Fall back to first configured origin
508
- * 4. Fall back to DEFAULT_HOST_ORIGIN
509
- *
510
- * When a valid override is found in the query parameter, it is persisted to
511
- * sessionStorage so that subsequent page navigations within the same tab
512
- * automatically use the same override without requiring it in every URL.
513
- *
514
- * This origin will be used for all outgoing messages and incoming message validation.
593
+ * 1. `window.location.ancestorOrigins[0]` (when supported) — the browser's
594
+ * view of who our parent frame is. Cannot be forged by a malicious
595
+ * `lc_host_override` query param and therefore takes precedence.
596
+ * 2. `?lc_host_override=<origin>` query param (for staging / cross-tenant).
597
+ * 3. `sessionStorage` value saved from a previously-validated override.
598
+ * 4. First configured origin.
599
+ * 5. `DEFAULT_HOST_ORIGIN`.
600
+ *
601
+ * When a valid override is found in the query parameter, it is persisted
602
+ * to sessionStorage so subsequent in-iframe navigations in the same tab
603
+ * continue to use the same active origin.
515
604
  */
516
605
  private static readonly SESSION_STORAGE_KEY;
606
+ /**
607
+ * Read `window.location.ancestorOrigins[0]` without throwing if the
608
+ * property is unavailable (Firefox) or the list is empty (top-level
609
+ * context, e.g. running outside of an iframe).
610
+ */
611
+ private readAncestorOrigin;
517
612
  private configureActiveOrigin;
613
+ private persistOverride;
518
614
  private isOriginNativeApp;
519
615
  /**
520
- * Check if an origin is in the configured whitelist
616
+ * Internal placeholder substituted in for `*` so that `new URL(...)` can
617
+ * parse a wildcard pattern. Chosen to be a syntactically-valid DNS label
618
+ * that cannot collide with a real hostname.
619
+ */
620
+ private static readonly WILDCARD_PLACEHOLDER;
621
+ /** `*` (any number of occurrences) for replacement in the pattern. */
622
+ private static readonly WILDCARD_REGEX;
623
+ /** The required leading-label form a wildcard pattern must take. */
624
+ private static readonly WILDCARD_LEADING_PREFIX;
625
+ /**
626
+ * Check whether a candidate origin matches a configured whitelist entry.
627
+ *
628
+ * Supports exact matches and wildcard patterns. A wildcard entry has the
629
+ * form `<protocol>://*.<domain>` and matches any origin with the same
630
+ * protocol, same port, and a host ending in `.<domain>` with at least
631
+ * one non-empty DNS label in place of the `*`.
632
+ *
633
+ * Examples with pattern `https://*.learncard.app`:
634
+ * - `https://staging.learncard.app` → match
635
+ * - `https://pr-1.preview.learncard.app` → match
636
+ * - `https://learncard.app` → no match (no subdomain)
637
+ * - `http://staging.learncard.app` → no match (protocol mismatch)
638
+ * - `https://learncard.app.attacker.com` → no match (suffix mismatch)
639
+ *
640
+ * Exposed as a public static so it can be unit-tested directly without
641
+ * standing up a full SDK instance.
642
+ */
643
+ static matchesOriginPattern(candidate: string, pattern: string): boolean;
644
+ /**
645
+ * Check if an origin is in the effective whitelist (exact origins +
646
+ * wildcard patterns + optional native-app origins).
521
647
  */
522
648
  private isOriginInWhitelist;
523
649
  /**
@@ -851,4 +977,4 @@ declare class PartnerConnect {
851
977
  */
852
978
  declare function createPartnerConnect(options?: PartnerConnectOptions): PartnerConnect;
853
979
 
854
- export { AppNotificationInput, AppNotificationResponse, CheckCredentialInput, CheckCredentialResponse, CheckIssuanceStatusInput, ConsentResponse, CredentialSearchResponse, CredentialSpecificResponse, ErrorCode, GetCounterResponse, GetCountersResponse, GetTemplateRecipientsInput, IdentityResponse, IncrementCounterResponse, LearnCardError, LearnerContextRawData, LearnerContextResponse, PartnerConnect, PartnerConnectOptions, PendingRequest, PostMessageRequest, PostMessageResponse, RequestConsentOptions, RequestConsentPayload, RequestLearnerContextOptions, SendAiSessionCredentialInput, SendAiSessionCredentialResponse, SendCredentialResponse, SummaryCredentialData, SummaryCredentialKeyword, SummaryCredentialNextStep, SummaryCredentialReflection, SummaryCredentialSkill, TemplateCredentialInput, TemplateCredentialResponse, TemplateIssuanceStatusResponse, TemplateIssueResponse, TemplateRecipientRecord, TemplateRecipientsResponse, VPRQuery, VerifiablePresentationRequest, createPartnerConnect, createPartnerConnect as default };
980
+ export { AppNotificationInput, AppNotificationResponse, CheckCredentialInput, CheckCredentialResponse, CheckIssuanceStatusInput, ConsentResponse, CredentialSearchResponse, CredentialSpecificResponse, ErrorCode, GetCounterResponse, GetCountersResponse, GetTemplateRecipientsInput, IdentityResponse, IncrementCounterResponse, LearnCardError, LearnerContextRawData, LearnerContextResponse, PartnerConnect, PartnerConnectError, PartnerConnectOptions, PendingRequest, PostMessageRequest, PostMessageResponse, RequestConsentOptions, RequestConsentPayload, RequestLearnerContextOptions, SendAiSessionCredentialInput, SendAiSessionCredentialResponse, SendCredentialResponse, SummaryCredentialData, SummaryCredentialKeyword, SummaryCredentialNextStep, SummaryCredentialReflection, SummaryCredentialSkill, TemplateCredentialInput, TemplateCredentialResponse, TemplateIssuanceStatusResponse, TemplateIssueResponse, TemplateRecipientRecord, TemplateRecipientsResponse, VPRQuery, VerifiablePresentationRequest, createPartnerConnect, createPartnerConnect as default };
@@ -1,3 +1,36 @@
1
+ var __defProp$1 = Object.defineProperty;
2
+ var __defNormalProp$1 = (obj, key, value) => key in obj ? __defProp$1(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField$1 = (obj, key, value) => __defNormalProp$1(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+ class PartnerConnectError extends Error {
5
+ constructor(code, message) {
6
+ super(message);
7
+ __publicField$1(this, "code");
8
+ this.name = "PartnerConnectError";
9
+ this.code = code;
10
+ Object.setPrototypeOf(this, PartnerConnectError.prototype);
11
+ }
12
+ /**
13
+ * Wrap any incoming `LearnCardError`-shaped value into a `PartnerConnectError`.
14
+ * Returns the value unchanged if it is already an instance.
15
+ *
16
+ * Used internally at every reject site so callers always receive a typed
17
+ * `PartnerConnectError`, regardless of whether the failure originated from
18
+ * the host (over postMessage), an SDK timeout, or `destroy()`.
19
+ */
20
+ static from(input) {
21
+ if (input instanceof PartnerConnectError) return input;
22
+ if (input && typeof input === "object" && "code" in input && typeof input.code === "string") {
23
+ const candidate = input;
24
+ const message = typeof candidate.message === "string" ? candidate.message : "Partner Connect request failed";
25
+ return new PartnerConnectError(candidate.code, message);
26
+ }
27
+ return new PartnerConnectError(
28
+ "UNKNOWN_ERROR",
29
+ input instanceof Error ? input.message : "An unknown error occurred"
30
+ );
31
+ }
32
+ }
33
+
1
34
  var __defProp = Object.defineProperty;
2
35
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
36
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
@@ -11,68 +44,160 @@ const _PartnerConnect = class _PartnerConnect {
11
44
  __publicField(this, "pendingRequests");
12
45
  __publicField(this, "messageListener", null);
13
46
  __publicField(this, "isInitialized", false);
14
- var _a;
15
- const hostOrigin = (options == null ? void 0 : options.hostOrigin) || _PartnerConnect.DEFAULT_HOST_ORIGIN;
16
- this.hostOrigins = Array.isArray(hostOrigin) ? hostOrigin : [hostOrigin];
47
+ var _a, _b;
48
+ const hostOrigin = (_a = options == null ? void 0 : options.hostOrigin) != null ? _a : _PartnerConnect.DEFAULT_HOST_ORIGIN;
49
+ const configured = Array.isArray(hostOrigin) ? hostOrigin : [hostOrigin];
50
+ const disableDefaults = (options == null ? void 0 : options.disableDefaultTenants) === true;
51
+ const merged = disableDefaults ? [...configured] : [...configured, ..._PartnerConnect.DEFAULT_TRUSTED_TENANTS];
52
+ this.hostOrigins = Array.from(new Set(merged));
17
53
  this.protocol = (options == null ? void 0 : options.protocol) || "LEARNCARD_V1";
18
54
  this.requestTimeout = (options == null ? void 0 : options.requestTimeout) || 3e4;
19
- this.allowNativeAppOrigins = (_a = options == null ? void 0 : options.allowNativeAppOrigins) != null ? _a : true;
55
+ this.allowNativeAppOrigins = (_b = options == null ? void 0 : options.allowNativeAppOrigins) != null ? _b : true;
20
56
  this.pendingRequests = /* @__PURE__ */ new Map();
21
57
  this.configureActiveOrigin();
22
58
  this.setupMessageListener();
23
59
  }
60
+ /**
61
+ * Read `window.location.ancestorOrigins[0]` without throwing if the
62
+ * property is unavailable (Firefox) or the list is empty (top-level
63
+ * context, e.g. running outside of an iframe).
64
+ */
65
+ readAncestorOrigin() {
66
+ if (typeof window === "undefined") return null;
67
+ try {
68
+ const ancestors = window.location.ancestorOrigins;
69
+ if (ancestors && ancestors.length > 0) {
70
+ const parent = ancestors[0];
71
+ if (typeof parent === "string" && parent.length > 0) {
72
+ return parent;
73
+ }
74
+ }
75
+ } catch {
76
+ }
77
+ return null;
78
+ }
24
79
  configureActiveOrigin() {
25
80
  if (typeof window === "undefined") {
26
81
  this.activeHostOrigin = this.hostOrigins[0] || _PartnerConnect.DEFAULT_HOST_ORIGIN;
27
82
  return;
28
83
  }
29
84
  try {
85
+ const ancestorOrigin = this.readAncestorOrigin();
30
86
  const urlParams = new URLSearchParams(window.location.search);
31
87
  const hostOverride = urlParams.get("lc_host_override");
32
- if (hostOverride) {
33
- if (this.hostOrigins.length > 0 && !this.isOriginInWhitelist(hostOverride)) {
88
+ if (ancestorOrigin && this.isOriginInWhitelist(ancestorOrigin)) {
89
+ if (hostOverride && hostOverride !== ancestorOrigin) {
34
90
  console.warn(
35
- "[LearnCard SDK] lc_host_override value is not in the configured whitelist:",
36
- hostOverride,
37
- "Allowed:",
38
- this.hostOrigins
91
+ "[LearnCard SDK] lc_host_override does not match the real parent origin; preferring parent.",
92
+ { override: hostOverride, parent: ancestorOrigin }
39
93
  );
40
- this.activeHostOrigin = this.hostOrigins[0] || _PartnerConnect.DEFAULT_HOST_ORIGIN;
41
- } else {
94
+ }
95
+ this.activeHostOrigin = ancestorOrigin;
96
+ this.persistOverride(ancestorOrigin);
97
+ console.log("[LearnCard SDK] Using parent origin:", ancestorOrigin);
98
+ return;
99
+ }
100
+ if (hostOverride) {
101
+ if (this.isOriginInWhitelist(hostOverride)) {
42
102
  this.activeHostOrigin = hostOverride;
43
- try {
44
- sessionStorage.setItem(_PartnerConnect.SESSION_STORAGE_KEY, hostOverride);
45
- } catch {
46
- }
103
+ this.persistOverride(hostOverride);
47
104
  console.log("[LearnCard SDK] Using lc_host_override:", hostOverride);
105
+ return;
48
106
  }
49
- } else {
50
- let storedOverride = null;
51
- try {
52
- storedOverride = sessionStorage.getItem(_PartnerConnect.SESSION_STORAGE_KEY);
53
- } catch {
54
- }
55
- if (storedOverride && this.isOriginInWhitelist(storedOverride)) {
56
- this.activeHostOrigin = storedOverride;
57
- console.log("[LearnCard SDK] Using stored lc_host_override:", storedOverride);
58
- } else {
59
- this.activeHostOrigin = this.hostOrigins[0] || _PartnerConnect.DEFAULT_HOST_ORIGIN;
60
- console.log("[LearnCard SDK] Using configured origin:", this.activeHostOrigin);
61
- }
107
+ console.warn(
108
+ "[LearnCard SDK] lc_host_override value is not in the configured whitelist:",
109
+ hostOverride,
110
+ "Allowed:",
111
+ this.hostOrigins
112
+ );
62
113
  }
114
+ let storedOverride = null;
115
+ try {
116
+ storedOverride = sessionStorage.getItem(_PartnerConnect.SESSION_STORAGE_KEY);
117
+ } catch {
118
+ }
119
+ if (storedOverride && this.isOriginInWhitelist(storedOverride)) {
120
+ this.activeHostOrigin = storedOverride;
121
+ console.log("[LearnCard SDK] Using stored lc_host_override:", storedOverride);
122
+ return;
123
+ }
124
+ this.activeHostOrigin = this.hostOrigins[0] || _PartnerConnect.DEFAULT_HOST_ORIGIN;
125
+ console.log("[LearnCard SDK] Using configured origin:", this.activeHostOrigin);
63
126
  } catch (error) {
64
127
  console.error("[LearnCard SDK] Error configuring active origin:", error);
65
128
  this.activeHostOrigin = this.hostOrigins[0] || _PartnerConnect.DEFAULT_HOST_ORIGIN;
66
129
  }
67
130
  }
131
+ persistOverride(origin) {
132
+ try {
133
+ sessionStorage.setItem(_PartnerConnect.SESSION_STORAGE_KEY, origin);
134
+ } catch {
135
+ }
136
+ }
68
137
  isOriginNativeApp(origin) {
69
138
  return origin.startsWith("capacitor://") || origin.startsWith("ionic://") || origin.startsWith("https://localhost") || origin.startsWith("http://localhost") || origin.startsWith("http://127.0.0.1");
70
139
  }
71
140
  /**
72
- * Check if an origin is in the configured whitelist
141
+ * Check whether a candidate origin matches a configured whitelist entry.
142
+ *
143
+ * Supports exact matches and wildcard patterns. A wildcard entry has the
144
+ * form `<protocol>://*.<domain>` and matches any origin with the same
145
+ * protocol, same port, and a host ending in `.<domain>` with at least
146
+ * one non-empty DNS label in place of the `*`.
147
+ *
148
+ * Examples with pattern `https://*.learncard.app`:
149
+ * - `https://staging.learncard.app` → match
150
+ * - `https://pr-1.preview.learncard.app` → match
151
+ * - `https://learncard.app` → no match (no subdomain)
152
+ * - `http://staging.learncard.app` → no match (protocol mismatch)
153
+ * - `https://learncard.app.attacker.com` → no match (suffix mismatch)
154
+ *
155
+ * Exposed as a public static so it can be unit-tested directly without
156
+ * standing up a full SDK instance.
157
+ */
158
+ static matchesOriginPattern(candidate, pattern) {
159
+ if (candidate === pattern) return true;
160
+ if (!pattern.includes("*")) return false;
161
+ let patternUrl;
162
+ let candidateUrl;
163
+ try {
164
+ patternUrl = new URL(
165
+ pattern.replace(
166
+ _PartnerConnect.WILDCARD_REGEX,
167
+ _PartnerConnect.WILDCARD_PLACEHOLDER
168
+ )
169
+ );
170
+ candidateUrl = new URL(candidate);
171
+ } catch {
172
+ return false;
173
+ }
174
+ if (patternUrl.protocol !== candidateUrl.protocol) return false;
175
+ if (patternUrl.port !== candidateUrl.port) return false;
176
+ const patternHost = patternUrl.hostname;
177
+ const candidateHost = candidateUrl.hostname;
178
+ if (!patternHost.startsWith(_PartnerConnect.WILDCARD_LEADING_PREFIX)) return false;
179
+ const patternSuffix = patternHost.slice(_PartnerConnect.WILDCARD_LEADING_PREFIX.length);
180
+ if (patternSuffix.length === 0) return false;
181
+ if (patternSuffix.includes(_PartnerConnect.WILDCARD_PLACEHOLDER)) return false;
182
+ const required = "." + patternSuffix;
183
+ if (!candidateHost.endsWith(required)) return false;
184
+ const prefix = candidateHost.slice(0, candidateHost.length - required.length);
185
+ if (prefix.length === 0) return false;
186
+ if (prefix.startsWith(".") || prefix.endsWith(".")) return false;
187
+ if (prefix.split(".").some((label) => label.length === 0)) return false;
188
+ return true;
189
+ }
190
+ /**
191
+ * Check if an origin is in the effective whitelist (exact origins +
192
+ * wildcard patterns + optional native-app origins).
73
193
  */
74
194
  isOriginInWhitelist(origin) {
75
- return this.hostOrigins.includes(origin) || this.allowNativeAppOrigins && this.isOriginNativeApp(origin);
195
+ if (!origin) return false;
196
+ for (const entry of this.hostOrigins) {
197
+ if (_PartnerConnect.matchesOriginPattern(origin, entry)) return true;
198
+ }
199
+ if (this.allowNativeAppOrigins && this.isOriginNativeApp(origin)) return true;
200
+ return false;
76
201
  }
77
202
  /**
78
203
  * Check if an event origin is valid against the active host origin
@@ -111,7 +236,12 @@ const _PartnerConnect = class _PartnerConnect {
111
236
  pending.resolve(data.data);
112
237
  } else if (data.type === "ERROR") {
113
238
  pending.reject(
114
- data.error || { code: "UNKNOWN_ERROR", message: "An unknown error occurred" }
239
+ PartnerConnectError.from(
240
+ data.error || {
241
+ code: "UNKNOWN_ERROR",
242
+ message: "An unknown error occurred"
243
+ }
244
+ )
115
245
  );
116
246
  }
117
247
  };
@@ -129,20 +259,21 @@ const _PartnerConnect = class _PartnerConnect {
129
259
  */
130
260
  sendMessage(action, payload) {
131
261
  if (!this.isInitialized) {
132
- return Promise.reject({
133
- code: "SDK_NOT_INITIALIZED",
134
- message: "SDK is not initialized"
135
- });
262
+ return Promise.reject(
263
+ new PartnerConnectError("SDK_NOT_INITIALIZED", "SDK is not initialized")
264
+ );
136
265
  }
137
266
  return new Promise((resolve, reject) => {
138
267
  const requestId = this.generateRequestId(action);
139
268
  const timeoutId = setTimeout(() => {
140
269
  if (this.pendingRequests.has(requestId)) {
141
270
  this.pendingRequests.delete(requestId);
142
- reject({
143
- code: "LC_TIMEOUT",
144
- message: `Request ${action} timed out after ${this.requestTimeout}ms`
145
- });
271
+ reject(
272
+ new PartnerConnectError(
273
+ "LC_TIMEOUT",
274
+ `Request ${action} timed out after ${this.requestTimeout}ms`
275
+ )
276
+ );
146
277
  }
147
278
  }, this.requestTimeout);
148
279
  this.pendingRequests.set(requestId, {
@@ -544,10 +675,12 @@ const _PartnerConnect = class _PartnerConnect {
544
675
  }
545
676
  for (const [requestId, pending] of this.pendingRequests.entries()) {
546
677
  clearTimeout(pending.timeoutId);
547
- pending.reject({
548
- code: "SDK_DESTROYED",
549
- message: "SDK was destroyed before request completed"
550
- });
678
+ pending.reject(
679
+ new PartnerConnectError(
680
+ "SDK_DESTROYED",
681
+ "SDK was destroyed before request completed"
682
+ )
683
+ );
551
684
  }
552
685
  this.pendingRequests.clear();
553
686
  this.isInitialized = false;
@@ -556,23 +689,53 @@ const _PartnerConnect = class _PartnerConnect {
556
689
  /** Default host origin (security anchor) */
557
690
  __publicField(_PartnerConnect, "DEFAULT_HOST_ORIGIN", "https://learncard.app");
558
691
  /**
559
- * Configure the active host origin using the following hierarchy:
560
- * 1. Check for `lc_host_override` query parameter (for staging/testing)
561
- * 2. Check sessionStorage for a previously stored override (survives in-app navigation)
562
- * 3. Fall back to first configured origin
563
- * 4. Fall back to DEFAULT_HOST_ORIGIN
692
+ * Built-in list of LearnCard-managed tenant origins.
564
693
  *
565
- * When a valid override is found in the query parameter, it is persisted to
566
- * sessionStorage so that subsequent page navigations within the same tab
567
- * automatically use the same override without requiring it in every URL.
694
+ * These are merged with the partner app's configured `hostOrigin` whitelist
695
+ * unless `disableDefaultTenants: true` is passed. This lets a partner app
696
+ * run inside any current or future LearnCard tenant (staging, preview,
697
+ * VetPass, etc.) without needing a re-deploy each time a new tenant is
698
+ * onboarded.
568
699
  *
569
- * This origin will be used for all outgoing messages and incoming message validation.
700
+ * Patterns follow the same rules as user-supplied `hostOrigin` entries:
701
+ * `*` is a wildcard for one or more DNS labels in the host portion.
702
+ */
703
+ __publicField(_PartnerConnect, "DEFAULT_TRUSTED_TENANTS", [
704
+ "https://learncard.app",
705
+ "https://*.learncard.app",
706
+ "https://*.learncard.ai",
707
+ "https://vetpass.app",
708
+ "https://*.vetpass.app"
709
+ ]);
710
+ /**
711
+ * Configure the active host origin using the following hierarchy:
712
+ * 1. `window.location.ancestorOrigins[0]` (when supported) — the browser's
713
+ * view of who our parent frame is. Cannot be forged by a malicious
714
+ * `lc_host_override` query param and therefore takes precedence.
715
+ * 2. `?lc_host_override=<origin>` query param (for staging / cross-tenant).
716
+ * 3. `sessionStorage` value saved from a previously-validated override.
717
+ * 4. First configured origin.
718
+ * 5. `DEFAULT_HOST_ORIGIN`.
719
+ *
720
+ * When a valid override is found in the query parameter, it is persisted
721
+ * to sessionStorage so subsequent in-iframe navigations in the same tab
722
+ * continue to use the same active origin.
570
723
  */
571
724
  __publicField(_PartnerConnect, "SESSION_STORAGE_KEY", "lc_host_override");
725
+ /**
726
+ * Internal placeholder substituted in for `*` so that `new URL(...)` can
727
+ * parse a wildcard pattern. Chosen to be a syntactically-valid DNS label
728
+ * that cannot collide with a real hostname.
729
+ */
730
+ __publicField(_PartnerConnect, "WILDCARD_PLACEHOLDER", "__lc_wildcard__");
731
+ /** `*` (any number of occurrences) for replacement in the pattern. */
732
+ __publicField(_PartnerConnect, "WILDCARD_REGEX", /\*/g);
733
+ /** The required leading-label form a wildcard pattern must take. */
734
+ __publicField(_PartnerConnect, "WILDCARD_LEADING_PREFIX", `${_PartnerConnect.WILDCARD_PLACEHOLDER}.`);
572
735
  let PartnerConnect = _PartnerConnect;
573
736
  function createPartnerConnect(options) {
574
737
  return new PartnerConnect(options);
575
738
  }
576
739
 
577
- export { PartnerConnect, createPartnerConnect, createPartnerConnect as default };
740
+ export { PartnerConnect, PartnerConnectError, createPartnerConnect, createPartnerConnect as default };
578
741
  //# sourceMappingURL=partner-connect.esm.js.map