@atlasauth/js 0.18.0 → 0.20.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/attempt.d.ts +34 -1
  3. package/dist/attempt.js +2 -0
  4. package/dist/attempt.js.map +1 -1
  5. package/dist/captcha-controller.d.ts +3 -0
  6. package/dist/captcha-controller.js +10 -1
  7. package/dist/captcha-controller.js.map +1 -1
  8. package/dist/compat.d.ts +24 -0
  9. package/dist/compat.js +15 -1
  10. package/dist/compat.js.map +1 -1
  11. package/dist/fapi.d.ts +71 -0
  12. package/dist/fapi.js +109 -3
  13. package/dist/fapi.js.map +1 -1
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.js +6 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/passkey-sign-up.d.ts +3 -0
  18. package/dist/passkey-sign-up.js +1 -0
  19. package/dist/passkey-sign-up.js.map +1 -1
  20. package/dist/policies.d.ts +156 -0
  21. package/dist/policies.js +103 -0
  22. package/dist/policies.js.map +1 -0
  23. package/dist/policy-acceptance.d.ts +259 -0
  24. package/dist/policy-acceptance.js +698 -0
  25. package/dist/policy-acceptance.js.map +1 -0
  26. package/dist/policy-dom.d.ts +106 -0
  27. package/dist/policy-dom.js +438 -0
  28. package/dist/policy-dom.js.map +1 -0
  29. package/dist/redirect.d.ts +5 -1
  30. package/dist/redirect.js +4 -1
  31. package/dist/redirect.js.map +1 -1
  32. package/dist/referral-detect.d.ts +2 -2
  33. package/dist/referral-detect.js +25 -4
  34. package/dist/referral-detect.js.map +1 -1
  35. package/dist/referral.d.ts +3 -1
  36. package/dist/referral.js +21 -8
  37. package/dist/referral.js.map +1 -1
  38. package/dist/sign-in-with-atlas-loader.d.ts +42 -0
  39. package/dist/sign-in-with-atlas-loader.js +105 -0
  40. package/dist/sign-in-with-atlas-loader.js.map +1 -0
  41. package/dist/version.d.ts +1 -1
  42. package/dist/version.js +1 -1
  43. package/package.json +1 -1
@@ -0,0 +1,698 @@
1
+ "use strict";
2
+ /**
3
+ * Policy acceptance (terms of service, privacy, any policy) at sign-up and
4
+ * sign-in: the framework-agnostic half every prebuilt UI shares.
5
+ *
6
+ * What lives here:
7
+ * - the wire types and tolerant normalisers for `GET /v1/client/policies`,
8
+ * `GET /v1/client/me/policies` and a sign-in attempt's `pending_policies`;
9
+ * - {@link PolicyAcceptanceController}, a small observable state machine: the
10
+ * policies to show, one checked/read flag per policy, the document the user
11
+ * has open (and for how long), and the `policy_acceptances` payload to send;
12
+ * - the pure helpers a UI needs: scroll-to-end detection, a focus trap, a safe
13
+ * markdown renderer for policies delivered as markdown, and the 422
14
+ * `POLICY_ACCEPTANCE_REQUIRED` reader;
15
+ * - the FAPI calls: load policies, accept them in a sign-in attempt, accept
16
+ * them from inside the app.
17
+ *
18
+ * Display modes (a policy's `display`):
19
+ * - `checkbox`: a checkbox, combined into one sentence when several policies
20
+ * use it (or one each); the policy name opens the document in an in-page
21
+ * viewer, not a new tab;
22
+ * - `scroll`: an unskippable modal, accepted only after the end of the text is
23
+ * seen (or the text fits); declining cancels;
24
+ * - `link`: a clickwrap sentence under the submit button; submitting accepts.
25
+ *
26
+ * Nothing here is a security boundary: `scrolled_to_end` and `view_ms` are
27
+ * client-attested evidence the server records alongside the acceptance.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.PolicyGateController = exports.POLICY_STRINGS = exports.PolicyAcceptanceController = exports.acceptSignInPolicies = void 0;
31
+ exports.normalizePolicy = normalizePolicy;
32
+ exports.normalizePolicies = normalizePolicies;
33
+ exports.pendingPoliciesOf = pendingPoliciesOf;
34
+ exports.normalizeMyPolicies = normalizeMyPolicies;
35
+ exports.isPolicyAcceptanceRequired = isPolicyAcceptanceRequired;
36
+ exports.loadPolicies = loadPolicies;
37
+ exports.getSignUpPolicies = getSignUpPolicies;
38
+ exports.loadMyPolicies = loadMyPolicies;
39
+ exports.acceptMyPolicies = acceptMyPolicies;
40
+ exports.parsePolicyAcceptances = parsePolicyAcceptances;
41
+ exports.createPolicyController = createPolicyController;
42
+ exports.isScrolledToEnd = isScrolledToEnd;
43
+ exports.observeScrollEnd = observeScrollEnd;
44
+ exports.trapFocus = trapFocus;
45
+ exports.markdownToSafeHtml = markdownToSafeHtml;
46
+ exports.policyHtml = policyHtml;
47
+ exports.formatPolicyDate = formatPolicyDate;
48
+ exports.policyTranslator = policyTranslator;
49
+ exports.joinTitles = joinTitles;
50
+ exports.splitTemplate = splitTemplate;
51
+ exports.refreshPoliciesOnRequired = refreshPoliciesOnRequired;
52
+ exports.controllerForPending = controllerForPending;
53
+ const policies_1 = require("./policies");
54
+ const DISPLAYS = ['checkbox', 'scroll', 'link'];
55
+ function str(v) {
56
+ return typeof v === 'string' && v !== '' ? v : null;
57
+ }
58
+ /** A timestamp as ISO text: the API sends epoch milliseconds; text passes through. */
59
+ function when(v) {
60
+ if (typeof v === 'number' && Number.isFinite(v))
61
+ return new Date(v).toISOString();
62
+ return str(v);
63
+ }
64
+ /** Read one policy from the wire (snake_case, flat or with a nested `version`/`current_version` object). */
65
+ function normalizePolicy(raw) {
66
+ if (!raw || typeof raw !== 'object')
67
+ return null;
68
+ const r = raw;
69
+ const nested = [r.version, r.current_version].find((v) => v && typeof v === 'object');
70
+ const pick = (name) => (r[name] !== undefined ? r[name] : nested?.[name]);
71
+ const key = str(r.key) ?? str(r.policy);
72
+ const versionId = str(pick('version_id')) ?? str(nested?.id);
73
+ if (!key || !versionId)
74
+ return null;
75
+ const display = DISPLAYS.includes(r.display) ? r.display : 'checkbox';
76
+ const version = str(typeof r.version === 'string' ? r.version : nested?.version) ?? '';
77
+ return {
78
+ key,
79
+ title: str(r.title) ?? key,
80
+ display,
81
+ required: r.required !== false,
82
+ appliesTo: r.applies_to === 'sign_in_and_sign_up' ? 'sign_in_and_sign_up' : 'sign_up',
83
+ versionId,
84
+ version,
85
+ contentHtml: str(pick('content_html')),
86
+ contentMarkdown: str(pick('content_markdown')),
87
+ contentUrl: str(pick('content_url')),
88
+ contentSha256: str(pick('content_sha256')) ?? '',
89
+ effectiveAt: when(pick('effective_at')),
90
+ summaryOfChanges: str(pick('summary_of_changes')),
91
+ reaccept: typeof r.reaccept === 'boolean' ? r.reaccept : null,
92
+ };
93
+ }
94
+ /** Read a list of policies from any of the shapes the API uses (`[..]`, `{policies}`, `{data}`). */
95
+ function normalizePolicies(body) {
96
+ const list = Array.isArray(body)
97
+ ? body
98
+ : body && typeof body === 'object'
99
+ ? (body.policies ?? body.data)
100
+ : null;
101
+ if (!Array.isArray(list))
102
+ return [];
103
+ return list.map(normalizePolicy).filter((p) => p !== null);
104
+ }
105
+ /** The policies a sign-in attempt is waiting on (`pending_policies`). */
106
+ function pendingPoliciesOf(attempt) {
107
+ return normalizePolicies(attempt?.pending_policies);
108
+ }
109
+ /** Read `GET /v1/client/me/policies`. */
110
+ function normalizeMyPolicies(body) {
111
+ const list = Array.isArray(body)
112
+ ? body
113
+ : (body?.policies ?? body?.data);
114
+ if (!Array.isArray(list))
115
+ return [];
116
+ const out = [];
117
+ for (const item of list) {
118
+ if (!item || typeof item !== 'object')
119
+ continue;
120
+ const r = item;
121
+ const key = str(r.key) ?? str(r.policy);
122
+ if (!key)
123
+ continue;
124
+ out.push({
125
+ key,
126
+ title: str(r.title) ?? key,
127
+ acceptedVersion: str(r.accepted_version),
128
+ currentVersion: str(r.current_version),
129
+ needsReaccept: r.needs_reaccept === true,
130
+ graceEndsAt: when(r.grace_ends_at),
131
+ });
132
+ }
133
+ return out;
134
+ }
135
+ /** Whether an error list contains the server's "accept the policies" refusal. */
136
+ function isPolicyAcceptanceRequired(errors) {
137
+ return !!errors?.some((e) => e.code === 'POLICY_ACCEPTANCE_REQUIRED');
138
+ }
139
+ // ---- FAPI calls ----------------------------------------------------------
140
+ // Thin camel-cased views over the wire helpers in `policies.ts` (`getPolicies`,
141
+ // `getMyPolicies`, `acceptPolicies`, `acceptAttemptPolicies`), for the UIs.
142
+ /** `GET /v1/client/policies`, read into {@link Policy} objects (empty on failure). */
143
+ async function loadPolicies(client, opts = {}) {
144
+ const res = await (0, policies_1.getPolicies)(client, opts.locale ? { locale: opts.locale } : {});
145
+ if (!res.ok)
146
+ return [];
147
+ const all = normalizePolicies(res.data);
148
+ return opts.signIn ? all.filter((p) => p.appliesTo === 'sign_in_and_sign_up') : all;
149
+ }
150
+ /**
151
+ * The policies a sign-up must show: every one with a published version. Required
152
+ * ones block the submit; optional ones may be left unticked.
153
+ */
154
+ async function getSignUpPolicies(client, opts = {}) {
155
+ return loadPolicies(client, opts);
156
+ }
157
+ /** `GET /v1/client/me/policies`, read into {@link MyPolicyStatus} entries (empty on failure). */
158
+ async function loadMyPolicies(client) {
159
+ const res = await (0, policies_1.getMyPolicies)(client);
160
+ return res.ok ? normalizeMyPolicies(res.data) : [];
161
+ }
162
+ /** `POST /v1/client/me/policies/accept`: accept from inside the app (a banner). */
163
+ async function acceptMyPolicies(client, acceptances) {
164
+ const res = await (0, policies_1.acceptPolicies)(client, acceptances);
165
+ return res.ok ? { ok: true, errors: [] } : { ok: false, errors: res.errors };
166
+ }
167
+ /**
168
+ * `POST /v1/client/sign_ins/:id/accept_policies`: accept the `pending_policies`
169
+ * of an attempt in `needs_policy_acceptance` (the same call as
170
+ * {@link acceptAttemptPolicies}). Returns the continued flow (a session ticket,
171
+ * or still held when a newer version was published); on a refusal the attempt is kept.
172
+ */
173
+ exports.acceptSignInPolicies = policies_1.acceptAttemptPolicies;
174
+ /** Parse the JSON the headless `advance` step carries in `values.policy_acceptances`. */
175
+ function parsePolicyAcceptances(value) {
176
+ if (!value)
177
+ return [];
178
+ try {
179
+ const parsed = JSON.parse(value);
180
+ return Array.isArray(parsed) ? parsed : [];
181
+ }
182
+ catch {
183
+ return [];
184
+ }
185
+ }
186
+ /**
187
+ * The state machine behind every policy UI. Subscribe for changes; read
188
+ * {@link satisfied} to enable the submit and {@link build} for the request body.
189
+ * State is kept per `versionId`, so a refreshed list (after a 422) keeps the
190
+ * ticks the user already made on versions that did not change.
191
+ */
192
+ class PolicyAcceptanceController {
193
+ list = [];
194
+ entries = new Map();
195
+ open = null;
196
+ listeners = new Set();
197
+ now;
198
+ combined;
199
+ constructor(policies = [], options = {}) {
200
+ this.combined = options.combined !== false;
201
+ this.now = options.now ?? (() => Date.now());
202
+ this.setPolicies(policies);
203
+ }
204
+ subscribe(fn) {
205
+ this.listeners.add(fn);
206
+ return () => this.listeners.delete(fn);
207
+ }
208
+ emit() {
209
+ for (const fn of [...this.listeners])
210
+ fn();
211
+ }
212
+ entry(key) {
213
+ const policy = this.list.find((p) => p.key === key);
214
+ const id = policy ? policy.versionId : key;
215
+ let e = this.entries.get(id);
216
+ if (!e) {
217
+ e = { checked: false, scrolledToEnd: false, viewMs: 0, openedAt: null };
218
+ this.entries.set(id, e);
219
+ }
220
+ return e;
221
+ }
222
+ get policies() {
223
+ return this.list;
224
+ }
225
+ get viewing() {
226
+ return this.list.find((p) => p.key === this.open) ?? null;
227
+ }
228
+ snapshot() {
229
+ const boxes = this.checkboxPolicies();
230
+ return {
231
+ policies: this.list,
232
+ combined: this.combined,
233
+ viewing: this.open,
234
+ combinedChecked: boxes.length > 0 && boxes.every((p) => this.isChecked(p.key)),
235
+ };
236
+ }
237
+ /** Replace the policies (e.g. after a refresh); ticks on unchanged versions survive. */
238
+ setPolicies(policies) {
239
+ this.list = policies;
240
+ if (this.open && !policies.some((p) => p.key === this.open))
241
+ this.open = null;
242
+ this.emit();
243
+ }
244
+ checkboxPolicies() {
245
+ return this.list.filter((p) => p.display === 'checkbox');
246
+ }
247
+ scrollPolicies() {
248
+ return this.list.filter((p) => p.display === 'scroll');
249
+ }
250
+ linkPolicies() {
251
+ return this.list.filter((p) => p.display === 'link');
252
+ }
253
+ isChecked(key) {
254
+ const p = this.list.find((x) => x.key === key);
255
+ if (!p)
256
+ return false;
257
+ // A clickwrap sentence is accepted by submitting.
258
+ if (p.display === 'link')
259
+ return true;
260
+ return this.entry(key).checked;
261
+ }
262
+ setChecked(key, checked) {
263
+ this.entry(key).checked = checked;
264
+ this.emit();
265
+ }
266
+ /** Tick or untick every checkbox-mode policy at once (the combined box). */
267
+ setAllChecked(checked) {
268
+ for (const p of this.checkboxPolicies())
269
+ this.entry(p.key).checked = checked;
270
+ this.emit();
271
+ }
272
+ /** The user accepted a scroll-mode policy in its modal. */
273
+ acceptScroll(key) {
274
+ const e = this.entry(key);
275
+ e.checked = true;
276
+ e.scrolledToEnd = true;
277
+ this.emit();
278
+ }
279
+ /** The first scroll-mode policy still to be read and accepted, or null. */
280
+ pendingScroll() {
281
+ return this.scrollPolicies().find((p) => !this.isChecked(p.key)) ?? null;
282
+ }
283
+ /** Open a document in the in-page viewer and start its reading clock. */
284
+ openDocument(key) {
285
+ if (this.open)
286
+ this.closeDocument();
287
+ if (!this.list.some((p) => p.key === key))
288
+ return;
289
+ this.open = key;
290
+ this.entry(key).openedAt = this.now();
291
+ this.emit();
292
+ }
293
+ closeDocument() {
294
+ if (!this.open)
295
+ return;
296
+ this.stopClock(this.open);
297
+ this.open = null;
298
+ this.emit();
299
+ }
300
+ /** Start the reading clock for a policy shown some other way (a modal). */
301
+ startClock(key) {
302
+ const e = this.entry(key);
303
+ if (e.openedAt === null)
304
+ e.openedAt = this.now();
305
+ }
306
+ stopClock(key) {
307
+ const e = this.entry(key);
308
+ if (e.openedAt !== null) {
309
+ e.viewMs += Math.max(0, this.now() - e.openedAt);
310
+ e.openedAt = null;
311
+ }
312
+ }
313
+ /** The scroll-to-end flag was reached while reading `key`. */
314
+ markScrolledToEnd(key) {
315
+ const e = this.entry(key);
316
+ if (!e.scrolledToEnd) {
317
+ e.scrolledToEnd = true;
318
+ this.emit();
319
+ }
320
+ }
321
+ hasScrolledToEnd(key) {
322
+ return this.entry(key).scrolledToEnd;
323
+ }
324
+ viewMs(key) {
325
+ const e = this.entry(key);
326
+ return Math.round(e.viewMs + (e.openedAt !== null ? Math.max(0, this.now() - e.openedAt) : 0));
327
+ }
328
+ /** The required policies still unaccepted. Scroll-mode ones are accepted in their modal, not by a box. */
329
+ missing() {
330
+ return this.list.filter((p) => p.required && !this.isChecked(p.key));
331
+ }
332
+ /** Whether the submit may go: every required policy is accepted. Scroll-mode ones count as pending until read. */
333
+ satisfied() {
334
+ return this.missing().length === 0;
335
+ }
336
+ /** Whether the submit button may be enabled: scroll-mode policies are read on submit, so they do not block it. */
337
+ canSubmit() {
338
+ return this.list.every((p) => !p.required || p.display === 'scroll' || this.isChecked(p.key));
339
+ }
340
+ /** The `policy_acceptances` body: every accepted policy (and every clickwrap one). */
341
+ build() {
342
+ const out = [];
343
+ for (const p of this.list) {
344
+ if (!this.isChecked(p.key))
345
+ continue;
346
+ const e = this.entry(p.key);
347
+ const viewMs = this.viewMs(p.key);
348
+ out.push({
349
+ policy: p.key,
350
+ version_id: p.versionId,
351
+ content_sha256: p.contentSha256,
352
+ display: p.display,
353
+ ...(p.display === 'scroll' || e.scrolledToEnd ? { scrolled_to_end: e.scrolledToEnd } : {}),
354
+ ...(viewMs > 0 ? { view_ms: viewMs } : {}),
355
+ });
356
+ }
357
+ return out;
358
+ }
359
+ }
360
+ exports.PolicyAcceptanceController = PolicyAcceptanceController;
361
+ /** Build a controller already holding `policies` (a sign-in's `pending_policies`, a banner's, …). */
362
+ function createPolicyController(policies = [], options = {}) {
363
+ return new PolicyAcceptanceController(policies, options);
364
+ }
365
+ // ---- DOM-free helpers -----------------------------------------------------
366
+ /** Whether a scroll container has reached its end (also true when the content fits). */
367
+ function isScrolledToEnd(el, slack = 2) {
368
+ return el.scrollHeight - el.clientHeight - el.scrollTop <= slack;
369
+ }
370
+ /**
371
+ * Call `onEnd` once the end of a scroll container has been seen: an
372
+ * IntersectionObserver on an end sentinel (scrolling, the keyboard, a screen
373
+ * reader all count), with a scroll listener fallback; and straight away when the
374
+ * content fits. Returns a function that stops watching.
375
+ */
376
+ function observeScrollEnd(scroller, sentinel, onEnd) {
377
+ let done = false;
378
+ let observer = null;
379
+ const fire = () => {
380
+ if (done)
381
+ return;
382
+ done = true;
383
+ stop();
384
+ onEnd();
385
+ };
386
+ const check = () => {
387
+ if (isScrolledToEnd(scroller))
388
+ fire();
389
+ };
390
+ const stop = () => {
391
+ observer?.disconnect();
392
+ scroller.removeEventListener('scroll', check);
393
+ if (typeof window !== 'undefined')
394
+ window.removeEventListener('resize', check);
395
+ };
396
+ if (sentinel && typeof IntersectionObserver !== 'undefined') {
397
+ observer = new IntersectionObserver((entries) => {
398
+ if (entries.some((e) => e.isIntersecting))
399
+ fire();
400
+ }, { root: scroller, threshold: 0 });
401
+ observer.observe(sentinel);
402
+ }
403
+ scroller.addEventListener('scroll', check, { passive: true });
404
+ if (typeof window !== 'undefined')
405
+ window.addEventListener('resize', check);
406
+ // Content that fits needs no scrolling.
407
+ check();
408
+ return () => {
409
+ done = true;
410
+ stop();
411
+ };
412
+ }
413
+ const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
414
+ /**
415
+ * Keep Tab inside `container` (a modal) and, when `escape` is `false`, swallow
416
+ * Escape so the dialog can only be left by its buttons. Returns a release
417
+ * function that also restores focus to whatever had it.
418
+ */
419
+ function trapFocus(container, options = {}) {
420
+ const doc = container.ownerDocument;
421
+ const previous = doc.activeElement;
422
+ const onKey = (event) => {
423
+ if (event.key === 'Escape' && options.escape !== true) {
424
+ event.preventDefault();
425
+ event.stopPropagation();
426
+ return;
427
+ }
428
+ if (event.key !== 'Tab')
429
+ return;
430
+ const items = Array.from(container.querySelectorAll(FOCUSABLE)).filter((el) => !el.hasAttribute('hidden'));
431
+ if (items.length === 0) {
432
+ event.preventDefault();
433
+ container.focus();
434
+ return;
435
+ }
436
+ const first = items[0];
437
+ const last = items[items.length - 1];
438
+ const active = doc.activeElement;
439
+ if (event.shiftKey && (active === first || !container.contains(active))) {
440
+ event.preventDefault();
441
+ last.focus();
442
+ }
443
+ else if (!event.shiftKey && (active === last || !container.contains(active))) {
444
+ event.preventDefault();
445
+ first.focus();
446
+ }
447
+ };
448
+ const onFocusIn = (event) => {
449
+ if (event.target instanceof Node && !container.contains(event.target)) {
450
+ (options.initialFocus ?? container).focus();
451
+ }
452
+ };
453
+ doc.addEventListener('keydown', onKey, true);
454
+ doc.addEventListener('focusin', onFocusIn);
455
+ (options.initialFocus ?? container).focus();
456
+ return () => {
457
+ doc.removeEventListener('keydown', onKey, true);
458
+ doc.removeEventListener('focusin', onFocusIn);
459
+ previous?.focus?.();
460
+ };
461
+ }
462
+ // ---- Content rendering ----------------------------------------------------
463
+ function escapeHtml(s) {
464
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
465
+ }
466
+ function inlineMarkdown(text) {
467
+ let out = escapeHtml(text);
468
+ out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
469
+ out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
470
+ out = out.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>');
471
+ out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
472
+ return out;
473
+ }
474
+ /**
475
+ * A small, safe markdown renderer (headings, paragraphs, lists, bold, italics,
476
+ * code, http(s) links). Every character of input is HTML-escaped first, so it
477
+ * can never inject markup. It is the fallback for a policy delivered as
478
+ * markdown only; the server normally sends sanitised HTML.
479
+ */
480
+ function markdownToSafeHtml(markdown) {
481
+ const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
482
+ const html = [];
483
+ let para = [];
484
+ let list = null;
485
+ const flushPara = () => {
486
+ if (para.length)
487
+ html.push(`<p>${inlineMarkdown(para.join(' '))}</p>`);
488
+ para = [];
489
+ };
490
+ const closeList = () => {
491
+ if (list)
492
+ html.push(`</${list}>`);
493
+ list = null;
494
+ };
495
+ for (const line of lines) {
496
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
497
+ const bullet = /^\s*[-*+]\s+(.*)$/.exec(line);
498
+ const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
499
+ if (heading) {
500
+ flushPara();
501
+ closeList();
502
+ const level = heading[1].length;
503
+ html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
504
+ }
505
+ else if (bullet || numbered) {
506
+ flushPara();
507
+ const kind = bullet ? 'ul' : 'ol';
508
+ if (list !== kind) {
509
+ closeList();
510
+ html.push(`<${kind}>`);
511
+ list = kind;
512
+ }
513
+ html.push(`<li>${inlineMarkdown((bullet ?? numbered)[1])}</li>`);
514
+ }
515
+ else if (line.trim() === '') {
516
+ flushPara();
517
+ closeList();
518
+ }
519
+ else {
520
+ closeList();
521
+ para.push(line.trim());
522
+ }
523
+ }
524
+ flushPara();
525
+ closeList();
526
+ return html.join('\n');
527
+ }
528
+ /** The HTML to draw for a policy: the server's sanitised HTML, else its markdown rendered safely. */
529
+ function policyHtml(policy) {
530
+ if (policy.contentHtml)
531
+ return policy.contentHtml;
532
+ return policy.contentMarkdown ? markdownToSafeHtml(policy.contentMarkdown) : '';
533
+ }
534
+ /** A readable "effective 24 Oct 2026" style date, or '' when unparsable. */
535
+ function formatPolicyDate(iso) {
536
+ if (!iso)
537
+ return '';
538
+ const d = new Date(iso);
539
+ if (Number.isNaN(d.getTime()))
540
+ return '';
541
+ return d.toISOString().slice(0, 10);
542
+ }
543
+ // ---- Strings ---------------------------------------------------------------
544
+ /** The English strings every policy UI draws (`policy.*` catalog keys). */
545
+ exports.POLICY_STRINGS = Object.freeze({
546
+ 'policy.agreeCombined': 'I agree to the {policies}',
547
+ 'policy.agreeOne': 'I agree to the {policy}',
548
+ 'policy.clickwrap': 'By continuing you agree to the {policies}.',
549
+ 'policy.and': 'and',
550
+ 'policy.read': 'Read the {policy}',
551
+ 'policy.viewerBack': 'Back',
552
+ 'policy.version': 'Version {version}',
553
+ 'policy.effective': 'Effective {date}',
554
+ 'policy.scrollHint': 'Scroll to the end to accept',
555
+ 'policy.accept': 'Accept',
556
+ 'policy.decline': 'Decline',
557
+ 'policy.summaryTitle': 'What changed',
558
+ 'policy.reacceptTitle': '{policy} has been updated',
559
+ 'policy.reacceptIntro': 'Please review and accept the updated terms to continue.',
560
+ 'policy.pendingTitle': 'Accept to continue',
561
+ 'policy.pendingIntro': 'Please accept the following to continue signing in.',
562
+ 'policy.required': 'Please accept the {policy} to continue.',
563
+ 'policy.requiredGeneric': 'Please accept the required policies to continue.',
564
+ 'policy.declined': 'You need to accept the {policy} to use this app.',
565
+ 'policy.loading': 'Loading…',
566
+ 'policy.bannerTitle': 'Updated policies',
567
+ 'policy.bannerBody': 'We have updated: {policies}. Please review and accept.',
568
+ 'policy.bannerGrace': 'Accept by {date} to keep your access.',
569
+ 'policy.bannerReview': 'Review and accept',
570
+ 'policy.submit': 'Accept and continue',
571
+ });
572
+ /** A `t` function over a catalog lookup that falls back to the English policy strings. */
573
+ function policyTranslator(lookup) {
574
+ return (key, vars = {}) => {
575
+ const out = lookup(key, vars);
576
+ if (out && out !== key)
577
+ return out;
578
+ const template = exports.POLICY_STRINGS[key] ?? key;
579
+ return template.replace(/\{(\w+)\}/g, (m, n) => vars[n] ?? m);
580
+ };
581
+ }
582
+ /** Join titles as "A, B and C". */
583
+ function joinTitles(titles, and) {
584
+ if (titles.length <= 1)
585
+ return titles[0] ?? '';
586
+ return `${titles.slice(0, -1).join(', ')} ${and} ${titles[titles.length - 1]}`;
587
+ }
588
+ /** Split a `{name}` template into the text before and after the placeholder (for sentences that embed links). */
589
+ function splitTemplate(template, name) {
590
+ const at = template.indexOf(`{${name}}`);
591
+ if (at < 0)
592
+ return [template, ''];
593
+ return [template.slice(0, at), template.slice(at + name.length + 2)];
594
+ }
595
+ /**
596
+ * After the server answered 422 `POLICY_ACCEPTANCE_REQUIRED` (a policy was
597
+ * published or changed while the form was open): reload the policies into the
598
+ * controller so the form re-shows what is needed now. Returns whether the error
599
+ * was that refusal.
600
+ */
601
+ async function refreshPoliciesOnRequired(client, controller, errors, opts = {}) {
602
+ if (!isPolicyAcceptanceRequired(errors))
603
+ return false;
604
+ const fresh = await loadPolicies(client, opts);
605
+ if (fresh.length > 0)
606
+ controller.setPolicies(fresh);
607
+ return true;
608
+ }
609
+ /**
610
+ * For a sign-in attempt in `needs_policy_acceptance`: a controller holding its
611
+ * `pending_policies` — with the content fetched from `GET /v1/client/policies`
612
+ * when the attempt only listed them.
613
+ */
614
+ async function controllerForPending(client, attempt, opts = {}) {
615
+ const rawList = Array.isArray(attempt?.pending_policies) ? attempt.pending_policies : [];
616
+ if (rawList.length === 0) {
617
+ // Resumed from a redirect (the URL carries the attempt, not its pending
618
+ // list): show every required policy; the server checks each acceptance.
619
+ const all = await loadPolicies(client, opts);
620
+ return new PolicyAcceptanceController(all.filter((p) => p.required), opts);
621
+ }
622
+ let pending = rawList.map(normalizePolicy).filter((p) => p !== null);
623
+ if (pending.some((p) => !p.contentHtml && !p.contentMarkdown)) {
624
+ const all = await loadPolicies(client, opts);
625
+ pending = pending.map((p, i) => {
626
+ const full = all.find((a) => a.key === p.key);
627
+ if (!full)
628
+ return p;
629
+ const raw = (rawList.find((r) => normalizePolicy(r)?.key === p.key) ?? rawList[i]);
630
+ const has = (name) => raw[name] !== undefined && raw[name] !== null;
631
+ return {
632
+ ...full,
633
+ ...(has('title') ? { title: p.title } : {}),
634
+ ...(has('display') ? { display: p.display } : {}),
635
+ versionId: p.versionId,
636
+ version: p.version || full.version,
637
+ contentHtml: p.contentHtml ?? full.contentHtml,
638
+ contentMarkdown: p.contentMarkdown ?? full.contentMarkdown,
639
+ contentSha256: p.contentSha256 || full.contentSha256,
640
+ summaryOfChanges: p.summaryOfChanges ?? full.summaryOfChanges,
641
+ reaccept: p.reaccept,
642
+ };
643
+ });
644
+ }
645
+ // A sign-in acceptance is always required of the user, whatever the policy says elsewhere.
646
+ return new PolicyAcceptanceController(pending.map((p) => ({ ...p, required: true })), opts);
647
+ }
648
+ /**
649
+ * In-app re-acceptance for a signed-in user, framework-free: loads
650
+ * `GET /v1/client/me/policies`, builds a controller for the policies that need
651
+ * re-accepting ({@link begin}) and posts the acceptance ({@link accept}). The
652
+ * framework SDKs' `<PolicyGate>` wraps it.
653
+ */
654
+ class PolicyGateController {
655
+ client;
656
+ locale;
657
+ /** The policies that need re-acceptance (empty when none, or before {@link load}). */
658
+ pending = [];
659
+ loaded = false;
660
+ listeners = new Set();
661
+ constructor(client, locale) {
662
+ this.client = client;
663
+ this.locale = locale;
664
+ }
665
+ subscribe(fn) {
666
+ this.listeners.add(fn);
667
+ return () => this.listeners.delete(fn);
668
+ }
669
+ emit() {
670
+ for (const fn of [...this.listeners])
671
+ fn();
672
+ }
673
+ async load() {
674
+ this.pending = (await loadMyPolicies(this.client)).filter((p) => p.needsReaccept);
675
+ this.loaded = true;
676
+ this.emit();
677
+ }
678
+ /** The soonest end of grace among the pending policies, or null. */
679
+ get graceEndsAt() {
680
+ return this.pending.map((p) => p.graceEndsAt).filter((x) => !!x).sort()[0] ?? null;
681
+ }
682
+ /** A controller over the pending policies' current versions, all required. */
683
+ async begin() {
684
+ const all = await loadPolicies(this.client, { locale: this.locale });
685
+ const wanted = new Set(this.pending.map((p) => p.key));
686
+ return new PolicyAcceptanceController(all.filter((p) => wanted.has(p.key)).map((p) => ({ ...p, required: true })));
687
+ }
688
+ async accept(controller) {
689
+ const res = await acceptMyPolicies(this.client, controller.build());
690
+ if (res.ok) {
691
+ this.pending = [];
692
+ this.emit();
693
+ }
694
+ return res;
695
+ }
696
+ }
697
+ exports.PolicyGateController = PolicyGateController;
698
+ //# sourceMappingURL=policy-acceptance.js.map