@syndew/oluso-sdk 0.0.0-stage → 0.1.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.
package/dist/index.mjs ADDED
@@ -0,0 +1,2095 @@
1
+ // src/client.ts
2
+ var OlusoClient = class {
3
+ constructor(config) {
4
+ this.listeners = /* @__PURE__ */ new Map();
5
+ if (!config.serverUrl) {
6
+ throw new Error("serverUrl is required");
7
+ }
8
+ this.config = {
9
+ ...config,
10
+ serverUrl: config.serverUrl.replace(/\/$/, "")
11
+ // Remove trailing slash
12
+ };
13
+ this.fetchFn = config.fetch || globalThis.fetch.bind(globalThis);
14
+ }
15
+ /**
16
+ * Get journey information including form fields. Journeys are named by slug.
17
+ */
18
+ async getJourney(slug) {
19
+ const response = await this.request(`/api/public/journeys/${encodeURIComponent(slug)}`);
20
+ return response;
21
+ }
22
+ /**
23
+ * Submit data to a data collection journey (waitlist, contact, survey, etc.)
24
+ */
25
+ async submit(slug, data, options) {
26
+ try {
27
+ const result = await this.request(`/api/public/journeys/${encodeURIComponent(slug)}/submit`, {
28
+ method: "POST",
29
+ body: {
30
+ data,
31
+ metadata: {
32
+ ...options?.metadata,
33
+ referrer: typeof document !== "undefined" ? document.referrer : void 0,
34
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : void 0
35
+ },
36
+ honeypot: options?.honeypot
37
+ }
38
+ });
39
+ this.emit({
40
+ type: "submission:success",
41
+ slug,
42
+ data: result
43
+ });
44
+ return result;
45
+ } catch (error) {
46
+ this.emit({
47
+ type: "submission:error",
48
+ slug,
49
+ error: error instanceof Error ? error : new Error(String(error))
50
+ });
51
+ throw error;
52
+ }
53
+ }
54
+ /**
55
+ * Start a multi-step journey and run its first step. Without data the session's `step`
56
+ * is the first step to render; with data, that is the first step's input.
57
+ */
58
+ async startJourney(slug, data) {
59
+ const session = await this.request(
60
+ `/api/public/journeys/${encodeURIComponent(slug)}/start`,
61
+ { method: "POST", body: { data } }
62
+ );
63
+ this.emit({
64
+ type: "journey:started",
65
+ slug: session.slug,
66
+ sessionId: session.sessionId,
67
+ data: session
68
+ });
69
+ return session;
70
+ }
71
+ /**
72
+ * Start a sign-in (or any authentication) journey for a public client. When the session completes it carries an
73
+ * authorization code, redeemed at the token endpoint with the PKCE verifier and redirect_uri "postmessage". Use
74
+ * this when your app keeps its own tokens; OlusoAuth.mountSignIn does the whole round trip for you.
75
+ */
76
+ async startAuthJourney(slug, auth, data) {
77
+ const session = await this.request(
78
+ `/api/public/journeys/${encodeURIComponent(slug)}/start`,
79
+ {
80
+ method: "POST",
81
+ body: { data, clientId: auth.clientId, codeChallenge: auth.codeChallenge, codeChallengeMethod: "S256", scope: auth.scope }
82
+ }
83
+ );
84
+ this.emit({ type: "journey:started", slug: session.slug, sessionId: session.sessionId, data: session });
85
+ return session;
86
+ }
87
+ /**
88
+ * Submit input (or an action such as "resend") for the session's current step.
89
+ * The result is the session afterwards: the next step to render, or the completion.
90
+ */
91
+ async submitStep(sessionId, data, action) {
92
+ const result = await this.request(
93
+ `/api/public/journeys/sessions/${encodeURIComponent(sessionId)}/step`,
94
+ { method: "POST", body: { data, action } }
95
+ );
96
+ this.emit({
97
+ type: result.completed ? "journey:completed" : result.success ? "journey:step" : "journey:error",
98
+ slug: result.slug,
99
+ sessionId,
100
+ data: result
101
+ });
102
+ return result;
103
+ }
104
+ /**
105
+ * Submit the current step the way a form posts it: every field, files under their field's name, and the
106
+ * clicked button's name and value. Use this for steps with file fields; `action` is a step action such as "init".
107
+ */
108
+ async submitStepForm(sessionId, form, action) {
109
+ if (action) form.set("__journey_action", action);
110
+ const result = await this.request(
111
+ `/api/public/journeys/sessions/${encodeURIComponent(sessionId)}/step`,
112
+ { method: "POST", body: form }
113
+ );
114
+ this.emit({
115
+ type: result.completed ? "journey:completed" : result.success ? "journey:step" : "journey:error",
116
+ slug: result.slug,
117
+ sessionId,
118
+ data: result
119
+ });
120
+ return result;
121
+ }
122
+ /** The server this client talks to */
123
+ get serverUrl() {
124
+ return this.config.serverUrl;
125
+ }
126
+ /**
127
+ * Get where a multi-step session stands (status, current step), without running it
128
+ */
129
+ async getSession(sessionId) {
130
+ return this.request(`/api/public/journeys/sessions/${encodeURIComponent(sessionId)}`);
131
+ }
132
+ /**
133
+ * URL of the hosted journey page (/journey/start/{slug}) to redirect to
134
+ */
135
+ getJourneyUrl(slug, options) {
136
+ const params = new URLSearchParams();
137
+ if (this.config.tenant) params.set("tenant", this.config.tenant);
138
+ if (options?.redirectUri) {
139
+ params.set("redirect_uri", options.redirectUri);
140
+ }
141
+ if (options?.state) {
142
+ params.set("state", options.state);
143
+ }
144
+ return `${this.config.serverUrl}/journey/start/${encodeURIComponent(slug)}?${params}`;
145
+ }
146
+ /**
147
+ * URL of the hosted journey page (/journey/start/{slug}) for an iframe
148
+ */
149
+ getEmbedUrl(slug, options) {
150
+ const params = new URLSearchParams({ embed: "true" });
151
+ if (this.config.tenant) params.set("tenant", this.config.tenant);
152
+ if (options?.theme) {
153
+ params.set("theme", options.theme);
154
+ }
155
+ if (options?.hideHeader) {
156
+ params.set("hideHeader", "true");
157
+ }
158
+ return `${this.config.serverUrl}/journey/start/${encodeURIComponent(slug)}?${params}`;
159
+ }
160
+ /**
161
+ * Subscribe to SDK events
162
+ */
163
+ on(event, listener) {
164
+ if (!this.listeners.has(event)) {
165
+ this.listeners.set(event, /* @__PURE__ */ new Set());
166
+ }
167
+ this.listeners.get(event).add(listener);
168
+ return () => {
169
+ this.listeners.get(event)?.delete(listener);
170
+ };
171
+ }
172
+ /**
173
+ * Unsubscribe from SDK events
174
+ */
175
+ off(event, listener) {
176
+ this.listeners.get(event)?.delete(listener);
177
+ }
178
+ emit(event) {
179
+ this.listeners.get(event.type)?.forEach((listener) => listener(event));
180
+ this.listeners.get("*")?.forEach((listener) => listener(event));
181
+ }
182
+ async request(path, options) {
183
+ const url = `${this.config.serverUrl}${path}`;
184
+ const method = options?.method || "GET";
185
+ const isForm = typeof FormData !== "undefined" && options?.body instanceof FormData;
186
+ const headers = {};
187
+ if (this.config.tenant) {
188
+ headers["X-Tenant-ID"] = this.config.tenant;
189
+ }
190
+ if (!isForm) {
191
+ headers["Content-Type"] = "application/json";
192
+ }
193
+ if (this.config.apiKey) {
194
+ headers["X-API-Key"] = this.config.apiKey;
195
+ }
196
+ if (this.config.headers) {
197
+ Object.assign(headers, await this.config.headers({ method, url }));
198
+ }
199
+ const response = await this.fetchFn(url, {
200
+ method,
201
+ headers,
202
+ body: options?.body === void 0 ? void 0 : isForm ? options.body : JSON.stringify(options.body),
203
+ credentials: "include"
204
+ // Include cookies for session management
205
+ });
206
+ if (!response.ok) {
207
+ const error = await response.json().catch(() => ({ message: response.statusText }));
208
+ throw new OlusoError(
209
+ error.message || `Request failed: ${response.status}`,
210
+ response.status,
211
+ error.code,
212
+ error.errors
213
+ );
214
+ }
215
+ return response.json();
216
+ }
217
+ };
218
+ var OlusoError = class extends Error {
219
+ constructor(message, status, code, fieldErrors) {
220
+ super(message);
221
+ this.status = status;
222
+ this.code = code;
223
+ this.fieldErrors = fieldErrors;
224
+ this.name = "OlusoError";
225
+ }
226
+ };
227
+
228
+ // src/journey/conditions.ts
229
+ var ALIASES = {
230
+ eq: "eq",
231
+ equals: "eq",
232
+ "==": "eq",
233
+ "=": "eq",
234
+ ne: "ne",
235
+ neq: "ne",
236
+ notequals: "ne",
237
+ "!=": "ne",
238
+ gt: "gt",
239
+ ">": "gt",
240
+ greaterthan: "gt",
241
+ gte: "gte",
242
+ ">=": "gte",
243
+ greaterthanorequals: "gte",
244
+ lt: "lt",
245
+ "<": "lt",
246
+ lessthan: "lt",
247
+ lte: "lte",
248
+ "<=": "lte",
249
+ lessthanorequals: "lte",
250
+ contains: "contains",
251
+ notcontains: "not_contains",
252
+ "!contains": "not_contains",
253
+ startswith: "starts_with",
254
+ endswith: "ends_with",
255
+ exists: "exists",
256
+ notnull: "exists",
257
+ isset: "exists",
258
+ notexists: "not_exists",
259
+ null: "not_exists",
260
+ isnotset: "not_exists",
261
+ empty: "empty",
262
+ notempty: "not_empty",
263
+ in: "in",
264
+ notin: "not_in",
265
+ "!in": "not_in",
266
+ regex: "regex",
267
+ matches: "regex",
268
+ true: "true",
269
+ false: "false"
270
+ };
271
+ function normalizeOperator(op) {
272
+ if (!op) return void 0;
273
+ return ALIASES[op.trim().toLowerCase().replace(/_/g, "")];
274
+ }
275
+ var same = (a, b) => a.localeCompare(b, void 0, { sensitivity: "accent" }) === 0;
276
+ function order(actual, expected) {
277
+ if (expected === void 0) return void 0;
278
+ const a = Number(actual);
279
+ const e = Number(expected);
280
+ if (actual.trim() !== "" && expected.trim() !== "" && !Number.isNaN(a) && !Number.isNaN(e)) return a === e ? 0 : a < e ? -1 : 1;
281
+ const ad = Date.parse(actual);
282
+ const ed = Date.parse(expected);
283
+ if (!Number.isNaN(ad) && !Number.isNaN(ed)) return ad === ed ? 0 : ad < ed ? -1 : 1;
284
+ return actual.localeCompare(expected, void 0, { sensitivity: "accent" });
285
+ }
286
+ function compare(actual, op, expected) {
287
+ const lower = actual.toLowerCase();
288
+ const want = (expected ?? "").toLowerCase();
289
+ switch (op) {
290
+ case "eq":
291
+ return same(actual, expected ?? "");
292
+ case "ne":
293
+ return !same(actual, expected ?? "");
294
+ case "gt": {
295
+ const o = order(actual, expected);
296
+ return o !== void 0 && o > 0;
297
+ }
298
+ case "gte": {
299
+ const o = order(actual, expected);
300
+ return o !== void 0 && o >= 0;
301
+ }
302
+ case "lt": {
303
+ const o = order(actual, expected);
304
+ return o !== void 0 && o < 0;
305
+ }
306
+ case "lte": {
307
+ const o = order(actual, expected);
308
+ return o !== void 0 && o <= 0;
309
+ }
310
+ case "contains":
311
+ return lower.includes(want);
312
+ case "not_contains":
313
+ return !lower.includes(want);
314
+ case "starts_with":
315
+ return lower.startsWith(want);
316
+ case "ends_with":
317
+ return lower.endsWith(want);
318
+ case "regex":
319
+ if (!expected) return false;
320
+ try {
321
+ return new RegExp(expected, "i").test(actual);
322
+ } catch {
323
+ return false;
324
+ }
325
+ default:
326
+ return false;
327
+ }
328
+ }
329
+ function expectedList(expected) {
330
+ if (!expected || !expected.trim()) return [];
331
+ const trimmed = expected.trim();
332
+ if (trimmed.startsWith("[")) {
333
+ try {
334
+ const parsed = JSON.parse(trimmed);
335
+ if (Array.isArray(parsed)) return parsed.map(String);
336
+ } catch {
337
+ return [];
338
+ }
339
+ }
340
+ return trimmed.split(",").map((v) => v.trim()).filter(Boolean);
341
+ }
342
+ var truthy = (v) => v.toLowerCase() !== "false" && v !== "0";
343
+ function conditionMet(condition, values) {
344
+ const op = normalizeOperator(condition.operator);
345
+ const present = values.filter((v) => v !== "");
346
+ const members = () => present.flatMap((v) => v.split(",").map((p) => p.trim()).filter(Boolean));
347
+ switch (op) {
348
+ case "exists":
349
+ case "not_empty":
350
+ return present.length > 0;
351
+ case "not_exists":
352
+ case "empty":
353
+ return present.length === 0;
354
+ case "ne":
355
+ return !present.some((v) => compare(v, "eq", condition.value));
356
+ case "not_contains":
357
+ return !present.some((v) => compare(v, "contains", condition.value));
358
+ case "in": {
359
+ const list = expectedList(condition.value);
360
+ return members().some((m) => list.some((e) => same(m, e)));
361
+ }
362
+ case "not_in": {
363
+ const list = expectedList(condition.value);
364
+ return !members().some((m) => list.some((e) => same(m, e)));
365
+ }
366
+ case "true":
367
+ return present.some(truthy);
368
+ case "false":
369
+ return !present.some(truthy);
370
+ case void 0:
371
+ return false;
372
+ default:
373
+ return present.some((v) => compare(v, op, condition.value));
374
+ }
375
+ }
376
+
377
+ // src/journey/styles.ts
378
+ var JOURNEY_STYLES = `
379
+ .oluso-journey {
380
+ --oluso-accent: #4f46e5;
381
+ --oluso-accent-contrast: #ffffff;
382
+ --oluso-fg: #111827;
383
+ --oluso-muted: #6b7280;
384
+ --oluso-bg: #ffffff;
385
+ --oluso-field-bg: #ffffff;
386
+ --oluso-border: #d1d5db;
387
+ --oluso-danger: #b91c1c;
388
+ --oluso-danger-bg: #fef2f2;
389
+ --oluso-notice-bg: #eff6ff;
390
+ --oluso-notice: #1d4ed8;
391
+ --oluso-radius: 8px;
392
+ --oluso-gap: 14px;
393
+ --oluso-font: inherit;
394
+ font-family: var(--oluso-font);
395
+ color: var(--oluso-fg);
396
+ font-size: 15px;
397
+ line-height: 1.5;
398
+ }
399
+ .oluso-journey[data-theme="dark"] {
400
+ --oluso-fg: #f3f4f6;
401
+ --oluso-muted: #9ca3af;
402
+ --oluso-bg: #111827;
403
+ --oluso-field-bg: #1f2937;
404
+ --oluso-border: #374151;
405
+ --oluso-danger: #fca5a5;
406
+ --oluso-danger-bg: rgba(185, 28, 28, 0.15);
407
+ --oluso-notice-bg: rgba(59, 130, 246, 0.15);
408
+ --oluso-notice: #93c5fd;
409
+ }
410
+ @media (prefers-color-scheme: dark) {
411
+ .oluso-journey[data-theme="auto"] {
412
+ --oluso-fg: #f3f4f6;
413
+ --oluso-muted: #9ca3af;
414
+ --oluso-bg: #111827;
415
+ --oluso-field-bg: #1f2937;
416
+ --oluso-border: #374151;
417
+ --oluso-danger: #fca5a5;
418
+ --oluso-danger-bg: rgba(185, 28, 28, 0.15);
419
+ --oluso-notice-bg: rgba(59, 130, 246, 0.15);
420
+ --oluso-notice: #93c5fd;
421
+ }
422
+ }
423
+ .oluso-journey *, .oluso-journey *::before, .oluso-journey *::after { box-sizing: border-box; }
424
+ .oluso-journey__form { display: flex; flex-direction: column; gap: var(--oluso-gap); margin: 0; }
425
+ .oluso-journey__title { font-size: 1.25em; font-weight: 600; margin: 0; }
426
+ .oluso-journey__description { color: var(--oluso-muted); margin: 0; white-space: pre-line; }
427
+ .oluso-journey__alert { border-radius: var(--oluso-radius); padding: 10px 12px; margin: 0; }
428
+ .oluso-journey__alert--error { background: var(--oluso-danger-bg); color: var(--oluso-danger); }
429
+ .oluso-journey__alert--notice { background: var(--oluso-notice-bg); color: var(--oluso-notice); }
430
+ .oluso-journey__group { display: flex; flex-direction: column; gap: var(--oluso-gap); border: 0; margin: 0; padding: 0; }
431
+ .oluso-journey__field { display: flex; flex-direction: column; gap: 6px; }
432
+ .oluso-journey__field[hidden] { display: none; }
433
+ .oluso-journey__label { font-weight: 500; }
434
+ .oluso-journey__required { color: var(--oluso-danger); margin-left: 2px; }
435
+ .oluso-journey__input, .oluso-journey__select, .oluso-journey__textarea {
436
+ width: 100%; font: inherit; color: var(--oluso-fg); background: var(--oluso-field-bg);
437
+ border: 1px solid var(--oluso-border); border-radius: var(--oluso-radius); padding: 9px 11px; min-height: 40px;
438
+ }
439
+ .oluso-journey__input:focus, .oluso-journey__select:focus, .oluso-journey__textarea:focus {
440
+ outline: 2px solid var(--oluso-accent); outline-offset: 1px; border-color: transparent;
441
+ }
442
+ .oluso-journey__input[aria-invalid="true"], .oluso-journey__select[aria-invalid="true"], .oluso-journey__textarea[aria-invalid="true"] {
443
+ border-color: var(--oluso-danger);
444
+ }
445
+ .oluso-journey__input--otp { letter-spacing: 0.4em; font-variant-numeric: tabular-nums; text-align: center; font-size: 1.2em; }
446
+ .oluso-journey__phone { display: flex; gap: 8px; }
447
+ .oluso-journey__phone .oluso-journey__select { width: auto; max-width: 45%; }
448
+ .oluso-journey__choices { display: flex; flex-direction: column; gap: 8px; }
449
+ .oluso-journey__choice { display: flex; gap: 10px; align-items: flex-start; cursor: pointer; }
450
+ .oluso-journey__choice input { margin-top: 3px; accent-color: var(--oluso-accent); }
451
+ .oluso-journey__choice-description { display: block; color: var(--oluso-muted); font-size: 0.9em; }
452
+ .oluso-journey__help, .oluso-journey__hint { color: var(--oluso-muted); font-size: 0.875em; margin: 0; }
453
+ .oluso-journey__error { color: var(--oluso-danger); font-size: 0.875em; margin: 0; }
454
+ .oluso-journey__content { margin: 0; white-space: pre-line; }
455
+ .oluso-journey__actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
456
+ .oluso-journey__button {
457
+ font: inherit; font-weight: 500; border-radius: var(--oluso-radius); padding: 9px 16px; min-height: 40px; cursor: pointer;
458
+ border: 1px solid var(--oluso-border); background: var(--oluso-field-bg); color: var(--oluso-fg);
459
+ }
460
+ .oluso-journey__button--primary { background: var(--oluso-accent); color: var(--oluso-accent-contrast); border-color: var(--oluso-accent); }
461
+ .oluso-journey__button--primary:only-child, .oluso-journey__actions--stacked .oluso-journey__button--primary { flex: 1 1 auto; }
462
+ .oluso-journey__button--link { background: none; border: 0; color: var(--oluso-accent); padding: 4px 0; min-height: 0; }
463
+ .oluso-journey__button--cancel { background: none; }
464
+ .oluso-journey__button:disabled { opacity: 0.6; cursor: progress; }
465
+ .oluso-journey__enter { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }
466
+ .oluso-journey__action-line { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; margin: 0; }
467
+ .oluso-journey__links { display: flex; flex-wrap: wrap; gap: 12px; font-size: 0.9em; }
468
+ .oluso-journey__links a { color: var(--oluso-accent); }
469
+ .oluso-journey__qr { display: flex; flex-direction: column; align-items: center; gap: 8px; }
470
+ .oluso-journey__qr img { width: 180px; height: 180px; background: #fff; padding: 8px; border-radius: var(--oluso-radius); }
471
+ .oluso-journey__code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; word-break: break-all; }
472
+ .oluso-journey__codes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; margin: 0; padding: 0; list-style: none; }
473
+ .oluso-journey__codes li { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; padding: 6px 8px; border: 1px solid var(--oluso-border); border-radius: 6px; text-align: center; }
474
+ .oluso-journey__status { color: var(--oluso-muted); text-align: center; padding: 16px 0; }
475
+ .oluso-journey__done { text-align: center; padding: 8px 0; }
476
+ `;
477
+ var injected = false;
478
+ function injectJourneyStyles() {
479
+ if (injected || typeof document === "undefined") return;
480
+ const style = document.createElement("style");
481
+ style.setAttribute("data-oluso-journey", "");
482
+ style.textContent = JOURNEY_STYLES;
483
+ document.head.appendChild(style);
484
+ injected = true;
485
+ }
486
+
487
+ // src/journey/webauthn.ts
488
+ function base64UrlToBuffer(value) {
489
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
490
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
491
+ const binary = atob(padded);
492
+ const bytes = new Uint8Array(binary.length);
493
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
494
+ return bytes.buffer;
495
+ }
496
+ function bufferToBase64Url(buffer) {
497
+ if (!buffer) return null;
498
+ const bytes = new Uint8Array(buffer);
499
+ let binary = "";
500
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
501
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
502
+ }
503
+ function credentialDescriptors(list) {
504
+ if (!Array.isArray(list)) return void 0;
505
+ return list.map((c) => ({
506
+ type: c.type ?? "public-key",
507
+ id: base64UrlToBuffer(String(c.id)),
508
+ transports: c.transports
509
+ }));
510
+ }
511
+ function requestOptions(publicKey) {
512
+ return {
513
+ ...publicKey,
514
+ challenge: base64UrlToBuffer(String(publicKey.challenge)),
515
+ allowCredentials: credentialDescriptors(publicKey.allowCredentials)
516
+ };
517
+ }
518
+ function creationOptions(publicKey) {
519
+ const user = publicKey.user ?? {};
520
+ return {
521
+ ...publicKey,
522
+ challenge: base64UrlToBuffer(String(publicKey.challenge)),
523
+ user: { ...user, id: base64UrlToBuffer(String(user.id)) },
524
+ excludeCredentials: credentialDescriptors(publicKey.excludeCredentials)
525
+ };
526
+ }
527
+ async function getAssertion(publicKey) {
528
+ const credential = await navigator.credentials.get({ publicKey: requestOptions(publicKey) });
529
+ if (!credential) throw new Error("No passkey was chosen");
530
+ const response = credential.response;
531
+ return JSON.stringify({
532
+ id: credential.id,
533
+ rawId: bufferToBase64Url(credential.rawId),
534
+ type: credential.type,
535
+ response: {
536
+ clientDataJSON: bufferToBase64Url(response.clientDataJSON),
537
+ authenticatorData: bufferToBase64Url(response.authenticatorData),
538
+ signature: bufferToBase64Url(response.signature),
539
+ userHandle: bufferToBase64Url(response.userHandle)
540
+ },
541
+ authenticatorAttachment: credential.authenticatorAttachment ?? null
542
+ });
543
+ }
544
+ async function createCredential(publicKey) {
545
+ const credential = await navigator.credentials.create({ publicKey: creationOptions(publicKey) });
546
+ if (!credential) throw new Error("No passkey was created");
547
+ const response = credential.response;
548
+ return JSON.stringify({
549
+ id: credential.id,
550
+ rawId: bufferToBase64Url(credential.rawId),
551
+ type: credential.type,
552
+ response: {
553
+ clientDataJSON: bufferToBase64Url(response.clientDataJSON),
554
+ attestationObject: bufferToBase64Url(response.attestationObject),
555
+ transports: typeof response.getTransports === "function" ? response.getTransports() : void 0
556
+ },
557
+ authenticatorAttachment: credential.authenticatorAttachment ?? null
558
+ });
559
+ }
560
+
561
+ // src/journey/render.ts
562
+ var DEFAULT_LABELS = {
563
+ loading: "Loading\u2026",
564
+ completed: "All done. Thank you.",
565
+ failed: "Something went wrong. Please try again.",
566
+ expired: "This session has expired. Please start again.",
567
+ restart: "Start again",
568
+ takePhoto: "Take a photo",
569
+ copy: "Copy",
570
+ copied: "Copied",
571
+ passkey: "Use your passkey",
572
+ retry: "Try again",
573
+ waiting: "Waiting\u2026",
574
+ unavailable: "This step can\u2019t be shown here."
575
+ };
576
+ function el(tag, className, text) {
577
+ const node = document.createElement(tag);
578
+ if (className) node.className = className;
579
+ if (text !== void 0) node.textContent = text;
580
+ return node;
581
+ }
582
+ var uid = 0;
583
+ function mountJourney(container, options) {
584
+ const labels = { ...DEFAULT_LABELS, ...options.labels };
585
+ const client = options.client;
586
+ const id = `oluso-j${++uid}`;
587
+ let session;
588
+ let destroyed = false;
589
+ let timers = [];
590
+ if (!options.unstyled) injectJourneyStyles();
591
+ const root = el("div", "oluso-journey");
592
+ root.setAttribute("data-theme", options.theme ?? "auto");
593
+ container.appendChild(root);
594
+ const clearTimers = () => {
595
+ timers.forEach((t) => window.clearInterval(t));
596
+ timers = [];
597
+ };
598
+ const status = (text) => {
599
+ root.replaceChildren(el("p", "oluso-journey__status", text));
600
+ };
601
+ const fail = (error) => {
602
+ const err = error instanceof Error ? error : new Error(String(error));
603
+ options.onError?.(err, session);
604
+ return err;
605
+ };
606
+ const canRestart = () => Boolean(options.slug || options.start);
607
+ const begin = async () => {
608
+ clearTimers();
609
+ status(labels.loading);
610
+ try {
611
+ const started = options.start ? await options.start() : options.slug ? await client.startJourney(options.slug, options.data) : options.session;
612
+ if (!started) throw new Error("mountJourney needs a slug, a session or a start function");
613
+ await show(started);
614
+ } catch (error) {
615
+ showProblem(fail(error).message);
616
+ }
617
+ };
618
+ const show = async (next) => {
619
+ if (destroyed) return;
620
+ clearTimers();
621
+ session = next;
622
+ options.onStep?.(next);
623
+ if (next.completed) {
624
+ if (next.redirectUrl) {
625
+ await options.onComplete?.(next);
626
+ redirect(next.redirectUrl, next);
627
+ return;
628
+ }
629
+ if (options.showCompletion !== false) {
630
+ const done = el("div", "oluso-journey__done");
631
+ done.appendChild(el("p", "oluso-journey__content", next.message || labels.completed));
632
+ root.replaceChildren(done);
633
+ } else {
634
+ root.replaceChildren();
635
+ }
636
+ await options.onComplete?.(next);
637
+ return;
638
+ }
639
+ if (next.status !== "in_progress") {
640
+ const message = next.status === "expired" ? labels.expired : next.errorDescription || labels.failed;
641
+ fail(new Error(message));
642
+ showProblem(message);
643
+ return;
644
+ }
645
+ if (next.redirectUrl) {
646
+ redirect(next.redirectUrl, next);
647
+ return;
648
+ }
649
+ if (next.step?.form) {
650
+ renderForm(next.step.form);
651
+ } else if (next.step?.html) {
652
+ renderHtml(next.step.html);
653
+ } else {
654
+ showProblem(labels.unavailable);
655
+ }
656
+ };
657
+ const redirect = (url, current) => {
658
+ if (options.onRedirect) options.onRedirect(url, current);
659
+ else window.location.assign(url);
660
+ };
661
+ const showProblem = (message) => {
662
+ const box = el("div", "oluso-journey__form");
663
+ box.appendChild(el("p", "oluso-journey__alert oluso-journey__alert--error", message));
664
+ if (canRestart()) {
665
+ const again = el("button", "oluso-journey__button oluso-journey__button--primary", labels.restart);
666
+ again.type = "button";
667
+ again.addEventListener("click", () => void begin());
668
+ const row = el("div", "oluso-journey__actions");
669
+ row.appendChild(again);
670
+ box.appendChild(row);
671
+ }
672
+ root.replaceChildren(box);
673
+ };
674
+ const post = async (form, action) => {
675
+ if (!session) return;
676
+ const next = await client.submitStepForm(session.sessionId, form, action);
677
+ await show(next);
678
+ };
679
+ const submitting = (form, busy) => {
680
+ form.setAttribute("aria-busy", String(busy));
681
+ form.querySelectorAll("button").forEach((b) => b.disabled = busy);
682
+ };
683
+ const send = async (form, action, extra) => {
684
+ const data = action?.postsFields === false ? new FormData() : new FormData(form);
685
+ if (action?.name) data.append(action.name, action.value ?? "");
686
+ if (action?.values) for (const [k, v] of Object.entries(action.values)) data.append(k, v);
687
+ if (extra) for (const [k, v] of Object.entries(extra)) data.set(k, v);
688
+ submitting(form, true);
689
+ try {
690
+ await post(data);
691
+ } catch (error) {
692
+ submitting(form, false);
693
+ const message = fail(error).message;
694
+ let alert = form.querySelector("[data-oluso-request-error]");
695
+ if (!alert) {
696
+ alert = el("p", "oluso-journey__alert oluso-journey__alert--error");
697
+ alert.setAttribute("data-oluso-request-error", "");
698
+ alert.setAttribute("role", "alert");
699
+ form.prepend(alert);
700
+ }
701
+ alert.textContent = message;
702
+ }
703
+ };
704
+ const renderForm = (step) => {
705
+ const form = el("form", "oluso-journey__form");
706
+ if (step.title) form.appendChild(el("h2", "oluso-journey__title", step.title));
707
+ if (step.description) form.appendChild(el("p", "oluso-journey__description", step.description));
708
+ if (step.error) {
709
+ const alert = el("p", "oluso-journey__alert oluso-journey__alert--error", step.error);
710
+ alert.setAttribute("role", "alert");
711
+ form.appendChild(alert);
712
+ }
713
+ if (step.notice) {
714
+ const notice = el("p", "oluso-journey__alert oluso-journey__alert--notice", step.notice);
715
+ notice.setAttribute("role", "status");
716
+ form.appendChild(notice);
717
+ }
718
+ const wrappers = [];
719
+ for (const field of step.fields) {
720
+ const rendered = renderField(field);
721
+ if (!rendered) continue;
722
+ form.appendChild(rendered);
723
+ if (field.showWhen || field.showWhenAll?.length) wrappers.push({ field, wrapper: rendered });
724
+ }
725
+ if (step.widget) {
726
+ const widget = renderWidget(step.widget, form);
727
+ if (widget) form.appendChild(widget);
728
+ }
729
+ if (step.links.length > 0) {
730
+ const links = el("div", "oluso-journey__links");
731
+ for (const link of step.links) {
732
+ const a = el("a", void 0, link.label);
733
+ a.href = link.url;
734
+ a.target = "_blank";
735
+ a.rel = "noopener noreferrer";
736
+ links.appendChild(a);
737
+ }
738
+ form.appendChild(links);
739
+ }
740
+ if (step.actions.length > 0) {
741
+ const row = el("div", "oluso-journey__actions");
742
+ const primary = step.actions.findIndex((a) => a.kind === "primary");
743
+ if (primary >= 0) {
744
+ const enter = el("button", "oluso-journey__enter");
745
+ enter.type = "submit";
746
+ enter.tabIndex = -1;
747
+ enter.setAttribute("aria-hidden", "true");
748
+ enter.dataset.olusoAction = String(primary);
749
+ form.prepend(enter);
750
+ }
751
+ const described = [];
752
+ step.actions.forEach((action, index) => {
753
+ const button = el("button", `oluso-journey__button oluso-journey__button--${action.kind}`, action.label);
754
+ button.type = "submit";
755
+ button.formNoValidate = action.skipValidation || action.postsFields === false;
756
+ button.dataset.olusoAction = String(index);
757
+ if (action.description) {
758
+ const line = el("p", "oluso-journey__action-line");
759
+ line.appendChild(el("span", "oluso-journey__help", action.description));
760
+ line.appendChild(button);
761
+ described.push(line);
762
+ } else {
763
+ row.appendChild(button);
764
+ }
765
+ });
766
+ if (row.childElementCount > 0) form.appendChild(row);
767
+ described.forEach((line) => form.appendChild(line));
768
+ }
769
+ const applyConditions = () => {
770
+ for (const { field, wrapper } of wrappers) {
771
+ const conditions = [...field.showWhen ? [field.showWhen] : [], ...field.showWhenAll ?? []];
772
+ const shown = conditions.every((c) => conditionMet(c, currentValues(form.elements.namedItem(c.field))));
773
+ wrapper.hidden = !shown;
774
+ wrapper.querySelectorAll("input, select, textarea").forEach((input) => {
775
+ input.disabled = !shown || input.dataset.olusoReadonly === "true";
776
+ });
777
+ }
778
+ };
779
+ form.addEventListener("input", (event) => {
780
+ applyConditions();
781
+ const target = event.target;
782
+ const wrapper = target.closest(".oluso-journey__field");
783
+ wrapper?.querySelectorAll('[aria-invalid="true"]').forEach((n) => n.removeAttribute("aria-invalid"));
784
+ wrapper?.querySelector(".oluso-journey__error")?.remove();
785
+ });
786
+ form.addEventListener("change", applyConditions);
787
+ applyConditions();
788
+ form.addEventListener("submit", (event) => {
789
+ event.preventDefault();
790
+ const submitter = event.submitter;
791
+ const index = submitter?.dataset.olusoAction;
792
+ const action = index !== void 0 ? step.actions[Number(index)] : step.actions.find((a) => a.kind === "primary");
793
+ if (action?.confirm && submitter && submitter.dataset.olusoConfirming !== "true") {
794
+ submitter.dataset.olusoConfirming = "true";
795
+ submitter.textContent = action.confirm;
796
+ submitter.setAttribute("aria-live", "polite");
797
+ return;
798
+ }
799
+ void send(form, action);
800
+ });
801
+ root.replaceChildren(form);
802
+ const focus = form.querySelector("[autofocus]") ?? form.querySelector("input:not([type=hidden]):not([disabled]), select, textarea");
803
+ focus?.focus({ preventScroll: true });
804
+ };
805
+ const renderField = (field) => {
806
+ const fieldId = `${id}-${field.name}`;
807
+ if (field.type === "hidden") {
808
+ const input = el("input");
809
+ input.type = "hidden";
810
+ input.name = field.name;
811
+ input.value = field.value ?? "";
812
+ return input;
813
+ }
814
+ const wrapper = el("div", "oluso-journey__field");
815
+ if (field.group) wrapper.dataset.group = field.group;
816
+ if (field.type === "content") {
817
+ if (field.label) wrapper.appendChild(el("p", "oluso-journey__label", field.label));
818
+ wrapper.appendChild(el("p", "oluso-journey__content", field.value ?? ""));
819
+ return wrapper;
820
+ }
821
+ const label = () => {
822
+ if (!field.label) return;
823
+ const l = el("label", "oluso-journey__label", field.label);
824
+ l.htmlFor = fieldId;
825
+ if (field.required) {
826
+ const marker = el("span", "oluso-journey__required", "*");
827
+ marker.setAttribute("aria-hidden", "true");
828
+ l.appendChild(marker);
829
+ }
830
+ wrapper.appendChild(l);
831
+ };
832
+ const describedBy = [];
833
+ const common = (input) => {
834
+ input.id = fieldId;
835
+ input.name = field.name;
836
+ input.required = field.required;
837
+ if (field.readOnly) {
838
+ if (input instanceof HTMLSelectElement) input.disabled = true;
839
+ else input.readOnly = true;
840
+ input.dataset.olusoReadonly = String(input instanceof HTMLSelectElement);
841
+ }
842
+ if (field.autofocus) input.autofocus = true;
843
+ if (field.autocomplete) input.setAttribute("autocomplete", field.autocomplete);
844
+ if (field.error) input.setAttribute("aria-invalid", "true");
845
+ };
846
+ switch (field.type) {
847
+ case "textarea": {
848
+ label();
849
+ const input = el("textarea", "oluso-journey__textarea");
850
+ common(input);
851
+ input.value = field.value ?? "";
852
+ if (field.placeholder) input.placeholder = field.placeholder;
853
+ if (field.rows) input.rows = field.rows;
854
+ if (field.minLength) input.minLength = field.minLength;
855
+ if (field.maxLength) input.maxLength = field.maxLength;
856
+ wrapper.appendChild(input);
857
+ break;
858
+ }
859
+ case "select": {
860
+ label();
861
+ const input = el("select", "oluso-journey__select");
862
+ common(input);
863
+ const blank = el("option", void 0, field.placeholder ?? "");
864
+ blank.value = "";
865
+ input.appendChild(blank);
866
+ for (const option of field.options ?? []) {
867
+ const o = el("option", void 0, option.label);
868
+ o.value = option.value;
869
+ o.disabled = option.disabled;
870
+ o.selected = option.value === field.value;
871
+ input.appendChild(o);
872
+ }
873
+ wrapper.appendChild(input);
874
+ break;
875
+ }
876
+ case "radio": {
877
+ const group = el("fieldset", "oluso-journey__group");
878
+ if (field.label) {
879
+ const legend = el("legend", "oluso-journey__label", field.label);
880
+ if (field.required) legend.appendChild(el("span", "oluso-journey__required", "*"));
881
+ group.appendChild(legend);
882
+ }
883
+ const choices = el("div", "oluso-journey__choices");
884
+ (field.options ?? []).forEach((option, i) => {
885
+ const choice = el("label", "oluso-journey__choice");
886
+ const input = el("input");
887
+ input.type = "radio";
888
+ input.name = field.name;
889
+ input.value = option.value;
890
+ input.id = `${fieldId}-${i}`;
891
+ input.required = field.required;
892
+ input.disabled = option.disabled || field.readOnly;
893
+ input.checked = option.value === field.value;
894
+ const text = el("span", void 0, option.label);
895
+ if (option.description) text.appendChild(el("span", "oluso-journey__choice-description", option.description));
896
+ choice.append(input, text);
897
+ choices.appendChild(choice);
898
+ });
899
+ group.appendChild(choices);
900
+ wrapper.appendChild(group);
901
+ break;
902
+ }
903
+ case "checkbox": {
904
+ if (field.placeholder) label();
905
+ const choice = el("label", "oluso-journey__choice");
906
+ const input = el("input");
907
+ input.type = "checkbox";
908
+ input.value = "true";
909
+ common(input);
910
+ input.checked = field.value === "true";
911
+ if (field.readOnly) {
912
+ input.setAttribute("aria-readonly", "true");
913
+ input.addEventListener("click", (e) => e.preventDefault());
914
+ }
915
+ const text = el("span", void 0, field.placeholder ?? field.label ?? "");
916
+ if (!field.placeholder && field.required) text.appendChild(el("span", "oluso-journey__required", "*"));
917
+ choice.append(input, text);
918
+ wrapper.appendChild(choice);
919
+ break;
920
+ }
921
+ case "file": {
922
+ label();
923
+ const input = el("input", "oluso-journey__input");
924
+ input.type = "file";
925
+ common(input);
926
+ input.readOnly = false;
927
+ input.disabled = field.readOnly;
928
+ if (field.accept) input.accept = field.accept;
929
+ input.multiple = (field.maxFiles ?? 1) > 1;
930
+ input.required = field.required && !field.capture;
931
+ wrapper.appendChild(input);
932
+ if (field.capture && !field.readOnly) {
933
+ const cameraLabel = el("label", "oluso-journey__hint", labels.takePhoto);
934
+ cameraLabel.htmlFor = `${fieldId}-camera`;
935
+ const camera = el("input", "oluso-journey__input");
936
+ camera.type = "file";
937
+ camera.id = `${fieldId}-camera`;
938
+ camera.name = field.name;
939
+ camera.accept = "image/*";
940
+ camera.setAttribute("capture", field.capture);
941
+ wrapper.append(cameraLabel, camera);
942
+ }
943
+ break;
944
+ }
945
+ case "tel": {
946
+ label();
947
+ if (field.countries && field.countries.length > 0) {
948
+ const row = el("div", "oluso-journey__phone");
949
+ const country = el("select", "oluso-journey__select");
950
+ country.name = `${field.name}__country`;
951
+ country.setAttribute("aria-label", field.label ?? field.name);
952
+ country.disabled = field.readOnly;
953
+ for (const c of field.countries) {
954
+ const o = el("option", void 0, `${c.flag ? c.flag + " " : ""}${c.name} (${c.dialCode})`);
955
+ o.value = c.code;
956
+ o.selected = c.code === field.country;
957
+ country.appendChild(o);
958
+ }
959
+ const input = el("input", "oluso-journey__input");
960
+ input.type = "tel";
961
+ input.inputMode = "tel";
962
+ common(input);
963
+ input.value = field.value ?? "";
964
+ if (field.placeholder) input.placeholder = field.placeholder;
965
+ row.append(country, input);
966
+ wrapper.appendChild(row);
967
+ } else {
968
+ const input = textInput(field, "tel");
969
+ common(input);
970
+ wrapper.appendChild(input);
971
+ }
972
+ break;
973
+ }
974
+ case "otp": {
975
+ label();
976
+ const input = el("input", "oluso-journey__input oluso-journey__input--otp");
977
+ input.type = "text";
978
+ input.inputMode = "numeric";
979
+ common(input);
980
+ input.setAttribute("autocomplete", field.autocomplete ?? "one-time-code");
981
+ if (field.maxLength) {
982
+ input.maxLength = field.maxLength;
983
+ input.minLength = field.minLength ?? field.maxLength;
984
+ }
985
+ input.pattern = field.pattern ?? "[0-9]*";
986
+ input.value = field.value ?? "";
987
+ wrapper.appendChild(input);
988
+ break;
989
+ }
990
+ default: {
991
+ label();
992
+ const known = ["text", "email", "password", "number", "date", "url", "tel"];
993
+ const input = textInput(field, known.includes(field.type) ? field.type : "text");
994
+ common(input);
995
+ wrapper.appendChild(input);
996
+ }
997
+ }
998
+ if (field.description) {
999
+ const help = el("p", "oluso-journey__help", field.description);
1000
+ help.id = `${fieldId}-help`;
1001
+ describedBy.push(help.id);
1002
+ wrapper.appendChild(help);
1003
+ }
1004
+ if (field.hint) {
1005
+ const hint = el("p", "oluso-journey__hint", field.hint);
1006
+ hint.id = `${fieldId}-hint`;
1007
+ describedBy.push(hint.id);
1008
+ wrapper.appendChild(hint);
1009
+ }
1010
+ if (field.error) {
1011
+ const error = el("p", "oluso-journey__error", field.error);
1012
+ error.id = `${fieldId}-error`;
1013
+ describedBy.push(error.id);
1014
+ wrapper.appendChild(error);
1015
+ }
1016
+ if (describedBy.length > 0) {
1017
+ Array.from(wrapper.querySelectorAll("[id]")).find((n) => n.id === fieldId)?.setAttribute("aria-describedby", describedBy.join(" "));
1018
+ }
1019
+ return wrapper;
1020
+ };
1021
+ const textInput = (field, type) => {
1022
+ const input = el("input", "oluso-journey__input");
1023
+ input.type = type;
1024
+ input.value = field.value ?? "";
1025
+ if (field.placeholder) input.placeholder = field.placeholder;
1026
+ if (field.pattern) input.pattern = field.pattern;
1027
+ if (field.patternError) {
1028
+ input.title = field.patternError;
1029
+ input.addEventListener("invalid", () => {
1030
+ if (input.validity.patternMismatch) input.setCustomValidity(field.patternError);
1031
+ });
1032
+ input.addEventListener("input", () => input.setCustomValidity(""));
1033
+ }
1034
+ if (field.minLength) input.minLength = field.minLength;
1035
+ if (field.maxLength) input.maxLength = field.maxLength;
1036
+ if (field.min) input.min = field.min;
1037
+ if (field.max) input.max = field.max;
1038
+ if (type === "number") input.step = "any";
1039
+ return input;
1040
+ };
1041
+ const renderWidget = (widget, form) => {
1042
+ const data = widget.data ?? {};
1043
+ const box = el("div", "oluso-journey__widget");
1044
+ const result = (value) => {
1045
+ if (!widget.resultField) return;
1046
+ let input = Array.from(form.querySelectorAll("input[type=hidden]")).find((n) => n.name === widget.resultField) ?? null;
1047
+ if (!input) {
1048
+ input = el("input");
1049
+ input.type = "hidden";
1050
+ input.name = widget.resultField;
1051
+ form.appendChild(input);
1052
+ }
1053
+ input.value = value;
1054
+ };
1055
+ switch (widget.type) {
1056
+ case "webauthn_get":
1057
+ case "webauthn_create": {
1058
+ const run = el("button", "oluso-journey__button oluso-journey__button--primary", widget.submit?.label ?? labels.passkey);
1059
+ run.type = "button";
1060
+ const go = async () => {
1061
+ run.disabled = true;
1062
+ try {
1063
+ const options2 = await webAuthnOptions(data);
1064
+ const publicKey = options2.publicKey;
1065
+ let extra = options2.extra;
1066
+ const credential = widget.type === "webauthn_get" ? await getAssertion(publicKey) : await createCredential(publicKey);
1067
+ result(credential);
1068
+ const naming = data.credentialName;
1069
+ if (naming?.field) {
1070
+ const attachment = JSON.parse(credential).authenticatorAttachment;
1071
+ const name = attachment === "platform" ? naming.platform : attachment === "cross-platform" ? naming.crossPlatform : naming.other;
1072
+ (extra ?? (extra = {}))[naming.field] = name ?? "";
1073
+ }
1074
+ await send(form, widget.submit, extra);
1075
+ } catch (error) {
1076
+ run.disabled = false;
1077
+ run.textContent = labels.retry;
1078
+ options.onError?.(error instanceof Error ? error : new Error(String(error)), session);
1079
+ }
1080
+ };
1081
+ run.addEventListener("click", () => void go());
1082
+ box.appendChild(run);
1083
+ if (typeof PublicKeyCredential !== "undefined") queueMicrotask(() => void go());
1084
+ return box;
1085
+ }
1086
+ case "verification_connect": {
1087
+ const options2 = data.options ?? {};
1088
+ const reference = typeof data.referenceOption === "string" ? options2[data.referenceOption] : void 0;
1089
+ const field = typeof data.referenceField === "string" ? data.referenceField : "reference_id";
1090
+ const callback = async (withReference) => {
1091
+ const values = new FormData();
1092
+ if (withReference) values.set(field, reference == null ? "" : String(reference));
1093
+ submitting(form, true);
1094
+ try {
1095
+ await post(values, "callback");
1096
+ } catch (error) {
1097
+ submitting(form, false);
1098
+ fail(error);
1099
+ }
1100
+ };
1101
+ const start = async () => {
1102
+ await loadScript(String(data.scriptUrl));
1103
+ const Ctor = window[typeof data.global === "string" ? data.global : "Connect"];
1104
+ if (!Ctor) throw new Error("The verification widget could not be loaded");
1105
+ const connect = new Ctor({
1106
+ ...options2,
1107
+ onSuccess: () => void callback(true),
1108
+ onError: () => void callback(false),
1109
+ onClose: () => void 0
1110
+ });
1111
+ connect.setup?.();
1112
+ connect.open();
1113
+ return connect;
1114
+ };
1115
+ let instance;
1116
+ const open = () => {
1117
+ if (instance) {
1118
+ void instance.then((c) => c.open());
1119
+ return;
1120
+ }
1121
+ instance = start();
1122
+ instance.catch((error) => {
1123
+ instance = void 0;
1124
+ fail(error);
1125
+ });
1126
+ };
1127
+ if (widget.submit) {
1128
+ const button = el("button", `oluso-journey__button oluso-journey__button--${widget.submit.kind}`, widget.submit.label);
1129
+ button.type = "button";
1130
+ button.addEventListener("click", open);
1131
+ box.appendChild(button);
1132
+ }
1133
+ open();
1134
+ return box;
1135
+ }
1136
+ case "captcha": {
1137
+ const target = el("div", "oluso-journey__captcha");
1138
+ box.appendChild(target);
1139
+ void renderCaptcha(target, data, (token) => {
1140
+ result(token);
1141
+ if (widget.submit) void send(form, widget.submit);
1142
+ }).catch((error) => options.onError?.(error instanceof Error ? error : new Error(String(error)), session));
1143
+ return box;
1144
+ }
1145
+ case "qr": {
1146
+ const qr = el("div", "oluso-journey__qr");
1147
+ if (typeof data.image === "string") {
1148
+ const img = el("img");
1149
+ img.src = data.image;
1150
+ img.alt = "";
1151
+ qr.appendChild(img);
1152
+ }
1153
+ if (typeof data.secret === "string") {
1154
+ if (typeof data.secretLabel === "string") qr.appendChild(el("span", "oluso-journey__help", data.secretLabel));
1155
+ qr.appendChild(el("code", "oluso-journey__code", data.secret));
1156
+ } else if (typeof data.value === "string" && typeof data.image !== "string") qr.appendChild(el("code", "oluso-journey__code", data.value));
1157
+ box.appendChild(qr);
1158
+ return box;
1159
+ }
1160
+ case "codes": {
1161
+ const codes = Array.isArray(data.codes) ? data.codes.map(String) : [];
1162
+ const list = el("ul", "oluso-journey__codes");
1163
+ codes.forEach((c) => list.appendChild(el("li", void 0, c)));
1164
+ const copy = el("button", "oluso-journey__button", labels.copy);
1165
+ copy.type = "button";
1166
+ copy.addEventListener("click", () => {
1167
+ void navigator.clipboard?.writeText(codes.join("\n")).then(() => copy.textContent = labels.copied);
1168
+ });
1169
+ box.append(list, copy);
1170
+ return box;
1171
+ }
1172
+ case "poll": {
1173
+ const seconds = Math.max(2, Number(data.intervalSeconds) || 5);
1174
+ const stopAt = Number(data.maxSeconds) > 0 ? Date.now() + Number(data.maxSeconds) * 1e3 : Infinity;
1175
+ const rendered = session?.currentStep;
1176
+ timers.push(window.setInterval(() => {
1177
+ if (Date.now() > stopAt) {
1178
+ clearTimers();
1179
+ return;
1180
+ }
1181
+ if (!session || document.hidden) return;
1182
+ void client.getSession(session.sessionId).then(async (now) => {
1183
+ if (now.status !== "in_progress" || now.currentStep !== rendered) {
1184
+ clearTimers();
1185
+ await post(new FormData(), "init");
1186
+ }
1187
+ }).catch(() => void 0);
1188
+ }, seconds * 1e3));
1189
+ return null;
1190
+ }
1191
+ default:
1192
+ return null;
1193
+ }
1194
+ };
1195
+ const webAuthnOptions = async (data) => {
1196
+ if (data.publicKey) return { publicKey: data.publicKey, extra: void 0 };
1197
+ const url = String(data.optionsUrl);
1198
+ const absolute = /^https?:/i.test(url) ? url : `${client.serverUrl}${url.startsWith("/") ? "" : "/"}${url}`;
1199
+ const method = String(data.optionsMethod ?? "POST").toUpperCase();
1200
+ const response = await fetch(absolute, {
1201
+ method,
1202
+ headers: { "Content-Type": "application/json" },
1203
+ body: method === "GET" ? void 0 : JSON.stringify(data.optionsBody ?? {}),
1204
+ credentials: "include"
1205
+ });
1206
+ if (!response.ok) throw new Error(labels.failed);
1207
+ const json = await response.json();
1208
+ const extra = {};
1209
+ if (typeof data.idProperty === "string" && typeof data.idField === "string" && json[data.idProperty] != null) {
1210
+ extra[data.idField] = String(json[data.idProperty]);
1211
+ }
1212
+ return { publicKey: json.options ?? json, extra };
1213
+ };
1214
+ const renderHtml = (html) => {
1215
+ const wrapper = el("div", "oluso-journey__html");
1216
+ wrapper.innerHTML = html;
1217
+ wrapper.querySelectorAll("script").forEach((s) => s.remove());
1218
+ wrapper.addEventListener("submit", (event) => {
1219
+ const form = event.target;
1220
+ event.preventDefault();
1221
+ const submitter = event.submitter;
1222
+ const data = new FormData(form);
1223
+ if (submitter?.name) data.append(submitter.name, submitter.value);
1224
+ data.delete("__RequestVerificationToken");
1225
+ submitting(form, true);
1226
+ post(data).catch((error) => {
1227
+ submitting(form, false);
1228
+ fail(error);
1229
+ });
1230
+ });
1231
+ root.replaceChildren(wrapper);
1232
+ };
1233
+ void (options.session ? show(options.session) : begin());
1234
+ return {
1235
+ get session() {
1236
+ return session;
1237
+ },
1238
+ restart: begin,
1239
+ destroy() {
1240
+ destroyed = true;
1241
+ clearTimers();
1242
+ root.remove();
1243
+ }
1244
+ };
1245
+ }
1246
+ function currentValues(item) {
1247
+ if (!item) return [];
1248
+ const inputs = item instanceof RadioNodeList ? Array.from(item) : [item];
1249
+ return inputs.flatMap((node) => {
1250
+ const input = node;
1251
+ if (input.disabled) return [];
1252
+ if (input.type === "checkbox" || input.type === "radio") return input.checked ? [input.value] : [];
1253
+ if (input instanceof HTMLSelectElement && input.multiple) return Array.from(input.selectedOptions).map((o) => o.value);
1254
+ return [input.value];
1255
+ });
1256
+ }
1257
+ var loadedScripts = /* @__PURE__ */ new Map();
1258
+ function loadScript(src) {
1259
+ let loading = loadedScripts.get(src);
1260
+ if (!loading) {
1261
+ loading = new Promise((resolve, reject) => {
1262
+ const tag = document.createElement("script");
1263
+ tag.src = src;
1264
+ tag.async = true;
1265
+ tag.onload = () => resolve();
1266
+ tag.onerror = () => {
1267
+ loadedScripts.delete(src);
1268
+ reject(new Error("A script the step needs could not be loaded"));
1269
+ };
1270
+ document.head.appendChild(tag);
1271
+ });
1272
+ loadedScripts.set(src, loading);
1273
+ }
1274
+ return loading;
1275
+ }
1276
+ var CAPTCHA_SCRIPTS = {
1277
+ recaptcha: { src: "https://www.google.com/recaptcha/api.js?render=explicit&onload=", global: "grecaptcha" },
1278
+ hcaptcha: { src: "https://js.hcaptcha.com/1/api.js?render=explicit&onload=", global: "hcaptcha" },
1279
+ turnstile: { src: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=", global: "turnstile" }
1280
+ };
1281
+ async function renderCaptcha(target, data, done) {
1282
+ const provider = String(data.provider ?? "recaptcha").toLowerCase();
1283
+ const script = CAPTCHA_SCRIPTS[provider];
1284
+ if (!script) throw new Error(`Captcha provider '${provider}' isn't supported`);
1285
+ const w = window;
1286
+ if (!w[script.global]) {
1287
+ const callback = `__olusoCaptcha${Date.now()}`;
1288
+ await new Promise((resolve, reject) => {
1289
+ w[callback] = () => resolve();
1290
+ const tag = document.createElement("script");
1291
+ tag.src = script.src + callback + (data.language ? `&hl=${encodeURIComponent(String(data.language))}` : "");
1292
+ tag.async = true;
1293
+ tag.onerror = () => reject(new Error("The captcha could not be loaded"));
1294
+ document.head.appendChild(tag);
1295
+ });
1296
+ }
1297
+ const api = w[script.global];
1298
+ const siteKey = String(data.siteKey ?? "");
1299
+ if (provider === "recaptcha" && data.version === "v3" && api.execute) {
1300
+ api.ready?.(() => void api.execute(siteKey, { action: String(data.action ?? "submit") }).then(done));
1301
+ return;
1302
+ }
1303
+ api.render(target, {
1304
+ sitekey: siteKey,
1305
+ theme: data.theme,
1306
+ size: data.size,
1307
+ action: data.action,
1308
+ callback: (token) => done(token)
1309
+ });
1310
+ }
1311
+
1312
+ // src/auth/storage.ts
1313
+ var STORAGE_KEY = "oluso_auth_tokens";
1314
+ var MemoryStorage = class {
1315
+ constructor() {
1316
+ this.tokens = null;
1317
+ }
1318
+ getTokens() {
1319
+ if (this.tokens && Date.now() / 1e3 >= this.tokens.expiresAt) {
1320
+ this.tokens = null;
1321
+ }
1322
+ return this.tokens;
1323
+ }
1324
+ setTokens(tokens) {
1325
+ this.tokens = tokens;
1326
+ }
1327
+ clear() {
1328
+ this.tokens = null;
1329
+ }
1330
+ };
1331
+ var BrowserStorage = class {
1332
+ constructor(type) {
1333
+ this.store = type === "localStorage" ? localStorage : sessionStorage;
1334
+ }
1335
+ getTokens() {
1336
+ try {
1337
+ const raw = this.store.getItem(STORAGE_KEY);
1338
+ if (!raw) return null;
1339
+ const tokens = JSON.parse(raw);
1340
+ if (Date.now() / 1e3 >= tokens.expiresAt) {
1341
+ this.clear();
1342
+ return null;
1343
+ }
1344
+ return tokens;
1345
+ } catch {
1346
+ return null;
1347
+ }
1348
+ }
1349
+ setTokens(tokens) {
1350
+ this.store.setItem(STORAGE_KEY, JSON.stringify(tokens));
1351
+ }
1352
+ clear() {
1353
+ this.store.removeItem(STORAGE_KEY);
1354
+ }
1355
+ };
1356
+ function createStorage(type) {
1357
+ if (type === "memory") return new MemoryStorage();
1358
+ return new BrowserStorage(type);
1359
+ }
1360
+
1361
+ // src/auth/client.ts
1362
+ var OlusoAuth = class {
1363
+ constructor(config) {
1364
+ this.user = null;
1365
+ this.resolvedEndpoints = null;
1366
+ this.discoveryPromise = null;
1367
+ /** PKCE verifiers of inline journeys still in progress, by session */
1368
+ this.journeyVerifiers = /* @__PURE__ */ new Map();
1369
+ if (!config.serverUrl || !config.tenant || !config.clientId) {
1370
+ throw new Error("serverUrl, tenant, and clientId are required");
1371
+ }
1372
+ this.config = {
1373
+ scopes: "openid profile email",
1374
+ signInJourney: "signin",
1375
+ signUpJourney: "signup",
1376
+ passwordResetJourney: "password-reset",
1377
+ storage: "memory",
1378
+ ...config,
1379
+ serverUrl: config.serverUrl.replace(/\/$/, "")
1380
+ };
1381
+ this.storage = createStorage(this.config.storage);
1382
+ const tokens = this.storage.getTokens();
1383
+ if (tokens) {
1384
+ this.user = this.parseIdToken(tokens.idToken);
1385
+ }
1386
+ }
1387
+ // =========================================================================
1388
+ // Journey Auth (inline or modal)
1389
+ // =========================================================================
1390
+ /**
1391
+ * Sign in.
1392
+ * - With data: run the sign-in journey inline with these inputs (programmatic, first-party
1393
+ * only). If another step follows (e.g. MFA), the result has requiresStep, sessionId and
1394
+ * the step to render; continue with continueStep().
1395
+ * - Without data: open the hosted journey in a secure iframe modal (safe for any site)
1396
+ */
1397
+ async signIn(data) {
1398
+ if (data) {
1399
+ return this.executeJourney(this.config.signInJourney, data);
1400
+ }
1401
+ return this.openAuthModal("signin");
1402
+ }
1403
+ /**
1404
+ * Sign up.
1405
+ * - With data: run the sign-up journey inline (programmatic, first-party only)
1406
+ * - Without data: open the hosted journey in a secure iframe modal
1407
+ */
1408
+ async signUp(data) {
1409
+ if (data) {
1410
+ return this.executeJourney(this.config.signUpJourney, data);
1411
+ }
1412
+ return this.openAuthModal("signup");
1413
+ }
1414
+ /**
1415
+ * Sign in with a passkey (WebAuthn).
1416
+ * No form or iframe needed — the browser shows a native biometric/key prompt.
1417
+ * Works with discoverable credentials (usernameless) or username-based flow.
1418
+ *
1419
+ * @example
1420
+ * ```typescript
1421
+ * // Discoverable credential (passkey autofill or prompt)
1422
+ * const result = await auth.signInWithPasskey();
1423
+ *
1424
+ * // Username-based (shows credentials for specific user)
1425
+ * const result = await auth.signInWithPasskey({ username: 'user@example.com' });
1426
+ *
1427
+ * // Conditional mediation (passkey suggestions in input fields)
1428
+ * const result = await auth.signInWithPasskey({ conditional: true });
1429
+ * ```
1430
+ */
1431
+ async signInWithPasskey(options) {
1432
+ if (!window.PublicKeyCredential) {
1433
+ return { success: false, error: "WebAuthn is not supported in this browser" };
1434
+ }
1435
+ const { verifier, challenge } = await this.generatePkce();
1436
+ const optionsUrl = `${this.config.serverUrl}/connect/webauthn/assertion-options`;
1437
+ const optionsResponse = await fetch(optionsUrl, {
1438
+ method: "POST",
1439
+ headers: this.buildHeaders(),
1440
+ body: JSON.stringify({
1441
+ clientId: this.config.clientId,
1442
+ username: options?.username,
1443
+ scope: this.config.scopes,
1444
+ codeChallenge: challenge,
1445
+ codeChallengeMethod: "S256"
1446
+ })
1447
+ });
1448
+ if (!optionsResponse.ok) {
1449
+ const err = await optionsResponse.json().catch(() => ({ error_description: "Failed to get passkey options" }));
1450
+ return { success: false, error: err.error_description || err.error };
1451
+ }
1452
+ const assertionOptions = await optionsResponse.json();
1453
+ const publicKeyOptions = {
1454
+ challenge: this.base64UrlDecode(assertionOptions.challenge),
1455
+ timeout: assertionOptions.timeout,
1456
+ rpId: assertionOptions.rpId,
1457
+ userVerification: assertionOptions.userVerification || "preferred",
1458
+ allowCredentials: assertionOptions.allowCredentials?.map((c) => ({
1459
+ type: c.type,
1460
+ id: this.base64UrlDecode(c.id),
1461
+ transports: c.transports
1462
+ }))
1463
+ };
1464
+ let credential;
1465
+ try {
1466
+ const getOptions = {
1467
+ publicKey: publicKeyOptions,
1468
+ signal: options?.signal
1469
+ };
1470
+ if (options?.conditional) {
1471
+ getOptions.mediation = "conditional";
1472
+ }
1473
+ credential = await navigator.credentials.get(getOptions);
1474
+ } catch (err) {
1475
+ const message = err instanceof Error ? err.message : "Passkey request failed";
1476
+ if (err instanceof DOMException && err.name === "AbortError") {
1477
+ return { success: false, error: "cancelled" };
1478
+ }
1479
+ if (err instanceof DOMException && err.name === "NotAllowedError") {
1480
+ return { success: false, error: "cancelled" };
1481
+ }
1482
+ return { success: false, error: message };
1483
+ }
1484
+ if (!credential) {
1485
+ return { success: false, error: "No credential returned" };
1486
+ }
1487
+ const assertionResponse = credential.response;
1488
+ const responsePayload = JSON.stringify({
1489
+ id: credential.id,
1490
+ rawId: this.base64UrlEncode(new Uint8Array(credential.rawId)),
1491
+ type: credential.type,
1492
+ response: {
1493
+ clientDataJSON: this.base64UrlEncode(new Uint8Array(assertionResponse.clientDataJSON)),
1494
+ authenticatorData: this.base64UrlEncode(new Uint8Array(assertionResponse.authenticatorData)),
1495
+ signature: this.base64UrlEncode(new Uint8Array(assertionResponse.signature)),
1496
+ userHandle: assertionResponse.userHandle ? this.base64UrlEncode(new Uint8Array(assertionResponse.userHandle)) : null
1497
+ }
1498
+ });
1499
+ const verifyUrl = `${this.config.serverUrl}/connect/webauthn/assertion`;
1500
+ const verifyResponse = await fetch(verifyUrl, {
1501
+ method: "POST",
1502
+ headers: this.buildHeaders(),
1503
+ body: JSON.stringify({
1504
+ clientId: this.config.clientId,
1505
+ assertionId: assertionOptions.assertionId,
1506
+ assertionResponse: responsePayload,
1507
+ codeChallenge: challenge,
1508
+ codeChallengeMethod: "S256",
1509
+ scope: this.config.scopes
1510
+ })
1511
+ });
1512
+ if (!verifyResponse.ok) {
1513
+ const err = await verifyResponse.json().catch(() => ({ error_description: "Passkey verification failed" }));
1514
+ return { success: false, error: err.error_description || err.error };
1515
+ }
1516
+ const { code } = await verifyResponse.json();
1517
+ return this.exchangeCodeForTokens(code, verifier, "postmessage");
1518
+ }
1519
+ /**
1520
+ * Sign in with FedCM (Federated Credential Management).
1521
+ * Opens a browser-native account picker — no redirect or popup needed.
1522
+ * Requires the user to be already authenticated on the Oluso server (has session cookie).
1523
+ *
1524
+ * Browser support: Chrome/Edge 132+ (the provider `params` that carry the PKCE challenge).
1525
+ * The page origin must be registered for the client (CORS origin or redirect URI).
1526
+ *
1527
+ * @example
1528
+ * ```typescript
1529
+ * const result = await auth.signInWithFedCM();
1530
+ * if (result.success) {
1531
+ * console.log('Signed in as', result.user.name);
1532
+ * }
1533
+ * ```
1534
+ */
1535
+ async signInWithFedCM(options) {
1536
+ if (!("IdentityCredential" in window)) {
1537
+ return { success: false, error: "FedCM is not supported in this browser" };
1538
+ }
1539
+ const { verifier, challenge } = await this.generatePkce();
1540
+ try {
1541
+ const credential = await navigator.credentials.get({
1542
+ identity: {
1543
+ providers: [{
1544
+ configURL: `${this.config.serverUrl}/fedcm/config.json`,
1545
+ clientId: this.config.clientId,
1546
+ nonce: options?.nonce,
1547
+ loginHint: options?.loginHint,
1548
+ params: {
1549
+ code_challenge: challenge,
1550
+ code_challenge_method: "S256",
1551
+ scope: this.config.scopes
1552
+ }
1553
+ }]
1554
+ },
1555
+ mediation: options?.autoReauth ? "optional" : "required"
1556
+ });
1557
+ if (!credential?.token) {
1558
+ return { success: false, error: "No credential returned from FedCM" };
1559
+ }
1560
+ return this.exchangeCodeForTokens(credential.token, verifier, "fedcm");
1561
+ } catch (err) {
1562
+ if (err instanceof DOMException && err.name === "AbortError") {
1563
+ return { success: false, error: "cancelled" };
1564
+ }
1565
+ if (err instanceof DOMException && err.name === "NetworkError") {
1566
+ return { success: false, error: "FedCM network error \u2014 user may not be signed in to the identity provider" };
1567
+ }
1568
+ const message = err instanceof Error ? err.message : "FedCM request failed";
1569
+ return { success: false, error: message };
1570
+ }
1571
+ }
1572
+ /**
1573
+ * Render the sign-in journey into `container` and sign in without leaving the page, e.g. in a modal when the
1574
+ * session has expired. Every step the journey has (password, MFA, passkey, consent) is shown; when it completes,
1575
+ * the code is redeemed and `onSignedIn` gets the result.
1576
+ */
1577
+ mountSignIn(container, options = {}) {
1578
+ const client = new OlusoClient({ serverUrl: this.config.serverUrl, tenant: this.config.tenant, apiKey: this.config.apiKey });
1579
+ let verifier = "";
1580
+ return mountJourney(container, {
1581
+ client,
1582
+ theme: options.theme,
1583
+ unstyled: options.unstyled,
1584
+ labels: options.labels,
1585
+ showCompletion: false,
1586
+ onError: options.onError,
1587
+ onRedirect: options.onRedirect,
1588
+ start: async () => {
1589
+ const pkce = await this.generatePkce();
1590
+ verifier = pkce.verifier;
1591
+ return client.startAuthJourney(
1592
+ options.journey ?? this.config.signInJourney,
1593
+ { clientId: this.config.clientId, codeChallenge: pkce.challenge, scope: this.config.scopes }
1594
+ );
1595
+ },
1596
+ onComplete: async (session) => {
1597
+ const result = session.code ? await this.exchangeCodeForTokens(session.code, verifier, "postmessage") : { success: false, error: session.errorDescription || session.error || "Authentication failed", code: session.error };
1598
+ options.onSignedIn?.(result);
1599
+ }
1600
+ });
1601
+ }
1602
+ /**
1603
+ * Continue an inline journey with the input for its current step (e.g. an MFA code)
1604
+ */
1605
+ async continueStep(sessionId, data) {
1606
+ const verifier = this.journeyVerifiers.get(sessionId);
1607
+ if (!verifier) {
1608
+ return { success: false, error: "This journey was not started by this client", code: "invalid_session" };
1609
+ }
1610
+ const url = `${this.config.serverUrl}/api/public/journeys/sessions/${encodeURIComponent(sessionId)}/step`;
1611
+ const response = await fetch(url, {
1612
+ method: "POST",
1613
+ headers: this.buildHeaders(),
1614
+ body: JSON.stringify({ data }),
1615
+ credentials: "include"
1616
+ });
1617
+ if (!response.ok) {
1618
+ const error = await response.json().catch(() => ({ message: "Step failed" }));
1619
+ return { success: false, error: error.error_description || error.message, code: error.error || error.code };
1620
+ }
1621
+ return this.handleJourneyResult(await response.json(), verifier);
1622
+ }
1623
+ /**
1624
+ * Get the fields/config of a data-collection journey (for rendering in UI)
1625
+ */
1626
+ async getJourneyStep(journeySlug) {
1627
+ const url = `${this.config.serverUrl}/api/public/journeys/${encodeURIComponent(journeySlug)}`;
1628
+ const response = await fetch(url, {
1629
+ headers: this.buildHeaders(),
1630
+ credentials: "include"
1631
+ });
1632
+ if (!response.ok) return null;
1633
+ const journey = await response.json();
1634
+ return {
1635
+ step: "initial",
1636
+ fields: journey.fields || [],
1637
+ title: journey.ui?.title || journey.name,
1638
+ description: journey.description
1639
+ };
1640
+ }
1641
+ // =========================================================================
1642
+ // OIDC Redirect Flow
1643
+ // =========================================================================
1644
+ /**
1645
+ * Redirect to the Oluso login page (standard OIDC Authorization Code + PKCE)
1646
+ */
1647
+ async loginWithRedirect(options) {
1648
+ const { verifier, challenge } = await this.generatePkce();
1649
+ sessionStorage.setItem("oluso_pkce_verifier", verifier);
1650
+ if (options?.state) {
1651
+ sessionStorage.setItem("oluso_auth_state", options.state);
1652
+ }
1653
+ const endpoints = await this.getEndpoints();
1654
+ const params = new URLSearchParams({
1655
+ response_type: "code",
1656
+ client_id: this.config.clientId,
1657
+ redirect_uri: this.config.redirectUri || window.location.origin + "/callback",
1658
+ scope: this.config.scopes,
1659
+ code_challenge: challenge,
1660
+ code_challenge_method: "S256",
1661
+ tenant: this.config.tenant
1662
+ });
1663
+ if (options?.prompt) params.set("prompt", options.prompt);
1664
+ if (options?.state) params.set("state", options.state);
1665
+ window.location.href = `${endpoints.authorization}?${params}`;
1666
+ }
1667
+ /**
1668
+ * Handle the OIDC callback (exchange code for tokens)
1669
+ */
1670
+ async handleCallback(callbackUrl) {
1671
+ const url = new URL(callbackUrl || window.location.href);
1672
+ const code = url.searchParams.get("code");
1673
+ const error = url.searchParams.get("error");
1674
+ if (error) {
1675
+ return { success: false, error: url.searchParams.get("error_description") || error, code: error };
1676
+ }
1677
+ if (!code) {
1678
+ return { success: false, error: "No authorization code in callback" };
1679
+ }
1680
+ const verifier = sessionStorage.getItem("oluso_pkce_verifier");
1681
+ sessionStorage.removeItem("oluso_pkce_verifier");
1682
+ if (!verifier) {
1683
+ return { success: false, error: "PKCE verifier not found" };
1684
+ }
1685
+ return this.exchangeCodeForTokens(code, verifier, this.config.redirectUri || window.location.origin + "/callback");
1686
+ }
1687
+ // =========================================================================
1688
+ // Token & User Management
1689
+ // =========================================================================
1690
+ /**
1691
+ * Get the current user (null if not authenticated)
1692
+ */
1693
+ getUser() {
1694
+ return this.user;
1695
+ }
1696
+ /**
1697
+ * Check if user is authenticated (has valid tokens)
1698
+ */
1699
+ isAuthenticated() {
1700
+ return this.user !== null && this.storage.getTokens() !== null;
1701
+ }
1702
+ /**
1703
+ * Get the current access token (for API calls)
1704
+ */
1705
+ getAccessToken() {
1706
+ return this.storage.getTokens()?.accessToken ?? null;
1707
+ }
1708
+ /**
1709
+ * Check if user has a specific role
1710
+ */
1711
+ hasRole(role) {
1712
+ return this.user?.roles.includes(role) ?? false;
1713
+ }
1714
+ /**
1715
+ * Check if user has any of the specified roles
1716
+ */
1717
+ hasAnyRole(...roles) {
1718
+ return roles.some((r) => this.hasRole(r));
1719
+ }
1720
+ /**
1721
+ * Check if user has a specific permission
1722
+ */
1723
+ hasPermission(permission) {
1724
+ return this.user?.permissions.includes(permission) ?? false;
1725
+ }
1726
+ /**
1727
+ * Refresh the access token using the refresh token
1728
+ */
1729
+ async refreshToken() {
1730
+ const tokens = this.storage.getTokens();
1731
+ if (!tokens?.refreshToken) return false;
1732
+ const endpoints = await this.getEndpoints();
1733
+ const response = await fetch(endpoints.token, {
1734
+ method: "POST",
1735
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1736
+ body: new URLSearchParams({
1737
+ grant_type: "refresh_token",
1738
+ client_id: this.config.clientId,
1739
+ refresh_token: tokens.refreshToken
1740
+ })
1741
+ });
1742
+ if (!response.ok) {
1743
+ this.logout();
1744
+ return false;
1745
+ }
1746
+ const tokenData = await response.json();
1747
+ this.processTokenResponse(tokenData);
1748
+ return true;
1749
+ }
1750
+ /**
1751
+ * Log out — clear tokens and optionally redirect to end session.
1752
+ * If redirect is true, uses discovered or configured end_session_endpoint.
1753
+ */
1754
+ logout(options) {
1755
+ const tokens = this.storage.getTokens();
1756
+ this.storage.clear();
1757
+ this.user = null;
1758
+ this.config.onAuthStateChange?.(null);
1759
+ if (options?.redirect && tokens?.idToken) {
1760
+ if (this.resolvedEndpoints?.endSession || this.config.endpoints?.endSession) {
1761
+ const params = new URLSearchParams({
1762
+ id_token_hint: tokens.idToken,
1763
+ post_logout_redirect_uri: options.postLogoutRedirectUri || window.location.origin
1764
+ });
1765
+ window.location.href = `${this.resolvedEndpoints?.endSession || this.config.endpoints?.endSession}?${params}`;
1766
+ } else {
1767
+ this.getEndpoints().then((endpoints) => {
1768
+ const params = new URLSearchParams({
1769
+ id_token_hint: tokens.idToken,
1770
+ post_logout_redirect_uri: options.postLogoutRedirectUri || window.location.origin
1771
+ });
1772
+ window.location.href = `${endpoints.endSession}?${params}`;
1773
+ });
1774
+ }
1775
+ }
1776
+ }
1777
+ /**
1778
+ * Fetch the user's profile from the userinfo endpoint
1779
+ */
1780
+ async fetchUserInfo() {
1781
+ const token = this.getAccessToken();
1782
+ if (!token) return null;
1783
+ const endpoints = await this.getEndpoints();
1784
+ const response = await fetch(endpoints.userinfo, {
1785
+ headers: { Authorization: `Bearer ${token}` }
1786
+ });
1787
+ if (!response.ok) return null;
1788
+ const claims = await response.json();
1789
+ this.user = this.claimsToUser(claims);
1790
+ this.config.onAuthStateChange?.(this.user);
1791
+ return this.user;
1792
+ }
1793
+ // =========================================================================
1794
+ // Private Helpers
1795
+ // =========================================================================
1796
+ /**
1797
+ * Opens a secure iframe modal for authentication.
1798
+ * Credentials are entered inside the iframe (auth server origin).
1799
+ * Returns tokens via postMessage + PKCE code exchange.
1800
+ */
1801
+ async openAuthModal(mode) {
1802
+ const { verifier, challenge } = await this.generatePkce();
1803
+ const journeySlug = mode === "signin" ? this.config.signInJourney : this.config.signUpJourney;
1804
+ const params = new URLSearchParams({
1805
+ journey: journeySlug,
1806
+ client_id: this.config.clientId,
1807
+ origin: window.location.origin,
1808
+ code_challenge: challenge,
1809
+ code_challenge_method: "S256",
1810
+ scope: this.config.scopes
1811
+ });
1812
+ const iframeSrc = `${this.config.serverUrl}/embed/auth?${params}`;
1813
+ return new Promise((resolve) => {
1814
+ const overlay = document.createElement("div");
1815
+ overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);z-index:99999;display:flex;align-items:center;justify-content:center;padding:24px;";
1816
+ const modal = document.createElement("div");
1817
+ modal.style.cssText = "background:#fff;border-radius:16px;width:100%;max-width:420px;max-height:90vh;overflow:hidden;box-shadow:0 25px 50px -12px rgba(0,0,0,0.25);";
1818
+ const header = document.createElement("div");
1819
+ header.style.cssText = "display:flex;justify-content:flex-end;padding:12px 16px 0;";
1820
+ const closeBtn = document.createElement("button");
1821
+ closeBtn.innerHTML = "&times;";
1822
+ closeBtn.style.cssText = "background:none;border:none;cursor:pointer;color:#9ca3af;font-size:20px;padding:4px;border-radius:6px;";
1823
+ header.appendChild(closeBtn);
1824
+ const iframe = document.createElement("iframe");
1825
+ iframe.src = iframeSrc;
1826
+ iframe.allow = "camera; microphone";
1827
+ iframe.style.cssText = "border:none;width:100%;min-height:300px;";
1828
+ const branding = document.createElement("div");
1829
+ branding.style.cssText = "display:none;text-align:center;padding:10px 0;font-size:11px;color:#9ca3af;border-top:1px solid #e5e7eb;font-family:-apple-system,BlinkMacSystemFont,sans-serif;";
1830
+ branding.innerHTML = 'Powered by <a href="https://oluso.io" target="_blank" rel="noopener" style="color:#9ca3af;text-decoration:none;">Oluso</a>';
1831
+ modal.appendChild(header);
1832
+ modal.appendChild(iframe);
1833
+ modal.appendChild(branding);
1834
+ overlay.appendChild(modal);
1835
+ document.body.appendChild(overlay);
1836
+ document.body.style.overflow = "hidden";
1837
+ const cleanup = () => {
1838
+ window.removeEventListener("message", messageHandler);
1839
+ document.removeEventListener("keydown", escHandler);
1840
+ document.body.style.overflow = "";
1841
+ overlay.remove();
1842
+ };
1843
+ const cancel = () => {
1844
+ cleanup();
1845
+ resolve({ success: false, error: "cancelled" });
1846
+ };
1847
+ closeBtn.addEventListener("click", cancel);
1848
+ overlay.addEventListener("click", (e) => {
1849
+ if (e.target === overlay) cancel();
1850
+ });
1851
+ const escHandler = (e) => {
1852
+ if (e.key === "Escape") cancel();
1853
+ };
1854
+ document.addEventListener("keydown", escHandler);
1855
+ const messageHandler = async (event) => {
1856
+ const serverOrigin = new URL(this.config.serverUrl).origin;
1857
+ if (event.origin !== serverOrigin) return;
1858
+ const data = event.data;
1859
+ if (!data || typeof data !== "object") return;
1860
+ if (data.type === "oluso:auth:code") {
1861
+ cleanup();
1862
+ const result = await this.exchangeCodeForTokens(data.code, verifier, "postmessage");
1863
+ resolve(result);
1864
+ } else if (data.type === "oluso:auth:error") {
1865
+ cleanup();
1866
+ resolve({ success: false, error: data.error });
1867
+ } else if (data.type === "oluso:auth:ready") {
1868
+ branding.style.display = data.hideBranding ? "none" : "block";
1869
+ } else if (data.type === "oluso:auth:resize" && typeof data.height === "number") {
1870
+ iframe.style.height = `${data.height}px`;
1871
+ }
1872
+ };
1873
+ window.addEventListener("message", messageHandler);
1874
+ });
1875
+ }
1876
+ /**
1877
+ * Get OIDC endpoints — uses manual overrides, falls back to discovery.
1878
+ */
1879
+ async getEndpoints() {
1880
+ if (this.config.endpoints?.authorization && this.config.endpoints?.token) {
1881
+ const base = this.config.serverUrl;
1882
+ return {
1883
+ authorization: this.config.endpoints.authorization,
1884
+ token: this.config.endpoints.token,
1885
+ userinfo: this.config.endpoints.userinfo || `${base}/connect/userinfo`,
1886
+ endSession: this.config.endpoints.endSession || `${base}/connect/endsession`,
1887
+ jwks: this.config.endpoints.jwks || `${base}/.well-known/openid-configuration/jwks`
1888
+ };
1889
+ }
1890
+ if (this.resolvedEndpoints) {
1891
+ return this.resolvedEndpoints;
1892
+ }
1893
+ if (!this.discoveryPromise) {
1894
+ this.discoveryPromise = this.discoverEndpoints();
1895
+ }
1896
+ this.resolvedEndpoints = await this.discoveryPromise;
1897
+ return this.resolvedEndpoints;
1898
+ }
1899
+ async discoverEndpoints() {
1900
+ const base = this.config.serverUrl;
1901
+ const wellKnownUrl = `${base}/.well-known/openid-configuration`;
1902
+ try {
1903
+ const response = await fetch(wellKnownUrl);
1904
+ if (!response.ok) {
1905
+ return this.defaultEndpoints();
1906
+ }
1907
+ const doc = await response.json();
1908
+ return {
1909
+ authorization: doc.authorization_endpoint || `${base}/connect/authorize`,
1910
+ token: doc.token_endpoint || `${base}/connect/token`,
1911
+ userinfo: doc.userinfo_endpoint || `${base}/connect/userinfo`,
1912
+ endSession: doc.end_session_endpoint || `${base}/connect/endsession`,
1913
+ jwks: doc.jwks_uri || `${base}/.well-known/openid-configuration/jwks`
1914
+ };
1915
+ } catch {
1916
+ return this.defaultEndpoints();
1917
+ }
1918
+ }
1919
+ defaultEndpoints() {
1920
+ const base = this.config.serverUrl;
1921
+ return {
1922
+ authorization: `${base}/connect/authorize`,
1923
+ token: `${base}/connect/token`,
1924
+ userinfo: `${base}/connect/userinfo`,
1925
+ endSession: `${base}/connect/endsession`,
1926
+ jwks: `${base}/.well-known/openid-configuration/jwks`
1927
+ };
1928
+ }
1929
+ async executeJourney(journeySlug, data) {
1930
+ const { verifier, challenge } = await this.generatePkce();
1931
+ const url = `${this.config.serverUrl}/api/public/journeys/${encodeURIComponent(journeySlug)}/start`;
1932
+ const response = await fetch(url, {
1933
+ method: "POST",
1934
+ headers: this.buildHeaders(),
1935
+ body: JSON.stringify({
1936
+ data,
1937
+ clientId: this.config.clientId,
1938
+ codeChallenge: challenge,
1939
+ codeChallengeMethod: "S256",
1940
+ scope: this.config.scopes
1941
+ }),
1942
+ credentials: "include"
1943
+ });
1944
+ if (!response.ok) {
1945
+ const error = await response.json().catch(() => ({ message: "Auth failed" }));
1946
+ return { success: false, error: error.error_description || error.message, code: error.error || error.code };
1947
+ }
1948
+ return this.handleJourneyResult(await response.json(), verifier);
1949
+ }
1950
+ /**
1951
+ * Completed: redeem the code with the journey's verifier. In progress: hand back the step
1952
+ * to render (an error from the step's last input, e.g. a wrong password, rides along).
1953
+ */
1954
+ async handleJourneyResult(session, verifier) {
1955
+ if (session.completed && session.code) {
1956
+ this.journeyVerifiers.delete(session.sessionId);
1957
+ return this.exchangeCodeForTokens(session.code, verifier, "postmessage");
1958
+ }
1959
+ if (session.status === "in_progress") {
1960
+ this.journeyVerifiers.set(session.sessionId, verifier);
1961
+ const stepError = session.step?.model?.errorMessage ?? session.step?.model?.formError;
1962
+ return {
1963
+ success: false,
1964
+ requiresStep: session.step?.type ?? session.currentStep,
1965
+ sessionId: session.sessionId,
1966
+ step: session.step,
1967
+ redirectUrl: session.redirectUrl,
1968
+ error: typeof stepError === "string" ? stepError : void 0
1969
+ };
1970
+ }
1971
+ this.journeyVerifiers.delete(session.sessionId);
1972
+ return {
1973
+ success: false,
1974
+ error: session.errorDescription || session.error || "Authentication failed",
1975
+ code: session.error
1976
+ };
1977
+ }
1978
+ processTokenResponse(tokenData) {
1979
+ const tokens = {
1980
+ accessToken: tokenData.access_token,
1981
+ idToken: tokenData.id_token,
1982
+ refreshToken: tokenData.refresh_token,
1983
+ expiresAt: Math.floor(Date.now() / 1e3) + (tokenData.expires_in || 3600),
1984
+ tokenType: tokenData.token_type || "Bearer",
1985
+ scope: tokenData.scope
1986
+ };
1987
+ this.storage.setTokens(tokens);
1988
+ this.user = this.parseIdToken(tokens.idToken);
1989
+ this.config.onAuthStateChange?.(this.user);
1990
+ return { success: true, user: this.user, tokens };
1991
+ }
1992
+ parseIdToken(idToken) {
1993
+ if (!idToken) return null;
1994
+ try {
1995
+ const parts = idToken.split(".");
1996
+ if (parts.length !== 3) return null;
1997
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
1998
+ return this.claimsToUser(payload);
1999
+ } catch {
2000
+ return null;
2001
+ }
2002
+ }
2003
+ claimsToUser(claims) {
2004
+ return {
2005
+ sub: claims.sub,
2006
+ name: claims.name || claims.preferred_username,
2007
+ email: claims.email,
2008
+ emailVerified: claims.email_verified,
2009
+ roles: this.parseArrayClaim(claims.role || claims.roles),
2010
+ permissions: this.parseArrayClaim(claims.permission || claims.permissions),
2011
+ tenantId: claims.tenant_id,
2012
+ claims
2013
+ };
2014
+ }
2015
+ parseArrayClaim(claim) {
2016
+ if (!claim) return [];
2017
+ if (Array.isArray(claim)) return claim.map(String);
2018
+ if (typeof claim === "string") return [claim];
2019
+ return [];
2020
+ }
2021
+ buildHeaders() {
2022
+ const headers = {
2023
+ "Content-Type": "application/json",
2024
+ "X-Tenant-ID": this.config.tenant
2025
+ };
2026
+ if (this.config.apiKey) {
2027
+ headers["X-API-Key"] = this.config.apiKey;
2028
+ }
2029
+ return headers;
2030
+ }
2031
+ async generatePkce() {
2032
+ const array = new Uint8Array(32);
2033
+ crypto.getRandomValues(array);
2034
+ const verifier = this.base64UrlEncode(array);
2035
+ const encoder = new TextEncoder();
2036
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(verifier));
2037
+ const challenge = this.base64UrlEncode(new Uint8Array(digest));
2038
+ return { verifier, challenge };
2039
+ }
2040
+ base64UrlEncode(buffer) {
2041
+ let binary = "";
2042
+ for (let i = 0; i < buffer.length; i++) {
2043
+ binary += String.fromCharCode(buffer[i]);
2044
+ }
2045
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2046
+ }
2047
+ base64UrlDecode(str) {
2048
+ const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
2049
+ const padding = "=".repeat((4 - base64.length % 4) % 4);
2050
+ const binary = atob(base64 + padding);
2051
+ const bytes = new Uint8Array(binary.length);
2052
+ for (let i = 0; i < binary.length; i++) {
2053
+ bytes[i] = binary.charCodeAt(i);
2054
+ }
2055
+ return bytes.buffer;
2056
+ }
2057
+ /**
2058
+ * Exchange an authorization code for tokens via the token endpoint.
2059
+ * Used by passkey, FedCM, and modal auth flows.
2060
+ */
2061
+ async exchangeCodeForTokens(code, verifier, redirectUri) {
2062
+ const endpoints = await this.getEndpoints();
2063
+ const response = await fetch(endpoints.token, {
2064
+ method: "POST",
2065
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2066
+ body: new URLSearchParams({
2067
+ grant_type: "authorization_code",
2068
+ client_id: this.config.clientId,
2069
+ code,
2070
+ redirect_uri: redirectUri,
2071
+ code_verifier: verifier
2072
+ })
2073
+ });
2074
+ if (!response.ok) {
2075
+ const err = await response.json().catch(() => ({ error_description: "Token exchange failed" }));
2076
+ return { success: false, error: err.error_description || err.error };
2077
+ }
2078
+ const tokenData = await response.json();
2079
+ return this.processTokenResponse(tokenData);
2080
+ }
2081
+ };
2082
+
2083
+ // src/index.ts
2084
+ function createClient(config) {
2085
+ return new OlusoClient(config);
2086
+ }
2087
+ export {
2088
+ OlusoAuth,
2089
+ OlusoClient,
2090
+ OlusoError,
2091
+ conditionMet,
2092
+ createClient,
2093
+ mountJourney
2094
+ };
2095
+ //# sourceMappingURL=index.mjs.map