@medalsocial/booking 0.0.0-placeholder.0 → 0.2.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 (266) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +220 -1
  5. package/dist/core/age.d.mts +34 -0
  6. package/dist/core/age.mjs +95 -0
  7. package/dist/core/age.mjs.map +1 -0
  8. package/dist/core/attempt-store.d.mts +111 -0
  9. package/dist/core/attempt-store.mjs +159 -0
  10. package/dist/core/attempt-store.mjs.map +1 -0
  11. package/dist/core/categories.d.mts +27 -0
  12. package/dist/core/categories.mjs +42 -0
  13. package/dist/core/categories.mjs.map +1 -0
  14. package/dist/core/clock.d.mts +33 -0
  15. package/dist/core/clock.mjs +251 -0
  16. package/dist/core/clock.mjs.map +1 -0
  17. package/dist/core/config.d.mts +178 -0
  18. package/dist/core/config.mjs +394 -0
  19. package/dist/core/config.mjs.map +1 -0
  20. package/dist/core/consent.d.mts +14 -0
  21. package/dist/core/consent.mjs +17 -0
  22. package/dist/core/consent.mjs.map +1 -0
  23. package/dist/core/deep-link.d.mts +40 -0
  24. package/dist/core/deep-link.mjs +160 -0
  25. package/dist/core/deep-link.mjs.map +1 -0
  26. package/dist/core/display-name.d.mts +18 -0
  27. package/dist/core/display-name.mjs +30 -0
  28. package/dist/core/display-name.mjs.map +1 -0
  29. package/dist/core/draft-store.d.mts +89 -0
  30. package/dist/core/draft-store.mjs +131 -0
  31. package/dist/core/draft-store.mjs.map +1 -0
  32. package/dist/core/dto.d.mts +46 -0
  33. package/dist/core/dto.mjs +157 -0
  34. package/dist/core/dto.mjs.map +1 -0
  35. package/dist/core/ics.d.mts +63 -0
  36. package/dist/core/ics.mjs +130 -0
  37. package/dist/core/ics.mjs.map +1 -0
  38. package/dist/core/index.d.mts +35 -0
  39. package/dist/core/index.mjs +32 -0
  40. package/dist/core/labels.d.mts +88 -0
  41. package/dist/core/labels.mjs +93 -0
  42. package/dist/core/labels.mjs.map +1 -0
  43. package/dist/core/machine.d.mts +645 -0
  44. package/dist/core/machine.mjs +988 -0
  45. package/dist/core/machine.mjs.map +1 -0
  46. package/dist/core/money.d.mts +12 -0
  47. package/dist/core/money.mjs +51 -0
  48. package/dist/core/money.mjs.map +1 -0
  49. package/dist/core/next-available.d.mts +20 -0
  50. package/dist/core/next-available.mjs +36 -0
  51. package/dist/core/next-available.mjs.map +1 -0
  52. package/dist/core/next-free.d.mts +25 -0
  53. package/dist/core/next-free.mjs +44 -0
  54. package/dist/core/next-free.mjs.map +1 -0
  55. package/dist/core/party-slots.d.mts +79 -0
  56. package/dist/core/party-slots.mjs +229 -0
  57. package/dist/core/party-slots.mjs.map +1 -0
  58. package/dist/core/paths.d.mts +11 -0
  59. package/dist/core/paths.mjs +56 -0
  60. package/dist/core/paths.mjs.map +1 -0
  61. package/dist/core/phone.d.mts +17 -0
  62. package/dist/core/phone.mjs +58 -0
  63. package/dist/core/phone.mjs.map +1 -0
  64. package/dist/core/portal/cookies.d.mts +29 -0
  65. package/dist/core/portal/dto.d.mts +140 -0
  66. package/dist/core/portal/dto.mjs +142 -0
  67. package/dist/core/portal/dto.mjs.map +1 -0
  68. package/dist/core/portal/family.d.mts +39 -0
  69. package/dist/core/portal/family.mjs +39 -0
  70. package/dist/core/portal/family.mjs.map +1 -0
  71. package/dist/core/portal/login-input.d.mts +29 -0
  72. package/dist/core/portal/login-input.mjs +29 -0
  73. package/dist/core/portal/login-input.mjs.map +1 -0
  74. package/dist/core/portal/next-path.d.mts +12 -0
  75. package/dist/core/portal/next-path.mjs +98 -0
  76. package/dist/core/portal/next-path.mjs.map +1 -0
  77. package/dist/core/portal/return-path.d.mts +7 -0
  78. package/dist/core/portal/return-path.mjs +49 -0
  79. package/dist/core/portal/return-path.mjs.map +1 -0
  80. package/dist/core/portal/session-cookie.d.mts +27 -0
  81. package/dist/core/portal/session-cookie.mjs +30 -0
  82. package/dist/core/portal/session-cookie.mjs.map +1 -0
  83. package/dist/core/portal/vipps-link.d.mts +56 -0
  84. package/dist/core/portal/vipps-link.mjs +136 -0
  85. package/dist/core/portal/vipps-link.mjs.map +1 -0
  86. package/dist/core/portal/vipps-return.d.mts +39 -0
  87. package/dist/core/portal/vipps-return.mjs +69 -0
  88. package/dist/core/portal/vipps-return.mjs.map +1 -0
  89. package/dist/core/rebook-store.d.mts +28 -0
  90. package/dist/core/rebook-store.mjs +60 -0
  91. package/dist/core/rebook-store.mjs.map +1 -0
  92. package/dist/core/restore-gate.d.mts +40 -0
  93. package/dist/core/restore-gate.mjs +96 -0
  94. package/dist/core/restore-gate.mjs.map +1 -0
  95. package/dist/core/stores.d.mts +8 -0
  96. package/dist/core/stores.mjs +23 -0
  97. package/dist/core/stores.mjs.map +1 -0
  98. package/dist/core/types.d.mts +245 -0
  99. package/dist/core/visit.d.mts +26 -0
  100. package/dist/core/visit.mjs +40 -0
  101. package/dist/core/visit.mjs.map +1 -0
  102. package/dist/core/wire.d.mts +37 -0
  103. package/dist/next/cache/memory.d.mts +14 -0
  104. package/dist/next/cache/memory.mjs +51 -0
  105. package/dist/next/cache/memory.mjs.map +1 -0
  106. package/dist/next/cache/next-data.d.mts +6 -0
  107. package/dist/next/cache/next-data.mjs +35 -0
  108. package/dist/next/cache/next-data.mjs.map +1 -0
  109. package/dist/next/cache/noop.d.mts +6 -0
  110. package/dist/next/cache/noop.mjs +15 -0
  111. package/dist/next/cache/noop.mjs.map +1 -0
  112. package/dist/next/cache/workers.d.mts +29 -0
  113. package/dist/next/cache/workers.mjs +74 -0
  114. package/dist/next/cache/workers.mjs.map +1 -0
  115. package/dist/next/catalogue.d.mts +38 -0
  116. package/dist/next/catalogue.mjs +215 -0
  117. package/dist/next/catalogue.mjs.map +1 -0
  118. package/dist/next/handler.d.mts +11 -0
  119. package/dist/next/handler.mjs +150 -0
  120. package/dist/next/handler.mjs.map +1 -0
  121. package/dist/next/index.d.mts +17 -0
  122. package/dist/next/index.mjs +14 -0
  123. package/dist/next/loaders.d.mts +79 -0
  124. package/dist/next/loaders.mjs +195 -0
  125. package/dist/next/loaders.mjs.map +1 -0
  126. package/dist/next/manage-dto.d.mts +6 -0
  127. package/dist/next/manage-dto.mjs +28 -0
  128. package/dist/next/manage-dto.mjs.map +1 -0
  129. package/dist/next/medal.d.mts +105 -0
  130. package/dist/next/medal.mjs +155 -0
  131. package/dist/next/medal.mjs.map +1 -0
  132. package/dist/next/options.d.mts +158 -0
  133. package/dist/next/options.mjs +65 -0
  134. package/dist/next/options.mjs.map +1 -0
  135. package/dist/next/portal/guardian.d.mts +42 -0
  136. package/dist/next/portal/guardian.mjs +76 -0
  137. package/dist/next/portal/guardian.mjs.map +1 -0
  138. package/dist/next/portal/medal-portal.d.mts +161 -0
  139. package/dist/next/portal/medal-portal.mjs +825 -0
  140. package/dist/next/portal/medal-portal.mjs.map +1 -0
  141. package/dist/next/portal/session.d.mts +38 -0
  142. package/dist/next/portal/session.mjs +40 -0
  143. package/dist/next/portal/session.mjs.map +1 -0
  144. package/dist/next/portal/vipps-flash.d.mts +14 -0
  145. package/dist/next/portal/vipps-flash.mjs +41 -0
  146. package/dist/next/portal/vipps-flash.mjs.map +1 -0
  147. package/dist/next/portal.d.mts +205 -0
  148. package/dist/next/portal.mjs +469 -0
  149. package/dist/next/portal.mjs.map +1 -0
  150. package/dist/next/request.d.mts +51 -0
  151. package/dist/next/request.mjs +123 -0
  152. package/dist/next/request.mjs.map +1 -0
  153. package/dist/next/routes/catalogue-routes.mjs +344 -0
  154. package/dist/next/routes/catalogue-routes.mjs.map +1 -0
  155. package/dist/next/routes/create.mjs +460 -0
  156. package/dist/next/routes/create.mjs.map +1 -0
  157. package/dist/next/routes/manage.mjs +191 -0
  158. package/dist/next/routes/manage.mjs.map +1 -0
  159. package/dist/next/routes/portal-routes.mjs +312 -0
  160. package/dist/next/routes/portal-routes.mjs.map +1 -0
  161. package/dist/next/routes/shared.mjs +162 -0
  162. package/dist/next/routes/shared.mjs.map +1 -0
  163. package/dist/next/routes/vipps-return.mjs +166 -0
  164. package/dist/next/routes/vipps-return.mjs.map +1 -0
  165. package/dist/next/runtime.d.mts +49 -0
  166. package/dist/next/runtime.mjs +94 -0
  167. package/dist/next/runtime.mjs.map +1 -0
  168. package/dist/next/seed.d.mts +71 -0
  169. package/dist/next/seed.mjs +495 -0
  170. package/dist/next/seed.mjs.map +1 -0
  171. package/dist/next/server.d.mts +26 -0
  172. package/dist/next/server.mjs +63 -0
  173. package/dist/next/server.mjs.map +1 -0
  174. package/dist/react/BookingLink.d.mts +61 -0
  175. package/dist/react/BookingLink.mjs +102 -0
  176. package/dist/react/BookingLink.mjs.map +1 -0
  177. package/dist/react/BookingWizard.d.mts +49 -0
  178. package/dist/react/BookingWizard.mjs +598 -0
  179. package/dist/react/BookingWizard.mjs.map +1 -0
  180. package/dist/react/LoginPageQuery.d.mts +37 -0
  181. package/dist/react/LoginPageQuery.mjs +106 -0
  182. package/dist/react/LoginPageQuery.mjs.map +1 -0
  183. package/dist/react/LoginSheet.d.mts +49 -0
  184. package/dist/react/LoginSheet.mjs +98 -0
  185. package/dist/react/LoginSheet.mjs.map +1 -0
  186. package/dist/react/ManageBooking.d.mts +50 -0
  187. package/dist/react/ManageBooking.mjs +197 -0
  188. package/dist/react/ManageBooking.mjs.map +1 -0
  189. package/dist/react/PortalDashboard.d.mts +10 -0
  190. package/dist/react/Provider.d.mts +82 -0
  191. package/dist/react/Provider.mjs +132 -0
  192. package/dist/react/Provider.mjs.map +1 -0
  193. package/dist/react/actions.d.mts +133 -0
  194. package/dist/react/actions.mjs +90 -0
  195. package/dist/react/actions.mjs.map +1 -0
  196. package/dist/react/index.d.mts +28 -0
  197. package/dist/react/index.mjs +20 -0
  198. package/dist/react/kit.d.mts +54 -0
  199. package/dist/react/kit.mjs +127 -0
  200. package/dist/react/kit.mjs.map +1 -0
  201. package/dist/react/labels/index.d.mts +23 -0
  202. package/dist/react/labels/index.mjs +44 -0
  203. package/dist/react/labels/index.mjs.map +1 -0
  204. package/dist/react/labels/login.d.mts +10 -0
  205. package/dist/react/labels/login.mjs +13 -0
  206. package/dist/react/labels/login.mjs.map +1 -0
  207. package/dist/react/labels/manage.d.mts +15 -0
  208. package/dist/react/labels/manage.mjs +19 -0
  209. package/dist/react/labels/manage.mjs.map +1 -0
  210. package/dist/react/labels/portal.d.mts +25 -0
  211. package/dist/react/labels/portal.mjs +41 -0
  212. package/dist/react/labels/portal.mjs.map +1 -0
  213. package/dist/react/labels/screens.d.mts +11 -0
  214. package/dist/react/labels/screens.mjs +721 -0
  215. package/dist/react/labels/screens.mjs.map +1 -0
  216. package/dist/react/labels/wizard.d.mts +76 -0
  217. package/dist/react/labels/wizard.mjs +97 -0
  218. package/dist/react/labels/wizard.mjs.map +1 -0
  219. package/dist/react/link/index.d.mts +5 -0
  220. package/dist/react/link/index.mjs +5 -0
  221. package/dist/react/portal/SessionTouch.d.mts +19 -0
  222. package/dist/react/portal/SessionTouch.mjs +40 -0
  223. package/dist/react/portal/SessionTouch.mjs.map +1 -0
  224. package/dist/react/portal/age-prompt.d.mts +10 -0
  225. package/dist/react/portal/age-prompt.mjs +21 -0
  226. package/dist/react/portal/age-prompt.mjs.map +1 -0
  227. package/dist/react/portal/book-again.d.mts +18 -0
  228. package/dist/react/portal/book-again.mjs +21 -0
  229. package/dist/react/portal/book-again.mjs.map +1 -0
  230. package/dist/react/portal/child-summary.d.mts +17 -0
  231. package/dist/react/portal/child-summary.mjs +45 -0
  232. package/dist/react/portal/child-summary.mjs.map +1 -0
  233. package/dist/react/portal/dashboard.d.mts +74 -0
  234. package/dist/react/portal/dashboard.mjs +204 -0
  235. package/dist/react/portal/dashboard.mjs.map +1 -0
  236. package/dist/react/portal/leave.d.mts +17 -0
  237. package/dist/react/portal/leave.mjs +21 -0
  238. package/dist/react/portal/leave.mjs.map +1 -0
  239. package/dist/react/portal/sections.d.mts +12 -0
  240. package/dist/react/portal/sections.mjs +48 -0
  241. package/dist/react/portal/sections.mjs.map +1 -0
  242. package/dist/react/portal/tabs.d.mts +23 -0
  243. package/dist/react/portal/tabs.mjs +36 -0
  244. package/dist/react/portal/tabs.mjs.map +1 -0
  245. package/dist/react/portal/vipps-flash.d.mts +11 -0
  246. package/dist/react/portal/vipps-flash.mjs +18 -0
  247. package/dist/react/portal/vipps-flash.mjs.map +1 -0
  248. package/dist/react/portal/wired.mjs +202 -0
  249. package/dist/react/portal/wired.mjs.map +1 -0
  250. package/dist/react/screen-labels.mjs +14 -0
  251. package/dist/react/screen-labels.mjs.map +1 -0
  252. package/dist/react/shared.d.mts +13 -0
  253. package/dist/react/shared.mjs +7 -0
  254. package/dist/react/use-next-free.d.mts +19 -0
  255. package/dist/react/use-next-free.mjs +56 -0
  256. package/dist/react/use-next-free.mjs.map +1 -0
  257. package/dist/react/useBooking.d.mts +179 -0
  258. package/dist/react/useBooking.mjs +1331 -0
  259. package/dist/react/useBooking.mjs.map +1 -0
  260. package/dist/react/wizard/adapters.mjs +189 -0
  261. package/dist/react/wizard/adapters.mjs.map +1 -0
  262. package/dist/react/wizard/index.d.mts +11 -0
  263. package/dist/react/wizard/index.mjs +6 -0
  264. package/dist/react/wizard-error.mjs +29 -0
  265. package/dist/react/wizard-error.mjs.map +1 -0
  266. package/package.json +126 -5
@@ -0,0 +1,825 @@
1
+ import { createPortalDto, toPortalProfileDto } from "../../core/portal/dto.mjs";
2
+ import { DEFAULT_PORTAL_MESSAGES } from "../options.mjs";
3
+ import { unwrap } from "../medal.mjs";
4
+ import { MedalApiError } from "@medalsocial/sdk";
5
+ //#region src/next/portal/medal-portal.ts
6
+ /**
7
+ * The portal seam: everything `/min-side` asks Medal, in this site's own terms.
8
+ *
9
+ * The sibling of `lib/booking/medal-client.ts`, and built on the same SDK. The
10
+ * difference is the credential. The booking seam carries a per-appointment
11
+ * manage token on a handful of calls; every call here except the two login
12
+ * steps carries the parent's SESSION — a bearer credential for their whole
13
+ * account, read from the session cookie by `portal/session.ts` and
14
+ * handed in as a plain string. This module is the last place that string is
15
+ * known by name before it goes on the wire, which makes it the last place two
16
+ * things can be guaranteed:
17
+ *
18
+ * 1. **It is not in any error that leaves here.** The SDK formats the failing
19
+ * request into its messages, and the session travels in a header of that
20
+ * request; whatever an action lets escape is logged verbatim by
21
+ * `next-safe-action`'s error handler and a log is copied to places a cookie
22
+ * is not. `withSession` scrubs the value out of the message and stack of
23
+ * anything it rethrows — the same instance, so `instanceof`, `status` and
24
+ * `code` still hold for whoever catches it.
25
+ * 2. **It is never logged here.** There is no `logger` import in this file on
26
+ * purpose. The one thing a log line about a portal failure must not contain
27
+ * is the thing every function in this file holds.
28
+ *
29
+ * Two errors are this module's own vocabulary, because two upstream answers
30
+ * are decisions for the page rather than failures: `PortalSessionExpiredError`
31
+ * (Medal no longer honours the cookie — clear it and send the parent back to
32
+ * the login) and `PortalThrottledError` (Medal is rate-limiting this address —
33
+ * the login copy already says «if we have your address, a code is on its way»,
34
+ * and a throttle must not become an oracle for which addresses exist).
35
+ * `PortalValidationError` carries the engine's message for a rejected profile
36
+ * edit; it is written for integrators and quotes wire field names, so the form
37
+ * should prefer its own copy and fall back to this.
38
+ */
39
+ /** Medal answered 401 with a `PORTAL_SESSION_*` code: the cookie is dead. */
40
+ var PortalSessionExpiredError = class extends Error {
41
+ constructor() {
42
+ super("portal session expired");
43
+ this.name = "PortalSessionExpiredError";
44
+ }
45
+ };
46
+ /** Medal answered 429: this address has asked for too many codes. */
47
+ var PortalThrottledError = class extends Error {
48
+ constructor() {
49
+ super("portal request throttled");
50
+ this.name = "PortalThrottledError";
51
+ }
52
+ };
53
+ /** Medal rejected a profile edit; `message` is the engine's own sentence. */
54
+ var PortalValidationError = class extends Error {
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = "PortalValidationError";
58
+ }
59
+ };
60
+ /**
61
+ * Medal answered 503 `VIPPS_NOT_CONFIGURED`: this workspace has no Vipps
62
+ * credentials (yet). A decision for the login page — show the e-mail form
63
+ * and say so — not a failure.
64
+ */
65
+ var PortalVippsUnavailableError = class extends Error {
66
+ constructor() {
67
+ super("vipps login unavailable");
68
+ this.name = "PortalVippsUnavailableError";
69
+ }
70
+ };
71
+ /**
72
+ * Medal answered 409 `VIPPS_IDENTITY_CONFLICT` to a pending-link code: the code
73
+ * was right, but this Vipps account has since been bound to another customer.
74
+ * An answer for the login sheet — «log in with e-mail, or ring the salon».
75
+ */
76
+ var PortalVippsConflictError = class extends Error {
77
+ constructor() {
78
+ super("vipps identity conflict");
79
+ this.name = "PortalVippsConflictError";
80
+ }
81
+ };
82
+ /** The portal seam over one Medal seam. */
83
+ function createPortalSeam(medal, options) {
84
+ const messages = {
85
+ ...DEFAULT_PORTAL_MESSAGES,
86
+ ...options.messages
87
+ };
88
+ const { toPortalBookingDto } = createPortalDto(options.config);
89
+ /**
90
+ * The SDK's portal namespace (`Medal#portal`, SDK ≥ 1.8.0). `requireMedal()`
91
+ * throws `MedalConfigError` for a missing key, and that is left to propagate:
92
+ * a portal that silently cannot reach Medal is worse than one that fails
93
+ * visibly.
94
+ */
95
+ function portalClient() {
96
+ return medal.requireMedal().portal;
97
+ }
98
+ const REDACTED = "<session>";
99
+ /**
100
+ * How far below the thrown error the scrub follows `cause` and
101
+ * `AggregateError.errors`. Three is deeper than any chain the SDK or `fetch`
102
+ * builds (undici wraps one `cause` under `fetch failed`), and a bound is
103
+ * needed at all because `cause` may point back up the chain: an error that is
104
+ * its own cause would otherwise recurse until the stack ran out.
105
+ */
106
+ const REDACT_DEPTH = 3;
107
+ function scrub(text, session, label = REDACTED) {
108
+ return text.includes(session) ? text.split(session).join(label) : text;
109
+ }
110
+ /**
111
+ * Scrub `message` and `stack` on `error` and on every `Error` hanging off it.
112
+ *
113
+ * `cause` and `errors` are followed because a logger prints them: Node's
114
+ * `util.inspect`, Pino's error serializer and Sentry all walk the cause chain,
115
+ * so a clean top-level message over an unscrubbed `cause.message` is the same
116
+ * leak one level down. A string `cause` is rewritten in place for the same
117
+ * reason; anything else there (a response object, say) is not a string a
118
+ * logger prints verbatim and is left alone.
119
+ */
120
+ function redactError(error, session, depth, label = REDACTED) {
121
+ if (error.message.includes(session)) error.message = scrub(error.message, session, label);
122
+ if (typeof error.stack === "string" && error.stack.includes(session)) error.stack = scrub(error.stack, session, label);
123
+ if (depth >= REDACT_DEPTH) return;
124
+ if (error.cause instanceof Error) redactError(error.cause, session, depth + 1, label);
125
+ else if (typeof error.cause === "string") error.cause = scrub(error.cause, session, label);
126
+ if (error instanceof AggregateError) {
127
+ for (const inner of error.errors) if (inner instanceof Error) redactError(inner, session, depth + 1, label);
128
+ }
129
+ }
130
+ /**
131
+ * Scrub `session` out of whatever was thrown, in place, and hand it back.
132
+ *
133
+ * In place rather than re-wrapped: callers up the stack switch on
134
+ * `instanceof MedalApiError` and read `.status` / `.code`, and a fresh `Error`
135
+ * would erase all three. `message` and `stack` are the two strings a logger
136
+ * prints — on the error and, via `redactError`, on its `cause` chain;
137
+ * `MedalApiError.details` is `readonly` and carries the engine's structured
138
+ * body, which never echoes a request header.
139
+ *
140
+ * A non-`Error` throw that contains the session (a bare string, say) is
141
+ * REPLACED — there is nothing else on it worth keeping, and nothing safe to
142
+ * pass on.
143
+ */
144
+ function redactSession(error, session) {
145
+ return redactSecret(error, {
146
+ value: session,
147
+ label: REDACTED
148
+ });
149
+ }
150
+ /** `redactSession` for any secret: the Vipps grant, the token it buys, the API key. */
151
+ function redactSecret(error, secret) {
152
+ if (secret.value === "") return error;
153
+ if (error instanceof Error) {
154
+ redactError(error, secret.value, 0, secret.label);
155
+ return error;
156
+ }
157
+ if (typeof error === "string" && error.includes(secret.value)) return new Error(scrub(error, secret.value, secret.label));
158
+ return error;
159
+ }
160
+ function isDeadSession(error) {
161
+ return error instanceof MedalApiError && error.status === 401 && (error.code === "PORTAL_SESSION_REQUIRED" || error.code === "PORTAL_SESSION_INVALID");
162
+ }
163
+ function isThrottled(error) {
164
+ return error instanceof MedalApiError && error.status === 429;
165
+ }
166
+ /**
167
+ * Run one session-bound SDK call with the two guarantees from the module
168
+ * comment, plus the one mapping every such call shares: a 401 that names the
169
+ * session becomes `PortalSessionExpiredError`. Everything else is rethrown
170
+ * redacted, for the caller to interpret or for the action's error handler to
171
+ * turn into «something went wrong».
172
+ */
173
+ async function withSession(session, call) {
174
+ try {
175
+ return await call(portalClient());
176
+ } catch (error) {
177
+ const scrubbed = redactSession(error, session);
178
+ if (isDeadSession(scrubbed)) throw new PortalSessionExpiredError();
179
+ throw scrubbed;
180
+ }
181
+ }
182
+ /**
183
+ * Ask Medal to e-mail a one-time code.
184
+ *
185
+ * Resolves on «sent» and says nothing about whether the address is known —
186
+ * that is Medal's silence to keep, and the action above this keeps it too. A
187
+ * 429 is the one answer translated (`PortalThrottledError`); anything else is
188
+ * rethrown as-is, because a login page that cannot reach Medal should say so.
189
+ */
190
+ async function startLogin(email, locale = "no") {
191
+ try {
192
+ unwrap(await portalClient().login.start({
193
+ email,
194
+ locale
195
+ }), "/api/v1/portal/login/start");
196
+ } catch (error) {
197
+ if (isThrottled(error)) throw new PortalThrottledError();
198
+ throw error;
199
+ }
200
+ }
201
+ /**
202
+ * Exchange the code for a session. `null` for a wrong or expired code — that
203
+ * is an answer for the form, not a failure — and the token plus Medal's own
204
+ * expiry on success. The contact summary that rides along is dropped here:
205
+ * the page fetches the profile with the session it has just been given, and
206
+ * a second copy of the parent's name has no reader.
207
+ */
208
+ async function verifyLogin(email, code) {
209
+ try {
210
+ const session = unwrap(await portalClient().login.verify({
211
+ email,
212
+ code
213
+ }), "/api/v1/portal/login/verify");
214
+ return {
215
+ sessionToken: session.session_token,
216
+ expiresAt: session.expires_at
217
+ };
218
+ } catch (error) {
219
+ if (error instanceof MedalApiError && error.status === 401 && error.code === "PORTAL_CODE_INVALID") return null;
220
+ if (isThrottled(error)) throw new PortalThrottledError();
221
+ throw error;
222
+ }
223
+ }
224
+ /**
225
+ * Revoke the session upstream. A 401 of any flavour is swallowed: it means
226
+ * Medal no longer honours this token, which is exactly the state a logout is
227
+ * trying to reach. A 5xx is NOT — the token may still be live, and the caller
228
+ * decides whether to clear the cookie anyway (it should: a cookie that stays
229
+ * in the jar is a cookie that stays stealable).
230
+ */
231
+ async function logout(session) {
232
+ try {
233
+ await withSession(session, (portal) => portal.logout(session));
234
+ } catch (error) {
235
+ if (error instanceof PortalSessionExpiredError) return;
236
+ if (error instanceof MedalApiError && error.status === 401) return;
237
+ throw error;
238
+ }
239
+ }
240
+ async function getMe(session) {
241
+ const profile = await withSession(session, async (portal) => unwrap(await portal.me(session), "/api/v1/portal/me"));
242
+ return toPortalProfileDto(profile);
243
+ }
244
+ /**
245
+ * The engine names the code `VALIDATION_ERROR` whether it answers 400 or 422
246
+ * (`medal-client.ts` records that the booking routes see the latter), so the
247
+ * match is on the code alone. The message has already been through
248
+ * `redactSession` by the time it is re-thrown here — it is bound for a form.
249
+ */
250
+ async function updateMe(session, patch) {
251
+ try {
252
+ const profile = await withSession(session, async (portal) => unwrap(await portal.updateMe(session, patch), "/api/v1/portal/me"));
253
+ return toPortalProfileDto(profile);
254
+ } catch (error) {
255
+ if (error instanceof MedalApiError && error.code === "VALIDATION_ERROR") throw new PortalValidationError(error.message);
256
+ throw error;
257
+ }
258
+ }
259
+ async function getMyBookings(session) {
260
+ const bookings = await withSession(session, async (portal) => unwrap(await portal.myBookings(session), "/api/v1/portal/bookings"));
261
+ return {
262
+ upcoming: bookings.upcoming.map(toPortalBookingDto),
263
+ past: bookings.past.map(toPortalBookingDto)
264
+ };
265
+ }
266
+ /**
267
+ * The parent's own data, as Medal exports it — snake_case and all, because
268
+ * this is THEIR file (the GDPR right of access) and a projection would be
269
+ * this site editorialising it. One thing is taken out: every `manage_token`
270
+ * is nulled. A download sits in a Downloads folder, gets attached to an
271
+ * e-mail, gets synced to a phone; a live credential for moving an appointment
272
+ * does not belong in any of those places, and the same bookings are managed
273
+ * from the page with the session instead.
274
+ *
275
+ * Copies rather than mutates: the SDK's object is nobody else's, today, but a
276
+ * function that quietly edits its input is a bug waiting for a second reader.
277
+ */
278
+ async function exportMyData(session) {
279
+ const exported = await withSession(session, async (portal) => unwrap(await portal.exportMyData(session), "/api/v1/portal/export"));
280
+ return {
281
+ ...exported,
282
+ bookings: exported.bookings.map((booking) => ({
283
+ ...booking,
284
+ manage_token: null
285
+ }))
286
+ };
287
+ }
288
+ async function deleteMe(session) {
289
+ await withSession(session, (portal) => portal.deleteMe(session));
290
+ }
291
+ /** Long enough for Medal to round-trip Vipps; short enough that a parent
292
+ * whose click went nowhere is told so before they give up on the page. */
293
+ const VIPPS_TIMEOUT_MS = 15e3;
294
+ /** The SDK's portal namespace on a client with the `VIPPS_TIMEOUT_MS` deadline. */
295
+ function quickPortal() {
296
+ return medal.requireMedalWithTimeout(VIPPS_TIMEOUT_MS).portal;
297
+ }
298
+ /** `body[key]`, or `undefined` for a body that is not an object. */
299
+ function field(body, key) {
300
+ return body && typeof body === "object" ? body[key] : void 0;
301
+ }
302
+ /** The `code` of a standard error envelope; `UNKNOWN_ERROR` for anything else. */
303
+ function vippsErrorCode(body) {
304
+ const code = field(field(body, "error"), "code");
305
+ return typeof code === "string" && code !== "" ? code : "UNKNOWN_ERROR";
306
+ }
307
+ /** The engine's own 404s on the persons routes; any other 404 is a missing route. */
308
+ const DOMAIN_NOT_FOUND = /* @__PURE__ */ new Set(["Person not found", "Resource not found"]);
309
+ function isDomainNotFound(message) {
310
+ return typeof message === "string" && DOMAIN_NOT_FOUND.has(message);
311
+ }
312
+ /** A 2xx whose body is not the shape the contract promises is not a success. */
313
+ function malformed(path) {
314
+ return new MedalApiError(200, "NON_JSON_BODY", `Medal returned a malformed body from ${path}`);
315
+ }
316
+ /**
317
+ * `POST` one Vipps route with the API key. Resolves with the `data` member
318
+ * of the parsed 2xx envelope; throws `MedalApiError` for anything else, with
319
+ * a message that names the status and the code and NOT the response text —
320
+ * the only strings Medal echoes on these routes are ones this site sent it.
321
+ */
322
+ async function postVipps(path, body) {
323
+ return callMedal(path, {
324
+ method: "POST",
325
+ body
326
+ });
327
+ }
328
+ /**
329
+ * One raw request to Medal with the API key — and, for the routes that act as
330
+ * the parent, their session in `x-portal-session`, the header the SDK's own
331
+ * portal methods use. `postVipps` is this with `POST` and no session.
332
+ *
333
+ * A 204 resolves `undefined`; any other 2xx has to carry the `data` member.
334
+ */
335
+ async function callMedal(path, { method, body, session }) {
336
+ const { key, base } = medal.requireMedalConfig();
337
+ const response = await fetch(new URL(path, base).toString(), {
338
+ method,
339
+ headers: {
340
+ authorization: `Bearer ${key}`,
341
+ /* v8 ignore next -- defensive: no caller sends a request without a body */
342
+ ...body === void 0 ? {} : { "content-type": "application/json" },
343
+ accept: "application/json",
344
+ ...session === void 0 ? {} : { "x-portal-session": session }
345
+ },
346
+ /* v8 ignore next -- defensive: no caller sends a request without a body */
347
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
348
+ signal: AbortSignal.timeout(VIPPS_TIMEOUT_MS)
349
+ });
350
+ if (response.status === 204) return void 0;
351
+ const text = await response.text();
352
+ let parsed;
353
+ try {
354
+ parsed = text ? JSON.parse(text) : void 0;
355
+ } catch {
356
+ parsed = void 0;
357
+ }
358
+ if (!response.ok) {
359
+ const code = vippsErrorCode(parsed);
360
+ throw new MedalApiError(response.status, code, `Medal answered ${response.status} ${code} from ${path}`, { domainNotFound: isDomainNotFound(field(field(parsed, "error"), "message")) });
361
+ }
362
+ const data = field(parsed, "data");
363
+ if (data === void 0) throw malformed(path);
364
+ return data;
365
+ }
366
+ /** Run `call`, scrubbing every secret in `secrets` out of whatever it throws. */
367
+ async function withVippsSecrets(secrets, call) {
368
+ try {
369
+ return await call();
370
+ } catch (error) {
371
+ let scrubbed = error;
372
+ for (const secret of secrets()) scrubbed = redactSecret(scrubbed, secret);
373
+ throw scrubbed;
374
+ }
375
+ }
376
+ function apiKeySecret() {
377
+ try {
378
+ return [{
379
+ value: medal.requireMedalConfig().key,
380
+ label: "<key>"
381
+ }];
382
+ } catch {
383
+ return [];
384
+ }
385
+ }
386
+ /** The start route's three decisions, by code (and 429 by status); anything else is rethrown. */
387
+ function mapStartError(error) {
388
+ if (error instanceof MedalApiError) {
389
+ if (error.code === "VIPPS_NOT_CONFIGURED") throw new PortalVippsUnavailableError();
390
+ if (error.code === "INVALID_RETURN_URL") throw new PortalValidationError(error.message);
391
+ if (error.status === 429) throw new PortalThrottledError();
392
+ }
393
+ throw error;
394
+ }
395
+ /**
396
+ * Ask Medal where to send the parent to log in with Vipps.
397
+ *
398
+ * `returnUrl` is the absolute address of this site's `/min-side/vipps` route,
399
+ * which is where Vipps — via Medal — sends the browser back with either a
400
+ * grant or a reason. Medal checks it against the workspace's site and answers
401
+ * 400 `INVALID_RETURN_URL` for one it does not recognise; that surfaces as
402
+ * `PortalValidationError` because it is a configuration mistake on one side
403
+ * or the other, not something the parent did. 503 `VIPPS_NOT_CONFIGURED`
404
+ * means the workspace has no Vipps credentials and becomes
405
+ * `PortalVippsUnavailableError`, which the login page turns into «use e-mail
406
+ * instead». The address Medal hands back is checked to be HTTPS before anyone
407
+ * is redirected to it.
408
+ */
409
+ async function startVippsLogin({ returnUrl, browserBinding, locale = "no" }) {
410
+ const path = "/api/v1/portal/vipps/start";
411
+ const secrets = () => [...browserBinding ? [{
412
+ value: browserBinding,
413
+ label: "<binding>"
414
+ }] : [], ...apiKeySecret()];
415
+ return withVippsSecrets(secrets, async () => {
416
+ const authorizeUrl = field(await postVipps(path, {
417
+ return_url: returnUrl,
418
+ ...browserBinding ? { browser_binding: browserBinding } : {},
419
+ locale
420
+ }).catch(mapStartError), "authorize_url");
421
+ if (typeof authorizeUrl !== "string" || !authorizeUrl.startsWith("https://")) throw malformed(path);
422
+ return { authorizeUrl };
423
+ });
424
+ }
425
+ /**
426
+ * Swap the grant Vipps sent the parent back with for a session. `null` when
427
+ * Medal does not know it (404 `GRANT_NOT_FOUND` — used already, expired, or
428
+ * minted for another workspace; the three are not distinguished, and none is
429
+ * a failure) and the token plus expiry otherwise, in the same shape
430
+ * `verifyLogin` returns so the route that receives it writes the cookie the
431
+ * same way.
432
+ *
433
+ * The grant and, once known, the token are scrubbed from anything thrown:
434
+ * the grant is in the request body, the token in the response, and a `fetch`
435
+ * failure or a serialiser can quote either.
436
+ */
437
+ async function exchangeVippsGrant(grant) {
438
+ const path = "/api/v1/portal/vipps/exchange";
439
+ let token = "";
440
+ const secrets = () => [
441
+ {
442
+ value: grant,
443
+ label: "<grant>"
444
+ },
445
+ {
446
+ value: token,
447
+ label: REDACTED
448
+ },
449
+ ...apiKeySecret()
450
+ ];
451
+ return withVippsSecrets(secrets, async () => {
452
+ let data;
453
+ try {
454
+ data = unwrap(await quickPortal().login.vipps.exchange({ grant }), path);
455
+ } catch (error) {
456
+ if (error instanceof MedalApiError && error.code === "GRANT_NOT_FOUND") return null;
457
+ if (isThrottled(error)) throw new PortalThrottledError();
458
+ throw error;
459
+ }
460
+ const sessionToken = field(data, "session_token");
461
+ const expiresAt = field(data, "expires_at");
462
+ if (typeof sessionToken === "string") token = sessionToken;
463
+ if (token === "" || typeof expiresAt !== "number") throw malformed(path);
464
+ return {
465
+ sessionToken: token,
466
+ expiresAt
467
+ };
468
+ });
469
+ }
470
+ const PERSONS_PATH = "/api/v1/portal/me/persons";
471
+ /**
472
+ * A 404 that is not the engine's own «Person not found» / «Resource not
473
+ * found». The SDK keeps the engine's sentence as `message`; the raw
474
+ * `callMedal` drops it and records the verdict in `details.domainNotFound`.
475
+ */
476
+ function isMissingRoute(error) {
477
+ if (!(error instanceof MedalApiError) || error.status !== 404) return false;
478
+ const domain = error.details?.domainNotFound === true || isDomainNotFound(error.message);
479
+ return !(error.code === "NOT_FOUND" && domain);
480
+ }
481
+ /** The editor's copy for what the engine refused. Never the engine's own
482
+ * sentence: it is written for integrators and names wire fields. */
483
+ function mapPersonError(error) {
484
+ if (error instanceof MedalApiError) {
485
+ if (error.code === "CONFLICT") throw new PortalValidationError(messages.duplicateChild);
486
+ if (error.code === "VALIDATION_ERROR" || error.code === "INVALID_INPUT") throw new PortalValidationError(messages.invalidChild);
487
+ if (error.status === 404) throw new PortalValidationError(messages.childNotFound);
488
+ if (error.status === 429) throw new PortalThrottledError();
489
+ }
490
+ throw error;
491
+ }
492
+ function personPatch(input) {
493
+ const body = {};
494
+ if (input.name !== void 0) body.name = input.name;
495
+ if (input.birthYear !== void 0) body.birth_year = input.birthYear;
496
+ if (input.birthMonth !== void 0) body.birth_month = input.birthMonth;
497
+ if (input.notes !== void 0) body.notes = input.notes;
498
+ if (input.preferredResourceId !== void 0) body.preferred_resource_id = input.preferredResourceId;
499
+ return body;
500
+ }
501
+ /**
502
+ * On create an absent key and `null` mean the same — nothing — and the
503
+ * create schema is STRICT about nulls it does not take.
504
+ */
505
+ function personCreate(input) {
506
+ const body = {
507
+ name: input.name,
508
+ birth_year: input.birthYear
509
+ };
510
+ if (input.birthMonth != null) body.birth_month = input.birthMonth;
511
+ if (input.notes != null) body.notes = input.notes;
512
+ if (input.preferredResourceId != null) body.preferred_resource_id = input.preferredResourceId;
513
+ return body;
514
+ }
515
+ /** Run one persons call with the session scrubbed and a dead one mapped. */
516
+ async function withPersonsCall(session, call) {
517
+ return withVippsSecrets(() => [{
518
+ value: session,
519
+ label: REDACTED
520
+ }, ...apiKeySecret()], async () => {
521
+ try {
522
+ return await call();
523
+ } catch (error) {
524
+ if (isDeadSession(error)) throw new PortalSessionExpiredError();
525
+ throw error;
526
+ }
527
+ });
528
+ }
529
+ function wireFamily(profile) {
530
+ return profile.family.map((member) => ({
531
+ name: member.name,
532
+ birth_year: member.birthYear
533
+ }));
534
+ }
535
+ /** The id the refreshed profile gives the child with this name and year. */
536
+ function idOf(profile, name, birthYear) {
537
+ const matches = profile.family.filter((member) => member.name === name && member.birthYear === birthYear);
538
+ return matches.length === 1 ? matches[0].personId : null;
539
+ }
540
+ /** Where a child sits in the list the fallback rewrites: by id, else by index. */
541
+ function familyIndex(profile, target) {
542
+ if (target.personId) return profile.family.findIndex((member) => member.personId === target.personId);
543
+ const index = target.index ?? -1;
544
+ return index >= 0 && index < profile.family.length ? index : -1;
545
+ }
546
+ async function createPerson(session, input) {
547
+ const created = await withPersonsCall(session, async () => {
548
+ try {
549
+ const personId = field(unwrap(await quickPortal().persons.create(session, personCreate(input)), PERSONS_PATH), "person_id");
550
+ return { personId: typeof personId === "string" ? personId : null };
551
+ } catch (error) {
552
+ if (isMissingRoute(error)) return null;
553
+ return mapPersonError(error);
554
+ }
555
+ });
556
+ if (created !== null) return {
557
+ profile: await getMe(session),
558
+ personId: created.personId,
559
+ fallback: false
560
+ };
561
+ const profile = await updateMe(session, { family: [...wireFamily(await getMe(session)), {
562
+ name: input.name,
563
+ birth_year: input.birthYear
564
+ }] });
565
+ return {
566
+ profile,
567
+ personId: idOf(profile, input.name, input.birthYear),
568
+ fallback: true
569
+ };
570
+ }
571
+ async function updatePerson(session, target, patch) {
572
+ const personId = target.personId ?? null;
573
+ if (personId !== null) {
574
+ if (await withPersonsCall(session, async () => {
575
+ try {
576
+ await callMedal(`${PERSONS_PATH}/${encodeURIComponent(personId)}`, {
577
+ method: "PATCH",
578
+ body: personPatch(patch),
579
+ session
580
+ });
581
+ return true;
582
+ } catch (error) {
583
+ if (isMissingRoute(error)) return false;
584
+ return mapPersonError(error);
585
+ }
586
+ })) return {
587
+ profile: await getMe(session),
588
+ personId,
589
+ fallback: false
590
+ };
591
+ }
592
+ const current = await getMe(session);
593
+ const index = familyIndex(current, target);
594
+ if (index === -1) mapPersonError(new MedalApiError(404, "NOT_FOUND", "person not in family"));
595
+ const family = wireFamily(current);
596
+ const name = patch.name ?? family[index].name;
597
+ const birthYear = patch.birthYear ?? family[index].birth_year;
598
+ family[index] = {
599
+ name,
600
+ birth_year: birthYear
601
+ };
602
+ const profile = await updateMe(session, { family });
603
+ return {
604
+ profile,
605
+ personId: idOf(profile, name, birthYear),
606
+ fallback: true
607
+ };
608
+ }
609
+ async function removePerson(session, target) {
610
+ const personId = target.personId ?? null;
611
+ if (personId !== null) {
612
+ if (await withPersonsCall(session, async () => {
613
+ try {
614
+ await quickPortal().persons.remove(session, personId);
615
+ return true;
616
+ } catch (error) {
617
+ if (isMissingRoute(error)) return false;
618
+ return mapPersonError(error);
619
+ }
620
+ })) return {
621
+ profile: await getMe(session),
622
+ personId,
623
+ fallback: false
624
+ };
625
+ }
626
+ const current = await getMe(session);
627
+ const index = familyIndex(current, target);
628
+ if (index === -1) mapPersonError(new MedalApiError(404, "NOT_FOUND", "person not in family"));
629
+ return {
630
+ profile: await updateMe(session, { family: wireFamily(current).filter((_, position) => position !== index) }),
631
+ personId,
632
+ fallback: true
633
+ };
634
+ }
635
+ /**
636
+ * Confirm a conflicted Vipps login with the six-digit code Medal e-mailed to
637
+ * the address on file (SP10 A5): `POST /api/v1/portal/vipps/link/verify`.
638
+ *
639
+ * `null` for every refusal Medal folds into 401 `PORTAL_CODE_INVALID` — a
640
+ * wrong or burned code, an unknown, expired or spent link, a browser binding
641
+ * that is not the one the login started with — which is one answer for the
642
+ * form. 409 is `PortalVippsConflictError`, 429 `PortalThrottledError`.
643
+ *
644
+ * The link, the code, the binding and the session it buys are all scrubbed
645
+ * from anything thrown, with the API key. Through the SDK's
646
+ * `portal.login.vipps.verifyLink`, which sends it exactly once: the right code
647
+ * consumes the link, so a retry would misreport a completed login.
648
+ */
649
+ async function verifyVippsLink({ link, code, browserBinding }) {
650
+ const path = "/api/v1/portal/vipps/link/verify";
651
+ let token = "";
652
+ const secrets = () => [
653
+ {
654
+ value: link,
655
+ label: "<link>"
656
+ },
657
+ {
658
+ value: code,
659
+ label: "<code>"
660
+ },
661
+ {
662
+ value: browserBinding,
663
+ label: "<binding>"
664
+ },
665
+ {
666
+ value: token,
667
+ label: REDACTED
668
+ },
669
+ ...apiKeySecret()
670
+ ];
671
+ return withVippsSecrets(secrets, async () => {
672
+ let data;
673
+ try {
674
+ data = unwrap(await quickPortal().login.vipps.verifyLink({
675
+ link,
676
+ code,
677
+ browser_binding: browserBinding
678
+ }), path);
679
+ } catch (error) {
680
+ return mapLinkError(error);
681
+ }
682
+ const sessionToken = field(data, "session_token");
683
+ const expiresAt = field(data, "expires_at");
684
+ if (typeof sessionToken === "string") token = sessionToken;
685
+ if (token === "" || typeof expiresAt !== "number") throw malformed(path);
686
+ return {
687
+ sessionToken: token,
688
+ expiresAt
689
+ };
690
+ });
691
+ }
692
+ /** The link verify's refusals: `null` for «wrong or expired», the two errors, else rethrown. */
693
+ function mapLinkError(error) {
694
+ if (error instanceof MedalApiError) {
695
+ if (error.status === 401 && error.code === "PORTAL_CODE_INVALID") return null;
696
+ if (error.status === 409 && error.code === "VIPPS_IDENTITY_CONFLICT") throw new PortalVippsConflictError();
697
+ if (error.status === 429) throw new PortalThrottledError();
698
+ }
699
+ throw error;
700
+ }
701
+ const VIPPS_LINK_PATH = "/api/v1/portal/me/vipps/link";
702
+ /** The session, the binding, the grant when there is one, and the key — scrubbed; a dead session mapped. */
703
+ async function withVippsLinkCall(session, extra, call) {
704
+ return withVippsSecrets(() => [
705
+ {
706
+ value: session,
707
+ label: REDACTED
708
+ },
709
+ ...extra,
710
+ ...apiKeySecret()
711
+ ], async () => {
712
+ try {
713
+ return await call();
714
+ } catch (error) {
715
+ if (isDeadSession(error)) throw new PortalSessionExpiredError();
716
+ throw error;
717
+ }
718
+ });
719
+ }
720
+ function isVippsNotConfigured(error) {
721
+ return error instanceof MedalApiError && error.code === "VIPPS_NOT_CONFIGURED";
722
+ }
723
+ /**
724
+ * Ask Medal where to send a logged-in parent to link Vipps. `null` against a
725
+ * Medal without the route AND for a workspace without Vipps (503
726
+ * `VIPPS_NOT_CONFIGURED`) — both hide the row, with nothing to say.
727
+ * `PortalThrottledError` for a 429, `PortalSessionExpiredError` for a dead
728
+ * session; the address is checked to be HTTPS.
729
+ */
730
+ async function startVippsLink(session, { returnUrl, browserBinding, locale = "no" }) {
731
+ const path = `${VIPPS_LINK_PATH}/start`;
732
+ return withVippsLinkCall(session, [{
733
+ value: browserBinding,
734
+ label: "<binding>"
735
+ }], async () => {
736
+ let data;
737
+ try {
738
+ data = await callMedal(path, {
739
+ method: "POST",
740
+ body: {
741
+ return_url: returnUrl,
742
+ browser_binding: browserBinding,
743
+ locale
744
+ },
745
+ session
746
+ });
747
+ } catch (error) {
748
+ if (isMissingRoute(error) || isVippsNotConfigured(error)) return null;
749
+ if (isDeadSession(error)) throw error;
750
+ return mapStartError(error);
751
+ }
752
+ const authorizeUrl = field(data, "authorize_url");
753
+ if (typeof authorizeUrl !== "string" || !authorizeUrl.startsWith("https://")) throw malformed(path);
754
+ return { authorizeUrl };
755
+ });
756
+ }
757
+ /**
758
+ * Spend the link grant Medal's callback handed back (medal-monorepo #5592).
759
+ *
760
+ * 200 `{ data: { status: 'linked', already_linked } }` is `linked` — a 2xx
761
+ * without that status is malformed, not a success. 409
762
+ * `VIPPS_IDENTITY_CONFLICT` is `conflict` (this Vipps account belongs to
763
+ * another profile). 404 `LINK_GRANT_NOT_FOUND` — an unknown, spent, expired
764
+ * or malformed grant, and (since the #5592 security review, which BURNS the
765
+ * grant on it) a wrong binding or another customer's session — is `invalid`,
766
+ * as are a 400 and a non-session 401. Every one of the three is final for
767
+ * the grant. A dead session and a throttle are the seam's usual errors.
768
+ */
769
+ async function completeVippsLink(session, { grant, browserBinding }) {
770
+ const path = `${VIPPS_LINK_PATH}/complete`;
771
+ return withVippsLinkCall(session, [{
772
+ value: grant,
773
+ label: "<grant>"
774
+ }, {
775
+ value: browserBinding,
776
+ label: "<binding>"
777
+ }], async () => {
778
+ try {
779
+ if (field(await callMedal(path, {
780
+ method: "POST",
781
+ body: {
782
+ grant,
783
+ browser_binding: browserBinding
784
+ },
785
+ session
786
+ }), "status") !== "linked") throw malformed(path);
787
+ return "linked";
788
+ } catch (error) {
789
+ return mapCompleteError(error);
790
+ }
791
+ });
792
+ }
793
+ /** The complete route's refusals, as `VippsLinkCompletion`; anything else is rethrown. */
794
+ function mapCompleteError(error) {
795
+ if (!(error instanceof MedalApiError)) throw error;
796
+ const { status } = error;
797
+ if (isDeadSession(error)) throw error;
798
+ if (status === 409) return "conflict";
799
+ if (status === 400 || status === 401 || status === 404) return "invalid";
800
+ if (status === 429) throw new PortalThrottledError();
801
+ throw error;
802
+ }
803
+ return {
804
+ startLogin,
805
+ verifyLogin,
806
+ logout,
807
+ getMe,
808
+ updateMe,
809
+ getMyBookings,
810
+ exportMyData,
811
+ deleteMe,
812
+ startVippsLogin,
813
+ exchangeVippsGrant,
814
+ createPerson,
815
+ updatePerson,
816
+ removePerson,
817
+ verifyVippsLink,
818
+ startVippsLink,
819
+ completeVippsLink
820
+ };
821
+ }
822
+ //#endregion
823
+ export { PortalSessionExpiredError, PortalThrottledError, PortalValidationError, PortalVippsConflictError, PortalVippsUnavailableError, createPortalSeam };
824
+
825
+ //# sourceMappingURL=medal-portal.mjs.map