@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 @@
1
+ {"version":3,"file":"medal-portal.mjs","names":[],"sources":["../../../src/next/portal/medal-portal.ts"],"sourcesContent":["import type {\n Portal,\n PortalExport,\n PortalLocale,\n PortalPersonCreateInput,\n PortalPersonPatch,\n PortalProfilePatch,\n} from '@medalsocial/sdk';\nimport { MedalApiError } from '@medalsocial/sdk';\nimport type { BookingConfig } from '../../core/config';\nimport {\n createPortalDto,\n type PortalBookingDto,\n type PortalProfileDto,\n toPortalProfileDto,\n} from '../../core/portal/dto';\nimport { type MedalSeam, unwrap } from '../medal';\nimport { DEFAULT_PORTAL_MESSAGES, type PortalMessages } from '../options';\n\n/**\n * The portal seam: everything `/min-side` asks Medal, in this site's own terms.\n *\n * The sibling of `lib/booking/medal-client.ts`, and built on the same SDK. The\n * difference is the credential. The booking seam carries a per-appointment\n * manage token on a handful of calls; every call here except the two login\n * steps carries the parent's SESSION — a bearer credential for their whole\n * account, read from the session cookie by `portal/session.ts` and\n * handed in as a plain string. This module is the last place that string is\n * known by name before it goes on the wire, which makes it the last place two\n * things can be guaranteed:\n *\n * 1. **It is not in any error that leaves here.** The SDK formats the failing\n * request into its messages, and the session travels in a header of that\n * request; whatever an action lets escape is logged verbatim by\n * `next-safe-action`'s error handler and a log is copied to places a cookie\n * is not. `withSession` scrubs the value out of the message and stack of\n * anything it rethrows — the same instance, so `instanceof`, `status` and\n * `code` still hold for whoever catches it.\n * 2. **It is never logged here.** There is no `logger` import in this file on\n * purpose. The one thing a log line about a portal failure must not contain\n * is the thing every function in this file holds.\n *\n * Two errors are this module's own vocabulary, because two upstream answers\n * are decisions for the page rather than failures: `PortalSessionExpiredError`\n * (Medal no longer honours the cookie — clear it and send the parent back to\n * the login) and `PortalThrottledError` (Medal is rate-limiting this address —\n * the login copy already says «if we have your address, a code is on its way»,\n * and a throttle must not become an oracle for which addresses exist).\n * `PortalValidationError` carries the engine's message for a rejected profile\n * edit; it is written for integrators and quotes wire field names, so the form\n * should prefer its own copy and fall back to this.\n */\n\n/** Medal answered 401 with a `PORTAL_SESSION_*` code: the cookie is dead. */\nexport class PortalSessionExpiredError extends Error {\n constructor() {\n super('portal session expired');\n this.name = 'PortalSessionExpiredError';\n }\n}\n\n/** Medal answered 429: this address has asked for too many codes. */\nexport class PortalThrottledError extends Error {\n constructor() {\n super('portal request throttled');\n this.name = 'PortalThrottledError';\n }\n}\n\n/** Medal rejected a profile edit; `message` is the engine's own sentence. */\nexport class PortalValidationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'PortalValidationError';\n }\n}\n\n/**\n * Medal answered 503 `VIPPS_NOT_CONFIGURED`: this workspace has no Vipps\n * credentials (yet). A decision for the login page — show the e-mail form\n * and say so — not a failure.\n */\nexport class PortalVippsUnavailableError extends Error {\n constructor() {\n super('vipps login unavailable');\n this.name = 'PortalVippsUnavailableError';\n }\n}\n\n/** One child as the editor writes it, in this site's own terms. */\nexport interface PortalPersonInput {\n name: string;\n birthYear: number;\n /** `null` clears; absent leaves it alone. */\n birthMonth?: number | null;\n notes?: string | null;\n preferredResourceId?: string | null;\n}\n\n/** What a person edit reports: the profile as Medal now holds it, and whether\n * the SP10 route was there to take the details. */\nexport interface PortalPersonResult {\n profile: PortalProfileDto;\n /** The id of the child just created or edited, when the profile can name it. */\n personId: string | null;\n fallback: boolean;\n}\n\n/**\n * Which child an edit is about. `personId` wherever the profile named one;\n * `index` into the profile's family only for the rare row it could not (two\n * same-named, same-year children against a Medal that sends no ids), which\n * only the list-replacing fallback can address.\n */\nexport interface PersonTarget {\n personId?: string | null;\n index?: number;\n}\n\n/**\n * Medal answered 409 `VIPPS_IDENTITY_CONFLICT` to a pending-link code: the code\n * was right, but this Vipps account has since been bound to another customer.\n * An answer for the login sheet — «log in with e-mail, or ring the salon».\n */\nexport class PortalVippsConflictError extends Error {\n constructor() {\n super('vipps identity conflict');\n this.name = 'PortalVippsConflictError';\n }\n}\n\n/** How a link grant ended: linked, bound to another profile, or not honoured. */\nexport type VippsLinkCompletion = 'linked' | 'conflict' | 'invalid';\n\ntype Seam = Pick<MedalSeam, 'requireMedal' | 'requireMedalWithTimeout' | 'requireMedalConfig'>;\n\nexport interface PortalSeamOptions {\n config: Pick<BookingConfig, 'paths'>;\n messages?: Partial<PortalMessages>;\n}\n\n/** The portal seam over one Medal seam. */\nexport function createPortalSeam(medal: Seam, options: PortalSeamOptions) {\n const messages = { ...DEFAULT_PORTAL_MESSAGES, ...options.messages };\n const { toPortalBookingDto } = createPortalDto(options.config);\n\n /**\n * The SDK's portal namespace (`Medal#portal`, SDK ≥ 1.8.0). `requireMedal()`\n * throws `MedalConfigError` for a missing key, and that is left to propagate:\n * a portal that silently cannot reach Medal is worse than one that fails\n * visibly.\n */\n function portalClient(): Portal {\n return medal.requireMedal().portal;\n }\n\n const REDACTED = '<session>';\n\n /** A value that must not appear in an error, and the placeholder it becomes. */\n type Secret = { value: string; label: string };\n\n /**\n * How far below the thrown error the scrub follows `cause` and\n * `AggregateError.errors`. Three is deeper than any chain the SDK or `fetch`\n * builds (undici wraps one `cause` under `fetch failed`), and a bound is\n * needed at all because `cause` may point back up the chain: an error that is\n * its own cause would otherwise recurse until the stack ran out.\n */\n const REDACT_DEPTH = 3;\n\n function scrub(text: string, session: string, label = REDACTED): string {\n return text.includes(session) ? text.split(session).join(label) : text;\n }\n\n /**\n * Scrub `message` and `stack` on `error` and on every `Error` hanging off it.\n *\n * `cause` and `errors` are followed because a logger prints them: Node's\n * `util.inspect`, Pino's error serializer and Sentry all walk the cause chain,\n * so a clean top-level message over an unscrubbed `cause.message` is the same\n * leak one level down. A string `cause` is rewritten in place for the same\n * reason; anything else there (a response object, say) is not a string a\n * logger prints verbatim and is left alone.\n */\n function redactError(error: Error, session: string, depth: number, label = REDACTED): void {\n if (error.message.includes(session)) error.message = scrub(error.message, session, label);\n if (typeof error.stack === 'string' && error.stack.includes(session)) {\n error.stack = scrub(error.stack, session, label);\n }\n if (depth >= REDACT_DEPTH) return;\n if (error.cause instanceof Error) {\n redactError(error.cause, session, depth + 1, label);\n } else if (typeof error.cause === 'string') {\n error.cause = scrub(error.cause, session, label);\n }\n if (error instanceof AggregateError) {\n for (const inner of error.errors) {\n if (inner instanceof Error) redactError(inner, session, depth + 1, label);\n }\n }\n }\n\n /**\n * Scrub `session` out of whatever was thrown, in place, and hand it back.\n *\n * In place rather than re-wrapped: callers up the stack switch on\n * `instanceof MedalApiError` and read `.status` / `.code`, and a fresh `Error`\n * would erase all three. `message` and `stack` are the two strings a logger\n * prints — on the error and, via `redactError`, on its `cause` chain;\n * `MedalApiError.details` is `readonly` and carries the engine's structured\n * body, which never echoes a request header.\n *\n * A non-`Error` throw that contains the session (a bare string, say) is\n * REPLACED — there is nothing else on it worth keeping, and nothing safe to\n * pass on.\n */\n function redactSession(error: unknown, session: string): unknown {\n return redactSecret(error, { value: session, label: REDACTED });\n }\n\n /** `redactSession` for any secret: the Vipps grant, the token it buys, the API key. */\n function redactSecret(error: unknown, secret: Secret): unknown {\n if (secret.value === '') return error;\n if (error instanceof Error) {\n redactError(error, secret.value, 0, secret.label);\n return error;\n }\n if (typeof error === 'string' && error.includes(secret.value)) {\n return new Error(scrub(error, secret.value, secret.label));\n }\n return error;\n }\n\n function isDeadSession(error: unknown): error is MedalApiError {\n return (\n error instanceof MedalApiError &&\n error.status === 401 &&\n (error.code === 'PORTAL_SESSION_REQUIRED' || error.code === 'PORTAL_SESSION_INVALID')\n );\n }\n\n function isThrottled(error: unknown): error is MedalApiError {\n return error instanceof MedalApiError && error.status === 429;\n }\n\n /**\n * Run one session-bound SDK call with the two guarantees from the module\n * comment, plus the one mapping every such call shares: a 401 that names the\n * session becomes `PortalSessionExpiredError`. Everything else is rethrown\n * redacted, for the caller to interpret or for the action's error handler to\n * turn into «something went wrong».\n */\n async function withSession<T>(session: string, call: (portal: Portal) => Promise<T>) {\n try {\n return await call(portalClient());\n } catch (error) {\n const scrubbed = redactSession(error, session);\n if (isDeadSession(scrubbed)) throw new PortalSessionExpiredError();\n throw scrubbed;\n }\n }\n\n /**\n * Ask Medal to e-mail a one-time code.\n *\n * Resolves on «sent» and says nothing about whether the address is known —\n * that is Medal's silence to keep, and the action above this keeps it too. A\n * 429 is the one answer translated (`PortalThrottledError`); anything else is\n * rethrown as-is, because a login page that cannot reach Medal should say so.\n */\n async function startLogin(email: string, locale: PortalLocale = 'no'): Promise<void> {\n try {\n unwrap(await portalClient().login.start({ email, locale }), '/api/v1/portal/login/start');\n } catch (error) {\n if (isThrottled(error)) throw new PortalThrottledError();\n throw error;\n }\n }\n\n /**\n * Exchange the code for a session. `null` for a wrong or expired code — that\n * is an answer for the form, not a failure — and the token plus Medal's own\n * expiry on success. The contact summary that rides along is dropped here:\n * the page fetches the profile with the session it has just been given, and\n * a second copy of the parent's name has no reader.\n */\n async function verifyLogin(\n email: string,\n code: string\n ): Promise<{ sessionToken: string; expiresAt: number } | null> {\n try {\n const session = unwrap(\n await portalClient().login.verify({ email, code }),\n '/api/v1/portal/login/verify'\n );\n return { sessionToken: session.session_token, expiresAt: session.expires_at };\n } catch (error) {\n if (\n error instanceof MedalApiError &&\n error.status === 401 &&\n error.code === 'PORTAL_CODE_INVALID'\n ) {\n return null;\n }\n if (isThrottled(error)) throw new PortalThrottledError();\n throw error;\n }\n }\n\n /**\n * Revoke the session upstream. A 401 of any flavour is swallowed: it means\n * Medal no longer honours this token, which is exactly the state a logout is\n * trying to reach. A 5xx is NOT — the token may still be live, and the caller\n * decides whether to clear the cookie anyway (it should: a cookie that stays\n * in the jar is a cookie that stays stealable).\n */\n async function logout(session: string): Promise<void> {\n try {\n await withSession(session, (portal) => portal.logout(session));\n } catch (error) {\n if (error instanceof PortalSessionExpiredError) return;\n if (error instanceof MedalApiError && error.status === 401) return;\n throw error;\n }\n }\n\n async function getMe(session: string): Promise<PortalProfileDto> {\n const profile = await withSession(session, async (portal) =>\n unwrap(await portal.me(session), '/api/v1/portal/me')\n );\n return toPortalProfileDto(profile);\n }\n\n /**\n * The engine names the code `VALIDATION_ERROR` whether it answers 400 or 422\n * (`medal-client.ts` records that the booking routes see the latter), so the\n * match is on the code alone. The message has already been through\n * `redactSession` by the time it is re-thrown here — it is bound for a form.\n */\n async function updateMe(session: string, patch: PortalProfilePatch): Promise<PortalProfileDto> {\n try {\n const profile = await withSession(session, async (portal) =>\n unwrap(await portal.updateMe(session, patch), '/api/v1/portal/me')\n );\n return toPortalProfileDto(profile);\n } catch (error) {\n if (error instanceof MedalApiError && error.code === 'VALIDATION_ERROR') {\n throw new PortalValidationError(error.message);\n }\n throw error;\n }\n }\n\n async function getMyBookings(\n session: string\n ): Promise<{ upcoming: PortalBookingDto[]; past: PortalBookingDto[] }> {\n const bookings = await withSession(session, async (portal) =>\n unwrap(await portal.myBookings(session), '/api/v1/portal/bookings')\n );\n return {\n upcoming: bookings.upcoming.map(toPortalBookingDto),\n past: bookings.past.map(toPortalBookingDto),\n };\n }\n\n /**\n * The parent's own data, as Medal exports it — snake_case and all, because\n * this is THEIR file (the GDPR right of access) and a projection would be\n * this site editorialising it. One thing is taken out: every `manage_token`\n * is nulled. A download sits in a Downloads folder, gets attached to an\n * e-mail, gets synced to a phone; a live credential for moving an appointment\n * does not belong in any of those places, and the same bookings are managed\n * from the page with the session instead.\n *\n * Copies rather than mutates: the SDK's object is nobody else's, today, but a\n * function that quietly edits its input is a bug waiting for a second reader.\n */\n async function exportMyData(session: string): Promise<PortalExport> {\n const exported = await withSession(session, async (portal) =>\n unwrap(await portal.exportMyData(session), '/api/v1/portal/export')\n );\n return {\n ...exported,\n bookings: exported.bookings.map((booking) => ({ ...booking, manage_token: null })),\n };\n }\n\n async function deleteMe(session: string): Promise<void> {\n await withSession(session, (portal) => portal.deleteMe(session));\n }\n\n /*\n * ---------------------------------------------------------------------------\n * Vipps login\n *\n * Three routes: start, exchange and (below) the pending-link verify. SDK 1.12\n * has a method for each; `exchange` and `verifyLink` go through it, because\n * both are sent exactly once there (`retry: false`) — the grant and the link\n * are single-use, so a retry would misreport a completed login — and the\n * 15 s budget is kept by a client of its own (`quickPortal`). `start` stays on\n * a raw `fetch` built from the same key and origin (`postVipps`): the SDK\n * retries it on 429/5xx with no per-call way to opt out, and this site wants\n * a throttle to surface at once as `PortalThrottledError` and an unreachable\n * Medal to fail inside the one 15 s budget, not three. Medal keeps the whole OpenID Connect exchange with\n * Vipps on its side; this site only asks for an address to send the parent to\n * and, when they come back, swaps the one-time GRANT in the return URL for a\n * session. The grant is a bearer credential for that swap — whoever presents\n * it first is logged in — so it is redacted from errors exactly like the\n * session, and the API key rides along in the same list because it is in the\n * `Authorization` header of a request `fetch` may well quote.\n *\n * Both routes answer in the platform's standard envelopes — `{ data: … }` on\n * a 2xx and `{ error: { code, message } }` otherwise — the same shapes the\n * SDK's own methods unwrap. `postVipps` mirrors that: the `data` member is\n * what it resolves with, and a 2xx WITHOUT one (a legacy bare body, an HTML\n * error page, an empty response) is a failure, not a success carrying\n * `undefined`. The `code` is what the mappings below key on, and it is kept\n * on the thrown `MedalApiError` for whoever reads the log; the `message` is\n * dropped, because a sentence written for integrators may quote the request\n * it is about, and the redaction here covers `message` and `stack`, not a\n * structured `details`.\n * ---------------------------------------------------------------------------\n */\n\n /** Long enough for Medal to round-trip Vipps; short enough that a parent\n * whose click went nowhere is told so before they give up on the page. */\n const VIPPS_TIMEOUT_MS = 15_000;\n\n /** The SDK's portal namespace on a client with the `VIPPS_TIMEOUT_MS` deadline. */\n function quickPortal(): Portal {\n return medal.requireMedalWithTimeout(VIPPS_TIMEOUT_MS).portal;\n }\n\n /** `body[key]`, or `undefined` for a body that is not an object. */\n function field(body: unknown, key: string): unknown {\n return body && typeof body === 'object' ? (body as Record<string, unknown>)[key] : undefined;\n }\n\n /** The `code` of a standard error envelope; `UNKNOWN_ERROR` for anything else. */\n function vippsErrorCode(body: unknown): string {\n const code = field(field(body, 'error'), 'code');\n return typeof code === 'string' && code !== '' ? code : 'UNKNOWN_ERROR';\n }\n\n /** The engine's own 404s on the persons routes; any other 404 is a missing route. */\n const DOMAIN_NOT_FOUND: ReadonlySet<string> = new Set(['Person not found', 'Resource not found']);\n\n function isDomainNotFound(message: unknown): boolean {\n return typeof message === 'string' && DOMAIN_NOT_FOUND.has(message);\n }\n\n /** A 2xx whose body is not the shape the contract promises is not a success. */\n function malformed(path: string): MedalApiError {\n return new MedalApiError(200, 'NON_JSON_BODY', `Medal returned a malformed body from ${path}`);\n }\n\n /**\n * `POST` one Vipps route with the API key. Resolves with the `data` member\n * of the parsed 2xx envelope; throws `MedalApiError` for anything else, with\n * a message that names the status and the code and NOT the response text —\n * the only strings Medal echoes on these routes are ones this site sent it.\n */\n async function postVipps(path: string, body: unknown): Promise<unknown> {\n return callMedal(path, { method: 'POST', body });\n }\n\n /**\n * One raw request to Medal with the API key — and, for the routes that act as\n * the parent, their session in `x-portal-session`, the header the SDK's own\n * portal methods use. `postVipps` is this with `POST` and no session.\n *\n * A 204 resolves `undefined`; any other 2xx has to carry the `data` member.\n */\n async function callMedal(\n path: string,\n {\n method,\n body,\n session,\n }: { method: 'POST' | 'PATCH' | 'DELETE'; body?: unknown; session?: string }\n ): Promise<unknown> {\n const { key, base } = medal.requireMedalConfig();\n const response = await fetch(new URL(path, base).toString(), {\n method,\n headers: {\n authorization: `Bearer ${key}`,\n /* v8 ignore next -- defensive: no caller sends a request without a body */\n ...(body === undefined ? {} : { 'content-type': 'application/json' }),\n accept: 'application/json',\n ...(session === undefined ? {} : { 'x-portal-session': session }),\n },\n /* v8 ignore next -- defensive: no caller sends a request without a body */\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n signal: AbortSignal.timeout(VIPPS_TIMEOUT_MS),\n });\n if (response.status === 204) return undefined;\n const text = await response.text();\n let parsed: unknown;\n try {\n parsed = text ? JSON.parse(text) : undefined;\n } catch {\n parsed = undefined;\n }\n if (!response.ok) {\n const code = vippsErrorCode(parsed);\n throw new MedalApiError(\n response.status,\n code,\n `Medal answered ${response.status} ${code} from ${path}`,\n // Not the engine's sentence (see above) — only whether it was one of\n // the two «no such person / resource» 404s, which is the one decision\n // that needs it: a 404 that is neither is a route this Medal lacks\n // (`isMissingRoute`).\n { domainNotFound: isDomainNotFound(field(field(parsed, 'error'), 'message')) }\n );\n }\n const data = field(parsed, 'data');\n if (data === undefined) throw malformed(path);\n return data;\n }\n\n /** Run `call`, scrubbing every secret in `secrets` out of whatever it throws. */\n async function withVippsSecrets<T>(secrets: () => Secret[], call: () => Promise<T>): Promise<T> {\n try {\n return await call();\n } catch (error) {\n let scrubbed = error;\n for (const secret of secrets()) scrubbed = redactSecret(scrubbed, secret);\n throw scrubbed;\n }\n }\n\n function apiKeySecret(): Secret[] {\n try {\n return [{ value: medal.requireMedalConfig().key, label: '<key>' }];\n } catch {\n return [];\n }\n }\n\n /** The start route's three decisions, by code (and 429 by status); anything else is rethrown. */\n function mapStartError(error: unknown): never {\n if (error instanceof MedalApiError) {\n if (error.code === 'VIPPS_NOT_CONFIGURED') throw new PortalVippsUnavailableError();\n if (error.code === 'INVALID_RETURN_URL') throw new PortalValidationError(error.message);\n if (error.status === 429) throw new PortalThrottledError();\n }\n throw error;\n }\n\n /**\n * Ask Medal where to send the parent to log in with Vipps.\n *\n * `returnUrl` is the absolute address of this site's `/min-side/vipps` route,\n * which is where Vipps — via Medal — sends the browser back with either a\n * grant or a reason. Medal checks it against the workspace's site and answers\n * 400 `INVALID_RETURN_URL` for one it does not recognise; that surfaces as\n * `PortalValidationError` because it is a configuration mistake on one side\n * or the other, not something the parent did. 503 `VIPPS_NOT_CONFIGURED`\n * means the workspace has no Vipps credentials and becomes\n * `PortalVippsUnavailableError`, which the login page turns into «use e-mail\n * instead». The address Medal hands back is checked to be HTTPS before anyone\n * is redirected to it.\n */\n async function startVippsLogin({\n returnUrl,\n browserBinding,\n locale = 'no',\n }: {\n returnUrl: string;\n /**\n * SP10: the value this site keeps in the browser-binding cookie, so a pending link\n * Medal offers can only be confirmed from this browser (`vipps-link.ts`).\n * A Medal without it ignores the key.\n */\n browserBinding?: string;\n /** The language of the code e-mail a conflicted login sends. */\n locale?: 'no' | 'en';\n }): Promise<{ authorizeUrl: string }> {\n const path = '/api/v1/portal/vipps/start';\n const secrets = (): Secret[] => [\n ...(browserBinding ? [{ value: browserBinding, label: '<binding>' }] : []),\n ...apiKeySecret(),\n ];\n return withVippsSecrets(secrets, async () => {\n const data = await postVipps(path, {\n return_url: returnUrl,\n ...(browserBinding ? { browser_binding: browserBinding } : {}),\n locale,\n }).catch(mapStartError);\n const authorizeUrl = field(data, 'authorize_url');\n if (typeof authorizeUrl !== 'string' || !authorizeUrl.startsWith('https://')) {\n throw malformed(path);\n }\n return { authorizeUrl };\n });\n }\n\n /**\n * Swap the grant Vipps sent the parent back with for a session. `null` when\n * Medal does not know it (404 `GRANT_NOT_FOUND` — used already, expired, or\n * minted for another workspace; the three are not distinguished, and none is\n * a failure) and the token plus expiry otherwise, in the same shape\n * `verifyLogin` returns so the route that receives it writes the cookie the\n * same way.\n *\n * The grant and, once known, the token are scrubbed from anything thrown:\n * the grant is in the request body, the token in the response, and a `fetch`\n * failure or a serialiser can quote either.\n */\n async function exchangeVippsGrant(\n grant: string\n ): Promise<{ sessionToken: string; expiresAt: number } | null> {\n const path = '/api/v1/portal/vipps/exchange';\n let token = '';\n const secrets = (): Secret[] => [\n { value: grant, label: '<grant>' },\n { value: token, label: REDACTED },\n ...apiKeySecret(),\n ];\n return withVippsSecrets(secrets, async () => {\n let data: unknown;\n try {\n data = unwrap(await quickPortal().login.vipps.exchange({ grant }), path);\n } catch (error) {\n if (error instanceof MedalApiError && error.code === 'GRANT_NOT_FOUND') return null;\n if (isThrottled(error)) throw new PortalThrottledError();\n throw error;\n }\n const sessionToken = field(data, 'session_token');\n const expiresAt = field(data, 'expires_at');\n if (typeof sessionToken === 'string') token = sessionToken;\n if (token === '' || typeof expiresAt !== 'number') throw malformed(path);\n return { sessionToken: token, expiresAt };\n });\n }\n\n /*\n * ---------------------------------------------------------------------------\n * Children as persons (SP10)\n *\n * `POST /api/v1/portal/me/persons`, `PATCH` and `DELETE /…/persons/{id}` edit\n * one child in place, so the id — which a booking carries as\n * `booked_for_person_id` — survives a rename. `create` and `remove` go\n * through the SDK's `portal.persons.{create,remove}` (1.12), which put the\n * session in `x-portal-session` and send each exactly once, on `quickPortal`\n * so the 15 s budget holds. `update` stays on the raw `callMedal` PATCH: the\n * SDK retries `persons.update` on 429/5xx (each attempt with its own\n * deadline) and takes no per-call `retry: false`, so a throttle would no\n * longer surface at once as `PortalThrottledError` and an unreachable Medal\n * would hold the editor for three budgets instead of one.\n *\n * FEATURE-DETECTED, because this site must keep working against a Medal that\n * does not have them: a 404 that is not the engine's own «Person not found» /\n * «Resource not found» is a route that does not exist (today's Medal answers\n * Convex's plain «No matching routes found»), and each function then falls\n * back to what the editor did before — the whole family list through\n * `PATCH /me`, which matches on name and year and so also keeps the id for a\n * child whose name and year did not change. Birth month, notes and preferred\n * stylist have nowhere to go in that fallback and are dropped; the answer\n * says so (`fallback: true`) and the editor only offers those fields where\n * `personDetails` says Medal can keep them.\n * ---------------------------------------------------------------------------\n */\n\n const PERSONS_PATH = '/api/v1/portal/me/persons';\n\n /**\n * A 404 that is not the engine's own «Person not found» / «Resource not\n * found». The SDK keeps the engine's sentence as `message`; the raw\n * `callMedal` drops it and records the verdict in `details.domainNotFound`.\n */\n function isMissingRoute(error: unknown): boolean {\n if (!(error instanceof MedalApiError) || error.status !== 404) return false;\n const details = error.details as { domainNotFound?: unknown } | undefined;\n const domain = details?.domainNotFound === true || isDomainNotFound(error.message);\n return !(error.code === 'NOT_FOUND' && domain);\n }\n\n /** The editor's copy for what the engine refused. Never the engine's own\n * sentence: it is written for integrators and names wire fields. */\n function mapPersonError(error: unknown): never {\n if (error instanceof MedalApiError) {\n if (error.code === 'CONFLICT') {\n throw new PortalValidationError(messages.duplicateChild);\n }\n if (error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_INPUT') {\n throw new PortalValidationError(messages.invalidChild);\n }\n if (error.status === 404) {\n throw new PortalValidationError(messages.childNotFound);\n }\n if (error.status === 429) throw new PortalThrottledError();\n }\n throw error;\n }\n\n function personPatch(input: Partial<PortalPersonInput>): PortalPersonPatch {\n const body: PortalPersonPatch = {};\n if (input.name !== undefined) body.name = input.name;\n if (input.birthYear !== undefined) body.birth_year = input.birthYear;\n if (input.birthMonth !== undefined) body.birth_month = input.birthMonth;\n if (input.notes !== undefined) body.notes = input.notes;\n if (input.preferredResourceId !== undefined) {\n body.preferred_resource_id = input.preferredResourceId;\n }\n return body;\n }\n\n /**\n * On create an absent key and `null` mean the same — nothing — and the\n * create schema is STRICT about nulls it does not take.\n */\n function personCreate(input: PortalPersonInput): PortalPersonCreateInput {\n const body: PortalPersonCreateInput = { name: input.name, birth_year: input.birthYear };\n if (input.birthMonth != null) body.birth_month = input.birthMonth;\n if (input.notes != null) body.notes = input.notes;\n if (input.preferredResourceId != null) body.preferred_resource_id = input.preferredResourceId;\n return body;\n }\n\n /** Run one persons call with the session scrubbed and a dead one mapped. */\n async function withPersonsCall<T>(session: string, call: () => Promise<T>): Promise<T> {\n return withVippsSecrets(\n () => [{ value: session, label: REDACTED }, ...apiKeySecret()],\n async () => {\n try {\n return await call();\n } catch (error) {\n if (isDeadSession(error)) throw new PortalSessionExpiredError();\n throw error;\n }\n }\n );\n }\n\n function wireFamily(profile: PortalProfileDto) {\n return profile.family.map((member) => ({ name: member.name, birth_year: member.birthYear }));\n }\n\n /** The id the refreshed profile gives the child with this name and year. */\n function idOf(profile: PortalProfileDto, name: string, birthYear: number): string | null {\n const matches = profile.family.filter(\n (member) => member.name === name && member.birthYear === birthYear\n );\n return matches.length === 1 ? matches[0].personId : null;\n }\n\n /** Where a child sits in the list the fallback rewrites: by id, else by index. */\n function familyIndex(profile: PortalProfileDto, target: PersonTarget): number {\n if (target.personId) {\n return profile.family.findIndex((member) => member.personId === target.personId);\n }\n const index = target.index ?? -1;\n return index >= 0 && index < profile.family.length ? index : -1;\n }\n\n async function createPerson(\n session: string,\n input: PortalPersonInput\n ): Promise<PortalPersonResult> {\n const created = await withPersonsCall(session, async () => {\n try {\n const data = unwrap(\n await quickPortal().persons.create(session, personCreate(input)),\n PERSONS_PATH\n );\n const personId = field(data, 'person_id');\n return { personId: typeof personId === 'string' ? personId : null };\n } catch (error) {\n if (isMissingRoute(error)) return null;\n return mapPersonError(error);\n }\n });\n if (created !== null) {\n return { profile: await getMe(session), personId: created.personId, fallback: false };\n }\n const current = await getMe(session);\n const profile = await updateMe(session, {\n family: [...wireFamily(current), { name: input.name, birth_year: input.birthYear }],\n });\n return { profile, personId: idOf(profile, input.name, input.birthYear), fallback: true };\n }\n\n async function updatePerson(\n session: string,\n target: PersonTarget,\n patch: Partial<PortalPersonInput>\n ): Promise<PortalPersonResult> {\n const personId = target.personId ?? null;\n if (personId !== null) {\n const done = await withPersonsCall(session, async () => {\n try {\n await callMedal(`${PERSONS_PATH}/${encodeURIComponent(personId)}`, {\n method: 'PATCH',\n body: personPatch(patch),\n session,\n });\n return true;\n } catch (error) {\n if (isMissingRoute(error)) return false;\n return mapPersonError(error);\n }\n });\n if (done) return { profile: await getMe(session), personId, fallback: false };\n }\n const current = await getMe(session);\n const index = familyIndex(current, target);\n if (index === -1) mapPersonError(new MedalApiError(404, 'NOT_FOUND', 'person not in family'));\n const family = wireFamily(current);\n const name = patch.name ?? family[index].name;\n const birthYear = patch.birthYear ?? family[index].birth_year;\n family[index] = { name, birth_year: birthYear };\n const profile = await updateMe(session, { family });\n return { profile, personId: idOf(profile, name, birthYear), fallback: true };\n }\n\n async function removePerson(session: string, target: PersonTarget): Promise<PortalPersonResult> {\n const personId = target.personId ?? null;\n if (personId !== null) {\n const done = await withPersonsCall(session, async () => {\n try {\n await quickPortal().persons.remove(session, personId);\n return true;\n } catch (error) {\n if (isMissingRoute(error)) return false;\n return mapPersonError(error);\n }\n });\n if (done) return { profile: await getMe(session), personId, fallback: false };\n }\n const current = await getMe(session);\n const index = familyIndex(current, target);\n if (index === -1) mapPersonError(new MedalApiError(404, 'NOT_FOUND', 'person not in family'));\n const profile = await updateMe(session, {\n family: wireFamily(current).filter((_, position) => position !== index),\n });\n return { profile, personId, fallback: true };\n }\n\n /**\n * Confirm a conflicted Vipps login with the six-digit code Medal e-mailed to\n * the address on file (SP10 A5): `POST /api/v1/portal/vipps/link/verify`.\n *\n * `null` for every refusal Medal folds into 401 `PORTAL_CODE_INVALID` — a\n * wrong or burned code, an unknown, expired or spent link, a browser binding\n * that is not the one the login started with — which is one answer for the\n * form. 409 is `PortalVippsConflictError`, 429 `PortalThrottledError`.\n *\n * The link, the code, the binding and the session it buys are all scrubbed\n * from anything thrown, with the API key. Through the SDK's\n * `portal.login.vipps.verifyLink`, which sends it exactly once: the right code\n * consumes the link, so a retry would misreport a completed login.\n */\n async function verifyVippsLink({\n link,\n code,\n browserBinding,\n }: {\n link: string;\n code: string;\n /** Mandatory: Medal refuses a verify without the binding the login started with. */\n browserBinding: string;\n }): Promise<{ sessionToken: string; expiresAt: number } | null> {\n const path = '/api/v1/portal/vipps/link/verify';\n let token = '';\n const secrets = (): Secret[] => [\n { value: link, label: '<link>' },\n { value: code, label: '<code>' },\n { value: browserBinding, label: '<binding>' },\n { value: token, label: REDACTED },\n ...apiKeySecret(),\n ];\n return withVippsSecrets(secrets, async () => {\n let data: unknown;\n try {\n data = unwrap(\n await quickPortal().login.vipps.verifyLink({\n link,\n code,\n browser_binding: browserBinding,\n }),\n path\n );\n } catch (error) {\n return mapLinkError(error);\n }\n const sessionToken = field(data, 'session_token');\n const expiresAt = field(data, 'expires_at');\n if (typeof sessionToken === 'string') token = sessionToken;\n if (token === '' || typeof expiresAt !== 'number') throw malformed(path);\n return { sessionToken: token, expiresAt };\n });\n }\n\n /** The link verify's refusals: `null` for «wrong or expired», the two errors, else rethrown. */\n function mapLinkError(error: unknown): null {\n if (error instanceof MedalApiError) {\n if (error.status === 401 && error.code === 'PORTAL_CODE_INVALID') return null;\n if (error.status === 409 && error.code === 'VIPPS_IDENTITY_CONFLICT') {\n throw new PortalVippsConflictError();\n }\n if (error.status === 429) throw new PortalThrottledError();\n }\n throw error;\n }\n\n /*\n * ---------------------------------------------------------------------------\n * «Koble til Vipps» from Min side → Profil\n *\n * A parent who logged in with e-mail links their Vipps account to the profile\n * they are already in: `POST /api/v1/portal/me/vipps/link/start` (session)\n * answers with the address to send them to, and Medal's callback either says\n * `?vipps=linked` / `?vipps=link_conflict` outright or hands back a one-time\n * link GRANT that this site spends with the same session through\n * `POST /api/v1/portal/me/vipps/link/complete`. Both carry the browser\n * binding from the `__Host-` binding cookie (`core/portal/vipps-link.ts`), so a grant lifted\n * out of one browser's address bar is worthless in another's.\n *\n * FEATURE-DETECTED like the persons routes: a start that answers with a\n * missing route (`isMissingRoute`) is `null`, and the Profil row hides. Raw\n * `callMedal`, because no SDK has these yet (1.12 included).\n * TODO(sdk): use `portal.vipps.link.{start,complete}` once the SDK has them.\n * ---------------------------------------------------------------------------\n */\n\n const VIPPS_LINK_PATH = '/api/v1/portal/me/vipps/link';\n\n /** The session, the binding, the grant when there is one, and the key — scrubbed; a dead session mapped. */\n async function withVippsLinkCall<T>(\n session: string,\n extra: Secret[],\n call: () => Promise<T>\n ): Promise<T> {\n return withVippsSecrets(\n () => [{ value: session, label: REDACTED }, ...extra, ...apiKeySecret()],\n async () => {\n try {\n return await call();\n } catch (error) {\n if (isDeadSession(error)) throw new PortalSessionExpiredError();\n throw error;\n }\n }\n );\n }\n\n function isVippsNotConfigured(error: unknown): boolean {\n return error instanceof MedalApiError && error.code === 'VIPPS_NOT_CONFIGURED';\n }\n\n /**\n * Ask Medal where to send a logged-in parent to link Vipps. `null` against a\n * Medal without the route AND for a workspace without Vipps (503\n * `VIPPS_NOT_CONFIGURED`) — both hide the row, with nothing to say.\n * `PortalThrottledError` for a 429, `PortalSessionExpiredError` for a dead\n * session; the address is checked to be HTTPS.\n */\n async function startVippsLink(\n session: string,\n {\n returnUrl,\n browserBinding,\n locale = 'no',\n }: { returnUrl: string; browserBinding: string; locale?: 'no' | 'en' }\n ): Promise<{ authorizeUrl: string } | null> {\n const path = `${VIPPS_LINK_PATH}/start`;\n return withVippsLinkCall(session, [{ value: browserBinding, label: '<binding>' }], async () => {\n let data: unknown;\n try {\n data = await callMedal(path, {\n method: 'POST',\n body: { return_url: returnUrl, browser_binding: browserBinding, locale },\n session,\n });\n } catch (error) {\n // No route, or a workspace with no Vipps (503 `VIPPS_NOT_CONFIGURED`):\n // either way there is nothing to link, and the Profil row hides.\n if (isMissingRoute(error) || isVippsNotConfigured(error)) return null;\n if (isDeadSession(error)) throw error;\n return mapStartError(error);\n }\n const authorizeUrl = field(data, 'authorize_url');\n if (typeof authorizeUrl !== 'string' || !authorizeUrl.startsWith('https://')) {\n throw malformed(path);\n }\n return { authorizeUrl };\n });\n }\n\n /**\n * Spend the link grant Medal's callback handed back (medal-monorepo #5592).\n *\n * 200 `{ data: { status: 'linked', already_linked } }` is `linked` — a 2xx\n * without that status is malformed, not a success. 409\n * `VIPPS_IDENTITY_CONFLICT` is `conflict` (this Vipps account belongs to\n * another profile). 404 `LINK_GRANT_NOT_FOUND` — an unknown, spent, expired\n * or malformed grant, and (since the #5592 security review, which BURNS the\n * grant on it) a wrong binding or another customer's session — is `invalid`,\n * as are a 400 and a non-session 401. Every one of the three is final for\n * the grant. A dead session and a throttle are the seam's usual errors.\n */\n async function completeVippsLink(\n session: string,\n { grant, browserBinding }: { grant: string; browserBinding: string }\n ): Promise<VippsLinkCompletion> {\n const path = `${VIPPS_LINK_PATH}/complete`;\n const secrets: Secret[] = [\n { value: grant, label: '<grant>' },\n { value: browserBinding, label: '<binding>' },\n ];\n return withVippsLinkCall(session, secrets, async () => {\n try {\n const data = await callMedal(path, {\n method: 'POST',\n body: { grant, browser_binding: browserBinding },\n session,\n });\n if (field(data, 'status') !== 'linked') throw malformed(path);\n return 'linked';\n } catch (error) {\n return mapCompleteError(error);\n }\n });\n }\n\n /** The complete route's refusals, as `VippsLinkCompletion`; anything else is rethrown. */\n function mapCompleteError(error: unknown): VippsLinkCompletion {\n if (!(error instanceof MedalApiError)) throw error;\n // Read before the dead-session guard, which narrows `error` away.\n const { status } = error;\n // Left for `withVippsLinkCall`, which maps it to `PortalSessionExpiredError`.\n if (isDeadSession(error)) throw error;\n if (status === 409) return 'conflict';\n if (status === 400 || status === 401 || status === 404) return 'invalid';\n if (status === 429) throw new PortalThrottledError();\n throw error;\n }\n\n return {\n startLogin,\n verifyLogin,\n logout,\n getMe,\n updateMe,\n getMyBookings,\n exportMyData,\n deleteMe,\n startVippsLogin,\n exchangeVippsGrant,\n createPerson,\n updatePerson,\n removePerson,\n verifyVippsLink,\n startVippsLink,\n completeVippsLink,\n };\n}\n\nexport type PortalSeam = ReturnType<typeof createPortalSeam>;\n"],"mappings":"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"}
@@ -0,0 +1,38 @@
1
+ //#region src/next/portal/session.d.ts
2
+ export declare const RENEWED_SESSION_LIFETIME_MS: number;
3
+ export interface PortalSession {
4
+ readonly PORTAL_SESSION_COOKIE: string;
5
+ /**
6
+ * The session token the request carries, or `null` — for no cookie, a
7
+ * cookie that is not the shape Medal issues, and an empty one alike. A
8
+ * caller has one decision to make about any of them: treat the visitor as
9
+ * logged out.
10
+ */
11
+ readPortalSession(): Promise<string | null>;
12
+ /**
13
+ * Store a freshly issued session token for the life of the session
14
+ * (`expiresAt`, Medal's own expiry in epoch milliseconds).
15
+ *
16
+ * `httpOnly`; `secure` unconditionally (browsers treat `localhost` as a
17
+ * secure context); `sameSite: 'lax'` because the parent arrives from a
18
+ * link in an e-mail or SMS — a top-level cross-site navigation `strict`
19
+ * would withhold the cookie on; `path: '/'`. A malformed token is a
20
+ * programming error and is thrown rather than stored.
21
+ */
22
+ writePortalSession(token: string, expiresAt: number): Promise<void>;
23
+ /**
24
+ * Rewrite the cookie after Medal accepted `token`, so an active parent is
25
+ * not logged out 30 days after their FIRST login. Only after a call that
26
+ * SUCCEEDED with this token.
27
+ */
28
+ renewPortalSession(token: string, now?: number): Promise<void>;
29
+ /**
30
+ * Log the parent out on this browser — only the cookie; revoking the
31
+ * session upstream is the caller's job, done BEFORE this. The path is
32
+ * spelled out because the browser matches an expiring cookie on name AND
33
+ * path.
34
+ */
35
+ clearPortalSession(): Promise<void>;
36
+ }
37
+ //#endregion
38
+ //# sourceMappingURL=session.d.mts.map
@@ -0,0 +1,40 @@
1
+ import { isPortalSessionToken } from "../../core/portal/session-cookie.mjs";
2
+ //#region src/next/portal/session.ts
3
+ const RENEWED_SESSION_LIFETIME_MS = 25884e5;
4
+ function createPortalSession(config, cookies) {
5
+ const PORTAL_SESSION_COOKIE = config.portal.cookieName;
6
+ async function readPortalSession() {
7
+ const value = (await cookies()).get(PORTAL_SESSION_COOKIE)?.value;
8
+ return isPortalSessionToken(value) ? value : null;
9
+ }
10
+ async function writePortalSession(token, expiresAt) {
11
+ if (!isPortalSessionToken(token)) throw new Error("invalid portal session token");
12
+ (await cookies()).set(PORTAL_SESSION_COOKIE, token, {
13
+ httpOnly: true,
14
+ secure: true,
15
+ sameSite: "lax",
16
+ path: "/",
17
+ expires: new Date(expiresAt)
18
+ });
19
+ }
20
+ async function renewPortalSession(token, now = Date.now()) {
21
+ await writePortalSession(token, now + RENEWED_SESSION_LIFETIME_MS);
22
+ }
23
+ async function clearPortalSession() {
24
+ (await cookies()).delete({
25
+ name: PORTAL_SESSION_COOKIE,
26
+ path: "/"
27
+ });
28
+ }
29
+ return {
30
+ PORTAL_SESSION_COOKIE,
31
+ readPortalSession,
32
+ writePortalSession,
33
+ renewPortalSession,
34
+ clearPortalSession
35
+ };
36
+ }
37
+ //#endregion
38
+ export { RENEWED_SESSION_LIFETIME_MS, createPortalSession };
39
+
40
+ //# sourceMappingURL=session.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.mjs","names":[],"sources":["../../../src/next/portal/session.ts"],"sourcesContent":["/**\n * The portal's session cookie — and, on the site side, the whole security\n * boundary of the customer portal.\n *\n * What the cookie holds is an OPAQUE session token that Medal minted when the\n * parent proved they owned their phone number or e-mail address. It is a\n * bearer credential for one contact: whoever presents it to Medal is that\n * parent, and sees every booking, every child and every manage link they\n * have. The site never decodes it, never derives anything from it, and never\n * stores it anywhere but here. Three rules follow, and every route that\n * touches the portal is written to keep them:\n *\n * 1. **It never reaches the browser's JavaScript.** `httpOnly`, always, and\n * the value is never echoed into a prop, a `data-` attribute, a URL or a\n * JSON body — the pages that need it read it here on the server and forward\n * it to Medal in a header.\n * 2. **It is never logged.** Not on a warning, not on a 502, not on a failed\n * read. A log line is copied to places a cookie is not.\n * 3. **It is never forwarded unread.** `readPortalSession` hands back a value\n * only when it has the exact shape Medal issues, so a tampered, truncated\n * or foreign cookie under the same name is a `null` rather than a request\n * to Medal carrying attacker-chosen bytes in an `Authorization` header.\n *\n * The cookie's name is `config.portal.cookieName`, so a site moving onto the\n * package keeps its existing sessions.\n */\n\nimport type { BookingConfig } from '../../core/config';\nimport type { CookieSource } from '../../core/portal/cookies';\nimport { isPortalSessionToken } from '../../core/portal/session-cookie';\n\n/**\n * How long a session Medal has just accepted is still good for, at least.\n *\n * Medal slides portal sessions on use: when the session was last seen more\n * than the touch interval (≈ 1 h) ago, it sets `expiresAt = now + 30 days`.\n * So right after any call it answered, the session expires no earlier than\n * now + 30 d − 1 h. The SDK does not return that expiry, so the site writes\n * the floor.\n */\nconst MEDAL_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;\nconst MEDAL_SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000;\nexport const RENEWED_SESSION_LIFETIME_MS = MEDAL_SESSION_TTL_MS - MEDAL_SESSION_TOUCH_INTERVAL_MS;\n\nexport interface PortalSession {\n readonly PORTAL_SESSION_COOKIE: string;\n /**\n * The session token the request carries, or `null` — for no cookie, a\n * cookie that is not the shape Medal issues, and an empty one alike. A\n * caller has one decision to make about any of them: treat the visitor as\n * logged out.\n */\n readPortalSession(): Promise<string | null>;\n /**\n * Store a freshly issued session token for the life of the session\n * (`expiresAt`, Medal's own expiry in epoch milliseconds).\n *\n * `httpOnly`; `secure` unconditionally (browsers treat `localhost` as a\n * secure context); `sameSite: 'lax'` because the parent arrives from a\n * link in an e-mail or SMS — a top-level cross-site navigation `strict`\n * would withhold the cookie on; `path: '/'`. A malformed token is a\n * programming error and is thrown rather than stored.\n */\n writePortalSession(token: string, expiresAt: number): Promise<void>;\n /**\n * Rewrite the cookie after Medal accepted `token`, so an active parent is\n * not logged out 30 days after their FIRST login. Only after a call that\n * SUCCEEDED with this token.\n */\n renewPortalSession(token: string, now?: number): Promise<void>;\n /**\n * Log the parent out on this browser — only the cookie; revoking the\n * session upstream is the caller's job, done BEFORE this. The path is\n * spelled out because the browser matches an expiring cookie on name AND\n * path.\n */\n clearPortalSession(): Promise<void>;\n}\n\nexport function createPortalSession(\n config: Pick<BookingConfig, 'portal'>,\n cookies: CookieSource\n): PortalSession {\n const PORTAL_SESSION_COOKIE = config.portal.cookieName;\n\n async function readPortalSession(): Promise<string | null> {\n const jar = await cookies();\n const value = jar.get(PORTAL_SESSION_COOKIE)?.value;\n return isPortalSessionToken(value) ? value : null;\n }\n\n async function writePortalSession(token: string, expiresAt: number): Promise<void> {\n if (!isPortalSessionToken(token)) {\n throw new Error('invalid portal session token');\n }\n const jar = await cookies();\n jar.set(PORTAL_SESSION_COOKIE, token, {\n httpOnly: true,\n secure: true,\n sameSite: 'lax',\n path: '/',\n expires: new Date(expiresAt),\n });\n }\n\n async function renewPortalSession(token: string, now: number = Date.now()): Promise<void> {\n await writePortalSession(token, now + RENEWED_SESSION_LIFETIME_MS);\n }\n\n async function clearPortalSession(): Promise<void> {\n const jar = await cookies();\n jar.delete({ name: PORTAL_SESSION_COOKIE, path: '/' });\n }\n\n return {\n PORTAL_SESSION_COOKIE,\n readPortalSession,\n writePortalSession,\n renewPortalSession,\n clearPortalSession,\n };\n}\n"],"mappings":";;AA0CA,MAAa,8BAA8B;AAqC3C,SAAgB,oBACd,QACA,SACe;CACf,MAAM,wBAAwB,OAAO,OAAO;CAE5C,eAAe,oBAA4C;EAEzD,MAAM,SAAQ,MADI,QAAQ,EAAA,CACR,IAAI,qBAAqB,CAAC,EAAE;EAC9C,OAAO,qBAAqB,KAAK,IAAI,QAAQ;CAC/C;CAEA,eAAe,mBAAmB,OAAe,WAAkC;EACjF,IAAI,CAAC,qBAAqB,KAAK,GAC7B,MAAM,IAAI,MAAM,8BAA8B;EAGhD,CAAA,MADkB,QAAQ,EAAA,CACtB,IAAI,uBAAuB,OAAO;GACpC,UAAU;GACV,QAAQ;GACR,UAAU;GACV,MAAM;GACN,SAAS,IAAI,KAAK,SAAS;EAC7B,CAAC;CACH;CAEA,eAAe,mBAAmB,OAAe,MAAc,KAAK,IAAI,GAAkB;EACxF,MAAM,mBAAmB,OAAO,MAAM,2BAA2B;CACnE;CAEA,eAAe,qBAAoC;EAEjD,CAAA,MADkB,QAAQ,EAAA,CACtB,OAAO;GAAE,MAAM;GAAuB,MAAM;EAAI,CAAC;CACvD;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA;CACF;AACF"}
@@ -0,0 +1,14 @@
1
+ //#region src/next/portal/vipps-flash.d.ts
2
+ export declare const VIPPS_LINK_FLASHES: readonly ['linked', 'link_conflict', 'link_failed'];
3
+ export type VippsLinkFlash = (typeof VIPPS_LINK_FLASHES)[number];
4
+ /** A cookie value as a flash, or `null` for anything else. */
5
+ export declare function vippsLinkFlash(value: unknown): VippsLinkFlash | null;
6
+ export interface VippsFlash {
7
+ readonly VIPPS_FLASH_COOKIE: string;
8
+ writeVippsLinkFlash(flash: VippsLinkFlash): Promise<void>;
9
+ /** The flash this request carries, if it is one of ours. */
10
+ readVippsLinkFlash(): Promise<VippsLinkFlash | null>;
11
+ clearVippsLinkFlash(): Promise<void>;
12
+ }
13
+ //#endregion
14
+ //# sourceMappingURL=vipps-flash.d.mts.map
@@ -0,0 +1,41 @@
1
+ //#region src/next/portal/vipps-flash.ts
2
+ const VIPPS_LINK_FLASHES = [
3
+ "linked",
4
+ "link_conflict",
5
+ "link_failed"
6
+ ];
7
+ /** A cookie value as a flash, or `null` for anything else. */
8
+ function vippsLinkFlash(value) {
9
+ return typeof value === "string" && VIPPS_LINK_FLASHES.includes(value) ? value : null;
10
+ }
11
+ function createVippsFlash(options, cookies) {
12
+ const VIPPS_FLASH_COOKIE = options.cookieName;
13
+ const ATTRIBUTES = {
14
+ httpOnly: true,
15
+ secure: true,
16
+ sameSite: "lax",
17
+ path: options.path
18
+ };
19
+ return {
20
+ VIPPS_FLASH_COOKIE,
21
+ async writeVippsLinkFlash(flash) {
22
+ (await cookies()).set(VIPPS_FLASH_COOKIE, flash, {
23
+ ...ATTRIBUTES,
24
+ maxAge: 60
25
+ });
26
+ },
27
+ async readVippsLinkFlash() {
28
+ return vippsLinkFlash((await cookies()).get(VIPPS_FLASH_COOKIE)?.value);
29
+ },
30
+ async clearVippsLinkFlash() {
31
+ (await cookies()).delete({
32
+ name: VIPPS_FLASH_COOKIE,
33
+ ...ATTRIBUTES
34
+ });
35
+ }
36
+ };
37
+ }
38
+ //#endregion
39
+ export { VIPPS_LINK_FLASHES, createVippsFlash, vippsLinkFlash };
40
+
41
+ //# sourceMappingURL=vipps-flash.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vipps-flash.mjs","names":[],"sources":["../../../src/next/portal/vipps-flash.ts"],"sourcesContent":["/**\n * The one-shot flash that carries a «link Vipps» outcome from the link return\n * route to the portal's profile section — «linked», the conflict sentence, or\n * the failure sentence.\n *\n * A cookie and not a query value, for two reasons. A URL value is anyone's\n * to type, and the success message must mean «this site spent a link grant\n * and Medal said yes» — `linked` is only ever written after a successful\n * `completeVippsLink`. And the return route redirects to a bare portal URL,\n * so no outcome (and no `link_grant`) stays in the address bar or history.\n * The dashboard reads it on the server; the profile row spends it\n * (`DELETE <portalApi>/vipps/flash` — a page may not write a cookie while\n * rendering).\n *\n * `httpOnly` (no script can plant or read it), `secure`, `SameSite=Lax` (the\n * redirect that carries it is a same-site navigation), scoped to the portal\n * path, and sixty seconds long: enough for the redirect to land, short enough\n * that a missed clear cannot speak on tomorrow's visit.\n */\n\nimport type { CookieSource } from '../../core/portal/cookies';\n\nexport const VIPPS_LINK_FLASHES = ['linked', 'link_conflict', 'link_failed'] as const;\n\nexport type VippsLinkFlash = (typeof VIPPS_LINK_FLASHES)[number];\n\n/** A cookie value as a flash, or `null` for anything else. */\nexport function vippsLinkFlash(value: unknown): VippsLinkFlash | null {\n return typeof value === 'string' && (VIPPS_LINK_FLASHES as readonly string[]).includes(value)\n ? (value as VippsLinkFlash)\n : null;\n}\n\nexport interface VippsFlash {\n readonly VIPPS_FLASH_COOKIE: string;\n writeVippsLinkFlash(flash: VippsLinkFlash): Promise<void>;\n /** The flash this request carries, if it is one of ours. */\n readVippsLinkFlash(): Promise<VippsLinkFlash | null>;\n clearVippsLinkFlash(): Promise<void>;\n}\n\nexport function createVippsFlash(\n options: { cookieName: string; path: string },\n cookies: CookieSource\n): VippsFlash {\n const VIPPS_FLASH_COOKIE = options.cookieName;\n const ATTRIBUTES = {\n httpOnly: true,\n secure: true,\n sameSite: 'lax' as const,\n path: options.path,\n };\n return {\n VIPPS_FLASH_COOKIE,\n async writeVippsLinkFlash(flash) {\n (await cookies()).set(VIPPS_FLASH_COOKIE, flash, { ...ATTRIBUTES, maxAge: 60 });\n },\n async readVippsLinkFlash() {\n return vippsLinkFlash((await cookies()).get(VIPPS_FLASH_COOKIE)?.value);\n },\n async clearVippsLinkFlash() {\n (await cookies()).delete({ name: VIPPS_FLASH_COOKIE, ...ATTRIBUTES });\n },\n };\n}\n"],"mappings":";AAsBA,MAAa,qBAAqB;CAAC;CAAU;CAAiB;AAAa;;AAK3E,SAAgB,eAAe,OAAuC;CACpE,OAAO,OAAO,UAAU,YAAa,mBAAyC,SAAS,KAAK,IACvF,QACD;AACN;AAUA,SAAgB,iBACd,SACA,SACY;CACZ,MAAM,qBAAqB,QAAQ;CACnC,MAAM,aAAa;EACjB,UAAU;EACV,QAAQ;EACR,UAAU;EACV,MAAM,QAAQ;CAChB;CACA,OAAO;EACL;EACA,MAAM,oBAAoB,OAAO;GAC/B,CAAC,MAAM,QAAQ,EAAA,CAAG,IAAI,oBAAoB,OAAO;IAAE,GAAG;IAAY,QAAQ;GAAG,CAAC;EAChF;EACA,MAAM,qBAAqB;GACzB,OAAO,gBAAgB,MAAM,QAAQ,EAAA,CAAG,IAAI,kBAAkB,CAAC,EAAE,KAAK;EACxE;EACA,MAAM,sBAAsB;GAC1B,CAAC,MAAM,QAAQ,EAAA,CAAG,OAAO;IAAE,MAAM;IAAoB,GAAG;GAAW,CAAC;EACtE;CACF;AACF"}
@@ -0,0 +1,205 @@
1
+ import { BookingGuardian } from "../core/types.mjs";
2
+ import { PortalProfileDto } from "../core/portal/dto.mjs";
3
+ import { BookingRuntime } from "./runtime.mjs";
4
+ import { z } from "zod";
5
+ //#region src/next/portal.d.ts
6
+ export type SessionFailure = {
7
+ ok: false;
8
+ reason: 'session';
9
+ };
10
+ export type InvalidInput = {
11
+ ok: false;
12
+ reason: 'invalid';
13
+ message: string;
14
+ };
15
+ /** What the Vipps button hears back when it did NOT get a redirect. */
16
+ export type VippsStartState = {
17
+ ok: false;
18
+ reason: 'unavailable' | 'throttled';
19
+ } | null;
20
+ /** What «link Vipps» hears back when it did NOT get a redirect. */
21
+ export type VippsLinkStartState = {
22
+ ok: false;
23
+ /** `missing`: this Medal has no link route — the row hides itself. */
24
+ reason: 'missing' | 'session' | 'unavailable' | 'throttled';
25
+ } | null;
26
+ export type UpdateProfileResult = {
27
+ ok: true;
28
+ profile: PortalProfileDto;
29
+ } | SessionFailure | InvalidInput;
30
+ export type PersonActionResult = {
31
+ ok: true;
32
+ profile: PortalProfileDto;
33
+ personId: string | null;
34
+ fallback: boolean;
35
+ } | SessionFailure | InvalidInput;
36
+ export type VerifyLoginResult = {
37
+ ok: true;
38
+ guardian: BookingGuardian | null;
39
+ } | {
40
+ ok: false;
41
+ reason: 'invalid' | 'throttled' | 'unreachable';
42
+ };
43
+ /** The schemas the actions validate with, for an app's own action client. */
44
+ export declare function createPortalSchemas(rt: BookingRuntime): {
45
+ startLogin: z.ZodObject<{
46
+ email: z.ZodPipe<z.ZodString, z.ZodEmail>;
47
+ }, z.core.$strip>;
48
+ verifyLogin: z.ZodObject<{
49
+ email: z.ZodPipe<z.ZodString, z.ZodEmail>;
50
+ code: z.ZodString;
51
+ }, z.core.$strip>;
52
+ updateProfile: z.ZodObject<{
53
+ first_name: z.ZodOptional<z.ZodString>;
54
+ last_name: z.ZodOptional<z.ZodString>;
55
+ phone: z.ZodOptional<z.ZodUnion<readonly [z.ZodNull, z.ZodPipe<z.ZodString, z.ZodTransform<string | null, string>>]>>;
56
+ family: z.ZodOptional<z.ZodArray<z.ZodObject<{
57
+ name: z.ZodString;
58
+ birth_year: z.ZodNumber;
59
+ }, z.core.$strip>>>;
60
+ }, z.core.$strip>;
61
+ createPerson: z.ZodObject<{
62
+ person: z.ZodObject<{
63
+ name: z.ZodString;
64
+ birth_year: z.ZodNumber;
65
+ birth_month: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
66
+ notes: z.ZodOptional<z.ZodNullable<z.ZodString>>;
67
+ preferred_resource_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
68
+ }, z.core.$strip>;
69
+ }, z.core.$strip>;
70
+ updatePerson: z.ZodObject<{
71
+ person_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
72
+ index: z.ZodOptional<z.ZodNumber>;
73
+ person: z.ZodObject<{
74
+ name: z.ZodString;
75
+ birth_year: z.ZodNumber;
76
+ birth_month: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
77
+ notes: z.ZodOptional<z.ZodNullable<z.ZodString>>;
78
+ preferred_resource_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
79
+ }, z.core.$strip>;
80
+ }, z.core.$strip>;
81
+ savePerson: z.ZodObject<{
82
+ person_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
83
+ index: z.ZodOptional<z.ZodNumber>;
84
+ person: z.ZodObject<{
85
+ name: z.ZodString;
86
+ birth_year: z.ZodNumber;
87
+ birth_month: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
88
+ notes: z.ZodOptional<z.ZodNullable<z.ZodString>>;
89
+ preferred_resource_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
90
+ }, z.core.$strip>;
91
+ create: z.ZodBoolean;
92
+ }, z.core.$strip>;
93
+ removePerson: z.ZodObject<{
94
+ person_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
95
+ index: z.ZodOptional<z.ZodNumber>;
96
+ }, z.core.$strip>;
97
+ setMarketingConsent: z.ZodObject<{
98
+ granted: z.ZodBoolean;
99
+ }, z.core.$strip>;
100
+ deleteMe: z.ZodObject<{
101
+ confirm: z.ZodLiteral<string>;
102
+ }, z.core.$strip>;
103
+ };
104
+ export type PortalSchemas = ReturnType<typeof createPortalSchemas>;
105
+ export declare function createPortalActions(rt: BookingRuntime): {
106
+ /**
107
+ * Ask for a code. Always «sent» — including when Medal is throttling this
108
+ * address: a different answer would let anyone confirm which parents are
109
+ * customers by asking a few times.
110
+ */
111
+ startLogin(input: unknown): Promise<{
112
+ status: 'sent';
113
+ } | InvalidInput>;
114
+ /**
115
+ * Exchange the code for the cookie. Prefer the `login/verify` ROUTE from
116
+ * the browser: an action that sets a cookie makes Next re-render the page
117
+ * it was called from. This is for a page that wants that.
118
+ */
119
+ verifyLogin(input: unknown): Promise<VerifyLoginResult>;
120
+ /**
121
+ * Send the parent to Vipps — the `action` of a `<form>` driven by
122
+ * `useActionState`, so `(previous, formData)` and the answer IS the next
123
+ * state; the success path never returns (`redirect()` throws, outside
124
+ * the `try`).
125
+ *
126
+ * The return URL is the Vipps return route on the origin the request
127
+ * arrived on (the canonical `baseUrl` when it names none): a preview host
128
+ * must not send its parents back to production. `next` is where a login
129
+ * that did not start on the portal should land; it is validated and kept
130
+ * in a cookie, written on EVERY attempt (`null` included) so a leftover
131
+ * destination cannot capture an ordinary login.
132
+ */
133
+ startVipps(_previous: VippsStartState, formData?: FormData): Promise<VippsStartState>;
134
+ /**
135
+ * «Link Vipps» on the profile: send a LOGGED-IN parent to Vipps to link it
136
+ * to the profile they are in. The same shape as `startVipps`, with the
137
+ * session as the difference. A Medal without the route is `missing`.
138
+ */
139
+ startVippsLink(_previous: VippsLinkStartState, _formData?: FormData): Promise<VippsLinkStartState>;
140
+ /**
141
+ * Log out. The cookie is cleared whatever Medal says, but only AFTER the
142
+ * revocation is attempted. A revocation that fails is swallowed, and not
143
+ * logged: the only thing worth saying about it would name the session.
144
+ */
145
+ logout(): Promise<{
146
+ ok: true;
147
+ }>;
148
+ /**
149
+ * Edit name, phone and children. The patch carries only the keys the
150
+ * parent sent. Every successful write revalidates the portal page, so Back
151
+ * does not show the values from before the save.
152
+ */
153
+ updateProfile(input: unknown): Promise<UpdateProfileResult>;
154
+ /** Add one child (the person endpoints, else the whole-list fallback). */
155
+ createPerson(input: unknown): Promise<PersonActionResult>;
156
+ /** Edit one child in place — the id survives a rename. */
157
+ updatePerson(input: unknown): Promise<PersonActionResult>;
158
+ /** `createPerson` or `updatePerson`, by the `create` flag — one form, one action. */
159
+ savePerson(input: unknown): Promise<PersonActionResult>;
160
+ /** Remove one child. Medal deactivates the person; its booking history stays. */
161
+ removePerson(input: unknown): Promise<PersonActionResult>;
162
+ /**
163
+ * The marketing box on the profile, through the same profile patch, and
164
+ * echoing the STORED value so the checkbox shows what Medal has.
165
+ */
166
+ setMarketingConsent(input: unknown): Promise<{
167
+ ok: true;
168
+ marketingConsent: boolean;
169
+ } | SessionFailure | InvalidInput>;
170
+ /**
171
+ * The parent's data as a file (GDPR art. 15), pretty-printed and dated.
172
+ * No renewal: a cookie set by an action re-renders the page, which would
173
+ * hold the download behind a full dashboard render.
174
+ */
175
+ exportData(): Promise<{
176
+ ok: true;
177
+ filename: string;
178
+ json: string;
179
+ } | SessionFailure>;
180
+ /**
181
+ * Delete the account (GDPR art. 17). The confirmation word is checked
182
+ * here rather than trusted from a disabled button. The cookie is cleared
183
+ * only after Medal has said yes.
184
+ */
185
+ deleteMe(input: unknown): Promise<{
186
+ ok: true;
187
+ } | SessionFailure | InvalidInput>;
188
+ };
189
+ export type PortalActions = ReturnType<typeof createPortalActions>;
190
+ export interface Portal {
191
+ /** The session cookie, for a page or route of the app's own. */
192
+ session: BookingRuntime['session'];
193
+ actions: PortalActions;
194
+ schemas: PortalSchemas;
195
+ /** `GET` for the Vipps login return route (`portal.vippsReturnPath`). */
196
+ vippsReturn: {
197
+ GET(request: Request): Promise<Response>;
198
+ };
199
+ /** `GET` for the «link Vipps» return route (`portal.vippsLinkReturnPath`). */
200
+ vippsLinkReturn: {
201
+ GET(request: Request): Promise<Response>;
202
+ };
203
+ }
204
+ //#endregion
205
+ //# sourceMappingURL=portal.d.mts.map