@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,56 @@
1
+ import { BookingConfig } from "../config.mjs";
2
+ import { CookieSource } from "./cookies.mjs";
3
+ //#region src/core/portal/vipps-link.d.ts
4
+ /**
5
+ * The two short-lived cookies behind «confirm a conflicted Vipps login with
6
+ * an e-mail code» (SP10 A5).
7
+ *
8
+ * BROWSER BINDING. When this site starts a Vipps login it mints 32 random
9
+ * bytes, keeps them in the binding cookie and sends them to Medal as
10
+ * `browser_binding`; Medal stores them with the pending link and will only
11
+ * verify a code from the browser that holds the same value. A `link` copied
12
+ * out of one browser's address bar is worthless in another's.
13
+ *
14
+ * THE LINK. Medal's callback hands the browser an opaque `link` for the
15
+ * pending login. `/min-side/vipps` — where the callback lands — moves it
16
+ * straight into the link cookie, scoped to the one route that spends it, and
17
+ * redirects on without it: the page the parent types the code on never has
18
+ * the link in its address bar, its history entry, its Referer or any
19
+ * analytics pageview. The verify route reads it back from the cookie and
20
+ * sends it to Medal in a POST body — the only place it is ever written.
21
+ *
22
+ * Both `httpOnly` (no script reads them), `secure`, `SameSite=Lax` (the Vipps
23
+ * callback is a top-level navigation from another site, which Lax admits) and
24
+ * fifteen minutes long — Medal's own lifetime for a pending link.
25
+ *
26
+ * THE BINDING IS MANDATORY. Medal binds every pending link to a browser and
27
+ * refuses a verify without the matching value, so this site does not pretend
28
+ * otherwise: no binding cookie, no link cookie (the return route), and no
29
+ * pair to verify (`readVippsLinkPair`). The binding cookie is `__Host-`
30
+ * prefixed — Secure, `Path=/`, no `Domain` — so no sibling host or insecure
31
+ * page can plant one. It is new with SP10, so there is no older name to keep
32
+ * reading.
33
+ */
34
+ /** The route that spends the link, and so the only path its cookie is sent to. */
35
+ export declare function vippsLinkVerifyPath(config: Pick<BookingConfig, 'paths'>): string;
36
+ export declare function isVippsToken(value: unknown): value is string;
37
+ /** 32 random bytes, unpadded base64url — 43 characters. */
38
+ export declare function mintBrowserBinding(): string;
39
+ export interface VippsLinkCookies {
40
+ readonly VIPPS_BINDING_COOKIE: string;
41
+ readonly VIPPS_LINK_COOKIE: string;
42
+ readonly VIPPS_LINK_VERIFY_PATH: string;
43
+ writeBrowserBinding(binding: string): Promise<void>;
44
+ writePendingLink(link: string): Promise<void>;
45
+ readBrowserBinding(): Promise<string | null>;
46
+ readVippsLinkPair(): Promise<{
47
+ link: string;
48
+ binding: string;
49
+ } | null>;
50
+ clearBrowserBinding(): Promise<void>;
51
+ clearVippsLinkPair(): Promise<void>;
52
+ }
53
+ /** The binding and link cookies, named by `portal.vippsBindingCookieName` / `vippsLinkCookieName`. */
54
+ export declare function createVippsLinkCookies(config: Pick<BookingConfig, 'portal' | 'paths'>, cookies: CookieSource): VippsLinkCookies;
55
+ //#endregion
56
+ //# sourceMappingURL=vipps-link.d.mts.map
@@ -0,0 +1,136 @@
1
+ //#region src/core/portal/vipps-link.ts
2
+ /**
3
+ * The two short-lived cookies behind «confirm a conflicted Vipps login with
4
+ * an e-mail code» (SP10 A5).
5
+ *
6
+ * BROWSER BINDING. When this site starts a Vipps login it mints 32 random
7
+ * bytes, keeps them in the binding cookie and sends them to Medal as
8
+ * `browser_binding`; Medal stores them with the pending link and will only
9
+ * verify a code from the browser that holds the same value. A `link` copied
10
+ * out of one browser's address bar is worthless in another's.
11
+ *
12
+ * THE LINK. Medal's callback hands the browser an opaque `link` for the
13
+ * pending login. `/min-side/vipps` — where the callback lands — moves it
14
+ * straight into the link cookie, scoped to the one route that spends it, and
15
+ * redirects on without it: the page the parent types the code on never has
16
+ * the link in its address bar, its history entry, its Referer or any
17
+ * analytics pageview. The verify route reads it back from the cookie and
18
+ * sends it to Medal in a POST body — the only place it is ever written.
19
+ *
20
+ * Both `httpOnly` (no script reads them), `secure`, `SameSite=Lax` (the Vipps
21
+ * callback is a top-level navigation from another site, which Lax admits) and
22
+ * fifteen minutes long — Medal's own lifetime for a pending link.
23
+ *
24
+ * THE BINDING IS MANDATORY. Medal binds every pending link to a browser and
25
+ * refuses a verify without the matching value, so this site does not pretend
26
+ * otherwise: no binding cookie, no link cookie (the return route), and no
27
+ * pair to verify (`readVippsLinkPair`). The binding cookie is `__Host-`
28
+ * prefixed — Secure, `Path=/`, no `Domain` — so no sibling host or insecure
29
+ * page can plant one. It is new with SP10, so there is no older name to keep
30
+ * reading.
31
+ */
32
+ /** The route that spends the link, and so the only path its cookie is sent to. */
33
+ function vippsLinkVerifyPath(config) {
34
+ return `${config.paths.portalApi}/vipps/link/verify`;
35
+ }
36
+ const MAX_AGE_SECONDS = 900;
37
+ /** Medal's link and this site's binding are both 32 bytes of unpadded base64url. */
38
+ const TOKEN_SHAPE = /^[A-Za-z0-9_-]{43}$/;
39
+ function isVippsToken(value) {
40
+ return typeof value === "string" && TOKEN_SHAPE.test(value);
41
+ }
42
+ /** 32 random bytes, unpadded base64url — 43 characters. */
43
+ function mintBrowserBinding() {
44
+ const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
45
+ let binary = "";
46
+ for (const byte of bytes) binary += String.fromCharCode(byte);
47
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
48
+ }
49
+ /** The binding and link cookies, named by `portal.vippsBindingCookieName` / `vippsLinkCookieName`. */
50
+ function createVippsLinkCookies(config, cookies) {
51
+ const VIPPS_BINDING_COOKIE = config.portal.vippsBindingCookieName;
52
+ const VIPPS_LINK_COOKIE = config.portal.vippsLinkCookieName;
53
+ const VIPPS_LINK_VERIFY_PATH = vippsLinkVerifyPath(config);
54
+ /** The attributes, without a lifetime — a delete must not carry `Max-Age`. */
55
+ const ATTRIBUTES = {
56
+ httpOnly: true,
57
+ secure: true,
58
+ sameSite: "lax"
59
+ };
60
+ const OPTIONS = {
61
+ ...ATTRIBUTES,
62
+ maxAge: MAX_AGE_SECONDS
63
+ };
64
+ async function writeBrowserBinding(binding) {
65
+ (await cookies()).set(VIPPS_BINDING_COOKIE, binding, {
66
+ ...OPTIONS,
67
+ path: "/"
68
+ });
69
+ }
70
+ async function writePendingLink(link) {
71
+ (await cookies()).set(VIPPS_LINK_COOKIE, link, {
72
+ ...OPTIONS,
73
+ path: VIPPS_LINK_VERIFY_PATH
74
+ });
75
+ }
76
+ /** This browser's binding, when it holds one of the right shape. */
77
+ async function readBrowserBinding() {
78
+ const binding = (await cookies()).get(VIPPS_BINDING_COOKIE)?.value;
79
+ return isVippsToken(binding) ? binding : null;
80
+ }
81
+ /**
82
+ * What the verify route needs: the link AND the binding, both the right
83
+ * shape — or `null`, which the route answers without asking Medal.
84
+ */
85
+ async function readVippsLinkPair() {
86
+ const jar = await cookies();
87
+ const link = jar.get(VIPPS_LINK_COOKIE)?.value;
88
+ const binding = jar.get(VIPPS_BINDING_COOKIE)?.value;
89
+ if (!isVippsToken(link) || !isVippsToken(binding)) return null;
90
+ return {
91
+ link,
92
+ binding
93
+ };
94
+ }
95
+ /**
96
+ * The binding alone gone — a login that ended without a pending link (a
97
+ * grant, a start that failed). Deleted with the attributes it was set with:
98
+ * a `__Host-` cookie is only replaced by a Secure `Path=/` one.
99
+ */
100
+ async function clearBrowserBinding() {
101
+ (await cookies()).delete({
102
+ name: VIPPS_BINDING_COOKIE,
103
+ ...ATTRIBUTES,
104
+ path: "/"
105
+ });
106
+ }
107
+ /** Both gone, once the link has been spent: neither is worth anything after. */
108
+ async function clearVippsLinkPair() {
109
+ const jar = await cookies();
110
+ jar.delete({
111
+ name: VIPPS_LINK_COOKIE,
112
+ ...ATTRIBUTES,
113
+ path: VIPPS_LINK_VERIFY_PATH
114
+ });
115
+ jar.delete({
116
+ name: VIPPS_BINDING_COOKIE,
117
+ ...ATTRIBUTES,
118
+ path: "/"
119
+ });
120
+ }
121
+ return {
122
+ VIPPS_BINDING_COOKIE,
123
+ VIPPS_LINK_COOKIE,
124
+ VIPPS_LINK_VERIFY_PATH,
125
+ writeBrowserBinding,
126
+ writePendingLink,
127
+ readBrowserBinding,
128
+ readVippsLinkPair,
129
+ clearBrowserBinding,
130
+ clearVippsLinkPair
131
+ };
132
+ }
133
+ //#endregion
134
+ export { createVippsLinkCookies, isVippsToken, mintBrowserBinding, vippsLinkVerifyPath };
135
+
136
+ //# sourceMappingURL=vipps-link.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vipps-link.mjs","names":[],"sources":["../../../src/core/portal/vipps-link.ts"],"sourcesContent":["import type { BookingConfig } from '../config';\nimport type { CookieSource } from './cookies';\n\n/**\n * The two short-lived cookies behind «confirm a conflicted Vipps login with\n * an e-mail code» (SP10 A5).\n *\n * BROWSER BINDING. When this site starts a Vipps login it mints 32 random\n * bytes, keeps them in the binding cookie and sends them to Medal as\n * `browser_binding`; Medal stores them with the pending link and will only\n * verify a code from the browser that holds the same value. A `link` copied\n * out of one browser's address bar is worthless in another's.\n *\n * THE LINK. Medal's callback hands the browser an opaque `link` for the\n * pending login. `/min-side/vipps` — where the callback lands — moves it\n * straight into the link cookie, scoped to the one route that spends it, and\n * redirects on without it: the page the parent types the code on never has\n * the link in its address bar, its history entry, its Referer or any\n * analytics pageview. The verify route reads it back from the cookie and\n * sends it to Medal in a POST body — the only place it is ever written.\n *\n * Both `httpOnly` (no script reads them), `secure`, `SameSite=Lax` (the Vipps\n * callback is a top-level navigation from another site, which Lax admits) and\n * fifteen minutes long — Medal's own lifetime for a pending link.\n *\n * THE BINDING IS MANDATORY. Medal binds every pending link to a browser and\n * refuses a verify without the matching value, so this site does not pretend\n * otherwise: no binding cookie, no link cookie (the return route), and no\n * pair to verify (`readVippsLinkPair`). The binding cookie is `__Host-`\n * prefixed — Secure, `Path=/`, no `Domain` — so no sibling host or insecure\n * page can plant one. It is new with SP10, so there is no older name to keep\n * reading.\n */\n\n/** The route that spends the link, and so the only path its cookie is sent to. */\nexport function vippsLinkVerifyPath(config: Pick<BookingConfig, 'paths'>): string {\n return `${config.paths.portalApi}/vipps/link/verify`;\n}\n\nconst MAX_AGE_SECONDS = 15 * 60;\n\n/** Medal's link and this site's binding are both 32 bytes of unpadded base64url. */\nconst TOKEN_SHAPE = /^[A-Za-z0-9_-]{43}$/;\n\nexport function isVippsToken(value: unknown): value is string {\n return typeof value === 'string' && TOKEN_SHAPE.test(value);\n}\n\n/** 32 random bytes, unpadded base64url — 43 characters. */\nexport function mintBrowserBinding(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(32));\n let binary = '';\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');\n}\n\nexport interface VippsLinkCookies {\n readonly VIPPS_BINDING_COOKIE: string;\n readonly VIPPS_LINK_COOKIE: string;\n readonly VIPPS_LINK_VERIFY_PATH: string;\n writeBrowserBinding(binding: string): Promise<void>;\n writePendingLink(link: string): Promise<void>;\n readBrowserBinding(): Promise<string | null>;\n readVippsLinkPair(): Promise<{ link: string; binding: string } | null>;\n clearBrowserBinding(): Promise<void>;\n clearVippsLinkPair(): Promise<void>;\n}\n\n/** The binding and link cookies, named by `portal.vippsBindingCookieName` / `vippsLinkCookieName`. */\nexport function createVippsLinkCookies(\n config: Pick<BookingConfig, 'portal' | 'paths'>,\n cookies: CookieSource\n): VippsLinkCookies {\n const VIPPS_BINDING_COOKIE = config.portal.vippsBindingCookieName;\n const VIPPS_LINK_COOKIE = config.portal.vippsLinkCookieName;\n const VIPPS_LINK_VERIFY_PATH = vippsLinkVerifyPath(config);\n\n /** The attributes, without a lifetime — a delete must not carry `Max-Age`. */\n const ATTRIBUTES = { httpOnly: true, secure: true, sameSite: 'lax' as const };\n const OPTIONS = { ...ATTRIBUTES, maxAge: MAX_AGE_SECONDS };\n\n async function writeBrowserBinding(binding: string): Promise<void> {\n (await cookies()).set(VIPPS_BINDING_COOKIE, binding, { ...OPTIONS, path: '/' });\n }\n\n async function writePendingLink(link: string): Promise<void> {\n (await cookies()).set(VIPPS_LINK_COOKIE, link, { ...OPTIONS, path: VIPPS_LINK_VERIFY_PATH });\n }\n\n /** This browser's binding, when it holds one of the right shape. */\n async function readBrowserBinding(): Promise<string | null> {\n const binding = (await cookies()).get(VIPPS_BINDING_COOKIE)?.value;\n return isVippsToken(binding) ? binding : null;\n }\n\n /**\n * What the verify route needs: the link AND the binding, both the right\n * shape — or `null`, which the route answers without asking Medal.\n */\n async function readVippsLinkPair(): Promise<{ link: string; binding: string } | null> {\n const jar = await cookies();\n const link = jar.get(VIPPS_LINK_COOKIE)?.value;\n const binding = jar.get(VIPPS_BINDING_COOKIE)?.value;\n if (!isVippsToken(link) || !isVippsToken(binding)) return null;\n return { link, binding };\n }\n\n /**\n * The binding alone gone — a login that ended without a pending link (a\n * grant, a start that failed). Deleted with the attributes it was set with:\n * a `__Host-` cookie is only replaced by a Secure `Path=/` one.\n */\n async function clearBrowserBinding(): Promise<void> {\n (await cookies()).delete({ name: VIPPS_BINDING_COOKIE, ...ATTRIBUTES, path: '/' });\n }\n\n /** Both gone, once the link has been spent: neither is worth anything after. */\n async function clearVippsLinkPair(): Promise<void> {\n const jar = await cookies();\n jar.delete({ name: VIPPS_LINK_COOKIE, ...ATTRIBUTES, path: VIPPS_LINK_VERIFY_PATH });\n jar.delete({ name: VIPPS_BINDING_COOKIE, ...ATTRIBUTES, path: '/' });\n }\n\n return {\n VIPPS_BINDING_COOKIE,\n VIPPS_LINK_COOKIE,\n VIPPS_LINK_VERIFY_PATH,\n writeBrowserBinding,\n writePendingLink,\n readBrowserBinding,\n readVippsLinkPair,\n clearBrowserBinding,\n clearVippsLinkPair,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmCA,SAAgB,oBAAoB,QAA8C;CAChF,OAAO,GAAG,OAAO,MAAM,UAAU;AACnC;AAEA,MAAM,kBAAkB;;AAGxB,MAAM,cAAc;AAEpB,SAAgB,aAAa,OAAiC;CAC5D,OAAO,OAAO,UAAU,YAAY,YAAY,KAAK,KAAK;AAC5D;;AAGA,SAAgB,qBAA6B;CAC3C,MAAM,QAAQ,OAAO,gCAAgB,IAAI,WAAW,EAAE,CAAC;CACvD,IAAI,SAAS;CACb,KAAK,MAAM,QAAQ,OAAO,UAAU,OAAO,aAAa,IAAI;CAC5D,OAAO,KAAK,MAAM,CAAC,CAAC,WAAW,KAAK,GAAG,CAAC,CAAC,WAAW,KAAK,GAAG,CAAC,CAAC,QAAQ,OAAO,EAAE;AACjF;;AAeA,SAAgB,uBACd,QACA,SACkB;CAClB,MAAM,uBAAuB,OAAO,OAAO;CAC3C,MAAM,oBAAoB,OAAO,OAAO;CACxC,MAAM,yBAAyB,oBAAoB,MAAM;;CAGzD,MAAM,aAAa;EAAE,UAAU;EAAM,QAAQ;EAAM,UAAU;CAAe;CAC5E,MAAM,UAAU;EAAE,GAAG;EAAY,QAAQ;CAAgB;CAEzD,eAAe,oBAAoB,SAAgC;EACjE,CAAC,MAAM,QAAQ,EAAA,CAAG,IAAI,sBAAsB,SAAS;GAAE,GAAG;GAAS,MAAM;EAAI,CAAC;CAChF;CAEA,eAAe,iBAAiB,MAA6B;EAC3D,CAAC,MAAM,QAAQ,EAAA,CAAG,IAAI,mBAAmB,MAAM;GAAE,GAAG;GAAS,MAAM;EAAuB,CAAC;CAC7F;;CAGA,eAAe,qBAA6C;EAC1D,MAAM,WAAW,MAAM,QAAQ,EAAA,CAAG,IAAI,oBAAoB,CAAC,EAAE;EAC7D,OAAO,aAAa,OAAO,IAAI,UAAU;CAC3C;;;;;CAMA,eAAe,oBAAuE;EACpF,MAAM,MAAM,MAAM,QAAQ;EAC1B,MAAM,OAAO,IAAI,IAAI,iBAAiB,CAAC,EAAE;EACzC,MAAM,UAAU,IAAI,IAAI,oBAAoB,CAAC,EAAE;EAC/C,IAAI,CAAC,aAAa,IAAI,KAAK,CAAC,aAAa,OAAO,GAAG,OAAO;EAC1D,OAAO;GAAE;GAAM;EAAQ;CACzB;;;;;;CAOA,eAAe,sBAAqC;EAClD,CAAC,MAAM,QAAQ,EAAA,CAAG,OAAO;GAAE,MAAM;GAAsB,GAAG;GAAY,MAAM;EAAI,CAAC;CACnF;;CAGA,eAAe,qBAAoC;EACjD,MAAM,MAAM,MAAM,QAAQ;EAC1B,IAAI,OAAO;GAAE,MAAM;GAAmB,GAAG;GAAY,MAAM;EAAuB,CAAC;EACnF,IAAI,OAAO;GAAE,MAAM;GAAsB,GAAG;GAAY,MAAM;EAAI,CAAC;CACrE;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF;AACF"}
@@ -0,0 +1,39 @@
1
+ //#region src/core/portal/vipps-return.d.ts
2
+ /**
3
+ * The «confirm with a code» return from Vipps (SP10), as the pages that
4
+ * receive it read it — free of `server-only`, because both readers are in the
5
+ * browser (the Min side login and the booking wizard).
6
+ *
7
+ * `/min-side/vipps` sends the parent on with `?vipps=confirm_email` and, when
8
+ * Medal named one, `&to=<masked address>` — the address the code went to, as
9
+ * `k•••@g•••.com`. The link itself is NOT in this URL: the route moved it into
10
+ * an httpOnly cookie (`vipps-link.ts`).
11
+ *
12
+ * `to` is written by a third party's redirect and is anyone's to edit in an
13
+ * address bar, so it is RENDERED AS TEXT ONLY and only when it has exactly
14
+ * the masked shape Medal produces; anything else is the generic sentence.
15
+ */
16
+ export declare const VIPPS_CONFIRM_QUERY = "confirm_email";
17
+ /** The masked address, or `null` for anything that is not one. */
18
+ export declare function maskedAddress(value: string | null | undefined): string | null;
19
+ /**
20
+ * Whether the query says «confirm with a code», and to which address.
21
+ * `undefined` for an ordinary visit; `{ to: null }` when the address is
22
+ * absent (Medal found the contact by phone) or not the masked shape.
23
+ */
24
+ export declare function vippsConfirmFrom(query: URLSearchParams | null): {
25
+ to: string | null;
26
+ } | undefined;
27
+ /**
28
+ * Take the Vipps return's keys out of the address bar, in place — so the
29
+ * masked address, and anything else a callback left there, is not in the
30
+ * history entry, a bookmark, a shared link or a later pageview. The
31
+ * `?vipps=confirm_email` marker goes too: the page has read it, and a reload
32
+ * must not reopen a code step for a link that may be spent.
33
+ *
34
+ * Other keys stay (`resume`, a deep link's `kategori`…). No-op when there is
35
+ * nothing to take.
36
+ */
37
+ export declare function stripVippsReturn(): void;
38
+ //#endregion
39
+ //# sourceMappingURL=vipps-return.d.mts.map
@@ -0,0 +1,69 @@
1
+ //#region src/core/portal/vipps-return.ts
2
+ /**
3
+ * The «confirm with a code» return from Vipps (SP10), as the pages that
4
+ * receive it read it — free of `server-only`, because both readers are in the
5
+ * browser (the Min side login and the booking wizard).
6
+ *
7
+ * `/min-side/vipps` sends the parent on with `?vipps=confirm_email` and, when
8
+ * Medal named one, `&to=<masked address>` — the address the code went to, as
9
+ * `k•••@g•••.com`. The link itself is NOT in this URL: the route moved it into
10
+ * an httpOnly cookie (`vipps-link.ts`).
11
+ *
12
+ * `to` is written by a third party's redirect and is anyone's to edit in an
13
+ * address bar, so it is RENDERED AS TEXT ONLY and only when it has exactly
14
+ * the masked shape Medal produces; anything else is the generic sentence.
15
+ */
16
+ const VIPPS_CONFIRM_QUERY = "confirm_email";
17
+ /** One visible character, the mask, `@`, one character, the mask, a TLD. */
18
+ const MASKED_ADDRESS = /^[^\s<>@]•••@[^\s<>@.]•••\.[A-Za-z]{2,24}$/;
19
+ /** The masked address, or `null` for anything that is not one. */
20
+ function maskedAddress(value) {
21
+ return typeof value === "string" && MASKED_ADDRESS.test(value) ? value : null;
22
+ }
23
+ /**
24
+ * Whether the query says «confirm with a code», and to which address.
25
+ * `undefined` for an ordinary visit; `{ to: null }` when the address is
26
+ * absent (Medal found the contact by phone) or not the masked shape.
27
+ */
28
+ function vippsConfirmFrom(query) {
29
+ const values = query?.getAll("vipps") ?? [];
30
+ if (values.length !== 1 || values[0] !== "confirm_email") return void 0;
31
+ /* v8 ignore next -- `query` is non-null once a `vipps` value was read off it */
32
+ const to = query?.getAll("to") ?? [];
33
+ return { to: to.length === 1 ? maskedAddress(to[0]) : null };
34
+ }
35
+ /** The keys a Vipps return can leave in an address bar. */
36
+ const VIPPS_KEYS = [
37
+ "link",
38
+ "to",
39
+ "grant"
40
+ ];
41
+ /**
42
+ * Take the Vipps return's keys out of the address bar, in place — so the
43
+ * masked address, and anything else a callback left there, is not in the
44
+ * history entry, a bookmark, a shared link or a later pageview. The
45
+ * `?vipps=confirm_email` marker goes too: the page has read it, and a reload
46
+ * must not reopen a code step for a link that may be spent.
47
+ *
48
+ * Other keys stay (`resume`, a deep link's `kategori`…). No-op when there is
49
+ * nothing to take.
50
+ */
51
+ function stripVippsReturn() {
52
+ if (typeof window === "undefined") return;
53
+ const url = new URL(window.location.href);
54
+ let changed = false;
55
+ for (const key of VIPPS_KEYS) if (url.searchParams.has(key)) {
56
+ url.searchParams.delete(key);
57
+ changed = true;
58
+ }
59
+ if (url.searchParams.get("vipps") === "confirm_email") {
60
+ url.searchParams.delete("vipps");
61
+ changed = true;
62
+ }
63
+ if (!changed) return;
64
+ window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
65
+ }
66
+ //#endregion
67
+ export { VIPPS_CONFIRM_QUERY, maskedAddress, stripVippsReturn, vippsConfirmFrom };
68
+
69
+ //# sourceMappingURL=vipps-return.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vipps-return.mjs","names":[],"sources":["../../../src/core/portal/vipps-return.ts"],"sourcesContent":["/**\n * The «confirm with a code» return from Vipps (SP10), as the pages that\n * receive it read it — free of `server-only`, because both readers are in the\n * browser (the Min side login and the booking wizard).\n *\n * `/min-side/vipps` sends the parent on with `?vipps=confirm_email` and, when\n * Medal named one, `&to=<masked address>` — the address the code went to, as\n * `k•••@g•••.com`. The link itself is NOT in this URL: the route moved it into\n * an httpOnly cookie (`vipps-link.ts`).\n *\n * `to` is written by a third party's redirect and is anyone's to edit in an\n * address bar, so it is RENDERED AS TEXT ONLY and only when it has exactly\n * the masked shape Medal produces; anything else is the generic sentence.\n */\n\nexport const VIPPS_CONFIRM_QUERY = 'confirm_email';\n\n/** One visible character, the mask, `@`, one character, the mask, a TLD. */\nconst MASKED_ADDRESS = /^[^\\s<>@]•••@[^\\s<>@.]•••\\.[A-Za-z]{2,24}$/;\n\n/** The masked address, or `null` for anything that is not one. */\nexport function maskedAddress(value: string | null | undefined): string | null {\n return typeof value === 'string' && MASKED_ADDRESS.test(value) ? value : null;\n}\n\n/**\n * Whether the query says «confirm with a code», and to which address.\n * `undefined` for an ordinary visit; `{ to: null }` when the address is\n * absent (Medal found the contact by phone) or not the masked shape.\n */\nexport function vippsConfirmFrom(query: URLSearchParams | null): { to: string | null } | undefined {\n const values = query?.getAll('vipps') ?? [];\n if (values.length !== 1 || values[0] !== VIPPS_CONFIRM_QUERY) return undefined;\n /* v8 ignore next -- `query` is non-null once a `vipps` value was read off it */\n const to = query?.getAll('to') ?? [];\n return { to: to.length === 1 ? maskedAddress(to[0]) : null };\n}\n\n/** The keys a Vipps return can leave in an address bar. */\nconst VIPPS_KEYS = ['link', 'to', 'grant'] as const;\n\n/**\n * Take the Vipps return's keys out of the address bar, in place — so the\n * masked address, and anything else a callback left there, is not in the\n * history entry, a bookmark, a shared link or a later pageview. The\n * `?vipps=confirm_email` marker goes too: the page has read it, and a reload\n * must not reopen a code step for a link that may be spent.\n *\n * Other keys stay (`resume`, a deep link's `kategori`…). No-op when there is\n * nothing to take.\n */\nexport function stripVippsReturn(): void {\n if (typeof window === 'undefined') return;\n const url = new URL(window.location.href);\n let changed = false;\n for (const key of VIPPS_KEYS) {\n if (url.searchParams.has(key)) {\n url.searchParams.delete(key);\n changed = true;\n }\n }\n if (url.searchParams.get('vipps') === VIPPS_CONFIRM_QUERY) {\n url.searchParams.delete('vipps');\n changed = true;\n }\n if (!changed) return;\n window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);\n}\n"],"mappings":";;;;;;;;;;;;;;;AAeA,MAAa,sBAAsB;;AAGnC,MAAM,iBAAiB;;AAGvB,SAAgB,cAAc,OAAiD;CAC7E,OAAO,OAAO,UAAU,YAAY,eAAe,KAAK,KAAK,IAAI,QAAQ;AAC3E;;;;;;AAOA,SAAgB,iBAAiB,OAAkE;CACjG,MAAM,SAAS,OAAO,OAAO,OAAO,KAAK,CAAC;CAC1C,IAAI,OAAO,WAAW,KAAK,OAAO,OAAA,iBAA4B,OAAO,KAAA;;CAErE,MAAM,KAAK,OAAO,OAAO,IAAI,KAAK,CAAC;CACnC,OAAO,EAAE,IAAI,GAAG,WAAW,IAAI,cAAc,GAAG,EAAE,IAAI,KAAK;AAC7D;;AAGA,MAAM,aAAa;CAAC;CAAQ;CAAM;AAAO;;;;;;;;;;;AAYzC,SAAgB,mBAAyB;CACvC,IAAI,OAAO,WAAW,aAAa;CACnC,MAAM,MAAM,IAAI,IAAI,OAAO,SAAS,IAAI;CACxC,IAAI,UAAU;CACd,KAAK,MAAM,OAAO,YAChB,IAAI,IAAI,aAAa,IAAI,GAAG,GAAG;EAC7B,IAAI,aAAa,OAAO,GAAG;EAC3B,UAAU;CACZ;CAEF,IAAI,IAAI,aAAa,IAAI,OAAO,MAAA,iBAA2B;EACzD,IAAI,aAAa,OAAO,OAAO;EAC/B,UAAU;CACZ;CACA,IAAI,CAAC,SAAS;CACd,OAAO,QAAQ,aAAa,OAAO,QAAQ,OAAO,IAAI,GAAG,IAAI,WAAW,IAAI,SAAS,IAAI,MAAM;AACjG"}
@@ -0,0 +1,28 @@
1
+ //#region src/core/rebook-store.d.ts
2
+ /**
3
+ * The child's name a «Bestill igjen» card hands the wizard — carried in the
4
+ * tab's `sessionStorage`, never in the URL.
5
+ *
6
+ * The service and stylist ids ride in the query string because they are
7
+ * catalogue ids and a parent may as well see them; a child's name is personal
8
+ * data about a minor, and a query string is copied into browser history, the
9
+ * edge access log and every analytics pageview that records `location`.
10
+ * `sessionStorage` is none of those: one tab, gone with it, read once.
11
+ *
12
+ * Read-once (`take`) so a name stashed for one rebook cannot resurface on a
13
+ * later, unrelated visit to `/bestill` in the same tab.
14
+ *
15
+ * Every path falls back to «nothing stashed» rather than throwing — no
16
+ * storage during the server render, Safari private mode, storage turned off —
17
+ * because the worst case is a parent typing a name they have typed before.
18
+ */
19
+ /** `<namespace>:booking:rebook-who`. */
20
+ export declare function rebookStorageKey(namespace: string): string;
21
+ export interface RebookStore {
22
+ stashRebookWho(name: string | null): void;
23
+ takeRebookWho(): string | null;
24
+ }
25
+ /** The «book again» name under `<namespace>:booking:rebook-who`. */
26
+ export declare function createRebookStore(namespace: string): RebookStore;
27
+ //#endregion
28
+ //# sourceMappingURL=rebook-store.d.mts.map
@@ -0,0 +1,60 @@
1
+ //#region src/core/rebook-store.ts
2
+ /**
3
+ * The child's name a «Bestill igjen» card hands the wizard — carried in the
4
+ * tab's `sessionStorage`, never in the URL.
5
+ *
6
+ * The service and stylist ids ride in the query string because they are
7
+ * catalogue ids and a parent may as well see them; a child's name is personal
8
+ * data about a minor, and a query string is copied into browser history, the
9
+ * edge access log and every analytics pageview that records `location`.
10
+ * `sessionStorage` is none of those: one tab, gone with it, read once.
11
+ *
12
+ * Read-once (`take`) so a name stashed for one rebook cannot resurface on a
13
+ * later, unrelated visit to `/bestill` in the same tab.
14
+ *
15
+ * Every path falls back to «nothing stashed» rather than throwing — no
16
+ * storage during the server render, Safari private mode, storage turned off —
17
+ * because the worst case is a parent typing a name they have typed before.
18
+ */
19
+ /** `<namespace>:booking:rebook-who`. */
20
+ function rebookStorageKey(namespace) {
21
+ return `${namespace}:booking:rebook-who`;
22
+ }
23
+ /** The «book again» name under `<namespace>:booking:rebook-who`. */
24
+ function createRebookStore(namespace) {
25
+ const STORAGE_KEY = rebookStorageKey(namespace);
26
+ /**
27
+ * Every tap REPLACES what is stashed — `null` clears it — so a name left
28
+ * behind by an earlier tap that opened its wizard in another tab (Cmd-click)
29
+ * cannot be picked up by a later tap for a booking that named nobody, and
30
+ * prefill the wrong child.
31
+ */
32
+ function stashRebookWho(name) {
33
+ if (typeof window === "undefined") return;
34
+ try {
35
+ if (name === null) {
36
+ window.sessionStorage.removeItem(STORAGE_KEY);
37
+ return;
38
+ }
39
+ window.sessionStorage.setItem(STORAGE_KEY, name);
40
+ } catch {}
41
+ }
42
+ function takeRebookWho() {
43
+ if (typeof window === "undefined") return null;
44
+ try {
45
+ const name = window.sessionStorage.getItem(STORAGE_KEY);
46
+ window.sessionStorage.removeItem(STORAGE_KEY);
47
+ return name !== null && name.trim().length > 0 ? name : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+ return {
53
+ stashRebookWho,
54
+ takeRebookWho
55
+ };
56
+ }
57
+ //#endregion
58
+ export { createRebookStore, rebookStorageKey };
59
+
60
+ //# sourceMappingURL=rebook-store.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rebook-store.mjs","names":[],"sources":["../../src/core/rebook-store.ts"],"sourcesContent":["/**\n * The child's name a «Bestill igjen» card hands the wizard — carried in the\n * tab's `sessionStorage`, never in the URL.\n *\n * The service and stylist ids ride in the query string because they are\n * catalogue ids and a parent may as well see them; a child's name is personal\n * data about a minor, and a query string is copied into browser history, the\n * edge access log and every analytics pageview that records `location`.\n * `sessionStorage` is none of those: one tab, gone with it, read once.\n *\n * Read-once (`take`) so a name stashed for one rebook cannot resurface on a\n * later, unrelated visit to `/bestill` in the same tab.\n *\n * Every path falls back to «nothing stashed» rather than throwing — no\n * storage during the server render, Safari private mode, storage turned off —\n * because the worst case is a parent typing a name they have typed before.\n */\n/** `<namespace>:booking:rebook-who`. */\nexport function rebookStorageKey(namespace: string): string {\n return `${namespace}:booking:rebook-who`;\n}\n\nexport interface RebookStore {\n stashRebookWho(name: string | null): void;\n takeRebookWho(): string | null;\n}\n\n/** The «book again» name under `<namespace>:booking:rebook-who`. */\nexport function createRebookStore(namespace: string): RebookStore {\n const STORAGE_KEY = rebookStorageKey(namespace);\n\n /**\n * Every tap REPLACES what is stashed — `null` clears it — so a name left\n * behind by an earlier tap that opened its wizard in another tab (Cmd-click)\n * cannot be picked up by a later tap for a booking that named nobody, and\n * prefill the wrong child.\n */\n function stashRebookWho(name: string | null): void {\n if (typeof window === 'undefined') return;\n try {\n if (name === null) {\n window.sessionStorage.removeItem(STORAGE_KEY);\n return;\n }\n window.sessionStorage.setItem(STORAGE_KEY, name);\n } catch {\n // The wizard simply asks for the name.\n }\n }\n\n function takeRebookWho(): string | null {\n if (typeof window === 'undefined') return null;\n try {\n const name = window.sessionStorage.getItem(STORAGE_KEY);\n window.sessionStorage.removeItem(STORAGE_KEY);\n return name !== null && name.trim().length > 0 ? name : null;\n } catch {\n return null;\n }\n }\n\n return { stashRebookWho, takeRebookWho };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAkBA,SAAgB,iBAAiB,WAA2B;CAC1D,OAAO,GAAG,UAAU;AACtB;;AAQA,SAAgB,kBAAkB,WAAgC;CAChE,MAAM,cAAc,iBAAiB,SAAS;;;;;;;CAQ9C,SAAS,eAAe,MAA2B;EACjD,IAAI,OAAO,WAAW,aAAa;EACnC,IAAI;GACF,IAAI,SAAS,MAAM;IACjB,OAAO,eAAe,WAAW,WAAW;IAC5C;GACF;GACA,OAAO,eAAe,QAAQ,aAAa,IAAI;EACjD,QAAQ,CAER;CACF;CAEA,SAAS,gBAA+B;EACtC,IAAI,OAAO,WAAW,aAAa,OAAO;EAC1C,IAAI;GACF,MAAM,OAAO,OAAO,eAAe,QAAQ,WAAW;GACtD,OAAO,eAAe,WAAW,WAAW;GAC5C,OAAO,SAAS,QAAQ,KAAK,KAAK,CAAC,CAAC,SAAS,IAAI,OAAO;EAC1D,QAAQ;GACN,OAAO;EACT;CACF;CAEA,OAAO;EAAE;EAAgB;CAAc;AACzC"}
@@ -0,0 +1,40 @@
1
+ //#region src/core/restore-gate.d.ts
2
+ /**
3
+ * Whether this tab's storage holds something the booking wizard's mount effect
4
+ * will restore — a confirmation, a submission still owed an answer, or (behind
5
+ * `?resume=1`) a Vipps draft — read WITHOUT consuming or minting anything.
6
+ *
7
+ * It mirrors the mount effect's own order and rules (the attempt TTL, the
8
+ * draft's 30-minute window and its future-stamp refusal, a link — «Bestill
9
+ * igjen» or any deep-link parameter, which is what `prefilled` means —
10
+ * outranking a stored confirmation) so the gate is up exactly when the
11
+ * screen is about to change.
12
+ *
13
+ * SELF-CONTAINED ON PURPOSE: its source is inlined as a script by
14
+ * `restoreGateScript`, so it may reference nothing but its arguments and
15
+ * browser globals. Only the storage reads are guarded; anything else that
16
+ * throws (a stray module reference) surfaces in the tests instead of
17
+ * silently reading as «nothing to restore».
18
+ */
19
+ export declare function restorePendingInStorage(attemptKey: string, attemptTtlMs: number, draftKey: string, draftMaxAgeMs: number, prefilled: boolean, resuming: boolean): boolean;
20
+ /**
21
+ * The same check, as the script the SERVER's HTML carries.
22
+ *
23
+ * The server cannot see this tab's storage, so it always renders step 1 — and
24
+ * hydration must match it. This runs while the HTML is parsed, before first
25
+ * paint, and marks the wizard root `data-restoring` when there is something to
26
+ * restore; CSS then hides step 1 behind the skeleton until React takes over.
27
+ * If React never takes over, a timer lifts the mark after
28
+ * `RESTORE_FALLBACK_MS`. The expression evaluates to the check's answer, which is what lets a test
29
+ * tell «nothing to restore» from «the script broke».
30
+ */
31
+ /** How long the pre-hydration gate may hide step 1 before it gives up. */
32
+ export declare const RESTORE_FALLBACK_MS = 10000;
33
+ export interface RestoreGate {
34
+ restorePendingInStorage: typeof restorePendingInStorage;
35
+ restoreGateScript(rootId: string, prefilled: boolean, resuming: boolean): string;
36
+ }
37
+ /** The gate for the stores under `namespace` (see `createStores`). */
38
+ export declare function createRestoreGate(namespace: string): RestoreGate;
39
+ //#endregion
40
+ //# sourceMappingURL=restore-gate.d.mts.map
@@ -0,0 +1,96 @@
1
+ import { ATTEMPT_TTL_MS, attemptStorageKey } from "./attempt-store.mjs";
2
+ import { DRAFT_MAX_AGE_MS, draftStorageKey } from "./draft-store.mjs";
3
+ //#region src/core/restore-gate.ts
4
+ /**
5
+ * Whether this tab's storage holds something the booking wizard's mount effect
6
+ * will restore — a confirmation, a submission still owed an answer, or (behind
7
+ * `?resume=1`) a Vipps draft — read WITHOUT consuming or minting anything.
8
+ *
9
+ * It mirrors the mount effect's own order and rules (the attempt TTL, the
10
+ * draft's 30-minute window and its future-stamp refusal, a link — «Bestill
11
+ * igjen» or any deep-link parameter, which is what `prefilled` means —
12
+ * outranking a stored confirmation) so the gate is up exactly when the
13
+ * screen is about to change.
14
+ *
15
+ * SELF-CONTAINED ON PURPOSE: its source is inlined as a script by
16
+ * `restoreGateScript`, so it may reference nothing but its arguments and
17
+ * browser globals. Only the storage reads are guarded; anything else that
18
+ * throws (a stray module reference) surfaces in the tests instead of
19
+ * silently reading as «nothing to restore».
20
+ */
21
+ function restorePendingInStorage(attemptKey, attemptTtlMs, draftKey, draftMaxAgeMs, prefilled, resuming) {
22
+ const read = (key) => {
23
+ try {
24
+ const value = JSON.parse(window.sessionStorage.getItem(key) || "null");
25
+ return value !== null && typeof value === "object" ? value : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ };
30
+ const now = Date.now();
31
+ const attempt = read(attemptKey);
32
+ const live = attempt !== null && typeof attempt.nonce === "string" && typeof attempt.at === "number" && now - attempt.at <= attemptTtlMs;
33
+ if (live && attempt.pending !== void 0) return true;
34
+ if (live && attempt.confirmed !== void 0 && !prefilled) return true;
35
+ if (!resuming) return false;
36
+ const draft = read(draftKey);
37
+ if (draft === null || typeof draft.savedAt !== "number") return false;
38
+ const age = now - draft.savedAt;
39
+ return age >= 0 && age <= draftMaxAgeMs && Array.isArray(draft.items) && draft.items.length > 0;
40
+ }
41
+ /**
42
+ * The same check, as the script the SERVER's HTML carries.
43
+ *
44
+ * The server cannot see this tab's storage, so it always renders step 1 — and
45
+ * hydration must match it. This runs while the HTML is parsed, before first
46
+ * paint, and marks the wizard root `data-restoring` when there is something to
47
+ * restore; CSS then hides step 1 behind the skeleton until React takes over.
48
+ * If React never takes over, a timer lifts the mark after
49
+ * `RESTORE_FALLBACK_MS`. The expression evaluates to the check's answer, which is what lets a test
50
+ * tell «nothing to restore» from «the script broke».
51
+ */
52
+ /** How long the pre-hydration gate may hide step 1 before it gives up. */
53
+ const RESTORE_FALLBACK_MS = 1e4;
54
+ /** The characters that can end or bend an inline `<script>` from inside a string literal. */
55
+ const SCRIPT_UNSAFE = {
56
+ "<": "\\u003C",
57
+ ">": "\\u003E",
58
+ "/": "\\u002F",
59
+ "\u2028": "\\u2028",
60
+ "\u2029": "\\u2029"
61
+ };
62
+ /**
63
+ * A value as a JavaScript literal that is safe INSIDE an HTML `<script>`.
64
+ *
65
+ * `JSON.stringify` alone is a valid literal but not a safe one: a root id
66
+ * holding `<\/script>` would end the element mid-string, and U+2028/U+2029 end
67
+ * a line in older parsers. Escaping them as `\uXXXX` keeps the value exactly
68
+ * the same once the script runs — the storage keys and ids this site passes
69
+ * contain none of them, so the emitted script is unchanged for them.
70
+ */
71
+ function scriptLiteral(value) {
72
+ return JSON.stringify(value).replace(/[<>/\u2028\u2029]/g, (char) => SCRIPT_UNSAFE[char]);
73
+ }
74
+ function restoreGateScript(namespace, rootId, prefilled, resuming) {
75
+ const args = [
76
+ attemptStorageKey(namespace),
77
+ ATTEMPT_TTL_MS,
78
+ draftStorageKey(namespace),
79
+ DRAFT_MAX_AGE_MS,
80
+ prefilled,
81
+ resuming
82
+ ].map(scriptLiteral);
83
+ const id = scriptLiteral(rootId);
84
+ return `(function(){var on=(${restorePendingInStorage.toString()})(${args.join(",")});if(on){var r=document.getElementById(${id});if(r){r.setAttribute('data-restoring','');setTimeout(function(){var n=document.getElementById(${id});if(n&&!n.hasAttribute('data-hydrated'))n.removeAttribute('data-restoring')},${RESTORE_FALLBACK_MS})}}return on})()`;
85
+ }
86
+ /** The gate for the stores under `namespace` (see `createStores`). */
87
+ function createRestoreGate(namespace) {
88
+ return {
89
+ restorePendingInStorage,
90
+ restoreGateScript: (rootId, prefilled, resuming) => restoreGateScript(namespace, rootId, prefilled, resuming)
91
+ };
92
+ }
93
+ //#endregion
94
+ export { RESTORE_FALLBACK_MS, createRestoreGate, restorePendingInStorage };
95
+
96
+ //# sourceMappingURL=restore-gate.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"restore-gate.mjs","names":[],"sources":["../../src/core/restore-gate.ts"],"sourcesContent":["import { ATTEMPT_TTL_MS, attemptStorageKey } from './attempt-store';\nimport { DRAFT_MAX_AGE_MS, draftStorageKey } from './draft-store';\n\n/**\n * Whether this tab's storage holds something the booking wizard's mount effect\n * will restore — a confirmation, a submission still owed an answer, or (behind\n * `?resume=1`) a Vipps draft — read WITHOUT consuming or minting anything.\n *\n * It mirrors the mount effect's own order and rules (the attempt TTL, the\n * draft's 30-minute window and its future-stamp refusal, a link — «Bestill\n * igjen» or any deep-link parameter, which is what `prefilled` means —\n * outranking a stored confirmation) so the gate is up exactly when the\n * screen is about to change.\n *\n * SELF-CONTAINED ON PURPOSE: its source is inlined as a script by\n * `restoreGateScript`, so it may reference nothing but its arguments and\n * browser globals. Only the storage reads are guarded; anything else that\n * throws (a stray module reference) surfaces in the tests instead of\n * silently reading as «nothing to restore».\n */\nexport function restorePendingInStorage(\n attemptKey: string,\n attemptTtlMs: number,\n draftKey: string,\n draftMaxAgeMs: number,\n prefilled: boolean,\n resuming: boolean\n): boolean {\n const read = (key: string): Record<string, unknown> | null => {\n try {\n const value = JSON.parse(window.sessionStorage.getItem(key) || 'null');\n return value !== null && typeof value === 'object' ? value : null;\n } catch {\n return null;\n }\n };\n const now = Date.now();\n const attempt = read(attemptKey);\n const live =\n attempt !== null &&\n typeof attempt.nonce === 'string' &&\n typeof attempt.at === 'number' &&\n now - attempt.at <= attemptTtlMs;\n if (live && attempt.pending !== undefined) return true;\n // A link outranks a stored confirmation — the mount effect forgets it and\n // carries on — so only an unlinked visit is about to show one.\n if (live && attempt.confirmed !== undefined && !prefilled) return true;\n if (!resuming) return false;\n const draft = read(draftKey);\n if (draft === null || typeof draft.savedAt !== 'number') return false;\n const age = now - draft.savedAt;\n return age >= 0 && age <= draftMaxAgeMs && Array.isArray(draft.items) && draft.items.length > 0;\n}\n\n/**\n * The same check, as the script the SERVER's HTML carries.\n *\n * The server cannot see this tab's storage, so it always renders step 1 — and\n * hydration must match it. This runs while the HTML is parsed, before first\n * paint, and marks the wizard root `data-restoring` when there is something to\n * restore; CSS then hides step 1 behind the skeleton until React takes over.\n * If React never takes over, a timer lifts the mark after\n * `RESTORE_FALLBACK_MS`. The expression evaluates to the check's answer, which is what lets a test\n * tell «nothing to restore» from «the script broke».\n */\n/** How long the pre-hydration gate may hide step 1 before it gives up. */\nexport const RESTORE_FALLBACK_MS = 10_000;\n\n/** The characters that can end or bend an inline `<script>` from inside a string literal. */\nconst SCRIPT_UNSAFE: Readonly<Record<string, string>> = {\n '<': '\\\\u003C',\n '>': '\\\\u003E',\n '/': '\\\\u002F',\n '\\u2028': '\\\\u2028',\n '\\u2029': '\\\\u2029',\n};\n\n/**\n * A value as a JavaScript literal that is safe INSIDE an HTML `<script>`.\n *\n * `JSON.stringify` alone is a valid literal but not a safe one: a root id\n * holding `</script>` would end the element mid-string, and U+2028/U+2029 end\n * a line in older parsers. Escaping them as `\\uXXXX` keeps the value exactly\n * the same once the script runs — the storage keys and ids this site passes\n * contain none of them, so the emitted script is unchanged for them.\n */\nfunction scriptLiteral(value: unknown): string {\n return JSON.stringify(value).replace(/[<>/\\u2028\\u2029]/g, (char) => SCRIPT_UNSAFE[char]);\n}\n\nfunction restoreGateScript(\n namespace: string,\n rootId: string,\n prefilled: boolean,\n resuming: boolean\n): string {\n const args = [\n attemptStorageKey(namespace),\n ATTEMPT_TTL_MS,\n draftStorageKey(namespace),\n DRAFT_MAX_AGE_MS,\n prefilled,\n resuming,\n ].map(scriptLiteral);\n const id = scriptLiteral(rootId);\n // The fallback: if React has not taken the root over (`data-hydrated`)\n // within RESTORE_FALLBACK_MS — a script error, a stalled bundle — step 1 is\n // shown again rather than a skeleton for ever.\n return `(function(){var on=(${restorePendingInStorage.toString()})(${args.join(',')});if(on){var r=document.getElementById(${id});if(r){r.setAttribute('data-restoring','');setTimeout(function(){var n=document.getElementById(${id});if(n&&!n.hasAttribute('data-hydrated'))n.removeAttribute('data-restoring')},${RESTORE_FALLBACK_MS})}}return on})()`;\n}\n\nexport interface RestoreGate {\n restorePendingInStorage: typeof restorePendingInStorage;\n restoreGateScript(rootId: string, prefilled: boolean, resuming: boolean): string;\n}\n\n/** The gate for the stores under `namespace` (see `createStores`). */\nexport function createRestoreGate(namespace: string): RestoreGate {\n return {\n restorePendingInStorage,\n restoreGateScript: (rootId, prefilled, resuming) =>\n restoreGateScript(namespace, rootId, prefilled, resuming),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,wBACd,YACA,cACA,UACA,eACA,WACA,UACS;CACT,MAAM,QAAQ,QAAgD;EAC5D,IAAI;GACF,MAAM,QAAQ,KAAK,MAAM,OAAO,eAAe,QAAQ,GAAG,KAAK,MAAM;GACrE,OAAO,UAAU,QAAQ,OAAO,UAAU,WAAW,QAAQ;EAC/D,QAAQ;GACN,OAAO;EACT;CACF;CACA,MAAM,MAAM,KAAK,IAAI;CACrB,MAAM,UAAU,KAAK,UAAU;CAC/B,MAAM,OACJ,YAAY,QACZ,OAAO,QAAQ,UAAU,YACzB,OAAO,QAAQ,OAAO,YACtB,MAAM,QAAQ,MAAM;CACtB,IAAI,QAAQ,QAAQ,YAAY,KAAA,GAAW,OAAO;CAGlD,IAAI,QAAQ,QAAQ,cAAc,KAAA,KAAa,CAAC,WAAW,OAAO;CAClE,IAAI,CAAC,UAAU,OAAO;CACtB,MAAM,QAAQ,KAAK,QAAQ;CAC3B,IAAI,UAAU,QAAQ,OAAO,MAAM,YAAY,UAAU,OAAO;CAChE,MAAM,MAAM,MAAM,MAAM;CACxB,OAAO,OAAO,KAAK,OAAO,iBAAiB,MAAM,QAAQ,MAAM,KAAK,KAAK,MAAM,MAAM,SAAS;AAChG;;;;;;;;;;;;;AAcA,MAAa,sBAAsB;;AAGnC,MAAM,gBAAkD;CACtD,KAAK;CACL,KAAK;CACL,KAAK;CACL,UAAU;CACV,UAAU;AACZ;;;;;;;;;;AAWA,SAAS,cAAc,OAAwB;CAC7C,OAAO,KAAK,UAAU,KAAK,CAAC,CAAC,QAAQ,uBAAuB,SAAS,cAAc,KAAK;AAC1F;AAEA,SAAS,kBACP,WACA,QACA,WACA,UACQ;CACR,MAAM,OAAO;EACX,kBAAkB,SAAS;EAC3B;EACA,gBAAgB,SAAS;EACzB;EACA;EACA;CACF,CAAC,CAAC,IAAI,aAAa;CACnB,MAAM,KAAK,cAAc,MAAM;CAI/B,OAAO,uBAAuB,wBAAwB,SAAS,EAAE,IAAI,KAAK,KAAK,GAAG,EAAE,yCAAyC,GAAG,kGAAkG,GAAG,gFAAgF,oBAAoB;AAC3U;;AAQA,SAAgB,kBAAkB,WAAgC;CAChE,OAAO;EACL;EACA,oBAAoB,QAAQ,WAAW,aACrC,kBAAkB,WAAW,QAAQ,WAAW,QAAQ;CAC5D;AACF"}
@@ -0,0 +1,8 @@
1
+ import { AttemptStore } from "./attempt-store.mjs";
2
+ import { DraftStore } from "./draft-store.mjs";
3
+ import { RebookStore } from "./rebook-store.mjs";
4
+ //#region src/core/stores.d.ts
5
+ export type Stores = DraftStore & AttemptStore & RebookStore;
6
+ export declare function createStores(namespace: string): Stores;
7
+ //#endregion
8
+ //# sourceMappingURL=stores.d.mts.map
@@ -0,0 +1,23 @@
1
+ import { createAttemptStore } from "./attempt-store.mjs";
2
+ import { createDraftStore } from "./draft-store.mjs";
3
+ import { createRebookStore } from "./rebook-store.mjs";
4
+ //#region src/core/stores.ts
5
+ /**
6
+ * The three browser stores the wizard keeps, under one namespace.
7
+ *
8
+ * Keys are `<namespace>:booking:draft`, `<namespace>:booking:attempt` and
9
+ * `<namespace>:booking:rebook-who` — so a site moving onto the package keeps
10
+ * every draft and pending attempt a visitor already has, by passing the
11
+ * namespace it used before.
12
+ */
13
+ function createStores(namespace) {
14
+ return {
15
+ ...createDraftStore(namespace),
16
+ ...createAttemptStore(namespace),
17
+ ...createRebookStore(namespace)
18
+ };
19
+ }
20
+ //#endregion
21
+ export { createStores };
22
+
23
+ //# sourceMappingURL=stores.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stores.mjs","names":[],"sources":["../../src/core/stores.ts"],"sourcesContent":["/**\n * The three browser stores the wizard keeps, under one namespace.\n *\n * Keys are `<namespace>:booking:draft`, `<namespace>:booking:attempt` and\n * `<namespace>:booking:rebook-who` — so a site moving onto the package keeps\n * every draft and pending attempt a visitor already has, by passing the\n * namespace it used before.\n */\n\nimport { type AttemptStore, createAttemptStore } from './attempt-store';\nimport { createDraftStore, type DraftStore } from './draft-store';\nimport { createRebookStore, type RebookStore } from './rebook-store';\n\nexport type Stores = DraftStore & AttemptStore & RebookStore;\n\nexport function createStores(namespace: string): Stores {\n return {\n ...createDraftStore(namespace),\n ...createAttemptStore(namespace),\n ...createRebookStore(namespace),\n };\n}\n"],"mappings":";;;;;;;;;;;;AAeA,SAAgB,aAAa,WAA2B;CACtD,OAAO;EACL,GAAG,iBAAiB,SAAS;EAC7B,GAAG,mBAAmB,SAAS;EAC/B,GAAG,kBAAkB,SAAS;CAChC;AACF"}