@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,162 @@
1
+ import { MedalConfigError } from "../medal.mjs";
2
+ import { MedalApiError } from "@medalsocial/sdk";
3
+ import { NextResponse } from "next/server";
4
+ //#region src/next/routes/shared.ts
5
+ /**
6
+ * What the booking routes are allowed to say to a browser.
7
+ *
8
+ * The wizard reads `error` straight into `WizardState.error`, which is a closed
9
+ * union — `'maxParty' | 'slotTaken' | 'conflict' | 'invalidInput' |
10
+ * 'unconfigured' | 'upstreamError'` — so the spelling here is load-bearing
11
+ * rather than descriptive.
12
+ *
13
+ * `upstreamError` is the catch-all. Something has to be in the field either
14
+ * way, because `NextResponse.json({ error: undefined })` serialises to `{}`
15
+ * and reads as a failure with no reason at all.
16
+ *
17
+ * Only the create route is held to the union. `catalogueErrorResponse` also
18
+ * relays the upstream's own code where there is one, so the read routes can
19
+ * say words the wizard has never heard of — which is safe precisely because
20
+ * nothing is reading them into `WizardState`.
21
+ *
22
+ * `message` appears only when THIS file wrote it. Medal's own error bodies are
23
+ * written for the integrator holding the API key — they name workspaces, ids
24
+ * and internal field paths — and none of that belongs in front of a visitor.
25
+ */
26
+ function bookingErrorResponse(code, status, message) {
27
+ return NextResponse.json(message === void 0 ? { error: code } : {
28
+ error: code,
29
+ message
30
+ }, { status });
31
+ }
32
+ /**
33
+ * The catch for the read routes, in one place because it is one behaviour.
34
+ *
35
+ * The upstream *status* is relayed rather than flattened, because it is the
36
+ * part that says what to do — a 404 means this service no longer exists and
37
+ * the wizard should reload the catalogue, a 429 means back off, a 403 means
38
+ * the plan lost the bookings module. The upstream *message* is not.
39
+ *
40
+ * A `MedalApiError` already carries its own explanation on both sides of the
41
+ * wire; a missing API key and an unrecognised throw do not, and those two are
42
+ * logged.
43
+ */
44
+ function catalogueErrorResponse(rt, error, endpoint) {
45
+ if (error instanceof MedalConfigError) {
46
+ rt.logger.error({
47
+ err: error,
48
+ endpoint
49
+ }, "Booking API is not configured");
50
+ return bookingErrorResponse("unconfigured", 503);
51
+ }
52
+ if (error instanceof MedalApiError) return bookingErrorResponse(error.code ?? "upstreamError", error.status);
53
+ rt.logger.error({
54
+ err: error,
55
+ endpoint
56
+ }, "Unexpected booking API failure");
57
+ return bookingErrorResponse("upstreamError", 502);
58
+ }
59
+ /** What a service id looks like: Medal's ids are word characters and dashes. */
60
+ const SERVICE_ID_SHAPE = /^[\w-]{1,64}$/;
61
+ /**
62
+ * A `service_id` from the query string, checked before it can reach a cache
63
+ * key or a cache tag: the right shape, and a service the catalogue actually
64
+ * has. Otherwise every invented id would be a Medal call and a cache entry.
65
+ *
66
+ * The catalogue read is the cached one, so a real id costs nothing extra.
67
+ */
68
+ async function knownServiceId(rt, raw) {
69
+ if (!SERVICE_ID_SHAPE.test(raw)) return { error: bookingErrorResponse("invalidInput", 400, "service_id is malformed") };
70
+ let services;
71
+ try {
72
+ services = await rt.catalogue.cachedServices();
73
+ } catch (error) {
74
+ return { error: catalogueErrorResponse(rt, error, "services") };
75
+ }
76
+ if (!services.some((service) => service.id === raw)) return { error: bookingErrorResponse("invalidInput", 400, "Unknown service_id") };
77
+ return { serviceId: raw };
78
+ }
79
+ /**
80
+ * `extra_service_ids` from the query string — the rest of ONE person's visit
81
+ * after `service_id`, comma-separated, in order. Blank entries are dropped, so
82
+ * an absent, empty or `,`-only value is a one-service visit (`[]`).
83
+ *
84
+ * Held to everything the engine would refuse with a 422 (more than three, one
85
+ * twice, `service_id` again) and to what `knownServiceId` holds `service_id` to
86
+ * (the shape, and a service the catalogue has), for the same reason: every id
87
+ * here lands in a cache key and a cache tag. Call it after `knownServiceId`.
88
+ */
89
+ async function knownExtraServiceIds(rt, raw, serviceId) {
90
+ const ids = (raw ?? "").split(",").map((id) => id.trim()).filter((id) => id.length > 0);
91
+ if (ids.length === 0) return { extraServiceIds: [] };
92
+ const refuse = (message) => ({ error: bookingErrorResponse("invalidInput", 400, message) });
93
+ if (ids.length > 3) return refuse(`extra_service_ids must name at most 3 services`);
94
+ if (new Set(ids).size !== ids.length) return refuse("extra_service_ids names a service twice");
95
+ if (ids.includes(serviceId)) return refuse("extra_service_ids repeats service_id");
96
+ if (!ids.every((id) => SERVICE_ID_SHAPE.test(id))) return refuse("extra_service_ids is malformed");
97
+ let services;
98
+ try {
99
+ services = await rt.catalogue.cachedServices();
100
+ } catch (error) {
101
+ return { error: catalogueErrorResponse(rt, error, "services") };
102
+ }
103
+ const known = new Set(services.map((service) => service.id));
104
+ if (!ids.every((id) => known.has(id))) return refuse("Unknown service in extra_service_ids");
105
+ return { extraServiceIds: ids };
106
+ }
107
+ /**
108
+ * The engine's «that slot is no longer free».
109
+ *
110
+ * It arrives as `CONFLICT` with `SLOT_TAKEN` in the message: the SDK exposes no
111
+ * finer code (`MedalApiError.code` is the generic `CONFLICT`, shared with a
112
+ * duplicate key and an unseatable party). The message is read HERE and nowhere
113
+ * else, so the day the engine grows a structured code there is one line to
114
+ * change.
115
+ */
116
+ function isSlotTaken(error) {
117
+ return error instanceof MedalApiError && error.code === "CONFLICT" && error.message.includes("SLOT_TAKEN");
118
+ }
119
+ /**
120
+ * 62 days. A bound on what a visitor-controlled query string may ask the site
121
+ * to fetch on their behalf — not the engine's own limit, which clamps rather
122
+ * than refuses. The wizard asks for a week at a time.
123
+ */
124
+ const MAX_RANGE_MS = 53568e5;
125
+ /**
126
+ * Epoch milliseconds or an ISO 8601 string — the same two forms the engine's
127
+ * own parser accepts, so a bound this site admits is one Medal would have
128
+ * admitted too. `null` for anything it cannot read as a time.
129
+ */
130
+ function parseTimestamp(raw) {
131
+ const trimmed = raw?.trim();
132
+ if (!trimmed) return null;
133
+ const numeric = Number(trimmed);
134
+ const timestamp = Number.isFinite(numeric) ? numeric : Date.parse(trimmed);
135
+ return Number.isFinite(timestamp) ? timestamp : null;
136
+ }
137
+ /**
138
+ * The `from_ts`/`to_ts` pair the read routes take, validated before Medal is
139
+ * called. Every guard answers a request the engine would also refuse, and an
140
+ * unreadable bound would otherwise throw inside the client and surface as an
141
+ * opaque 502 pointing at Medal for a typo in our own query string.
142
+ */
143
+ function parseRange(searchParams) {
144
+ const fromTs = parseTimestamp(searchParams.get("from_ts"));
145
+ const toTs = parseTimestamp(searchParams.get("to_ts"));
146
+ if (fromTs === null || toTs === null) return { error: bookingErrorResponse("invalidInput", 400, "from_ts and to_ts must be Unix milliseconds or ISO 8601 date-times") };
147
+ if (toTs <= fromTs) return { error: bookingErrorResponse("invalidInput", 400, "to_ts must be after from_ts") };
148
+ if (toTs - fromTs > MAX_RANGE_MS) return { error: bookingErrorResponse("invalidInput", 400, "Range must not exceed 62 days") };
149
+ return {
150
+ fromTs,
151
+ toTs
152
+ };
153
+ }
154
+ /** SHA-256, hex — for idempotency keys that must never carry what they hash. */
155
+ async function digestOf(input) {
156
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
157
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
158
+ }
159
+ //#endregion
160
+ export { bookingErrorResponse, catalogueErrorResponse, digestOf, isSlotTaken, knownExtraServiceIds, knownServiceId, parseRange };
161
+
162
+ //# sourceMappingURL=shared.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared.mjs","names":[],"sources":["../../../src/next/routes/shared.ts"],"sourcesContent":["/**\n * What the booking routes are allowed to say to a browser.\n *\n * The wizard reads `error` straight into `WizardState.error`, which is a closed\n * union — `'maxParty' | 'slotTaken' | 'conflict' | 'invalidInput' |\n * 'unconfigured' | 'upstreamError'` — so the spelling here is load-bearing\n * rather than descriptive.\n *\n * `upstreamError` is the catch-all. Something has to be in the field either\n * way, because `NextResponse.json({ error: undefined })` serialises to `{}`\n * and reads as a failure with no reason at all.\n *\n * Only the create route is held to the union. `catalogueErrorResponse` also\n * relays the upstream's own code where there is one, so the read routes can\n * say words the wizard has never heard of — which is safe precisely because\n * nothing is reading them into `WizardState`.\n *\n * `message` appears only when THIS file wrote it. Medal's own error bodies are\n * written for the integrator holding the API key — they name workspaces, ids\n * and internal field paths — and none of that belongs in front of a visitor.\n */\n\nimport { MedalApiError } from '@medalsocial/sdk';\nimport { NextResponse } from 'next/server';\nimport { MedalConfigError } from '../medal';\nimport type { BookingRuntime } from '../runtime';\n\nexport function bookingErrorResponse(code: string, status: number, message?: string): NextResponse {\n return NextResponse.json(message === undefined ? { error: code } : { error: code, message }, {\n status,\n });\n}\n\n/**\n * The catch for the read routes, in one place because it is one behaviour.\n *\n * The upstream *status* is relayed rather than flattened, because it is the\n * part that says what to do — a 404 means this service no longer exists and\n * the wizard should reload the catalogue, a 429 means back off, a 403 means\n * the plan lost the bookings module. The upstream *message* is not.\n *\n * A `MedalApiError` already carries its own explanation on both sides of the\n * wire; a missing API key and an unrecognised throw do not, and those two are\n * logged.\n */\nexport function catalogueErrorResponse(\n rt: Pick<BookingRuntime, 'logger'>,\n error: unknown,\n endpoint: string\n): NextResponse {\n if (error instanceof MedalConfigError) {\n rt.logger.error({ err: error, endpoint }, 'Booking API is not configured');\n return bookingErrorResponse('unconfigured', 503);\n }\n if (error instanceof MedalApiError) {\n return bookingErrorResponse(error.code ?? 'upstreamError', error.status);\n }\n rt.logger.error({ err: error, endpoint }, 'Unexpected booking API failure');\n return bookingErrorResponse('upstreamError', 502);\n}\n\n/** What a service id looks like: Medal's ids are word characters and dashes. */\nconst SERVICE_ID_SHAPE = /^[\\w-]{1,64}$/;\n\n/**\n * A `service_id` from the query string, checked before it can reach a cache\n * key or a cache tag: the right shape, and a service the catalogue actually\n * has. Otherwise every invented id would be a Medal call and a cache entry.\n *\n * The catalogue read is the cached one, so a real id costs nothing extra.\n */\nexport async function knownServiceId(\n rt: Pick<BookingRuntime, 'logger' | 'catalogue'>,\n raw: string\n): Promise<{ serviceId: string } | { error: NextResponse }> {\n if (!SERVICE_ID_SHAPE.test(raw)) {\n return { error: bookingErrorResponse('invalidInput', 400, 'service_id is malformed') };\n }\n let services: Array<{ id: string }>;\n try {\n services = await rt.catalogue.cachedServices();\n } catch (error) {\n return { error: catalogueErrorResponse(rt, error, 'services') };\n }\n if (!services.some((service) => service.id === raw)) {\n return { error: bookingErrorResponse('invalidInput', 400, 'Unknown service_id') };\n }\n return { serviceId: raw };\n}\n\n/**\n * How many services one person's visit may add after the first — the engine's\n * own bound (four services in all), refused here so a visitor's query string\n * cannot make it a Medal call.\n */\nexport const MAX_EXTRA_SERVICES = 3;\n\n/**\n * `extra_service_ids` from the query string — the rest of ONE person's visit\n * after `service_id`, comma-separated, in order. Blank entries are dropped, so\n * an absent, empty or `,`-only value is a one-service visit (`[]`).\n *\n * Held to everything the engine would refuse with a 422 (more than three, one\n * twice, `service_id` again) and to what `knownServiceId` holds `service_id` to\n * (the shape, and a service the catalogue has), for the same reason: every id\n * here lands in a cache key and a cache tag. Call it after `knownServiceId`.\n */\nexport async function knownExtraServiceIds(\n rt: Pick<BookingRuntime, 'logger' | 'catalogue'>,\n raw: string | null,\n serviceId: string\n): Promise<{ extraServiceIds: string[] } | { error: NextResponse }> {\n const ids = (raw ?? '')\n .split(',')\n .map((id) => id.trim())\n .filter((id) => id.length > 0);\n if (ids.length === 0) return { extraServiceIds: [] };\n const refuse = (message: string) => ({\n error: bookingErrorResponse('invalidInput', 400, message),\n });\n if (ids.length > MAX_EXTRA_SERVICES) {\n return refuse(`extra_service_ids must name at most ${MAX_EXTRA_SERVICES} services`);\n }\n if (new Set(ids).size !== ids.length) return refuse('extra_service_ids names a service twice');\n if (ids.includes(serviceId)) return refuse('extra_service_ids repeats service_id');\n if (!ids.every((id) => SERVICE_ID_SHAPE.test(id))) {\n return refuse('extra_service_ids is malformed');\n }\n let services: Array<{ id: string }>;\n try {\n services = await rt.catalogue.cachedServices();\n } catch (error) {\n return { error: catalogueErrorResponse(rt, error, 'services') };\n }\n const known = new Set(services.map((service) => service.id));\n if (!ids.every((id) => known.has(id))) return refuse('Unknown service in extra_service_ids');\n return { extraServiceIds: ids };\n}\n\n/**\n * The engine's «that slot is no longer free».\n *\n * It arrives as `CONFLICT` with `SLOT_TAKEN` in the message: the SDK exposes no\n * finer code (`MedalApiError.code` is the generic `CONFLICT`, shared with a\n * duplicate key and an unseatable party). The message is read HERE and nowhere\n * else, so the day the engine grows a structured code there is one line to\n * change.\n */\nexport function isSlotTaken(error: unknown): boolean {\n return (\n error instanceof MedalApiError &&\n error.code === 'CONFLICT' &&\n error.message.includes('SLOT_TAKEN')\n );\n}\n\n/**\n * 62 days. A bound on what a visitor-controlled query string may ask the site\n * to fetch on their behalf — not the engine's own limit, which clamps rather\n * than refuses. The wizard asks for a week at a time.\n */\nconst MAX_RANGE_MS = 62 * 24 * 60 * 60 * 1000;\n\n/**\n * Epoch milliseconds or an ISO 8601 string — the same two forms the engine's\n * own parser accepts, so a bound this site admits is one Medal would have\n * admitted too. `null` for anything it cannot read as a time.\n */\nfunction parseTimestamp(raw: string | null): number | null {\n const trimmed = raw?.trim();\n if (!trimmed) return null;\n const numeric = Number(trimmed);\n const timestamp = Number.isFinite(numeric) ? numeric : Date.parse(trimmed);\n return Number.isFinite(timestamp) ? timestamp : null;\n}\n\n/**\n * The `from_ts`/`to_ts` pair the read routes take, validated before Medal is\n * called. Every guard answers a request the engine would also refuse, and an\n * unreadable bound would otherwise throw inside the client and surface as an\n * opaque 502 pointing at Medal for a typo in our own query string.\n */\nexport function parseRange(\n searchParams: URLSearchParams\n): { fromTs: number; toTs: number } | { error: NextResponse } {\n const fromTs = parseTimestamp(searchParams.get('from_ts'));\n const toTs = parseTimestamp(searchParams.get('to_ts'));\n if (fromTs === null || toTs === null) {\n return {\n error: bookingErrorResponse(\n 'invalidInput',\n 400,\n 'from_ts and to_ts must be Unix milliseconds or ISO 8601 date-times'\n ),\n };\n }\n if (toTs <= fromTs) {\n return { error: bookingErrorResponse('invalidInput', 400, 'to_ts must be after from_ts') };\n }\n if (toTs - fromTs > MAX_RANGE_MS) {\n return { error: bookingErrorResponse('invalidInput', 400, 'Range must not exceed 62 days') };\n }\n return { fromTs, toTs };\n}\n\n/** SHA-256, hex — for idempotency keys that must never carry what they hash. */\nexport async function digestOf(input: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));\n return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');\n}\n"],"mappings":"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"}
@@ -0,0 +1,166 @@
1
+ import { isVippsToken } from "../../core/portal/vipps-link.mjs";
2
+ import { VIPPS_CONFIRM_QUERY, maskedAddress } from "../../core/portal/vipps-return.mjs";
3
+ import { PortalSessionExpiredError, PortalThrottledError } from "../portal/medal-portal.mjs";
4
+ import { NextResponse } from "next/server";
5
+ //#region src/next/routes/vipps-return.ts
6
+ /**
7
+ * The two addresses Vipps (through Medal) sends a browser back to. Route
8
+ * handlers and not pages: only an action or a route handler may write a
9
+ * cookie, and a page cannot. They return NO body, so nothing a third party
10
+ * wrote into the query string can be echoed into HTML, and the only thing
11
+ * they do with a query value is compare it to a constant. Redirect targets
12
+ * are built on the request's own origin.
13
+ */
14
+ /** Whether the portal is on for this request (the site's switch, else the config). */
15
+ async function portalEnabled(rt) {
16
+ if (rt.paths.portal === null) return false;
17
+ const enabled = rt.options.portal?.enabled;
18
+ return enabled ? enabled() : rt.config.portal.enabled;
19
+ }
20
+ function seeOther(path, request, headers = {}) {
21
+ const location = new URL(path, request.url);
22
+ return new NextResponse(null, {
23
+ status: 303,
24
+ headers: {
25
+ Location: location.toString(),
26
+ "Cache-Control": "no-store",
27
+ ...headers
28
+ }
29
+ });
30
+ }
31
+ const VIPPS_LOGIN_QUERY = "vipps";
32
+ /**
33
+ * `GET <vippsReturnPath>` — where Vipps sends the parent back, and the one
34
+ * place a Vipps grant becomes a session cookie.
35
+ *
36
+ * Medal lands the browser here with exactly one of: `?grant=<opaque>` (a
37
+ * session is waiting), `?vipps=cancelled`, `?vipps=needs_email_login` (more
38
+ * than one profile, log in with e-mail to pick one), `?vipps=confirm_email`
39
+ * (one plausible profile, a code is on its way) or `?vipps=failed`.
40
+ *
41
+ * The cookie is written on ONE branch: a grant Medal accepted, arriving in
42
+ * the browser that started the login (it holds the binding cookie). A grant
43
+ * in any other browser is never exchanged. That, a grant Medal refused, or an
44
+ * exchange that could not be made at all goes to the login with
45
+ * `?vipps=failed` and the e-mail form; so does anything unrecognised.
46
+ *
47
+ * WHERE THE PARENT LANDS is the portal unless a login that started somewhere
48
+ * else said otherwise; that destination travels in a read-once httpOnly
49
+ * cookie (`core/portal/next-path.ts`), re-validated on the way out, and it is
50
+ * taken on EVERY branch.
51
+ */
52
+ async function vippsReturnRoute(rt, request) {
53
+ if (!await portalEnabled(rt)) return seeOther("/", request);
54
+ const portalPath = rt.paths.portal;
55
+ const login = rt.paths.portalLogin;
56
+ const loginWith = (outcome) => `${login}?${VIPPS_LOGIN_QUERY}=${outcome}`;
57
+ const { searchParams } = new URL(request.url);
58
+ const grant = searchParams.get("grant");
59
+ const next = await rt.nextPath.takePortalNextPath();
60
+ if (grant) {
61
+ if (await rt.vippsLink.readBrowserBinding() === null) {
62
+ rt.logger.warn({}, "Vipps grant arrived without this browser’s login binding");
63
+ return seeOther(next ?? loginWith("failed"), request);
64
+ }
65
+ await rt.vippsLink.clearBrowserBinding();
66
+ let session;
67
+ try {
68
+ session = await rt.portal.exchangeVippsGrant(grant);
69
+ } catch (error) {
70
+ rt.logger.error({ err: error }, "Vipps grant could not be exchanged");
71
+ return seeOther(next ?? loginWith("failed"), request);
72
+ }
73
+ if (session === null) return seeOther(next ?? loginWith("failed"), request);
74
+ await rt.session.writePortalSession(session.sessionToken, session.expiresAt);
75
+ return seeOther(next ?? portalPath, request);
76
+ }
77
+ if (searchParams.get(VIPPS_LOGIN_QUERY) === "confirm_email") {
78
+ const link = searchParams.getAll("link");
79
+ if (link.length !== 1 || !isVippsToken(link[0]) || await rt.vippsLink.readBrowserBinding() === null) return seeOther(next ?? loginWith("failed"), request);
80
+ await rt.vippsLink.writePendingLink(link[0]);
81
+ const target = new URL(next ?? login, request.url);
82
+ target.searchParams.set(VIPPS_LOGIN_QUERY, VIPPS_CONFIRM_QUERY);
83
+ const to = maskedAddress(searchParams.get("to"));
84
+ if (to !== null) target.searchParams.set("to", to);
85
+ return seeOther(`${target.pathname}${target.search}`, request);
86
+ }
87
+ if (next !== null) return seeOther(next, request);
88
+ switch (searchParams.get(VIPPS_LOGIN_QUERY)) {
89
+ case "cancelled": return seeOther(login, request);
90
+ case "needs_email_login": return seeOther(loginWith("needs_email_login"), request);
91
+ default: return seeOther(loginWith("failed"), request);
92
+ }
93
+ }
94
+ const GRANT_KEY = "link_grant";
95
+ const MARKER_KEY = "vipps";
96
+ /**
97
+ * What a bare marker may say: a sentence at most. `linked` without a grant is
98
+ * not proof of anything and says nothing; `cancelled` says nothing either.
99
+ */
100
+ function markerFlash(value) {
101
+ if (value === "linked" || value === "cancelled") return null;
102
+ if (value === "link_conflict") return "link_conflict";
103
+ return "link_failed";
104
+ }
105
+ /**
106
+ * `GET <vippsLinkReturnPath>` — where Vipps, via Medal, sends a LOGGED-IN
107
+ * parent back from «link Vipps» on the profile.
108
+ *
109
+ * `?link_grant=<43 chars>` is a one-time link grant this handler spends with
110
+ * the parent's session and this browser's binding; `?vipps=link_conflict`,
111
+ * `cancelled` or `failed` are bare markers.
112
+ *
113
+ * NOTHING IN THE QUERY IS TRUSTED AS AN OUTCOME, AND NOTHING STAYS IN IT.
114
+ * Every answer is a 303 to a bare portal URL with `Referrer-Policy:
115
+ * no-referrer`; the outcome travels as an httpOnly flash cookie, and `linked`
116
+ * is written ONLY after Medal accepted a grant this handler exchanged.
117
+ *
118
+ * The binding is spent once Medal has answered about an exchanged grant. A
119
+ * navigation without a grant, a malformed grant, a throttle or an outage
120
+ * leave it to expire on its own. A session Medal no longer honours is cleared
121
+ * here and the parent goes to the login.
122
+ */
123
+ async function vippsLinkReturnRoute(rt, request) {
124
+ const noReferrer = { "Referrer-Policy": "no-referrer" };
125
+ if (!await portalEnabled(rt)) return seeOther("/", request, noReferrer);
126
+ const portalPath = rt.paths.portal;
127
+ const session = await rt.session.readPortalSession();
128
+ if (session === null) return seeOther(rt.paths.portalLogin, request, noReferrer);
129
+ const landWith = async (flash) => {
130
+ if (flash !== null) await rt.flash.writeVippsLinkFlash(flash);
131
+ return seeOther(portalPath, request, noReferrer);
132
+ };
133
+ const { searchParams } = new URL(request.url);
134
+ const grants = searchParams.getAll(GRANT_KEY);
135
+ if (grants.length > 0) {
136
+ const flash = await spendGrant(rt, session, grants);
137
+ if (flash === "session") return seeOther(rt.paths.portalLogin, request, noReferrer);
138
+ return landWith(flash);
139
+ }
140
+ return landWith(markerFlash(searchParams.get(MARKER_KEY)));
141
+ }
142
+ /** Spend one well-formed grant with this browser's binding; what the profile should say. */
143
+ async function spendGrant(rt, session, grants) {
144
+ const binding = await rt.vippsLink.readBrowserBinding();
145
+ if (grants.length !== 1 || !isVippsToken(grants[0]) || binding === null) return "link_failed";
146
+ try {
147
+ const outcome = await rt.portal.completeVippsLink(session, {
148
+ grant: grants[0],
149
+ browserBinding: binding
150
+ });
151
+ await rt.vippsLink.clearBrowserBinding();
152
+ if (outcome === "linked") return "linked";
153
+ return outcome === "conflict" ? "link_conflict" : "link_failed";
154
+ } catch (error) {
155
+ if (error instanceof PortalSessionExpiredError) {
156
+ await rt.session.clearPortalSession();
157
+ return "session";
158
+ }
159
+ if (!(error instanceof PortalThrottledError)) rt.logger.error({ err: error }, "Vipps link grant could not be completed");
160
+ return "link_failed";
161
+ }
162
+ }
163
+ //#endregion
164
+ export { portalEnabled, vippsLinkReturnRoute, vippsReturnRoute };
165
+
166
+ //# sourceMappingURL=vipps-return.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vipps-return.mjs","names":[],"sources":["../../../src/next/routes/vipps-return.ts"],"sourcesContent":["/**\n * The two addresses Vipps (through Medal) sends a browser back to. Route\n * handlers and not pages: only an action or a route handler may write a\n * cookie, and a page cannot. They return NO body, so nothing a third party\n * wrote into the query string can be echoed into HTML, and the only thing\n * they do with a query value is compare it to a constant. Redirect targets\n * are built on the request's own origin.\n */\n\nimport { NextResponse } from 'next/server';\nimport { isVippsToken } from '../../core/portal/vipps-link';\nimport { maskedAddress, VIPPS_CONFIRM_QUERY } from '../../core/portal/vipps-return';\nimport { PortalSessionExpiredError, PortalThrottledError } from '../portal/medal-portal';\nimport type { VippsLinkFlash } from '../portal/vipps-flash';\nimport type { BookingRuntime } from '../runtime';\n\n/** Whether the portal is on for this request (the site's switch, else the config). */\nexport async function portalEnabled(rt: BookingRuntime): Promise<boolean> {\n if (rt.paths.portal === null) return false;\n const enabled = rt.options.portal?.enabled;\n return enabled ? enabled() : rt.config.portal.enabled;\n}\n\nfunction seeOther(path: string, request: Request, headers: Record<string, string> = {}): Response {\n const location = new URL(path, request.url);\n return new NextResponse(null, {\n status: 303,\n headers: { Location: location.toString(), 'Cache-Control': 'no-store', ...headers },\n });\n}\n\nconst VIPPS_LOGIN_QUERY = 'vipps';\n\n/**\n * `GET <vippsReturnPath>` — where Vipps sends the parent back, and the one\n * place a Vipps grant becomes a session cookie.\n *\n * Medal lands the browser here with exactly one of: `?grant=<opaque>` (a\n * session is waiting), `?vipps=cancelled`, `?vipps=needs_email_login` (more\n * than one profile, log in with e-mail to pick one), `?vipps=confirm_email`\n * (one plausible profile, a code is on its way) or `?vipps=failed`.\n *\n * The cookie is written on ONE branch: a grant Medal accepted, arriving in\n * the browser that started the login (it holds the binding cookie). A grant\n * in any other browser is never exchanged. That, a grant Medal refused, or an\n * exchange that could not be made at all goes to the login with\n * `?vipps=failed` and the e-mail form; so does anything unrecognised.\n *\n * WHERE THE PARENT LANDS is the portal unless a login that started somewhere\n * else said otherwise; that destination travels in a read-once httpOnly\n * cookie (`core/portal/next-path.ts`), re-validated on the way out, and it is\n * taken on EVERY branch.\n */\nexport async function vippsReturnRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n // A route handler runs no layout, so the portal's gate is repeated here:\n // this is the one address under the account a third party can send a\n // browser to.\n if (!(await portalEnabled(rt))) return seeOther('/', request);\n const portalPath = rt.paths.portal as string;\n const login = rt.paths.portalLogin;\n const loginWith = (outcome: 'needs_email_login' | 'failed') =>\n `${login}?${VIPPS_LOGIN_QUERY}=${outcome}`;\n\n const { searchParams } = new URL(request.url);\n const grant = searchParams.get('grant');\n // Read once, before any branch returns: the value describes THIS attempt.\n const next = await rt.nextPath.takePortalNextPath();\n\n if (grant) {\n // LOGIN CSRF. A grant is a session for whichever Vipps account finished\n // the login, so one lifted from an attacker's own callback and sent to a\n // parent as a link would sign the parent into the attacker's account —\n // where they would then type their own and their children's details.\n // Only the browser that STARTED a login holds its binding (minted per\n // attempt by `startVipps`, httpOnly and `__Host-`), so no binding (a\n // malformed one reads as none) is no exchange: the grant is left unspent\n // and the parent goes to the login. The binding is read before anything\n // is cleared, and spent only once it has been checked.\n //\n // PRESENCE, NOT A MATCH. Medal's `/vipps/exchange` takes the grant alone\n // and does not keep the binding with it, so this proves the browser\n // started A Vipps login in the last fifteen minutes — not that the grant\n // came from THAT login. Binding the grant itself is Medal's half: it has\n // to keep the binding's hash on the grant and require it on exchange, as\n // `/me/vipps/link/complete` already does.\n const binding = await rt.vippsLink.readBrowserBinding();\n if (binding === null) {\n rt.logger.warn({}, 'Vipps grant arrived without this browser’s login binding');\n return seeOther(next ?? loginWith('failed'), request);\n }\n // A grant needs no pending link, so this attempt's binding is spent.\n await rt.vippsLink.clearBrowserBinding();\n let session: Awaited<ReturnType<BookingRuntime['portal']['exchangeVippsGrant']>>;\n try {\n session = await rt.portal.exchangeVippsGrant(grant);\n } catch (error) {\n rt.logger.error({ err: error }, 'Vipps grant could not be exchanged');\n return seeOther(next ?? loginWith('failed'), request);\n }\n if (session === null) return seeOther(next ?? loginWith('failed'), request);\n await rt.session.writePortalSession(session.sessionToken, session.expiresAt);\n return seeOther(next ?? portalPath, request);\n }\n\n // Medal found one plausible profile and e-mailed its address a code. The\n // link moves into an httpOnly cookie scoped to the route that spends it,\n // and the parent goes on with only the marker and the masked address.\n if (searchParams.get(VIPPS_LOGIN_QUERY) === VIPPS_CONFIRM_QUERY) {\n const link = searchParams.getAll('link');\n // No binding cookie means this browser did not start the login — or its\n // cookie expired — and Medal would refuse the code whatever was typed.\n if (\n link.length !== 1 ||\n !isVippsToken(link[0]) ||\n (await rt.vippsLink.readBrowserBinding()) === null\n ) {\n return seeOther(next ?? loginWith('failed'), request);\n }\n await rt.vippsLink.writePendingLink(link[0]);\n const target = new URL(next ?? login, request.url);\n target.searchParams.set(VIPPS_LOGIN_QUERY, VIPPS_CONFIRM_QUERY);\n const to = maskedAddress(searchParams.get('to'));\n if (to !== null) target.searchParams.set('to', to);\n return seeOther(`${target.pathname}${target.search}`, request);\n }\n\n // A login that started outside the portal goes back where it started,\n // whatever the outcome was.\n if (next !== null) return seeOther(next, request);\n\n switch (searchParams.get(VIPPS_LOGIN_QUERY)) {\n case 'cancelled':\n return seeOther(login, request);\n case 'needs_email_login':\n return seeOther(loginWith('needs_email_login'), request);\n default:\n return seeOther(loginWith('failed'), request);\n }\n}\n\nconst GRANT_KEY = 'link_grant';\nconst MARKER_KEY = 'vipps';\n\n/**\n * What a bare marker may say: a sentence at most. `linked` without a grant is\n * not proof of anything and says nothing; `cancelled` says nothing either.\n */\nfunction markerFlash(value: string | null): VippsLinkFlash | null {\n if (value === 'linked' || value === 'cancelled') return null;\n if (value === 'link_conflict') return 'link_conflict';\n return 'link_failed';\n}\n\n/**\n * `GET <vippsLinkReturnPath>` — where Vipps, via Medal, sends a LOGGED-IN\n * parent back from «link Vipps» on the profile.\n *\n * `?link_grant=<43 chars>` is a one-time link grant this handler spends with\n * the parent's session and this browser's binding; `?vipps=link_conflict`,\n * `cancelled` or `failed` are bare markers.\n *\n * NOTHING IN THE QUERY IS TRUSTED AS AN OUTCOME, AND NOTHING STAYS IN IT.\n * Every answer is a 303 to a bare portal URL with `Referrer-Policy:\n * no-referrer`; the outcome travels as an httpOnly flash cookie, and `linked`\n * is written ONLY after Medal accepted a grant this handler exchanged.\n *\n * The binding is spent once Medal has answered about an exchanged grant. A\n * navigation without a grant, a malformed grant, a throttle or an outage\n * leave it to expire on its own. A session Medal no longer honours is cleared\n * here and the parent goes to the login.\n */\nexport async function vippsLinkReturnRoute(\n rt: BookingRuntime,\n request: Request\n): Promise<Response> {\n const noReferrer = { 'Referrer-Policy': 'no-referrer' };\n if (!(await portalEnabled(rt))) return seeOther('/', request, noReferrer);\n const portalPath = rt.paths.portal as string;\n\n const session = await rt.session.readPortalSession();\n if (session === null) return seeOther(rt.paths.portalLogin, request, noReferrer);\n\n const landWith = async (flash: VippsLinkFlash | null) => {\n if (flash !== null) await rt.flash.writeVippsLinkFlash(flash);\n return seeOther(portalPath, request, noReferrer);\n };\n\n const { searchParams } = new URL(request.url);\n const grants = searchParams.getAll(GRANT_KEY);\n if (grants.length > 0) {\n const flash = await spendGrant(rt, session, grants);\n if (flash === 'session') return seeOther(rt.paths.portalLogin, request, noReferrer);\n return landWith(flash);\n }\n return landWith(markerFlash(searchParams.get(MARKER_KEY)));\n}\n\n/** Spend one well-formed grant with this browser's binding; what the profile should say. */\nasync function spendGrant(\n rt: BookingRuntime,\n session: string,\n grants: string[]\n): Promise<VippsLinkFlash | 'session'> {\n const binding = await rt.vippsLink.readBrowserBinding();\n if (grants.length !== 1 || !isVippsToken(grants[0]) || binding === null) return 'link_failed';\n try {\n const outcome = await rt.portal.completeVippsLink(session, {\n grant: grants[0],\n browserBinding: binding,\n });\n // Medal has answered about THIS grant, and every answer spends it.\n await rt.vippsLink.clearBrowserBinding();\n if (outcome === 'linked') return 'linked';\n return outcome === 'conflict' ? 'link_conflict' : 'link_failed';\n } catch (error) {\n if (error instanceof PortalSessionExpiredError) {\n await rt.session.clearPortalSession();\n return 'session';\n }\n if (!(error instanceof PortalThrottledError)) {\n rt.logger.error({ err: error }, 'Vipps link grant could not be completed');\n }\n return 'link_failed';\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,49 @@
1
+ import { BookingConfig } from "../core/config.mjs";
2
+ import { createClock } from "../core/clock.mjs";
3
+ import { createDto } from "../core/dto.mjs";
4
+ import { createPaths } from "../core/paths.mjs";
5
+ import { createPhone } from "../core/phone.mjs";
6
+ import { CookieSource } from "../core/portal/cookies.mjs";
7
+ import { createPortalNextPath } from "../core/portal/next-path.mjs";
8
+ import { createVippsLinkCookies } from "../core/portal/vipps-link.mjs";
9
+ import { MedalSeam } from "./medal.mjs";
10
+ import { BookingLogger, BookingServerOptions, PortalMessages } from "./options.mjs";
11
+ import { Catalogue } from "./catalogue.mjs";
12
+ import { PortalSeam } from "./portal/medal-portal.mjs";
13
+ import { createGuardian } from "./portal/guardian.mjs";
14
+ import { PortalSession } from "./portal/session.mjs";
15
+ import { VippsFlash } from "./portal/vipps-flash.mjs";
16
+ import { Seed } from "./seed.mjs";
17
+ //#region src/next/runtime.d.ts
18
+ export interface BookingRuntime {
19
+ options: BookingServerOptions;
20
+ config: Readonly<BookingConfig>;
21
+ logger: BookingLogger;
22
+ cookies: CookieSource;
23
+ medal: MedalSeam;
24
+ catalogue: Catalogue;
25
+ seed: Seed;
26
+ portal: PortalSeam;
27
+ session: PortalSession;
28
+ guardianFromSession: ReturnType<typeof createGuardian>;
29
+ nextPath: ReturnType<typeof createPortalNextPath>;
30
+ vippsLink: ReturnType<typeof createVippsLinkCookies>;
31
+ flash: VippsFlash;
32
+ dto: ReturnType<typeof createDto>;
33
+ clock: ReturnType<typeof createClock>;
34
+ phone: ReturnType<typeof createPhone>;
35
+ paths: ReturnType<typeof createPaths> & {
36
+ /** `null` when the site has no portal. */
37
+ portal: string | null;
38
+ portalLogin: string;
39
+ sessionExpired: string;
40
+ vippsReturn: string;
41
+ vippsLinkReturn: string;
42
+ };
43
+ messages: PortalMessages;
44
+ /** The site's canonical origin, for a request that names none. */
45
+ baseUrl(): string | undefined;
46
+ }
47
+ export declare function createBookingRuntime(options: BookingServerOptions, overrides?: Partial<BookingRuntime>): BookingRuntime;
48
+ //#endregion
49
+ //# sourceMappingURL=runtime.d.mts.map
@@ -0,0 +1,94 @@
1
+ import { createClock } from "../core/clock.mjs";
2
+ import { createDto } from "../core/dto.mjs";
3
+ import { createPaths } from "../core/paths.mjs";
4
+ import { createPhone } from "../core/phone.mjs";
5
+ import { createPortalNextPath } from "../core/portal/next-path.mjs";
6
+ import { createVippsLinkCookies } from "../core/portal/vipps-link.mjs";
7
+ import { DEFAULT_PORTAL_MESSAGES, SILENT_LOGGER } from "./options.mjs";
8
+ import { createCatalogue } from "./catalogue.mjs";
9
+ import { createMedalSeam } from "./medal.mjs";
10
+ import { createGuardian } from "./portal/guardian.mjs";
11
+ import { createPortalSeam } from "./portal/medal-portal.mjs";
12
+ import { createPortalSession } from "./portal/session.mjs";
13
+ import { createVippsFlash } from "./portal/vipps-flash.mjs";
14
+ import { createSeed } from "./seed.mjs";
15
+ import { cookies } from "next/headers";
16
+ //#region src/next/runtime.ts
17
+ /**
18
+ * One booking site's server half, wired once: the Medal seam, the two caches,
19
+ * the seed, the portal seam and the cookie helpers, all built from one
20
+ * `BookingServerOptions`. The handler, the loaders and the portal actions are
21
+ * views over the same runtime, so a site that builds one with
22
+ * `createBookingServer` shares its in-flight refreshes and its clients
23
+ * across all three.
24
+ *
25
+ * Every part can be replaced (`overrides`) — the tests do, and a site with an
26
+ * unusual Medal setup can.
27
+ */
28
+ /** Next's own jar, typed as the package's (a structural subset). */
29
+ const defaultCookies = async () => await cookies();
30
+ function createBookingRuntime(options, overrides = {}) {
31
+ const { config } = options;
32
+ const logger = overrides.logger ?? options.logger ?? SILENT_LOGGER;
33
+ const cookies = overrides.cookies ?? options.cookies ?? defaultCookies;
34
+ const medal = overrides.medal ?? createMedalSeam(options.medal);
35
+ const catalogue = overrides.catalogue ?? createCatalogue({
36
+ config,
37
+ cache: options.cache,
38
+ logger
39
+ }, medal);
40
+ const seed = overrides.seed ?? createSeed({
41
+ config,
42
+ cache: options.cache,
43
+ logger,
44
+ timing: options.timing
45
+ }, catalogue);
46
+ const messages = {
47
+ ...DEFAULT_PORTAL_MESSAGES,
48
+ ...options.portal?.messages
49
+ };
50
+ const portal = overrides.portal ?? createPortalSeam(medal, {
51
+ config,
52
+ messages
53
+ });
54
+ const portalPath = config.paths.portal;
55
+ const portalBase = portalPath ?? config.paths.booking;
56
+ return {
57
+ options,
58
+ config,
59
+ logger,
60
+ cookies,
61
+ medal,
62
+ catalogue,
63
+ seed,
64
+ portal,
65
+ session: overrides.session ?? createPortalSession(config, cookies),
66
+ guardianFromSession: overrides.guardianFromSession ?? createGuardian(portal, logger),
67
+ nextPath: overrides.nextPath ?? createPortalNextPath(config, cookies),
68
+ vippsLink: overrides.vippsLink ?? createVippsLinkCookies(config, cookies),
69
+ flash: overrides.flash ?? createVippsFlash({
70
+ cookieName: options.portal?.vippsFlashCookieName ?? `${config.portal.cookieName}_vipps_flash`,
71
+ path: portalBase
72
+ }, cookies),
73
+ dto: overrides.dto ?? createDto(config),
74
+ clock: overrides.clock ?? createClock(config),
75
+ phone: overrides.phone ?? createPhone(config),
76
+ paths: overrides.paths ?? {
77
+ ...createPaths(config),
78
+ portal: portalPath,
79
+ portalLogin: config.paths.portalLogin,
80
+ sessionExpired: `${config.paths.portalApi}/session/expired`,
81
+ vippsReturn: options.portal?.vippsReturnPath ?? `${portalBase}/vipps`,
82
+ vippsLinkReturn: options.portal?.vippsLinkReturnPath ?? `${portalBase}/vipps/link`
83
+ },
84
+ messages,
85
+ baseUrl: overrides.baseUrl ?? (() => {
86
+ const value = options.baseUrl;
87
+ return typeof value === "function" ? value() : value;
88
+ })
89
+ };
90
+ }
91
+ //#endregion
92
+ export { createBookingRuntime };
93
+
94
+ //# sourceMappingURL=runtime.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.mjs","names":["nextCookies"],"sources":["../../src/next/runtime.ts"],"sourcesContent":["/**\n * One booking site's server half, wired once: the Medal seam, the two caches,\n * the seed, the portal seam and the cookie helpers, all built from one\n * `BookingServerOptions`. The handler, the loaders and the portal actions are\n * views over the same runtime, so a site that builds one with\n * `createBookingServer` shares its in-flight refreshes and its clients\n * across all three.\n *\n * Every part can be replaced (`overrides`) — the tests do, and a site with an\n * unusual Medal setup can.\n */\n\nimport { cookies as nextCookies } from 'next/headers';\nimport { createClock } from '../core/clock';\nimport type { BookingConfig } from '../core/config';\nimport { createDto } from '../core/dto';\nimport { createPaths } from '../core/paths';\nimport { createPhone } from '../core/phone';\nimport type { CookieJar, CookieSource } from '../core/portal/cookies';\nimport { createPortalNextPath } from '../core/portal/next-path';\nimport { createVippsLinkCookies } from '../core/portal/vipps-link';\nimport { type Catalogue, createCatalogue } from './catalogue';\nimport { createMedalSeam, type MedalSeam } from './medal';\nimport type { BookingLogger, BookingServerOptions, PortalMessages } from './options';\nimport { DEFAULT_PORTAL_MESSAGES, SILENT_LOGGER } from './options';\nimport { createGuardian } from './portal/guardian';\nimport { createPortalSeam, type PortalSeam } from './portal/medal-portal';\nimport { createPortalSession, type PortalSession } from './portal/session';\nimport { createVippsFlash, type VippsFlash } from './portal/vipps-flash';\nimport { createSeed, type Seed } from './seed';\n\nexport interface BookingRuntime {\n options: BookingServerOptions;\n config: Readonly<BookingConfig>;\n logger: BookingLogger;\n cookies: CookieSource;\n medal: MedalSeam;\n catalogue: Catalogue;\n seed: Seed;\n portal: PortalSeam;\n session: PortalSession;\n guardianFromSession: ReturnType<typeof createGuardian>;\n nextPath: ReturnType<typeof createPortalNextPath>;\n vippsLink: ReturnType<typeof createVippsLinkCookies>;\n flash: VippsFlash;\n dto: ReturnType<typeof createDto>;\n clock: ReturnType<typeof createClock>;\n phone: ReturnType<typeof createPhone>;\n paths: ReturnType<typeof createPaths> & {\n /** `null` when the site has no portal. */\n portal: string | null;\n portalLogin: string;\n sessionExpired: string;\n vippsReturn: string;\n vippsLinkReturn: string;\n };\n messages: PortalMessages;\n /** The site's canonical origin, for a request that names none. */\n baseUrl(): string | undefined;\n}\n\n/** Next's own jar, typed as the package's (a structural subset). */\nconst defaultCookies: CookieSource = async () => (await nextCookies()) as unknown as CookieJar;\n\nexport function createBookingRuntime(\n options: BookingServerOptions,\n overrides: Partial<BookingRuntime> = {}\n): BookingRuntime {\n const { config } = options;\n const logger = overrides.logger ?? options.logger ?? SILENT_LOGGER;\n const cookies = overrides.cookies ?? options.cookies ?? defaultCookies;\n const medal = overrides.medal ?? createMedalSeam(options.medal);\n const catalogue =\n overrides.catalogue ?? createCatalogue({ config, cache: options.cache, logger }, medal);\n const seed =\n overrides.seed ??\n createSeed({ config, cache: options.cache, logger, timing: options.timing }, catalogue);\n const messages = { ...DEFAULT_PORTAL_MESSAGES, ...options.portal?.messages };\n const portal = overrides.portal ?? createPortalSeam(medal, { config, messages });\n const portalPath = config.paths.portal;\n const portalBase = portalPath ?? config.paths.booking;\n return {\n options,\n config,\n logger,\n cookies,\n medal,\n catalogue,\n seed,\n portal,\n session: overrides.session ?? createPortalSession(config, cookies),\n guardianFromSession: overrides.guardianFromSession ?? createGuardian(portal, logger),\n nextPath: overrides.nextPath ?? createPortalNextPath(config, cookies),\n vippsLink: overrides.vippsLink ?? createVippsLinkCookies(config, cookies),\n flash:\n overrides.flash ??\n createVippsFlash(\n {\n cookieName:\n options.portal?.vippsFlashCookieName ?? `${config.portal.cookieName}_vipps_flash`,\n path: portalBase,\n },\n cookies\n ),\n dto: overrides.dto ?? createDto(config),\n clock: overrides.clock ?? createClock(config),\n phone: overrides.phone ?? createPhone(config),\n paths: overrides.paths ?? {\n ...createPaths(config),\n portal: portalPath,\n portalLogin: config.paths.portalLogin,\n sessionExpired: `${config.paths.portalApi}/session/expired`,\n vippsReturn: options.portal?.vippsReturnPath ?? `${portalBase}/vipps`,\n vippsLinkReturn: options.portal?.vippsLinkReturnPath ?? `${portalBase}/vipps/link`,\n },\n messages,\n baseUrl:\n overrides.baseUrl ??\n (() => {\n const value = options.baseUrl;\n return typeof value === 'function' ? value() : value;\n }),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8DA,MAAM,iBAA+B,YAAa,MAAMA,QAAY;AAEpE,SAAgB,qBACd,SACA,YAAqC,CAAC,GACtB;CAChB,MAAM,EAAE,WAAW;CACnB,MAAM,SAAS,UAAU,UAAU,QAAQ,UAAU;CACrD,MAAM,UAAU,UAAU,WAAW,QAAQ,WAAW;CACxD,MAAM,QAAQ,UAAU,SAAS,gBAAgB,QAAQ,KAAK;CAC9D,MAAM,YACJ,UAAU,aAAa,gBAAgB;EAAE;EAAQ,OAAO,QAAQ;EAAO;CAAO,GAAG,KAAK;CACxF,MAAM,OACJ,UAAU,QACV,WAAW;EAAE;EAAQ,OAAO,QAAQ;EAAO;EAAQ,QAAQ,QAAQ;CAAO,GAAG,SAAS;CACxF,MAAM,WAAW;EAAE,GAAG;EAAyB,GAAG,QAAQ,QAAQ;CAAS;CAC3E,MAAM,SAAS,UAAU,UAAU,iBAAiB,OAAO;EAAE;EAAQ;CAAS,CAAC;CAC/E,MAAM,aAAa,OAAO,MAAM;CAChC,MAAM,aAAa,cAAc,OAAO,MAAM;CAC9C,OAAO;EACL;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,SAAS,UAAU,WAAW,oBAAoB,QAAQ,OAAO;EACjE,qBAAqB,UAAU,uBAAuB,eAAe,QAAQ,MAAM;EACnF,UAAU,UAAU,YAAY,qBAAqB,QAAQ,OAAO;EACpE,WAAW,UAAU,aAAa,uBAAuB,QAAQ,OAAO;EACxE,OACE,UAAU,SACV,iBACE;GACE,YACE,QAAQ,QAAQ,wBAAwB,GAAG,OAAO,OAAO,WAAW;GACtE,MAAM;EACR,GACA,OACF;EACF,KAAK,UAAU,OAAO,UAAU,MAAM;EACtC,OAAO,UAAU,SAAS,YAAY,MAAM;EAC5C,OAAO,UAAU,SAAS,YAAY,MAAM;EAC5C,OAAO,UAAU,SAAS;GACxB,GAAG,YAAY,MAAM;GACrB,QAAQ;GACR,aAAa,OAAO,MAAM;GAC1B,gBAAgB,GAAG,OAAO,MAAM,UAAU;GAC1C,aAAa,QAAQ,QAAQ,mBAAmB,GAAG,WAAW;GAC9D,iBAAiB,QAAQ,QAAQ,uBAAuB,GAAG,WAAW;EACxE;EACA;EACA,SACE,UAAU,kBACH;GACL,MAAM,QAAQ,QAAQ;GACtB,OAAO,OAAO,UAAU,aAAa,MAAM,IAAI;EACjD;CACJ;AACF"}
@@ -0,0 +1,71 @@
1
+ import { BookingConfig } from "../core/config.mjs";
2
+ import { BookingDayDto, BookingResourceDto, BookingServiceDto, BookingSlotDto } from "../core/types.mjs";
3
+ import { BookingCacheAdapter, BookingLogger, BookingTiming } from "./options.mjs";
4
+ import { Catalogue } from "./catalogue.mjs";
5
+ //#region src/next/seed.d.ts
6
+ export declare const SEED_STALE_MAX_MS: number;
7
+ /**
8
+ * How long after the first delete the second one runs. The first can land
9
+ * BEFORE the write it answers is visible to a concurrent seed build: the slot
10
+ * tag expiry is written in the platform's deferred work, and a booking-page
11
+ * miss already in flight puts the seed it built from pre-booking slots once it
12
+ * is done. Either puts a stale seed back after the first delete; the second
13
+ * pass removes it. A build that starts after the tag expiry has landed reads
14
+ * live.
15
+ */
16
+ export declare const SEED_RECHECK_MS = 2000;
17
+ export interface BookingCatalogue {
18
+ services: BookingServiceDto[];
19
+ /** `null` when the stylists could not be read; the wizard fetches them. */
20
+ resources: BookingResourceDto[] | null;
21
+ }
22
+ export interface BookingSeed extends BookingCatalogue {
23
+ slots: Record<string, BookingSlotDto[]>;
24
+ schedules: Record<string, BookingDayDto[]>;
25
+ nextAvailable: Record<string, Record<string, number>>;
26
+ /** When the slot/schedule part was read from `catalogue.ts`. */
27
+ generatedAt: number;
28
+ /** The window the slots and schedules describe. */
29
+ fromTs: number;
30
+ toTs: number;
31
+ }
32
+ /** How a seed read ended, for `x-booking-seed` and the timing sink. */
33
+ export type SeedState = 'hit' | 'stale' | 'miss' | 'bypass';
34
+ export interface SeedOptions {
35
+ config: Pick<BookingConfig, 'timeZone' | 'locale' | 'dayparts' | 'window' | 'categories' | 'fallbackCategory' | 'paths'>;
36
+ cache: {
37
+ edge: BookingCacheAdapter;
38
+ environment: string;
39
+ edgePrefix?: string;
40
+ };
41
+ logger: BookingLogger;
42
+ timing?: BookingTiming;
43
+ }
44
+ export interface Seed {
45
+ /** The window the wizard shows and prefetches for, in local days. */
46
+ readonly RANGE_DAYS: number;
47
+ /** How many services to warm the cache with. */
48
+ readonly PREFETCH_LIMIT: number;
49
+ prefetchTargets(services: BookingServiceDto[], named: string | undefined): BookingServiceDto[];
50
+ prefetchKey(targets: readonly {
51
+ id: string;
52
+ }[]): string;
53
+ seedBucket(now: number): number;
54
+ expiryMarkerKey(serviceId: string): string | null;
55
+ anyExpiryMarkerKey(): string | null;
56
+ catalogueKeyAt(now: number): string | null;
57
+ catalogueLatestKey(): string | null;
58
+ seedLatestKeyAt(key: string, now: number): string | null;
59
+ seedKeyAt(key: string, now: number): string | null;
60
+ loadBookingCatalogue(now?: number): Promise<BookingCatalogue>;
61
+ loadBookingSeed(named: string | undefined, now?: number): Promise<BookingSeed>;
62
+ allPrefetchKeys(services: BookingServiceDto[]): string[];
63
+ seedKeysFor(services: BookingServiceDto[], serviceIds: Iterable<string>, now: number): string[];
64
+ expireBookingSeeds(serviceIds: Iterable<string>, now?: number, recheckAfterMs?: number): Promise<void>;
65
+ /** Test seam: forget in-flight refreshes. */
66
+ resetSeedRefreshes(): void;
67
+ }
68
+ type SeedCatalogue = Pick<Catalogue, 'cachedServices' | 'cachedResources' | 'cachedAvailability' | 'cachedSchedule'>;
69
+ export declare function createSeed(options: SeedOptions, catalogue: SeedCatalogue): Seed;
70
+ //#endregion
71
+ //# sourceMappingURL=seed.d.mts.map