@medalsocial/booking 0.2.1 → 0.3.1

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 (55) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +21 -3
  3. package/dist/core/attempt-store.d.mts +1 -0
  4. package/dist/core/attempt-store.mjs +13 -0
  5. package/dist/core/attempt-store.mjs.map +1 -1
  6. package/dist/core/config.d.mts +37 -0
  7. package/dist/core/config.mjs +29 -0
  8. package/dist/core/config.mjs.map +1 -1
  9. package/dist/core/machine.d.mts +27 -1
  10. package/dist/core/machine.mjs +52 -4
  11. package/dist/core/machine.mjs.map +1 -1
  12. package/dist/next/medal.d.mts +8 -1
  13. package/dist/next/medal.mjs +16 -5
  14. package/dist/next/medal.mjs.map +1 -1
  15. package/dist/next/portal/medal-portal.mjs +1 -62
  16. package/dist/next/portal/medal-portal.mjs.map +1 -1
  17. package/dist/next/redact.mjs +76 -0
  18. package/dist/next/redact.mjs.map +1 -0
  19. package/dist/next/routes/create.mjs +67 -9
  20. package/dist/next/routes/create.mjs.map +1 -1
  21. package/dist/next/routes/shared.mjs +3 -0
  22. package/dist/next/routes/shared.mjs.map +1 -1
  23. package/dist/react/BookingWizard.mjs +143 -11
  24. package/dist/react/BookingWizard.mjs.map +1 -1
  25. package/dist/react/LoginSheet.d.mts +19 -1
  26. package/dist/react/LoginSheet.mjs +15 -1
  27. package/dist/react/LoginSheet.mjs.map +1 -1
  28. package/dist/react/Provider.d.mts +2 -1
  29. package/dist/react/Provider.mjs.map +1 -1
  30. package/dist/react/actions.d.mts +16 -5
  31. package/dist/react/actions.mjs +5 -1
  32. package/dist/react/actions.mjs.map +1 -1
  33. package/dist/react/index.d.mts +2 -2
  34. package/dist/react/kit.d.mts +6 -1
  35. package/dist/react/kit.mjs +4 -3
  36. package/dist/react/kit.mjs.map +1 -1
  37. package/dist/react/labels/login.d.mts +2 -0
  38. package/dist/react/labels/login.mjs +4 -2
  39. package/dist/react/labels/login.mjs.map +1 -1
  40. package/dist/react/labels/screens.mjs +58 -2
  41. package/dist/react/labels/screens.mjs.map +1 -1
  42. package/dist/react/labels/wizard.d.mts +8 -0
  43. package/dist/react/labels/wizard.mjs +6 -0
  44. package/dist/react/labels/wizard.mjs.map +1 -1
  45. package/dist/react/screen-labels.mjs +5 -0
  46. package/dist/react/screen-labels.mjs.map +1 -1
  47. package/dist/react/useBooking.d.mts +19 -1
  48. package/dist/react/useBooking.mjs +269 -42
  49. package/dist/react/useBooking.mjs.map +1 -1
  50. package/dist/react/wizard/adapters.mjs +66 -3
  51. package/dist/react/wizard/adapters.mjs.map +1 -1
  52. package/dist/react/wizard/index.d.mts +2 -2
  53. package/dist/react/wizard/wall-clock.mjs +24 -0
  54. package/dist/react/wizard/wall-clock.mjs.map +1 -0
  55. package/package.json +4 -4
@@ -1 +1 @@
1
- {"version":3,"file":"medal.mjs","names":["MedalApiError"],"sources":["../../src/next/medal.ts"],"sourcesContent":["/**\n * The booking seam: everything a booking site asks Medal, in the site's own\n * terms.\n *\n * Backed by `@medalsocial/sdk`, which owns the wire format, auth, timeouts and\n * 429/5xx retries. What stays here is the handful of decisions that are the\n * deployment's to make and must not become a per-call option for whoever\n * calls in: `created_via: 'web'` forced last, a 200 that is not JSON treated\n * as a failure, the manage summary narrowed, and a missing or placeholder key\n * failing loudly as `MedalConfigError` instead of a confusing 401.\n *\n * THE KEY IS THE APP'S. The package reads no environment variable. A site\n * passes its key — or, where the platform only fills `process.env` per\n * request (OpenNext on Cloudflare Workers), a function that reads it — and\n * the seam reads it again on every call, re-keying its client when it\n * changes rather than pinning the first one an isolate saw.\n */\n\nimport type {\n BookingClaimableCreatedVia,\n CreateBookingInput,\n RecordConsentInput,\n} from '@medalsocial/sdk';\nimport { Medal, MedalApiError } from '@medalsocial/sdk';\nimport type {\n MedalManageSummary,\n MedalResource,\n MedalScheduleDay,\n MedalService,\n MedalSlot,\n} from '../core/wire';\n\nexport { MedalApiError };\n\nexport class MedalConfigError extends Error {\n constructor(message: string) {\n super(message);\n // `extends Error` leaves `name` as \"Error\"; an uncaught one in a Workers\n // log would then say nothing about where to look.\n this.name = 'MedalConfigError';\n }\n}\n\n/**\n * Medal's HTTP-actions origin. `app.medalsocial.com` is the dashboard and\n * answers `/api/v1/...` with its own 200 HTML \"Page not found\", so a wrong\n * value fails as a JSON parse error several layers from the cause. A\n * deployment should pass its origin explicitly; this is only ever reached by\n * a run started without one — exactly when a wrong default costs most.\n */\nexport const DEFAULT_MEDAL_ENDPOINT = 'https://io.medalsocial.com';\n\n/** A value, or a function that reads it when it is needed. */\ntype Lazy<T> = T | (() => T);\n\nexport interface MedalSeamOptions {\n apiKey: Lazy<string | undefined>;\n baseUrl?: Lazy<string | undefined>;\n}\n\n/**\n * Whether a key is a secret manager's placeholder rather than a key. Secret\n * stores seed new folders with values like `pending…`, so a deployment can\n * hold a syntactically present key Medal will reject; treated as absent, it\n * fails as an obvious config error rather than a 401.\n */\nexport function looksLikePlaceholderKey(key: string): boolean {\n return /^(?:pending|placeholder|changeme|change-me|todo|replace-me|<)/i.test(key.trim());\n}\n\n/**\n * A 200 whose body is not JSON is not a success. The SDK hands a non-JSON body\n * back as-is, so `data` is `undefined` — and, undetected, that travels into\n * whichever route tried to `.map` over it and surfaces there, pointing at a\n * file with nothing wrong in it. The reachable trigger is an endpoint aimed at\n * a host that answers with an HTML page (the dashboard does).\n */\nexport function unwrap<T>(response: { data: T } | undefined, path: string): T {\n const data = response?.data;\n if (data === undefined) {\n throw new MedalApiError(200, 'NON_JSON_BODY', `Medal returned a non-JSON body from ${path}`);\n }\n return data;\n}\n\nexport interface RangeArgs {\n serviceId: string;\n /**\n * The rest of ONE person's visit after `serviceId`, in order — absent (or\n * empty) for a one-service visit. With it the engine answers for the whole\n * visit: slots as long as every service together, on stylists who perform\n * all of them, and a schedule whose last start leaves room for the lot.\n */\n extraServiceIds?: readonly string[];\n resourceId?: string;\n fromTs: number;\n toTs: number;\n}\n\n/**\n * No `created_via` here: the site is unambiguously `'web'`, so `createBooking`\n * sets it rather than taking it. Provenance is a fact about the deployment,\n * not a per-call option.\n *\n * `booked_for_name` and `contact.name` are validated `.min(1)` by the engine\n * (`''` is a 400) while `contact.email` is not, so whatever maps wizard state\n * onto this body must coalesce blanks to `undefined` before calling in.\n */\nexport type CreateBookingBody = Omit<CreateBookingInput, 'created_via'>;\n\n/**\n * Deliberately no `start_ts`: the create response carries only the new id and\n * the one-time manage token per booking. `manage_token` is OPTIONAL: it is\n * minted exactly once, and an idempotent replay returns the same ids WITHOUT\n * it.\n */\nexport interface CreateBookingResult {\n bookings: Array<{ id: string; manage_token?: string }>;\n}\n\n/**\n * The marketing box, filed where a consent belongs. This endpoint is scoped\n * `write:contacts` where the booking endpoints are scoped to bookings: a key\n * minted for bookings alone will 403 here.\n */\nexport type RecordConsentBody = RecordConsentInput & { consent_type: 'marketing_email' };\n\n/**\n * What a completed move hands back. A reschedule is an insert plus a cancel,\n * so `manage_token` is the only way to reach the new booking; it is minted\n * once and absent from an idempotent replay.\n */\nexport interface RescheduleResult {\n booking_id?: string;\n manage_token?: string;\n}\n\nexport interface MedalSeam {\n /** The client, or `null` when the deployment has no usable key. */\n getMedal(): Medal | null;\n /** The client, or a loud `MedalConfigError`. */\n requireMedal(): Medal;\n /** `requireMedal()` with a shorter per-attempt deadline than the SDK's 30 s. */\n requireMedalWithTimeout(timeout: number): Medal;\n /** The key and origin, for the few raw routes the SDK lacks. */\n requireMedalConfig(): { key: string; base: string };\n listServices(): Promise<MedalService[]>;\n listResources(): Promise<MedalResource[]>;\n listAvailability(args: RangeArgs): Promise<MedalSlot[]>;\n listSchedule(args: RangeArgs): Promise<MedalScheduleDay[]>;\n createBooking(body: CreateBookingBody, idempotencyKey: string): Promise<CreateBookingResult>;\n recordConsent(body: RecordConsentBody): Promise<unknown>;\n getManage(token: string): Promise<MedalManageSummary>;\n cancelManage(token: string, reason?: string, idempotencyKey?: string): Promise<unknown>;\n rescheduleManage(\n token: string,\n startTs: number,\n idempotencyKey: string\n ): Promise<RescheduleResult>;\n}\n\nconst SITE_PROVENANCE: BookingClaimableCreatedVia = 'web';\n\nfunction read<T>(value: Lazy<T>): T {\n return typeof value === 'function' ? (value as () => T)() : value;\n}\n\n/** ISO on the wire — the engine parses either, but a millisecond epoch reads\n * as nothing in a request log. */\nfunction rangeParams(args: RangeArgs) {\n return {\n service_id: args.serviceId,\n from_ts: new Date(args.fromTs).toISOString(),\n to_ts: new Date(args.toTs).toISOString(),\n ...(args.resourceId ? { resource_id: args.resourceId } : {}),\n // Only when there is a visit to describe: a one-service read asks exactly\n // what it asked before, so nothing about it changes on the wire.\n ...(args.extraServiceIds?.length ? { extra_service_ids: [...args.extraServiceIds] } : {}),\n };\n}\n\nexport function createMedalSeam(options: MedalSeamOptions): MedalSeam {\n let cached: { key: string; base: string; medal: Medal } | null = null;\n let cachedQuick: { key: string; base: string; timeout: number; medal: Medal } | null = null;\n\n function readConfig(): { key: string; base: string } | null {\n const key = read(options.apiKey);\n if (!key || looksLikePlaceholderKey(key)) return null;\n return { key, base: read(options.baseUrl) || DEFAULT_MEDAL_ENDPOINT };\n }\n\n function getMedal(): Medal | null {\n const config = readConfig();\n if (!config) return null;\n if (cached && cached.key === config.key && cached.base === config.base) {\n return cached.medal;\n }\n cached = { ...config, medal: new Medal(config.key, { baseUrl: config.base }) };\n return cached.medal;\n }\n\n function requireMedal(): Medal {\n const medal = getMedal();\n if (!medal) throw new MedalConfigError('The Medal API key is not configured');\n return medal;\n }\n\n function requireMedalWithTimeout(timeout: number): Medal {\n const config = readConfig();\n if (!config) throw new MedalConfigError('The Medal API key is not configured');\n if (\n cachedQuick &&\n cachedQuick.key === config.key &&\n cachedQuick.base === config.base &&\n cachedQuick.timeout === timeout\n ) {\n return cachedQuick.medal;\n }\n cachedQuick = {\n ...config,\n timeout,\n medal: new Medal(config.key, { baseUrl: config.base, timeout }),\n };\n return cachedQuick.medal;\n }\n\n function requireMedalConfig(): { key: string; base: string } {\n const config = readConfig();\n if (!config) throw new MedalConfigError('The Medal API key is not configured');\n return config;\n }\n\n return {\n getMedal,\n requireMedal,\n requireMedalWithTimeout,\n requireMedalConfig,\n\n async listServices() {\n return unwrap(await requireMedal().bookings.listServices(), '/api/v1/bookings/services');\n },\n\n async listResources() {\n return unwrap(await requireMedal().bookings.listResources(), '/api/v1/bookings/resources');\n },\n\n async listAvailability(args) {\n return unwrap(\n await requireMedal().bookings.availability(rangeParams(args)),\n '/api/v1/bookings/availability'\n );\n },\n\n /**\n * The open dates over a range — the half availability cannot answer. A\n * date ABSENT from this response is one the business keeps no hours on.\n */\n async listSchedule(args) {\n return unwrap(\n await requireMedal().bookings.schedule(rangeParams(args)),\n '/api/v1/bookings/schedule'\n );\n },\n\n async createBooking(body, idempotencyKey) {\n const result = unwrap(\n // `created_via` LAST, so it wins over anything a caller smuggled in.\n await requireMedal().bookings.create(\n { ...body, created_via: SITE_PROVENANCE },\n { idempotencyKey }\n ),\n '/api/v1/bookings'\n );\n return { bookings: result.bookings };\n },\n\n async recordConsent(body) {\n return unwrap(await requireMedal().gdpr.recordConsent(body), '/api/v1/gdpr/consent');\n },\n\n async getManage(token) {\n const path = '/api/v1/bookings/manage/<token>';\n const summary = unwrap(await requireMedal().bookings.manage.get(token), path);\n if (summary.status === null) {\n // Not a case the engine produces; if it ever does, a page that shows a\n // booking with no state is worse than the «cannot find» card this becomes.\n throw new MedalApiError(\n 200,\n 'MALFORMED_SUMMARY',\n `Medal returned a booking without a status from ${path}`\n );\n }\n return {\n ...summary,\n status: summary.status,\n service_name: summary.service_name ?? 'Unknown service',\n resource_name: summary.resource_name ?? 'Unknown resource',\n };\n },\n\n /**\n * `idempotencyKey` is optional only in the type — the caller derives one\n * from the token so a retry after a dropped response replays rather than\n * answering `CONFLICT` about a booking that is already cancelled.\n */\n async cancelManage(token, reason, idempotencyKey) {\n return unwrap(\n await requireMedal().bookings.manage.cancel(\n token,\n reason === undefined ? undefined : { reason },\n idempotencyKey ? { idempotencyKey } : undefined\n ),\n '/api/v1/bookings/manage/<token>/cancel'\n );\n },\n\n async rescheduleManage(token, startTs, idempotencyKey) {\n return unwrap(\n await requireMedal().bookings.manage.reschedule(\n token,\n { new_start_ts: new Date(startTs).toISOString() },\n { idempotencyKey }\n ),\n '/api/v1/bookings/manage/<token>/reschedule'\n );\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"medal.mjs","names":["MedalApiError"],"sources":["../../src/next/medal.ts"],"sourcesContent":["/**\n * The booking seam: everything a booking site asks Medal, in the site's own\n * terms.\n *\n * Backed by `@medalsocial/sdk`, which owns the wire format, auth, timeouts and\n * 429/5xx retries. What stays here is the handful of decisions that are the\n * deployment's to make and must not become a per-call option for whoever\n * calls in: `created_via: 'web'` forced last, a 200 that is not JSON treated\n * as a failure, the manage summary narrowed, and a missing or placeholder key\n * failing loudly as `MedalConfigError` instead of a confusing 401.\n *\n * THE KEY IS THE APP'S. The package reads no environment variable. A site\n * passes its key — or, where the platform only fills `process.env` per\n * request (OpenNext on Cloudflare Workers), a function that reads it — and\n * the seam reads it again on every call, re-keying its client when it\n * changes rather than pinning the first one an isolate saw.\n */\n\nimport type {\n BookingClaimableCreatedVia,\n CreateBookingInput,\n RecordConsentInput,\n} from '@medalsocial/sdk';\nimport { Medal, MedalApiError } from '@medalsocial/sdk';\nimport type {\n MedalManageSummary,\n MedalResource,\n MedalScheduleDay,\n MedalService,\n MedalSlot,\n} from '../core/wire';\nimport { redactSession } from './redact';\n\nexport { MedalApiError };\n\nexport class MedalConfigError extends Error {\n constructor(message: string) {\n super(message);\n // `extends Error` leaves `name` as \"Error\"; an uncaught one in a Workers\n // log would then say nothing about where to look.\n this.name = 'MedalConfigError';\n }\n}\n\n/**\n * Medal's HTTP-actions origin. `app.medalsocial.com` is the dashboard and\n * answers `/api/v1/...` with its own 200 HTML \"Page not found\", so a wrong\n * value fails as a JSON parse error several layers from the cause. A\n * deployment should pass its origin explicitly; this is only ever reached by\n * a run started without one — exactly when a wrong default costs most.\n */\nexport const DEFAULT_MEDAL_ENDPOINT = 'https://io.medalsocial.com';\n\n/** A value, or a function that reads it when it is needed. */\ntype Lazy<T> = T | (() => T);\n\nexport interface MedalSeamOptions {\n apiKey: Lazy<string | undefined>;\n baseUrl?: Lazy<string | undefined>;\n}\n\n/**\n * Whether a key is a secret manager's placeholder rather than a key. Secret\n * stores seed new folders with values like `pending…`, so a deployment can\n * hold a syntactically present key Medal will reject; treated as absent, it\n * fails as an obvious config error rather than a 401.\n */\nexport function looksLikePlaceholderKey(key: string): boolean {\n return /^(?:pending|placeholder|changeme|change-me|todo|replace-me|<)/i.test(key.trim());\n}\n\n/**\n * A 200 whose body is not JSON is not a success. The SDK hands a non-JSON body\n * back as-is, so `data` is `undefined` — and, undetected, that travels into\n * whichever route tried to `.map` over it and surfaces there, pointing at a\n * file with nothing wrong in it. The reachable trigger is an endpoint aimed at\n * a host that answers with an HTML page (the dashboard does).\n */\nexport function unwrap<T>(response: { data: T } | undefined, path: string): T {\n const data = response?.data;\n if (data === undefined) {\n throw new MedalApiError(200, 'NON_JSON_BODY', `Medal returned a non-JSON body from ${path}`);\n }\n return data;\n}\n\nexport interface RangeArgs {\n serviceId: string;\n /**\n * The rest of ONE person's visit after `serviceId`, in order — absent (or\n * empty) for a one-service visit. With it the engine answers for the whole\n * visit: slots as long as every service together, on stylists who perform\n * all of them, and a schedule whose last start leaves room for the lot.\n */\n extraServiceIds?: readonly string[];\n resourceId?: string;\n fromTs: number;\n toTs: number;\n}\n\n/**\n * No `created_via` here: the site is unambiguously `'web'`, so `createBooking`\n * sets it rather than taking it. Provenance is a fact about the deployment,\n * not a per-call option.\n *\n * `booked_for_name` and `contact.name` are validated `.min(1)` by the engine\n * (`''` is a 400) while `contact.email` is not, so whatever maps wizard state\n * onto this body must coalesce blanks to `undefined` before calling in.\n */\nexport type CreateBookingBody = Omit<CreateBookingInput, 'created_via'>;\n\n/**\n * Deliberately no `start_ts`: the create response carries only the new id and\n * the one-time manage token per booking. `manage_token` is OPTIONAL: it is\n * minted exactly once, and an idempotent replay returns the same ids WITHOUT\n * it.\n */\nexport interface CreateBookingResult {\n bookings: Array<{ id: string; manage_token?: string }>;\n}\n\n/**\n * The marketing box, filed where a consent belongs. This endpoint is scoped\n * `write:contacts` where the booking endpoints are scoped to bookings: a key\n * minted for bookings alone will 403 here.\n */\nexport type RecordConsentBody = RecordConsentInput & { consent_type: 'marketing_email' };\n\n/**\n * What a completed move hands back. A reschedule is an insert plus a cancel,\n * so `manage_token` is the only way to reach the new booking; it is minted\n * once and absent from an idempotent replay.\n */\nexport interface RescheduleResult {\n booking_id?: string;\n manage_token?: string;\n}\n\nexport interface MedalSeam {\n /** The client, or `null` when the deployment has no usable key. */\n getMedal(): Medal | null;\n /** The client, or a loud `MedalConfigError`. */\n requireMedal(): Medal;\n /** `requireMedal()` with a shorter per-attempt deadline than the SDK's 30 s. */\n requireMedalWithTimeout(timeout: number): Medal;\n /** The key and origin, for the few raw routes the SDK lacks. */\n requireMedalConfig(): { key: string; base: string };\n listServices(): Promise<MedalService[]>;\n listResources(): Promise<MedalResource[]>;\n listAvailability(args: RangeArgs): Promise<MedalSlot[]>;\n listSchedule(args: RangeArgs): Promise<MedalScheduleDay[]>;\n /**\n * `opts.portalSession` — the logged-in parent's portal session, sent as\n * `X-Portal-Session` so Medal books on THAT contact. Never logged: a throw\n * is scrubbed of it before it leaves.\n */\n createBooking(\n body: CreateBookingBody,\n idempotencyKey: string,\n opts?: { portalSession?: string }\n ): Promise<CreateBookingResult>;\n recordConsent(body: RecordConsentBody): Promise<unknown>;\n getManage(token: string): Promise<MedalManageSummary>;\n cancelManage(token: string, reason?: string, idempotencyKey?: string): Promise<unknown>;\n rescheduleManage(\n token: string,\n startTs: number,\n idempotencyKey: string\n ): Promise<RescheduleResult>;\n}\n\nconst SITE_PROVENANCE: BookingClaimableCreatedVia = 'web';\n\nfunction read<T>(value: Lazy<T>): T {\n return typeof value === 'function' ? (value as () => T)() : value;\n}\n\n/** ISO on the wire — the engine parses either, but a millisecond epoch reads\n * as nothing in a request log. */\nfunction rangeParams(args: RangeArgs) {\n return {\n service_id: args.serviceId,\n from_ts: new Date(args.fromTs).toISOString(),\n to_ts: new Date(args.toTs).toISOString(),\n ...(args.resourceId ? { resource_id: args.resourceId } : {}),\n // Only when there is a visit to describe: a one-service read asks exactly\n // what it asked before, so nothing about it changes on the wire.\n ...(args.extraServiceIds?.length ? { extra_service_ids: [...args.extraServiceIds] } : {}),\n };\n}\n\nexport function createMedalSeam(options: MedalSeamOptions): MedalSeam {\n let cached: { key: string; base: string; medal: Medal } | null = null;\n let cachedQuick: { key: string; base: string; timeout: number; medal: Medal } | null = null;\n\n function readConfig(): { key: string; base: string } | null {\n const key = read(options.apiKey);\n if (!key || looksLikePlaceholderKey(key)) return null;\n return { key, base: read(options.baseUrl) || DEFAULT_MEDAL_ENDPOINT };\n }\n\n function getMedal(): Medal | null {\n const config = readConfig();\n if (!config) return null;\n if (cached && cached.key === config.key && cached.base === config.base) {\n return cached.medal;\n }\n cached = { ...config, medal: new Medal(config.key, { baseUrl: config.base }) };\n return cached.medal;\n }\n\n function requireMedal(): Medal {\n const medal = getMedal();\n if (!medal) throw new MedalConfigError('The Medal API key is not configured');\n return medal;\n }\n\n function requireMedalWithTimeout(timeout: number): Medal {\n const config = readConfig();\n if (!config) throw new MedalConfigError('The Medal API key is not configured');\n if (\n cachedQuick &&\n cachedQuick.key === config.key &&\n cachedQuick.base === config.base &&\n cachedQuick.timeout === timeout\n ) {\n return cachedQuick.medal;\n }\n cachedQuick = {\n ...config,\n timeout,\n medal: new Medal(config.key, { baseUrl: config.base, timeout }),\n };\n return cachedQuick.medal;\n }\n\n function requireMedalConfig(): { key: string; base: string } {\n const config = readConfig();\n if (!config) throw new MedalConfigError('The Medal API key is not configured');\n return config;\n }\n\n return {\n getMedal,\n requireMedal,\n requireMedalWithTimeout,\n requireMedalConfig,\n\n async listServices() {\n return unwrap(await requireMedal().bookings.listServices(), '/api/v1/bookings/services');\n },\n\n async listResources() {\n return unwrap(await requireMedal().bookings.listResources(), '/api/v1/bookings/resources');\n },\n\n async listAvailability(args) {\n return unwrap(\n await requireMedal().bookings.availability(rangeParams(args)),\n '/api/v1/bookings/availability'\n );\n },\n\n /**\n * The open dates over a range — the half availability cannot answer. A\n * date ABSENT from this response is one the business keeps no hours on.\n */\n async listSchedule(args) {\n return unwrap(\n await requireMedal().bookings.schedule(rangeParams(args)),\n '/api/v1/bookings/schedule'\n );\n },\n\n async createBooking(body, idempotencyKey, opts) {\n const portalSession = opts?.portalSession;\n let response: Awaited<ReturnType<Medal['bookings']['create']>>;\n try {\n response = await requireMedal().bookings.create(\n // `created_via` LAST, so it wins over anything a caller smuggled in.\n { ...body, created_via: SITE_PROVENANCE },\n portalSession ? { idempotencyKey, portalSession } : { idempotencyKey }\n );\n } catch (error) {\n // The session is never logged, and the create route logs what Medal threw.\n throw portalSession ? redactSession(error, portalSession) : error;\n }\n const result = unwrap(response, '/api/v1/bookings');\n return { bookings: result.bookings };\n },\n\n async recordConsent(body) {\n return unwrap(await requireMedal().gdpr.recordConsent(body), '/api/v1/gdpr/consent');\n },\n\n async getManage(token) {\n const path = '/api/v1/bookings/manage/<token>';\n const summary = unwrap(await requireMedal().bookings.manage.get(token), path);\n if (summary.status === null) {\n // Not a case the engine produces; if it ever does, a page that shows a\n // booking with no state is worse than the «cannot find» card this becomes.\n throw new MedalApiError(\n 200,\n 'MALFORMED_SUMMARY',\n `Medal returned a booking without a status from ${path}`\n );\n }\n return {\n ...summary,\n status: summary.status,\n service_name: summary.service_name ?? 'Unknown service',\n resource_name: summary.resource_name ?? 'Unknown resource',\n };\n },\n\n /**\n * `idempotencyKey` is optional only in the type — the caller derives one\n * from the token so a retry after a dropped response replays rather than\n * answering `CONFLICT` about a booking that is already cancelled.\n */\n async cancelManage(token, reason, idempotencyKey) {\n return unwrap(\n await requireMedal().bookings.manage.cancel(\n token,\n reason === undefined ? undefined : { reason },\n idempotencyKey ? { idempotencyKey } : undefined\n ),\n '/api/v1/bookings/manage/<token>/cancel'\n );\n },\n\n async rescheduleManage(token, startTs, idempotencyKey) {\n return unwrap(\n await requireMedal().bookings.manage.reschedule(\n token,\n { new_start_ts: new Date(startTs).toISOString() },\n { idempotencyKey }\n ),\n '/api/v1/bookings/manage/<token>/reschedule'\n );\n },\n };\n}\n"],"mappings":";;;AAmCA,IAAa,mBAAb,cAAsC,MAAM;CAC1C,YAAY,SAAiB;EAC3B,MAAM,OAAO;EAGb,KAAK,OAAO;CACd;AACF;;;;;;;;AASA,MAAa,yBAAyB;;;;;;;AAgBtC,SAAgB,wBAAwB,KAAsB;CAC5D,OAAO,iEAAiE,KAAK,IAAI,KAAK,CAAC;AACzF;;;;;;;;AASA,SAAgB,OAAU,UAAmC,MAAiB;CAC5E,MAAM,OAAO,UAAU;CACvB,IAAI,SAAS,KAAA,GACX,MAAM,IAAIA,gBAAc,KAAK,iBAAiB,uCAAuC,MAAM;CAE7F,OAAO;AACT;AAuFA,MAAM,kBAA8C;AAEpD,SAAS,KAAQ,OAAmB;CAClC,OAAO,OAAO,UAAU,aAAc,MAAkB,IAAI;AAC9D;;;AAIA,SAAS,YAAY,MAAiB;CACpC,OAAO;EACL,YAAY,KAAK;EACjB,SAAS,IAAI,KAAK,KAAK,MAAM,CAAC,CAAC,YAAY;EAC3C,OAAO,IAAI,KAAK,KAAK,IAAI,CAAC,CAAC,YAAY;EACvC,GAAI,KAAK,aAAa,EAAE,aAAa,KAAK,WAAW,IAAI,CAAC;EAG1D,GAAI,KAAK,iBAAiB,SAAS,EAAE,mBAAmB,CAAC,GAAG,KAAK,eAAe,EAAE,IAAI,CAAC;CACzF;AACF;AAEA,SAAgB,gBAAgB,SAAsC;CACpE,IAAI,SAA6D;CACjE,IAAI,cAAmF;CAEvF,SAAS,aAAmD;EAC1D,MAAM,MAAM,KAAK,QAAQ,MAAM;EAC/B,IAAI,CAAC,OAAO,wBAAwB,GAAG,GAAG,OAAO;EACjD,OAAO;GAAE;GAAK,MAAM,KAAK,QAAQ,OAAO,KAAA;EAA4B;CACtE;CAEA,SAAS,WAAyB;EAChC,MAAM,SAAS,WAAW;EAC1B,IAAI,CAAC,QAAQ,OAAO;EACpB,IAAI,UAAU,OAAO,QAAQ,OAAO,OAAO,OAAO,SAAS,OAAO,MAChE,OAAO,OAAO;EAEhB,SAAS;GAAE,GAAG;GAAQ,OAAO,IAAI,MAAM,OAAO,KAAK,EAAE,SAAS,OAAO,KAAK,CAAC;EAAE;EAC7E,OAAO,OAAO;CAChB;CAEA,SAAS,eAAsB;EAC7B,MAAM,QAAQ,SAAS;EACvB,IAAI,CAAC,OAAO,MAAM,IAAI,iBAAiB,qCAAqC;EAC5E,OAAO;CACT;CAEA,SAAS,wBAAwB,SAAwB;EACvD,MAAM,SAAS,WAAW;EAC1B,IAAI,CAAC,QAAQ,MAAM,IAAI,iBAAiB,qCAAqC;EAC7E,IACE,eACA,YAAY,QAAQ,OAAO,OAC3B,YAAY,SAAS,OAAO,QAC5B,YAAY,YAAY,SAExB,OAAO,YAAY;EAErB,cAAc;GACZ,GAAG;GACH;GACA,OAAO,IAAI,MAAM,OAAO,KAAK;IAAE,SAAS,OAAO;IAAM;GAAQ,CAAC;EAChE;EACA,OAAO,YAAY;CACrB;CAEA,SAAS,qBAAoD;EAC3D,MAAM,SAAS,WAAW;EAC1B,IAAI,CAAC,QAAQ,MAAM,IAAI,iBAAiB,qCAAqC;EAC7E,OAAO;CACT;CAEA,OAAO;EACL;EACA;EACA;EACA;EAEA,MAAM,eAAe;GACnB,OAAO,OAAO,MAAM,aAAa,CAAC,CAAC,SAAS,aAAa,GAAG,2BAA2B;EACzF;EAEA,MAAM,gBAAgB;GACpB,OAAO,OAAO,MAAM,aAAa,CAAC,CAAC,SAAS,cAAc,GAAG,4BAA4B;EAC3F;EAEA,MAAM,iBAAiB,MAAM;GAC3B,OAAO,OACL,MAAM,aAAa,CAAC,CAAC,SAAS,aAAa,YAAY,IAAI,CAAC,GAC5D,+BACF;EACF;;;;;EAMA,MAAM,aAAa,MAAM;GACvB,OAAO,OACL,MAAM,aAAa,CAAC,CAAC,SAAS,SAAS,YAAY,IAAI,CAAC,GACxD,2BACF;EACF;EAEA,MAAM,cAAc,MAAM,gBAAgB,MAAM;GAC9C,MAAM,gBAAgB,MAAM;GAC5B,IAAI;GACJ,IAAI;IACF,WAAW,MAAM,aAAa,CAAC,CAAC,SAAS,OAEvC;KAAE,GAAG;KAAM,aAAa;IAAgB,GACxC,gBAAgB;KAAE;KAAgB;IAAc,IAAI,EAAE,eAAe,CACvE;GACF,SAAS,OAAO;IAEd,MAAM,gBAAgB,cAAc,OAAO,aAAa,IAAI;GAC9D;GAEA,OAAO,EAAE,UADM,OAAO,UAAU,kBACR,CAAC,CAAC,SAAS;EACrC;EAEA,MAAM,cAAc,MAAM;GACxB,OAAO,OAAO,MAAM,aAAa,CAAC,CAAC,KAAK,cAAc,IAAI,GAAG,sBAAsB;EACrF;EAEA,MAAM,UAAU,OAAO;GACrB,MAAM,OAAO;GACb,MAAM,UAAU,OAAO,MAAM,aAAa,CAAC,CAAC,SAAS,OAAO,IAAI,KAAK,GAAG,IAAI;GAC5E,IAAI,QAAQ,WAAW,MAGrB,MAAM,IAAIA,gBACR,KACA,qBACA,kDAAkD,MACpD;GAEF,OAAO;IACL,GAAG;IACH,QAAQ,QAAQ;IAChB,cAAc,QAAQ,gBAAgB;IACtC,eAAe,QAAQ,iBAAiB;GAC1C;EACF;;;;;;EAOA,MAAM,aAAa,OAAO,QAAQ,gBAAgB;GAChD,OAAO,OACL,MAAM,aAAa,CAAC,CAAC,SAAS,OAAO,OACnC,OACA,WAAW,KAAA,IAAY,KAAA,IAAY,EAAE,OAAO,GAC5C,iBAAiB,EAAE,eAAe,IAAI,KAAA,CACxC,GACA,wCACF;EACF;EAEA,MAAM,iBAAiB,OAAO,SAAS,gBAAgB;GACrD,OAAO,OACL,MAAM,aAAa,CAAC,CAAC,SAAS,OAAO,WACnC,OACA,EAAE,cAAc,IAAI,KAAK,OAAO,CAAC,CAAC,YAAY,EAAE,GAChD,EAAE,eAAe,CACnB,GACA,4CACF;EACF;CACF;AACF"}
@@ -1,5 +1,6 @@
1
1
  import { createPortalDto, toPortalProfileDto } from "../../core/portal/dto.mjs";
2
2
  import { DEFAULT_PORTAL_MESSAGES } from "../options.mjs";
3
+ import { REDACTED, redactSecret, redactSession } from "../redact.mjs";
3
4
  import { unwrap } from "../medal.mjs";
4
5
  import { MedalApiError } from "@medalsocial/sdk";
5
6
  //#region src/next/portal/medal-portal.ts
@@ -95,68 +96,6 @@ function createPortalSeam(medal, options) {
95
96
  function portalClient() {
96
97
  return medal.requireMedal().portal;
97
98
  }
98
- const REDACTED = "<session>";
99
- /**
100
- * How far below the thrown error the scrub follows `cause` and
101
- * `AggregateError.errors`. Three is deeper than any chain the SDK or `fetch`
102
- * builds (undici wraps one `cause` under `fetch failed`), and a bound is
103
- * needed at all because `cause` may point back up the chain: an error that is
104
- * its own cause would otherwise recurse until the stack ran out.
105
- */
106
- const REDACT_DEPTH = 3;
107
- function scrub(text, session, label = REDACTED) {
108
- return text.includes(session) ? text.split(session).join(label) : text;
109
- }
110
- /**
111
- * Scrub `message` and `stack` on `error` and on every `Error` hanging off it.
112
- *
113
- * `cause` and `errors` are followed because a logger prints them: Node's
114
- * `util.inspect`, Pino's error serializer and Sentry all walk the cause chain,
115
- * so a clean top-level message over an unscrubbed `cause.message` is the same
116
- * leak one level down. A string `cause` is rewritten in place for the same
117
- * reason; anything else there (a response object, say) is not a string a
118
- * logger prints verbatim and is left alone.
119
- */
120
- function redactError(error, session, depth, label = REDACTED) {
121
- if (error.message.includes(session)) error.message = scrub(error.message, session, label);
122
- if (typeof error.stack === "string" && error.stack.includes(session)) error.stack = scrub(error.stack, session, label);
123
- if (depth >= REDACT_DEPTH) return;
124
- if (error.cause instanceof Error) redactError(error.cause, session, depth + 1, label);
125
- else if (typeof error.cause === "string") error.cause = scrub(error.cause, session, label);
126
- if (error instanceof AggregateError) {
127
- for (const inner of error.errors) if (inner instanceof Error) redactError(inner, session, depth + 1, label);
128
- }
129
- }
130
- /**
131
- * Scrub `session` out of whatever was thrown, in place, and hand it back.
132
- *
133
- * In place rather than re-wrapped: callers up the stack switch on
134
- * `instanceof MedalApiError` and read `.status` / `.code`, and a fresh `Error`
135
- * would erase all three. `message` and `stack` are the two strings a logger
136
- * prints — on the error and, via `redactError`, on its `cause` chain;
137
- * `MedalApiError.details` is `readonly` and carries the engine's structured
138
- * body, which never echoes a request header.
139
- *
140
- * A non-`Error` throw that contains the session (a bare string, say) is
141
- * REPLACED — there is nothing else on it worth keeping, and nothing safe to
142
- * pass on.
143
- */
144
- function redactSession(error, session) {
145
- return redactSecret(error, {
146
- value: session,
147
- label: REDACTED
148
- });
149
- }
150
- /** `redactSession` for any secret: the Vipps grant, the token it buys, the API key. */
151
- function redactSecret(error, secret) {
152
- if (secret.value === "") return error;
153
- if (error instanceof Error) {
154
- redactError(error, secret.value, 0, secret.label);
155
- return error;
156
- }
157
- if (typeof error === "string" && error.includes(secret.value)) return new Error(scrub(error, secret.value, secret.label));
158
- return error;
159
- }
160
99
  function isDeadSession(error) {
161
100
  return error instanceof MedalApiError && error.status === 401 && (error.code === "PORTAL_SESSION_REQUIRED" || error.code === "PORTAL_SESSION_INVALID");
162
101
  }
@@ -1 +1 @@
1
- {"version":3,"file":"medal-portal.mjs","names":[],"sources":["../../../src/next/portal/medal-portal.ts"],"sourcesContent":["import type {\n Portal,\n PortalExport,\n PortalLocale,\n PortalPersonCreateInput,\n PortalPersonPatch,\n PortalProfilePatch,\n} from '@medalsocial/sdk';\nimport { MedalApiError } from '@medalsocial/sdk';\nimport type { BookingConfig } from '../../core/config';\nimport {\n createPortalDto,\n type PortalBookingDto,\n type PortalProfileDto,\n toPortalProfileDto,\n} from '../../core/portal/dto';\nimport { type MedalSeam, unwrap } from '../medal';\nimport { DEFAULT_PORTAL_MESSAGES, type PortalMessages } from '../options';\n\n/**\n * The portal seam: everything `/min-side` asks Medal, in this site's own terms.\n *\n * The sibling of `lib/booking/medal-client.ts`, and built on the same SDK. The\n * difference is the credential. The booking seam carries a per-appointment\n * manage token on a handful of calls; every call here except the two login\n * steps carries the parent's SESSION — a bearer credential for their whole\n * account, read from the session cookie by `portal/session.ts` and\n * handed in as a plain string. This module is the last place that string is\n * known by name before it goes on the wire, which makes it the last place two\n * things can be guaranteed:\n *\n * 1. **It is not in any error that leaves here.** The SDK formats the failing\n * request into its messages, and the session travels in a header of that\n * request; whatever an action lets escape is logged verbatim by\n * `next-safe-action`'s error handler and a log is copied to places a cookie\n * is not. `withSession` scrubs the value out of the message and stack of\n * anything it rethrows — the same instance, so `instanceof`, `status` and\n * `code` still hold for whoever catches it.\n * 2. **It is never logged here.** There is no `logger` import in this file on\n * purpose. The one thing a log line about a portal failure must not contain\n * is the thing every function in this file holds.\n *\n * Two errors are this module's own vocabulary, because two upstream answers\n * are decisions for the page rather than failures: `PortalSessionExpiredError`\n * (Medal no longer honours the cookie — clear it and send the parent back to\n * the login) and `PortalThrottledError` (Medal is rate-limiting this address —\n * the login copy already says «if we have your address, a code is on its way»,\n * and a throttle must not become an oracle for which addresses exist).\n * `PortalValidationError` carries the engine's message for a rejected profile\n * edit; it is written for integrators and quotes wire field names, so the form\n * should prefer its own copy and fall back to this.\n */\n\n/** Medal answered 401 with a `PORTAL_SESSION_*` code: the cookie is dead. */\nexport class PortalSessionExpiredError extends Error {\n constructor() {\n super('portal session expired');\n this.name = 'PortalSessionExpiredError';\n }\n}\n\n/** Medal answered 429: this address has asked for too many codes. */\nexport class PortalThrottledError extends Error {\n constructor() {\n super('portal request throttled');\n this.name = 'PortalThrottledError';\n }\n}\n\n/** Medal rejected a profile edit; `message` is the engine's own sentence. */\nexport class PortalValidationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'PortalValidationError';\n }\n}\n\n/**\n * Medal answered 503 `VIPPS_NOT_CONFIGURED`: this workspace has no Vipps\n * credentials (yet). A decision for the login page — show the e-mail form\n * and say so — not a failure.\n */\nexport class PortalVippsUnavailableError extends Error {\n constructor() {\n super('vipps login unavailable');\n this.name = 'PortalVippsUnavailableError';\n }\n}\n\n/** One child as the editor writes it, in this site's own terms. */\nexport interface PortalPersonInput {\n name: string;\n birthYear: number;\n /** `null` clears; absent leaves it alone. */\n birthMonth?: number | null;\n notes?: string | null;\n preferredResourceId?: string | null;\n}\n\n/** What a person edit reports: the profile as Medal now holds it, and whether\n * the SP10 route was there to take the details. */\nexport interface PortalPersonResult {\n profile: PortalProfileDto;\n /** The id of the child just created or edited, when the profile can name it. */\n personId: string | null;\n fallback: boolean;\n}\n\n/**\n * Which child an edit is about. `personId` wherever the profile named one;\n * `index` into the profile's family only for the rare row it could not (two\n * same-named, same-year children against a Medal that sends no ids), which\n * only the list-replacing fallback can address.\n */\nexport interface PersonTarget {\n personId?: string | null;\n index?: number;\n}\n\n/**\n * Medal answered 409 `VIPPS_IDENTITY_CONFLICT` to a pending-link code: the code\n * was right, but this Vipps account has since been bound to another customer.\n * An answer for the login sheet — «log in with e-mail, or ring the salon».\n */\nexport class PortalVippsConflictError extends Error {\n constructor() {\n super('vipps identity conflict');\n this.name = 'PortalVippsConflictError';\n }\n}\n\n/** How a link grant ended: linked, bound to another profile, or not honoured. */\nexport type VippsLinkCompletion = 'linked' | 'conflict' | 'invalid';\n\ntype Seam = Pick<MedalSeam, 'requireMedal' | 'requireMedalWithTimeout' | 'requireMedalConfig'>;\n\nexport interface PortalSeamOptions {\n config: Pick<BookingConfig, 'paths'>;\n messages?: Partial<PortalMessages>;\n}\n\n/** The portal seam over one Medal seam. */\nexport function createPortalSeam(medal: Seam, options: PortalSeamOptions) {\n const messages = { ...DEFAULT_PORTAL_MESSAGES, ...options.messages };\n const { toPortalBookingDto } = createPortalDto(options.config);\n\n /**\n * The SDK's portal namespace (`Medal#portal`, SDK ≥ 1.8.0). `requireMedal()`\n * throws `MedalConfigError` for a missing key, and that is left to propagate:\n * a portal that silently cannot reach Medal is worse than one that fails\n * visibly.\n */\n function portalClient(): Portal {\n return medal.requireMedal().portal;\n }\n\n const REDACTED = '<session>';\n\n /** A value that must not appear in an error, and the placeholder it becomes. */\n type Secret = { value: string; label: string };\n\n /**\n * How far below the thrown error the scrub follows `cause` and\n * `AggregateError.errors`. Three is deeper than any chain the SDK or `fetch`\n * builds (undici wraps one `cause` under `fetch failed`), and a bound is\n * needed at all because `cause` may point back up the chain: an error that is\n * its own cause would otherwise recurse until the stack ran out.\n */\n const REDACT_DEPTH = 3;\n\n function scrub(text: string, session: string, label = REDACTED): string {\n return text.includes(session) ? text.split(session).join(label) : text;\n }\n\n /**\n * Scrub `message` and `stack` on `error` and on every `Error` hanging off it.\n *\n * `cause` and `errors` are followed because a logger prints them: Node's\n * `util.inspect`, Pino's error serializer and Sentry all walk the cause chain,\n * so a clean top-level message over an unscrubbed `cause.message` is the same\n * leak one level down. A string `cause` is rewritten in place for the same\n * reason; anything else there (a response object, say) is not a string a\n * logger prints verbatim and is left alone.\n */\n function redactError(error: Error, session: string, depth: number, label = REDACTED): void {\n if (error.message.includes(session)) error.message = scrub(error.message, session, label);\n if (typeof error.stack === 'string' && error.stack.includes(session)) {\n error.stack = scrub(error.stack, session, label);\n }\n if (depth >= REDACT_DEPTH) return;\n if (error.cause instanceof Error) {\n redactError(error.cause, session, depth + 1, label);\n } else if (typeof error.cause === 'string') {\n error.cause = scrub(error.cause, session, label);\n }\n if (error instanceof AggregateError) {\n for (const inner of error.errors) {\n if (inner instanceof Error) redactError(inner, session, depth + 1, label);\n }\n }\n }\n\n /**\n * Scrub `session` out of whatever was thrown, in place, and hand it back.\n *\n * In place rather than re-wrapped: callers up the stack switch on\n * `instanceof MedalApiError` and read `.status` / `.code`, and a fresh `Error`\n * would erase all three. `message` and `stack` are the two strings a logger\n * prints — on the error and, via `redactError`, on its `cause` chain;\n * `MedalApiError.details` is `readonly` and carries the engine's structured\n * body, which never echoes a request header.\n *\n * A non-`Error` throw that contains the session (a bare string, say) is\n * REPLACED — there is nothing else on it worth keeping, and nothing safe to\n * pass on.\n */\n function redactSession(error: unknown, session: string): unknown {\n return redactSecret(error, { value: session, label: REDACTED });\n }\n\n /** `redactSession` for any secret: the Vipps grant, the token it buys, the API key. */\n function redactSecret(error: unknown, secret: Secret): unknown {\n if (secret.value === '') return error;\n if (error instanceof Error) {\n redactError(error, secret.value, 0, secret.label);\n return error;\n }\n if (typeof error === 'string' && error.includes(secret.value)) {\n return new Error(scrub(error, secret.value, secret.label));\n }\n return error;\n }\n\n function isDeadSession(error: unknown): error is MedalApiError {\n return (\n error instanceof MedalApiError &&\n error.status === 401 &&\n (error.code === 'PORTAL_SESSION_REQUIRED' || error.code === 'PORTAL_SESSION_INVALID')\n );\n }\n\n function isThrottled(error: unknown): error is MedalApiError {\n return error instanceof MedalApiError && error.status === 429;\n }\n\n /**\n * Run one session-bound SDK call with the two guarantees from the module\n * comment, plus the one mapping every such call shares: a 401 that names the\n * session becomes `PortalSessionExpiredError`. Everything else is rethrown\n * redacted, for the caller to interpret or for the action's error handler to\n * turn into «something went wrong».\n */\n async function withSession<T>(session: string, call: (portal: Portal) => Promise<T>) {\n try {\n return await call(portalClient());\n } catch (error) {\n const scrubbed = redactSession(error, session);\n if (isDeadSession(scrubbed)) throw new PortalSessionExpiredError();\n throw scrubbed;\n }\n }\n\n /**\n * Ask Medal to e-mail a one-time code.\n *\n * Resolves on «sent» and says nothing about whether the address is known —\n * that is Medal's silence to keep, and the action above this keeps it too. A\n * 429 is the one answer translated (`PortalThrottledError`); anything else is\n * rethrown as-is, because a login page that cannot reach Medal should say so.\n */\n async function startLogin(email: string, locale: PortalLocale = 'no'): Promise<void> {\n try {\n unwrap(await portalClient().login.start({ email, locale }), '/api/v1/portal/login/start');\n } catch (error) {\n if (isThrottled(error)) throw new PortalThrottledError();\n throw error;\n }\n }\n\n /**\n * Exchange the code for a session. `null` for a wrong or expired code — that\n * is an answer for the form, not a failure — and the token plus Medal's own\n * expiry on success. The contact summary that rides along is dropped here:\n * the page fetches the profile with the session it has just been given, and\n * a second copy of the parent's name has no reader.\n */\n async function verifyLogin(\n email: string,\n code: string\n ): Promise<{ sessionToken: string; expiresAt: number } | null> {\n try {\n const session = unwrap(\n await portalClient().login.verify({ email, code }),\n '/api/v1/portal/login/verify'\n );\n return { sessionToken: session.session_token, expiresAt: session.expires_at };\n } catch (error) {\n if (\n error instanceof MedalApiError &&\n error.status === 401 &&\n error.code === 'PORTAL_CODE_INVALID'\n ) {\n return null;\n }\n if (isThrottled(error)) throw new PortalThrottledError();\n throw error;\n }\n }\n\n /**\n * Revoke the session upstream. A 401 of any flavour is swallowed: it means\n * Medal no longer honours this token, which is exactly the state a logout is\n * trying to reach. A 5xx is NOT — the token may still be live, and the caller\n * decides whether to clear the cookie anyway (it should: a cookie that stays\n * in the jar is a cookie that stays stealable).\n */\n async function logout(session: string): Promise<void> {\n try {\n await withSession(session, (portal) => portal.logout(session));\n } catch (error) {\n if (error instanceof PortalSessionExpiredError) return;\n if (error instanceof MedalApiError && error.status === 401) return;\n throw error;\n }\n }\n\n async function getMe(session: string): Promise<PortalProfileDto> {\n const profile = await withSession(session, async (portal) =>\n unwrap(await portal.me(session), '/api/v1/portal/me')\n );\n return toPortalProfileDto(profile);\n }\n\n /**\n * The engine names the code `VALIDATION_ERROR` whether it answers 400 or 422\n * (`medal-client.ts` records that the booking routes see the latter), so the\n * match is on the code alone. The message has already been through\n * `redactSession` by the time it is re-thrown here — it is bound for a form.\n */\n async function updateMe(session: string, patch: PortalProfilePatch): Promise<PortalProfileDto> {\n try {\n const profile = await withSession(session, async (portal) =>\n unwrap(await portal.updateMe(session, patch), '/api/v1/portal/me')\n );\n return toPortalProfileDto(profile);\n } catch (error) {\n if (error instanceof MedalApiError && error.code === 'VALIDATION_ERROR') {\n throw new PortalValidationError(error.message);\n }\n throw error;\n }\n }\n\n async function getMyBookings(\n session: string\n ): Promise<{ upcoming: PortalBookingDto[]; past: PortalBookingDto[] }> {\n const bookings = await withSession(session, async (portal) =>\n unwrap(await portal.myBookings(session), '/api/v1/portal/bookings')\n );\n return {\n upcoming: bookings.upcoming.map(toPortalBookingDto),\n past: bookings.past.map(toPortalBookingDto),\n };\n }\n\n /**\n * The parent's own data, as Medal exports it — snake_case and all, because\n * this is THEIR file (the GDPR right of access) and a projection would be\n * this site editorialising it. One thing is taken out: every `manage_token`\n * is nulled. A download sits in a Downloads folder, gets attached to an\n * e-mail, gets synced to a phone; a live credential for moving an appointment\n * does not belong in any of those places, and the same bookings are managed\n * from the page with the session instead.\n *\n * Copies rather than mutates: the SDK's object is nobody else's, today, but a\n * function that quietly edits its input is a bug waiting for a second reader.\n */\n async function exportMyData(session: string): Promise<PortalExport> {\n const exported = await withSession(session, async (portal) =>\n unwrap(await portal.exportMyData(session), '/api/v1/portal/export')\n );\n return {\n ...exported,\n bookings: exported.bookings.map((booking) => ({ ...booking, manage_token: null })),\n };\n }\n\n async function deleteMe(session: string): Promise<void> {\n await withSession(session, (portal) => portal.deleteMe(session));\n }\n\n /*\n * ---------------------------------------------------------------------------\n * Vipps login\n *\n * Three routes: start, exchange and (below) the pending-link verify. SDK 1.12\n * has a method for each; `exchange` and `verifyLink` go through it, because\n * both are sent exactly once there (`retry: false`) — the grant and the link\n * are single-use, so a retry would misreport a completed login — and the\n * 15 s budget is kept by a client of its own (`quickPortal`). `start` stays on\n * a raw `fetch` built from the same key and origin (`postVipps`): the SDK\n * retries it on 429/5xx with no per-call way to opt out, and this site wants\n * a throttle to surface at once as `PortalThrottledError` and an unreachable\n * Medal to fail inside the one 15 s budget, not three. Medal keeps the whole OpenID Connect exchange with\n * Vipps on its side; this site only asks for an address to send the parent to\n * and, when they come back, swaps the one-time GRANT in the return URL for a\n * session. The grant is a bearer credential for that swap — whoever presents\n * it first is logged in — so it is redacted from errors exactly like the\n * session, and the API key rides along in the same list because it is in the\n * `Authorization` header of a request `fetch` may well quote.\n *\n * Both routes answer in the platform's standard envelopes — `{ data: … }` on\n * a 2xx and `{ error: { code, message } }` otherwise — the same shapes the\n * SDK's own methods unwrap. `postVipps` mirrors that: the `data` member is\n * what it resolves with, and a 2xx WITHOUT one (a legacy bare body, an HTML\n * error page, an empty response) is a failure, not a success carrying\n * `undefined`. The `code` is what the mappings below key on, and it is kept\n * on the thrown `MedalApiError` for whoever reads the log; the `message` is\n * dropped, because a sentence written for integrators may quote the request\n * it is about, and the redaction here covers `message` and `stack`, not a\n * structured `details`.\n * ---------------------------------------------------------------------------\n */\n\n /** Long enough for Medal to round-trip Vipps; short enough that a parent\n * whose click went nowhere is told so before they give up on the page. */\n const VIPPS_TIMEOUT_MS = 15_000;\n\n /** The SDK's portal namespace on a client with the `VIPPS_TIMEOUT_MS` deadline. */\n function quickPortal(): Portal {\n return medal.requireMedalWithTimeout(VIPPS_TIMEOUT_MS).portal;\n }\n\n /** `body[key]`, or `undefined` for a body that is not an object. */\n function field(body: unknown, key: string): unknown {\n return body && typeof body === 'object' ? (body as Record<string, unknown>)[key] : undefined;\n }\n\n /** The `code` of a standard error envelope; `UNKNOWN_ERROR` for anything else. */\n function vippsErrorCode(body: unknown): string {\n const code = field(field(body, 'error'), 'code');\n return typeof code === 'string' && code !== '' ? code : 'UNKNOWN_ERROR';\n }\n\n /** The engine's own 404s on the persons routes; any other 404 is a missing route. */\n const DOMAIN_NOT_FOUND: ReadonlySet<string> = new Set(['Person not found', 'Resource not found']);\n\n function isDomainNotFound(message: unknown): boolean {\n return typeof message === 'string' && DOMAIN_NOT_FOUND.has(message);\n }\n\n /** A 2xx whose body is not the shape the contract promises is not a success. */\n function malformed(path: string): MedalApiError {\n return new MedalApiError(200, 'NON_JSON_BODY', `Medal returned a malformed body from ${path}`);\n }\n\n /**\n * `POST` one Vipps route with the API key. Resolves with the `data` member\n * of the parsed 2xx envelope; throws `MedalApiError` for anything else, with\n * a message that names the status and the code and NOT the response text —\n * the only strings Medal echoes on these routes are ones this site sent it.\n */\n async function postVipps(path: string, body: unknown): Promise<unknown> {\n return callMedal(path, { method: 'POST', body });\n }\n\n /**\n * One raw request to Medal with the API key — and, for the routes that act as\n * the parent, their session in `x-portal-session`, the header the SDK's own\n * portal methods use. `postVipps` is this with `POST` and no session.\n *\n * A 204 resolves `undefined`; any other 2xx has to carry the `data` member.\n */\n async function callMedal(\n path: string,\n {\n method,\n body,\n session,\n }: { method: 'POST' | 'PATCH' | 'DELETE'; body?: unknown; session?: string }\n ): Promise<unknown> {\n const { key, base } = medal.requireMedalConfig();\n const response = await fetch(new URL(path, base).toString(), {\n method,\n headers: {\n authorization: `Bearer ${key}`,\n /* v8 ignore next -- defensive: no caller sends a request without a body */\n ...(body === undefined ? {} : { 'content-type': 'application/json' }),\n accept: 'application/json',\n ...(session === undefined ? {} : { 'x-portal-session': session }),\n },\n /* v8 ignore next -- defensive: no caller sends a request without a body */\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n signal: AbortSignal.timeout(VIPPS_TIMEOUT_MS),\n });\n if (response.status === 204) return undefined;\n const text = await response.text();\n let parsed: unknown;\n try {\n parsed = text ? JSON.parse(text) : undefined;\n } catch {\n parsed = undefined;\n }\n if (!response.ok) {\n const code = vippsErrorCode(parsed);\n throw new MedalApiError(\n response.status,\n code,\n `Medal answered ${response.status} ${code} from ${path}`,\n // Not the engine's sentence (see above) — only whether it was one of\n // the two «no such person / resource» 404s, which is the one decision\n // that needs it: a 404 that is neither is a route this Medal lacks\n // (`isMissingRoute`).\n { domainNotFound: isDomainNotFound(field(field(parsed, 'error'), 'message')) }\n );\n }\n const data = field(parsed, 'data');\n if (data === undefined) throw malformed(path);\n return data;\n }\n\n /** Run `call`, scrubbing every secret in `secrets` out of whatever it throws. */\n async function withVippsSecrets<T>(secrets: () => Secret[], call: () => Promise<T>): Promise<T> {\n try {\n return await call();\n } catch (error) {\n let scrubbed = error;\n for (const secret of secrets()) scrubbed = redactSecret(scrubbed, secret);\n throw scrubbed;\n }\n }\n\n function apiKeySecret(): Secret[] {\n try {\n return [{ value: medal.requireMedalConfig().key, label: '<key>' }];\n } catch {\n return [];\n }\n }\n\n /** The start route's three decisions, by code (and 429 by status); anything else is rethrown. */\n function mapStartError(error: unknown): never {\n if (error instanceof MedalApiError) {\n if (error.code === 'VIPPS_NOT_CONFIGURED') throw new PortalVippsUnavailableError();\n if (error.code === 'INVALID_RETURN_URL') throw new PortalValidationError(error.message);\n if (error.status === 429) throw new PortalThrottledError();\n }\n throw error;\n }\n\n /**\n * Ask Medal where to send the parent to log in with Vipps.\n *\n * `returnUrl` is the absolute address of this site's `/min-side/vipps` route,\n * which is where Vipps — via Medal — sends the browser back with either a\n * grant or a reason. Medal checks it against the workspace's site and answers\n * 400 `INVALID_RETURN_URL` for one it does not recognise; that surfaces as\n * `PortalValidationError` because it is a configuration mistake on one side\n * or the other, not something the parent did. 503 `VIPPS_NOT_CONFIGURED`\n * means the workspace has no Vipps credentials and becomes\n * `PortalVippsUnavailableError`, which the login page turns into «use e-mail\n * instead». The address Medal hands back is checked to be HTTPS before anyone\n * is redirected to it.\n */\n async function startVippsLogin({\n returnUrl,\n browserBinding,\n locale = 'no',\n }: {\n returnUrl: string;\n /**\n * SP10: the value this site keeps in the browser-binding cookie, so a pending link\n * Medal offers can only be confirmed from this browser (`vipps-link.ts`).\n * A Medal without it ignores the key.\n */\n browserBinding?: string;\n /** The language of the code e-mail a conflicted login sends. */\n locale?: 'no' | 'en';\n }): Promise<{ authorizeUrl: string }> {\n const path = '/api/v1/portal/vipps/start';\n const secrets = (): Secret[] => [\n ...(browserBinding ? [{ value: browserBinding, label: '<binding>' }] : []),\n ...apiKeySecret(),\n ];\n return withVippsSecrets(secrets, async () => {\n const data = await postVipps(path, {\n return_url: returnUrl,\n ...(browserBinding ? { browser_binding: browserBinding } : {}),\n locale,\n }).catch(mapStartError);\n const authorizeUrl = field(data, 'authorize_url');\n if (typeof authorizeUrl !== 'string' || !authorizeUrl.startsWith('https://')) {\n throw malformed(path);\n }\n return { authorizeUrl };\n });\n }\n\n /**\n * Swap the grant Vipps sent the parent back with for a session. `null` when\n * Medal does not know it (404 `GRANT_NOT_FOUND` — used already, expired, or\n * minted for another workspace; the three are not distinguished, and none is\n * a failure) and the token plus expiry otherwise, in the same shape\n * `verifyLogin` returns so the route that receives it writes the cookie the\n * same way.\n *\n * The grant and, once known, the token are scrubbed from anything thrown:\n * the grant is in the request body, the token in the response, and a `fetch`\n * failure or a serialiser can quote either.\n */\n async function exchangeVippsGrant(\n grant: string\n ): Promise<{ sessionToken: string; expiresAt: number } | null> {\n const path = '/api/v1/portal/vipps/exchange';\n let token = '';\n const secrets = (): Secret[] => [\n { value: grant, label: '<grant>' },\n { value: token, label: REDACTED },\n ...apiKeySecret(),\n ];\n return withVippsSecrets(secrets, async () => {\n let data: unknown;\n try {\n data = unwrap(await quickPortal().login.vipps.exchange({ grant }), path);\n } catch (error) {\n if (error instanceof MedalApiError && error.code === 'GRANT_NOT_FOUND') return null;\n if (isThrottled(error)) throw new PortalThrottledError();\n throw error;\n }\n const sessionToken = field(data, 'session_token');\n const expiresAt = field(data, 'expires_at');\n if (typeof sessionToken === 'string') token = sessionToken;\n if (token === '' || typeof expiresAt !== 'number') throw malformed(path);\n return { sessionToken: token, expiresAt };\n });\n }\n\n /*\n * ---------------------------------------------------------------------------\n * Children as persons (SP10)\n *\n * `POST /api/v1/portal/me/persons`, `PATCH` and `DELETE /…/persons/{id}` edit\n * one child in place, so the id — which a booking carries as\n * `booked_for_person_id` — survives a rename. `create` and `remove` go\n * through the SDK's `portal.persons.{create,remove}` (1.12), which put the\n * session in `x-portal-session` and send each exactly once, on `quickPortal`\n * so the 15 s budget holds. `update` stays on the raw `callMedal` PATCH: the\n * SDK retries `persons.update` on 429/5xx (each attempt with its own\n * deadline) and takes no per-call `retry: false`, so a throttle would no\n * longer surface at once as `PortalThrottledError` and an unreachable Medal\n * would hold the editor for three budgets instead of one.\n *\n * FEATURE-DETECTED, because this site must keep working against a Medal that\n * does not have them: a 404 that is not the engine's own «Person not found» /\n * «Resource not found» is a route that does not exist (today's Medal answers\n * Convex's plain «No matching routes found»), and each function then falls\n * back to what the editor did before — the whole family list through\n * `PATCH /me`, which matches on name and year and so also keeps the id for a\n * child whose name and year did not change. Birth month, notes and preferred\n * stylist have nowhere to go in that fallback and are dropped; the answer\n * says so (`fallback: true`) and the editor only offers those fields where\n * `personDetails` says Medal can keep them.\n * ---------------------------------------------------------------------------\n */\n\n const PERSONS_PATH = '/api/v1/portal/me/persons';\n\n /**\n * A 404 that is not the engine's own «Person not found» / «Resource not\n * found». The SDK keeps the engine's sentence as `message`; the raw\n * `callMedal` drops it and records the verdict in `details.domainNotFound`.\n */\n function isMissingRoute(error: unknown): boolean {\n if (!(error instanceof MedalApiError) || error.status !== 404) return false;\n const details = error.details as { domainNotFound?: unknown } | undefined;\n const domain = details?.domainNotFound === true || isDomainNotFound(error.message);\n return !(error.code === 'NOT_FOUND' && domain);\n }\n\n /** The editor's copy for what the engine refused. Never the engine's own\n * sentence: it is written for integrators and names wire fields. */\n function mapPersonError(error: unknown): never {\n if (error instanceof MedalApiError) {\n if (error.code === 'CONFLICT') {\n throw new PortalValidationError(messages.duplicateChild);\n }\n if (error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_INPUT') {\n throw new PortalValidationError(messages.invalidChild);\n }\n if (error.status === 404) {\n throw new PortalValidationError(messages.childNotFound);\n }\n if (error.status === 429) throw new PortalThrottledError();\n }\n throw error;\n }\n\n function personPatch(input: Partial<PortalPersonInput>): PortalPersonPatch {\n const body: PortalPersonPatch = {};\n if (input.name !== undefined) body.name = input.name;\n if (input.birthYear !== undefined) body.birth_year = input.birthYear;\n if (input.birthMonth !== undefined) body.birth_month = input.birthMonth;\n if (input.notes !== undefined) body.notes = input.notes;\n if (input.preferredResourceId !== undefined) {\n body.preferred_resource_id = input.preferredResourceId;\n }\n return body;\n }\n\n /**\n * On create an absent key and `null` mean the same — nothing — and the\n * create schema is STRICT about nulls it does not take.\n */\n function personCreate(input: PortalPersonInput): PortalPersonCreateInput {\n const body: PortalPersonCreateInput = { name: input.name, birth_year: input.birthYear };\n if (input.birthMonth != null) body.birth_month = input.birthMonth;\n if (input.notes != null) body.notes = input.notes;\n if (input.preferredResourceId != null) body.preferred_resource_id = input.preferredResourceId;\n return body;\n }\n\n /** Run one persons call with the session scrubbed and a dead one mapped. */\n async function withPersonsCall<T>(session: string, call: () => Promise<T>): Promise<T> {\n return withVippsSecrets(\n () => [{ value: session, label: REDACTED }, ...apiKeySecret()],\n async () => {\n try {\n return await call();\n } catch (error) {\n if (isDeadSession(error)) throw new PortalSessionExpiredError();\n throw error;\n }\n }\n );\n }\n\n function wireFamily(profile: PortalProfileDto) {\n return profile.family.map((member) => ({ name: member.name, birth_year: member.birthYear }));\n }\n\n /** The id the refreshed profile gives the child with this name and year. */\n function idOf(profile: PortalProfileDto, name: string, birthYear: number): string | null {\n const matches = profile.family.filter(\n (member) => member.name === name && member.birthYear === birthYear\n );\n return matches.length === 1 ? matches[0].personId : null;\n }\n\n /** Where a child sits in the list the fallback rewrites: by id, else by index. */\n function familyIndex(profile: PortalProfileDto, target: PersonTarget): number {\n if (target.personId) {\n return profile.family.findIndex((member) => member.personId === target.personId);\n }\n const index = target.index ?? -1;\n return index >= 0 && index < profile.family.length ? index : -1;\n }\n\n async function createPerson(\n session: string,\n input: PortalPersonInput\n ): Promise<PortalPersonResult> {\n const created = await withPersonsCall(session, async () => {\n try {\n const data = unwrap(\n await quickPortal().persons.create(session, personCreate(input)),\n PERSONS_PATH\n );\n const personId = field(data, 'person_id');\n return { personId: typeof personId === 'string' ? personId : null };\n } catch (error) {\n if (isMissingRoute(error)) return null;\n return mapPersonError(error);\n }\n });\n if (created !== null) {\n return { profile: await getMe(session), personId: created.personId, fallback: false };\n }\n const current = await getMe(session);\n const profile = await updateMe(session, {\n family: [...wireFamily(current), { name: input.name, birth_year: input.birthYear }],\n });\n return { profile, personId: idOf(profile, input.name, input.birthYear), fallback: true };\n }\n\n async function updatePerson(\n session: string,\n target: PersonTarget,\n patch: Partial<PortalPersonInput>\n ): Promise<PortalPersonResult> {\n const personId = target.personId ?? null;\n if (personId !== null) {\n const done = await withPersonsCall(session, async () => {\n try {\n await callMedal(`${PERSONS_PATH}/${encodeURIComponent(personId)}`, {\n method: 'PATCH',\n body: personPatch(patch),\n session,\n });\n return true;\n } catch (error) {\n if (isMissingRoute(error)) return false;\n return mapPersonError(error);\n }\n });\n if (done) return { profile: await getMe(session), personId, fallback: false };\n }\n const current = await getMe(session);\n const index = familyIndex(current, target);\n if (index === -1) mapPersonError(new MedalApiError(404, 'NOT_FOUND', 'person not in family'));\n const family = wireFamily(current);\n const name = patch.name ?? family[index].name;\n const birthYear = patch.birthYear ?? family[index].birth_year;\n family[index] = { name, birth_year: birthYear };\n const profile = await updateMe(session, { family });\n return { profile, personId: idOf(profile, name, birthYear), fallback: true };\n }\n\n async function removePerson(session: string, target: PersonTarget): Promise<PortalPersonResult> {\n const personId = target.personId ?? null;\n if (personId !== null) {\n const done = await withPersonsCall(session, async () => {\n try {\n await quickPortal().persons.remove(session, personId);\n return true;\n } catch (error) {\n if (isMissingRoute(error)) return false;\n return mapPersonError(error);\n }\n });\n if (done) return { profile: await getMe(session), personId, fallback: false };\n }\n const current = await getMe(session);\n const index = familyIndex(current, target);\n if (index === -1) mapPersonError(new MedalApiError(404, 'NOT_FOUND', 'person not in family'));\n const profile = await updateMe(session, {\n family: wireFamily(current).filter((_, position) => position !== index),\n });\n return { profile, personId, fallback: true };\n }\n\n /**\n * Confirm a conflicted Vipps login with the six-digit code Medal e-mailed to\n * the address on file (SP10 A5): `POST /api/v1/portal/vipps/link/verify`.\n *\n * `null` for every refusal Medal folds into 401 `PORTAL_CODE_INVALID` — a\n * wrong or burned code, an unknown, expired or spent link, a browser binding\n * that is not the one the login started with — which is one answer for the\n * form. 409 is `PortalVippsConflictError`, 429 `PortalThrottledError`.\n *\n * The link, the code, the binding and the session it buys are all scrubbed\n * from anything thrown, with the API key. Through the SDK's\n * `portal.login.vipps.verifyLink`, which sends it exactly once: the right code\n * consumes the link, so a retry would misreport a completed login.\n */\n async function verifyVippsLink({\n link,\n code,\n browserBinding,\n }: {\n link: string;\n code: string;\n /** Mandatory: Medal refuses a verify without the binding the login started with. */\n browserBinding: string;\n }): Promise<{ sessionToken: string; expiresAt: number } | null> {\n const path = '/api/v1/portal/vipps/link/verify';\n let token = '';\n const secrets = (): Secret[] => [\n { value: link, label: '<link>' },\n { value: code, label: '<code>' },\n { value: browserBinding, label: '<binding>' },\n { value: token, label: REDACTED },\n ...apiKeySecret(),\n ];\n return withVippsSecrets(secrets, async () => {\n let data: unknown;\n try {\n data = unwrap(\n await quickPortal().login.vipps.verifyLink({\n link,\n code,\n browser_binding: browserBinding,\n }),\n path\n );\n } catch (error) {\n return mapLinkError(error);\n }\n const sessionToken = field(data, 'session_token');\n const expiresAt = field(data, 'expires_at');\n if (typeof sessionToken === 'string') token = sessionToken;\n if (token === '' || typeof expiresAt !== 'number') throw malformed(path);\n return { sessionToken: token, expiresAt };\n });\n }\n\n /** The link verify's refusals: `null` for «wrong or expired», the two errors, else rethrown. */\n function mapLinkError(error: unknown): null {\n if (error instanceof MedalApiError) {\n if (error.status === 401 && error.code === 'PORTAL_CODE_INVALID') return null;\n if (error.status === 409 && error.code === 'VIPPS_IDENTITY_CONFLICT') {\n throw new PortalVippsConflictError();\n }\n if (error.status === 429) throw new PortalThrottledError();\n }\n throw error;\n }\n\n /*\n * ---------------------------------------------------------------------------\n * «Koble til Vipps» from Min side → Profil\n *\n * A parent who logged in with e-mail links their Vipps account to the profile\n * they are already in: `POST /api/v1/portal/me/vipps/link/start` (session)\n * answers with the address to send them to, and Medal's callback either says\n * `?vipps=linked` / `?vipps=link_conflict` outright or hands back a one-time\n * link GRANT that this site spends with the same session through\n * `POST /api/v1/portal/me/vipps/link/complete`. Both carry the browser\n * binding from the `__Host-` binding cookie (`core/portal/vipps-link.ts`), so a grant lifted\n * out of one browser's address bar is worthless in another's.\n *\n * FEATURE-DETECTED like the persons routes: a start that answers with a\n * missing route (`isMissingRoute`) is `null`, and the Profil row hides. Raw\n * `callMedal`, because no SDK has these yet (1.12 included).\n * TODO(sdk): use `portal.vipps.link.{start,complete}` once the SDK has them.\n * ---------------------------------------------------------------------------\n */\n\n const VIPPS_LINK_PATH = '/api/v1/portal/me/vipps/link';\n\n /** The session, the binding, the grant when there is one, and the key — scrubbed; a dead session mapped. */\n async function withVippsLinkCall<T>(\n session: string,\n extra: Secret[],\n call: () => Promise<T>\n ): Promise<T> {\n return withVippsSecrets(\n () => [{ value: session, label: REDACTED }, ...extra, ...apiKeySecret()],\n async () => {\n try {\n return await call();\n } catch (error) {\n if (isDeadSession(error)) throw new PortalSessionExpiredError();\n throw error;\n }\n }\n );\n }\n\n function isVippsNotConfigured(error: unknown): boolean {\n return error instanceof MedalApiError && error.code === 'VIPPS_NOT_CONFIGURED';\n }\n\n /**\n * Ask Medal where to send a logged-in parent to link Vipps. `null` against a\n * Medal without the route AND for a workspace without Vipps (503\n * `VIPPS_NOT_CONFIGURED`) — both hide the row, with nothing to say.\n * `PortalThrottledError` for a 429, `PortalSessionExpiredError` for a dead\n * session; the address is checked to be HTTPS.\n */\n async function startVippsLink(\n session: string,\n {\n returnUrl,\n browserBinding,\n locale = 'no',\n }: { returnUrl: string; browserBinding: string; locale?: 'no' | 'en' }\n ): Promise<{ authorizeUrl: string } | null> {\n const path = `${VIPPS_LINK_PATH}/start`;\n return withVippsLinkCall(session, [{ value: browserBinding, label: '<binding>' }], async () => {\n let data: unknown;\n try {\n data = await callMedal(path, {\n method: 'POST',\n body: { return_url: returnUrl, browser_binding: browserBinding, locale },\n session,\n });\n } catch (error) {\n // No route, or a workspace with no Vipps (503 `VIPPS_NOT_CONFIGURED`):\n // either way there is nothing to link, and the Profil row hides.\n if (isMissingRoute(error) || isVippsNotConfigured(error)) return null;\n if (isDeadSession(error)) throw error;\n return mapStartError(error);\n }\n const authorizeUrl = field(data, 'authorize_url');\n if (typeof authorizeUrl !== 'string' || !authorizeUrl.startsWith('https://')) {\n throw malformed(path);\n }\n return { authorizeUrl };\n });\n }\n\n /**\n * Spend the link grant Medal's callback handed back (medal-monorepo #5592).\n *\n * 200 `{ data: { status: 'linked', already_linked } }` is `linked` — a 2xx\n * without that status is malformed, not a success. 409\n * `VIPPS_IDENTITY_CONFLICT` is `conflict` (this Vipps account belongs to\n * another profile). 404 `LINK_GRANT_NOT_FOUND` — an unknown, spent, expired\n * or malformed grant, and (since the #5592 security review, which BURNS the\n * grant on it) a wrong binding or another customer's session — is `invalid`,\n * as are a 400 and a non-session 401. Every one of the three is final for\n * the grant. A dead session and a throttle are the seam's usual errors.\n */\n async function completeVippsLink(\n session: string,\n { grant, browserBinding }: { grant: string; browserBinding: string }\n ): Promise<VippsLinkCompletion> {\n const path = `${VIPPS_LINK_PATH}/complete`;\n const secrets: Secret[] = [\n { value: grant, label: '<grant>' },\n { value: browserBinding, label: '<binding>' },\n ];\n return withVippsLinkCall(session, secrets, async () => {\n try {\n const data = await callMedal(path, {\n method: 'POST',\n body: { grant, browser_binding: browserBinding },\n session,\n });\n if (field(data, 'status') !== 'linked') throw malformed(path);\n return 'linked';\n } catch (error) {\n return mapCompleteError(error);\n }\n });\n }\n\n /** The complete route's refusals, as `VippsLinkCompletion`; anything else is rethrown. */\n function mapCompleteError(error: unknown): VippsLinkCompletion {\n if (!(error instanceof MedalApiError)) throw error;\n // Read before the dead-session guard, which narrows `error` away.\n const { status } = error;\n // Left for `withVippsLinkCall`, which maps it to `PortalSessionExpiredError`.\n if (isDeadSession(error)) throw error;\n if (status === 409) return 'conflict';\n if (status === 400 || status === 401 || status === 404) return 'invalid';\n if (status === 429) throw new PortalThrottledError();\n throw error;\n }\n\n return {\n startLogin,\n verifyLogin,\n logout,\n getMe,\n updateMe,\n getMyBookings,\n exportMyData,\n deleteMe,\n startVippsLogin,\n exchangeVippsGrant,\n createPerson,\n updatePerson,\n removePerson,\n verifyVippsLink,\n startVippsLink,\n completeVippsLink,\n };\n}\n\nexport type PortalSeam = ReturnType<typeof createPortalSeam>;\n"],"mappings":"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"}
1
+ {"version":3,"file":"medal-portal.mjs","names":[],"sources":["../../../src/next/portal/medal-portal.ts"],"sourcesContent":["import type {\n Portal,\n PortalExport,\n PortalLocale,\n PortalPersonCreateInput,\n PortalPersonPatch,\n PortalProfilePatch,\n} from '@medalsocial/sdk';\nimport { MedalApiError } from '@medalsocial/sdk';\nimport type { BookingConfig } from '../../core/config';\nimport {\n createPortalDto,\n type PortalBookingDto,\n type PortalProfileDto,\n toPortalProfileDto,\n} from '../../core/portal/dto';\nimport { type MedalSeam, unwrap } from '../medal';\nimport { DEFAULT_PORTAL_MESSAGES, type PortalMessages } from '../options';\nimport { REDACTED, redactSecret, redactSession, type Secret } from '../redact';\n\n/**\n * The portal seam: everything `/min-side` asks Medal, in this site's own terms.\n *\n * The sibling of `lib/booking/medal-client.ts`, and built on the same SDK. The\n * difference is the credential. The booking seam carries a per-appointment\n * manage token on a handful of calls; every call here except the two login\n * steps carries the parent's SESSION — a bearer credential for their whole\n * account, read from the session cookie by `portal/session.ts` and\n * handed in as a plain string. This module is the last place that string is\n * known by name before it goes on the wire, which makes it the last place two\n * things can be guaranteed:\n *\n * 1. **It is not in any error that leaves here.** The SDK formats the failing\n * request into its messages, and the session travels in a header of that\n * request; whatever an action lets escape is logged verbatim by\n * `next-safe-action`'s error handler and a log is copied to places a cookie\n * is not. `withSession` scrubs the value out of the message and stack of\n * anything it rethrows — the same instance, so `instanceof`, `status` and\n * `code` still hold for whoever catches it.\n * 2. **It is never logged here.** There is no `logger` import in this file on\n * purpose. The one thing a log line about a portal failure must not contain\n * is the thing every function in this file holds.\n *\n * Two errors are this module's own vocabulary, because two upstream answers\n * are decisions for the page rather than failures: `PortalSessionExpiredError`\n * (Medal no longer honours the cookie — clear it and send the parent back to\n * the login) and `PortalThrottledError` (Medal is rate-limiting this address —\n * the login copy already says «if we have your address, a code is on its way»,\n * and a throttle must not become an oracle for which addresses exist).\n * `PortalValidationError` carries the engine's message for a rejected profile\n * edit; it is written for integrators and quotes wire field names, so the form\n * should prefer its own copy and fall back to this.\n */\n\n/** Medal answered 401 with a `PORTAL_SESSION_*` code: the cookie is dead. */\nexport class PortalSessionExpiredError extends Error {\n constructor() {\n super('portal session expired');\n this.name = 'PortalSessionExpiredError';\n }\n}\n\n/** Medal answered 429: this address has asked for too many codes. */\nexport class PortalThrottledError extends Error {\n constructor() {\n super('portal request throttled');\n this.name = 'PortalThrottledError';\n }\n}\n\n/** Medal rejected a profile edit; `message` is the engine's own sentence. */\nexport class PortalValidationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'PortalValidationError';\n }\n}\n\n/**\n * Medal answered 503 `VIPPS_NOT_CONFIGURED`: this workspace has no Vipps\n * credentials (yet). A decision for the login page — show the e-mail form\n * and say so — not a failure.\n */\nexport class PortalVippsUnavailableError extends Error {\n constructor() {\n super('vipps login unavailable');\n this.name = 'PortalVippsUnavailableError';\n }\n}\n\n/** One child as the editor writes it, in this site's own terms. */\nexport interface PortalPersonInput {\n name: string;\n birthYear: number;\n /** `null` clears; absent leaves it alone. */\n birthMonth?: number | null;\n notes?: string | null;\n preferredResourceId?: string | null;\n}\n\n/** What a person edit reports: the profile as Medal now holds it, and whether\n * the SP10 route was there to take the details. */\nexport interface PortalPersonResult {\n profile: PortalProfileDto;\n /** The id of the child just created or edited, when the profile can name it. */\n personId: string | null;\n fallback: boolean;\n}\n\n/**\n * Which child an edit is about. `personId` wherever the profile named one;\n * `index` into the profile's family only for the rare row it could not (two\n * same-named, same-year children against a Medal that sends no ids), which\n * only the list-replacing fallback can address.\n */\nexport interface PersonTarget {\n personId?: string | null;\n index?: number;\n}\n\n/**\n * Medal answered 409 `VIPPS_IDENTITY_CONFLICT` to a pending-link code: the code\n * was right, but this Vipps account has since been bound to another customer.\n * An answer for the login sheet — «log in with e-mail, or ring the salon».\n */\nexport class PortalVippsConflictError extends Error {\n constructor() {\n super('vipps identity conflict');\n this.name = 'PortalVippsConflictError';\n }\n}\n\n/** How a link grant ended: linked, bound to another profile, or not honoured. */\nexport type VippsLinkCompletion = 'linked' | 'conflict' | 'invalid';\n\ntype Seam = Pick<MedalSeam, 'requireMedal' | 'requireMedalWithTimeout' | 'requireMedalConfig'>;\n\nexport interface PortalSeamOptions {\n config: Pick<BookingConfig, 'paths'>;\n messages?: Partial<PortalMessages>;\n}\n\n/** The portal seam over one Medal seam. */\nexport function createPortalSeam(medal: Seam, options: PortalSeamOptions) {\n const messages = { ...DEFAULT_PORTAL_MESSAGES, ...options.messages };\n const { toPortalBookingDto } = createPortalDto(options.config);\n\n /**\n * The SDK's portal namespace (`Medal#portal`, SDK ≥ 1.8.0). `requireMedal()`\n * throws `MedalConfigError` for a missing key, and that is left to propagate:\n * a portal that silently cannot reach Medal is worse than one that fails\n * visibly.\n */\n function portalClient(): Portal {\n return medal.requireMedal().portal;\n }\n\n function isDeadSession(error: unknown): error is MedalApiError {\n return (\n error instanceof MedalApiError &&\n error.status === 401 &&\n (error.code === 'PORTAL_SESSION_REQUIRED' || error.code === 'PORTAL_SESSION_INVALID')\n );\n }\n\n function isThrottled(error: unknown): error is MedalApiError {\n return error instanceof MedalApiError && error.status === 429;\n }\n\n /**\n * Run one session-bound SDK call with the two guarantees from the module\n * comment, plus the one mapping every such call shares: a 401 that names the\n * session becomes `PortalSessionExpiredError`. Everything else is rethrown\n * redacted, for the caller to interpret or for the action's error handler to\n * turn into «something went wrong».\n */\n async function withSession<T>(session: string, call: (portal: Portal) => Promise<T>) {\n try {\n return await call(portalClient());\n } catch (error) {\n const scrubbed = redactSession(error, session);\n if (isDeadSession(scrubbed)) throw new PortalSessionExpiredError();\n throw scrubbed;\n }\n }\n\n /**\n * Ask Medal to e-mail a one-time code.\n *\n * Resolves on «sent» and says nothing about whether the address is known —\n * that is Medal's silence to keep, and the action above this keeps it too. A\n * 429 is the one answer translated (`PortalThrottledError`); anything else is\n * rethrown as-is, because a login page that cannot reach Medal should say so.\n */\n async function startLogin(email: string, locale: PortalLocale = 'no'): Promise<void> {\n try {\n unwrap(await portalClient().login.start({ email, locale }), '/api/v1/portal/login/start');\n } catch (error) {\n if (isThrottled(error)) throw new PortalThrottledError();\n throw error;\n }\n }\n\n /**\n * Exchange the code for a session. `null` for a wrong or expired code — that\n * is an answer for the form, not a failure — and the token plus Medal's own\n * expiry on success. The contact summary that rides along is dropped here:\n * the page fetches the profile with the session it has just been given, and\n * a second copy of the parent's name has no reader.\n */\n async function verifyLogin(\n email: string,\n code: string\n ): Promise<{ sessionToken: string; expiresAt: number } | null> {\n try {\n const session = unwrap(\n await portalClient().login.verify({ email, code }),\n '/api/v1/portal/login/verify'\n );\n return { sessionToken: session.session_token, expiresAt: session.expires_at };\n } catch (error) {\n if (\n error instanceof MedalApiError &&\n error.status === 401 &&\n error.code === 'PORTAL_CODE_INVALID'\n ) {\n return null;\n }\n if (isThrottled(error)) throw new PortalThrottledError();\n throw error;\n }\n }\n\n /**\n * Revoke the session upstream. A 401 of any flavour is swallowed: it means\n * Medal no longer honours this token, which is exactly the state a logout is\n * trying to reach. A 5xx is NOT — the token may still be live, and the caller\n * decides whether to clear the cookie anyway (it should: a cookie that stays\n * in the jar is a cookie that stays stealable).\n */\n async function logout(session: string): Promise<void> {\n try {\n await withSession(session, (portal) => portal.logout(session));\n } catch (error) {\n if (error instanceof PortalSessionExpiredError) return;\n if (error instanceof MedalApiError && error.status === 401) return;\n throw error;\n }\n }\n\n async function getMe(session: string): Promise<PortalProfileDto> {\n const profile = await withSession(session, async (portal) =>\n unwrap(await portal.me(session), '/api/v1/portal/me')\n );\n return toPortalProfileDto(profile);\n }\n\n /**\n * The engine names the code `VALIDATION_ERROR` whether it answers 400 or 422\n * (`medal-client.ts` records that the booking routes see the latter), so the\n * match is on the code alone. The message has already been through\n * `redactSession` by the time it is re-thrown here — it is bound for a form.\n */\n async function updateMe(session: string, patch: PortalProfilePatch): Promise<PortalProfileDto> {\n try {\n const profile = await withSession(session, async (portal) =>\n unwrap(await portal.updateMe(session, patch), '/api/v1/portal/me')\n );\n return toPortalProfileDto(profile);\n } catch (error) {\n if (error instanceof MedalApiError && error.code === 'VALIDATION_ERROR') {\n throw new PortalValidationError(error.message);\n }\n throw error;\n }\n }\n\n async function getMyBookings(\n session: string\n ): Promise<{ upcoming: PortalBookingDto[]; past: PortalBookingDto[] }> {\n const bookings = await withSession(session, async (portal) =>\n unwrap(await portal.myBookings(session), '/api/v1/portal/bookings')\n );\n return {\n upcoming: bookings.upcoming.map(toPortalBookingDto),\n past: bookings.past.map(toPortalBookingDto),\n };\n }\n\n /**\n * The parent's own data, as Medal exports it — snake_case and all, because\n * this is THEIR file (the GDPR right of access) and a projection would be\n * this site editorialising it. One thing is taken out: every `manage_token`\n * is nulled. A download sits in a Downloads folder, gets attached to an\n * e-mail, gets synced to a phone; a live credential for moving an appointment\n * does not belong in any of those places, and the same bookings are managed\n * from the page with the session instead.\n *\n * Copies rather than mutates: the SDK's object is nobody else's, today, but a\n * function that quietly edits its input is a bug waiting for a second reader.\n */\n async function exportMyData(session: string): Promise<PortalExport> {\n const exported = await withSession(session, async (portal) =>\n unwrap(await portal.exportMyData(session), '/api/v1/portal/export')\n );\n return {\n ...exported,\n bookings: exported.bookings.map((booking) => ({ ...booking, manage_token: null })),\n };\n }\n\n async function deleteMe(session: string): Promise<void> {\n await withSession(session, (portal) => portal.deleteMe(session));\n }\n\n /*\n * ---------------------------------------------------------------------------\n * Vipps login\n *\n * Three routes: start, exchange and (below) the pending-link verify. SDK 1.12\n * has a method for each; `exchange` and `verifyLink` go through it, because\n * both are sent exactly once there (`retry: false`) — the grant and the link\n * are single-use, so a retry would misreport a completed login — and the\n * 15 s budget is kept by a client of its own (`quickPortal`). `start` stays on\n * a raw `fetch` built from the same key and origin (`postVipps`): the SDK\n * retries it on 429/5xx with no per-call way to opt out, and this site wants\n * a throttle to surface at once as `PortalThrottledError` and an unreachable\n * Medal to fail inside the one 15 s budget, not three. Medal keeps the whole OpenID Connect exchange with\n * Vipps on its side; this site only asks for an address to send the parent to\n * and, when they come back, swaps the one-time GRANT in the return URL for a\n * session. The grant is a bearer credential for that swap — whoever presents\n * it first is logged in — so it is redacted from errors exactly like the\n * session, and the API key rides along in the same list because it is in the\n * `Authorization` header of a request `fetch` may well quote.\n *\n * Both routes answer in the platform's standard envelopes — `{ data: … }` on\n * a 2xx and `{ error: { code, message } }` otherwise — the same shapes the\n * SDK's own methods unwrap. `postVipps` mirrors that: the `data` member is\n * what it resolves with, and a 2xx WITHOUT one (a legacy bare body, an HTML\n * error page, an empty response) is a failure, not a success carrying\n * `undefined`. The `code` is what the mappings below key on, and it is kept\n * on the thrown `MedalApiError` for whoever reads the log; the `message` is\n * dropped, because a sentence written for integrators may quote the request\n * it is about, and the redaction here covers `message` and `stack`, not a\n * structured `details`.\n * ---------------------------------------------------------------------------\n */\n\n /** Long enough for Medal to round-trip Vipps; short enough that a parent\n * whose click went nowhere is told so before they give up on the page. */\n const VIPPS_TIMEOUT_MS = 15_000;\n\n /** The SDK's portal namespace on a client with the `VIPPS_TIMEOUT_MS` deadline. */\n function quickPortal(): Portal {\n return medal.requireMedalWithTimeout(VIPPS_TIMEOUT_MS).portal;\n }\n\n /** `body[key]`, or `undefined` for a body that is not an object. */\n function field(body: unknown, key: string): unknown {\n return body && typeof body === 'object' ? (body as Record<string, unknown>)[key] : undefined;\n }\n\n /** The `code` of a standard error envelope; `UNKNOWN_ERROR` for anything else. */\n function vippsErrorCode(body: unknown): string {\n const code = field(field(body, 'error'), 'code');\n return typeof code === 'string' && code !== '' ? code : 'UNKNOWN_ERROR';\n }\n\n /** The engine's own 404s on the persons routes; any other 404 is a missing route. */\n const DOMAIN_NOT_FOUND: ReadonlySet<string> = new Set(['Person not found', 'Resource not found']);\n\n function isDomainNotFound(message: unknown): boolean {\n return typeof message === 'string' && DOMAIN_NOT_FOUND.has(message);\n }\n\n /** A 2xx whose body is not the shape the contract promises is not a success. */\n function malformed(path: string): MedalApiError {\n return new MedalApiError(200, 'NON_JSON_BODY', `Medal returned a malformed body from ${path}`);\n }\n\n /**\n * `POST` one Vipps route with the API key. Resolves with the `data` member\n * of the parsed 2xx envelope; throws `MedalApiError` for anything else, with\n * a message that names the status and the code and NOT the response text —\n * the only strings Medal echoes on these routes are ones this site sent it.\n */\n async function postVipps(path: string, body: unknown): Promise<unknown> {\n return callMedal(path, { method: 'POST', body });\n }\n\n /**\n * One raw request to Medal with the API key — and, for the routes that act as\n * the parent, their session in `x-portal-session`, the header the SDK's own\n * portal methods use. `postVipps` is this with `POST` and no session.\n *\n * A 204 resolves `undefined`; any other 2xx has to carry the `data` member.\n */\n async function callMedal(\n path: string,\n {\n method,\n body,\n session,\n }: { method: 'POST' | 'PATCH' | 'DELETE'; body?: unknown; session?: string }\n ): Promise<unknown> {\n const { key, base } = medal.requireMedalConfig();\n const response = await fetch(new URL(path, base).toString(), {\n method,\n headers: {\n authorization: `Bearer ${key}`,\n /* v8 ignore next -- defensive: no caller sends a request without a body */\n ...(body === undefined ? {} : { 'content-type': 'application/json' }),\n accept: 'application/json',\n ...(session === undefined ? {} : { 'x-portal-session': session }),\n },\n /* v8 ignore next -- defensive: no caller sends a request without a body */\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n signal: AbortSignal.timeout(VIPPS_TIMEOUT_MS),\n });\n if (response.status === 204) return undefined;\n const text = await response.text();\n let parsed: unknown;\n try {\n parsed = text ? JSON.parse(text) : undefined;\n } catch {\n parsed = undefined;\n }\n if (!response.ok) {\n const code = vippsErrorCode(parsed);\n throw new MedalApiError(\n response.status,\n code,\n `Medal answered ${response.status} ${code} from ${path}`,\n // Not the engine's sentence (see above) — only whether it was one of\n // the two «no such person / resource» 404s, which is the one decision\n // that needs it: a 404 that is neither is a route this Medal lacks\n // (`isMissingRoute`).\n { domainNotFound: isDomainNotFound(field(field(parsed, 'error'), 'message')) }\n );\n }\n const data = field(parsed, 'data');\n if (data === undefined) throw malformed(path);\n return data;\n }\n\n /** Run `call`, scrubbing every secret in `secrets` out of whatever it throws. */\n async function withVippsSecrets<T>(secrets: () => Secret[], call: () => Promise<T>): Promise<T> {\n try {\n return await call();\n } catch (error) {\n let scrubbed = error;\n for (const secret of secrets()) scrubbed = redactSecret(scrubbed, secret);\n throw scrubbed;\n }\n }\n\n function apiKeySecret(): Secret[] {\n try {\n return [{ value: medal.requireMedalConfig().key, label: '<key>' }];\n } catch {\n return [];\n }\n }\n\n /** The start route's three decisions, by code (and 429 by status); anything else is rethrown. */\n function mapStartError(error: unknown): never {\n if (error instanceof MedalApiError) {\n if (error.code === 'VIPPS_NOT_CONFIGURED') throw new PortalVippsUnavailableError();\n if (error.code === 'INVALID_RETURN_URL') throw new PortalValidationError(error.message);\n if (error.status === 429) throw new PortalThrottledError();\n }\n throw error;\n }\n\n /**\n * Ask Medal where to send the parent to log in with Vipps.\n *\n * `returnUrl` is the absolute address of this site's `/min-side/vipps` route,\n * which is where Vipps — via Medal — sends the browser back with either a\n * grant or a reason. Medal checks it against the workspace's site and answers\n * 400 `INVALID_RETURN_URL` for one it does not recognise; that surfaces as\n * `PortalValidationError` because it is a configuration mistake on one side\n * or the other, not something the parent did. 503 `VIPPS_NOT_CONFIGURED`\n * means the workspace has no Vipps credentials and becomes\n * `PortalVippsUnavailableError`, which the login page turns into «use e-mail\n * instead». The address Medal hands back is checked to be HTTPS before anyone\n * is redirected to it.\n */\n async function startVippsLogin({\n returnUrl,\n browserBinding,\n locale = 'no',\n }: {\n returnUrl: string;\n /**\n * SP10: the value this site keeps in the browser-binding cookie, so a pending link\n * Medal offers can only be confirmed from this browser (`vipps-link.ts`).\n * A Medal without it ignores the key.\n */\n browserBinding?: string;\n /** The language of the code e-mail a conflicted login sends. */\n locale?: 'no' | 'en';\n }): Promise<{ authorizeUrl: string }> {\n const path = '/api/v1/portal/vipps/start';\n const secrets = (): Secret[] => [\n ...(browserBinding ? [{ value: browserBinding, label: '<binding>' }] : []),\n ...apiKeySecret(),\n ];\n return withVippsSecrets(secrets, async () => {\n const data = await postVipps(path, {\n return_url: returnUrl,\n ...(browserBinding ? { browser_binding: browserBinding } : {}),\n locale,\n }).catch(mapStartError);\n const authorizeUrl = field(data, 'authorize_url');\n if (typeof authorizeUrl !== 'string' || !authorizeUrl.startsWith('https://')) {\n throw malformed(path);\n }\n return { authorizeUrl };\n });\n }\n\n /**\n * Swap the grant Vipps sent the parent back with for a session. `null` when\n * Medal does not know it (404 `GRANT_NOT_FOUND` — used already, expired, or\n * minted for another workspace; the three are not distinguished, and none is\n * a failure) and the token plus expiry otherwise, in the same shape\n * `verifyLogin` returns so the route that receives it writes the cookie the\n * same way.\n *\n * The grant and, once known, the token are scrubbed from anything thrown:\n * the grant is in the request body, the token in the response, and a `fetch`\n * failure or a serialiser can quote either.\n */\n async function exchangeVippsGrant(\n grant: string\n ): Promise<{ sessionToken: string; expiresAt: number } | null> {\n const path = '/api/v1/portal/vipps/exchange';\n let token = '';\n const secrets = (): Secret[] => [\n { value: grant, label: '<grant>' },\n { value: token, label: REDACTED },\n ...apiKeySecret(),\n ];\n return withVippsSecrets(secrets, async () => {\n let data: unknown;\n try {\n data = unwrap(await quickPortal().login.vipps.exchange({ grant }), path);\n } catch (error) {\n if (error instanceof MedalApiError && error.code === 'GRANT_NOT_FOUND') return null;\n if (isThrottled(error)) throw new PortalThrottledError();\n throw error;\n }\n const sessionToken = field(data, 'session_token');\n const expiresAt = field(data, 'expires_at');\n if (typeof sessionToken === 'string') token = sessionToken;\n if (token === '' || typeof expiresAt !== 'number') throw malformed(path);\n return { sessionToken: token, expiresAt };\n });\n }\n\n /*\n * ---------------------------------------------------------------------------\n * Children as persons (SP10)\n *\n * `POST /api/v1/portal/me/persons`, `PATCH` and `DELETE /…/persons/{id}` edit\n * one child in place, so the id — which a booking carries as\n * `booked_for_person_id` — survives a rename. `create` and `remove` go\n * through the SDK's `portal.persons.{create,remove}` (1.12), which put the\n * session in `x-portal-session` and send each exactly once, on `quickPortal`\n * so the 15 s budget holds. `update` stays on the raw `callMedal` PATCH: the\n * SDK retries `persons.update` on 429/5xx (each attempt with its own\n * deadline) and takes no per-call `retry: false`, so a throttle would no\n * longer surface at once as `PortalThrottledError` and an unreachable Medal\n * would hold the editor for three budgets instead of one.\n *\n * FEATURE-DETECTED, because this site must keep working against a Medal that\n * does not have them: a 404 that is not the engine's own «Person not found» /\n * «Resource not found» is a route that does not exist (today's Medal answers\n * Convex's plain «No matching routes found»), and each function then falls\n * back to what the editor did before — the whole family list through\n * `PATCH /me`, which matches on name and year and so also keeps the id for a\n * child whose name and year did not change. Birth month, notes and preferred\n * stylist have nowhere to go in that fallback and are dropped; the answer\n * says so (`fallback: true`) and the editor only offers those fields where\n * `personDetails` says Medal can keep them.\n * ---------------------------------------------------------------------------\n */\n\n const PERSONS_PATH = '/api/v1/portal/me/persons';\n\n /**\n * A 404 that is not the engine's own «Person not found» / «Resource not\n * found». The SDK keeps the engine's sentence as `message`; the raw\n * `callMedal` drops it and records the verdict in `details.domainNotFound`.\n */\n function isMissingRoute(error: unknown): boolean {\n if (!(error instanceof MedalApiError) || error.status !== 404) return false;\n const details = error.details as { domainNotFound?: unknown } | undefined;\n const domain = details?.domainNotFound === true || isDomainNotFound(error.message);\n return !(error.code === 'NOT_FOUND' && domain);\n }\n\n /** The editor's copy for what the engine refused. Never the engine's own\n * sentence: it is written for integrators and names wire fields. */\n function mapPersonError(error: unknown): never {\n if (error instanceof MedalApiError) {\n if (error.code === 'CONFLICT') {\n throw new PortalValidationError(messages.duplicateChild);\n }\n if (error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_INPUT') {\n throw new PortalValidationError(messages.invalidChild);\n }\n if (error.status === 404) {\n throw new PortalValidationError(messages.childNotFound);\n }\n if (error.status === 429) throw new PortalThrottledError();\n }\n throw error;\n }\n\n function personPatch(input: Partial<PortalPersonInput>): PortalPersonPatch {\n const body: PortalPersonPatch = {};\n if (input.name !== undefined) body.name = input.name;\n if (input.birthYear !== undefined) body.birth_year = input.birthYear;\n if (input.birthMonth !== undefined) body.birth_month = input.birthMonth;\n if (input.notes !== undefined) body.notes = input.notes;\n if (input.preferredResourceId !== undefined) {\n body.preferred_resource_id = input.preferredResourceId;\n }\n return body;\n }\n\n /**\n * On create an absent key and `null` mean the same — nothing — and the\n * create schema is STRICT about nulls it does not take.\n */\n function personCreate(input: PortalPersonInput): PortalPersonCreateInput {\n const body: PortalPersonCreateInput = { name: input.name, birth_year: input.birthYear };\n if (input.birthMonth != null) body.birth_month = input.birthMonth;\n if (input.notes != null) body.notes = input.notes;\n if (input.preferredResourceId != null) body.preferred_resource_id = input.preferredResourceId;\n return body;\n }\n\n /** Run one persons call with the session scrubbed and a dead one mapped. */\n async function withPersonsCall<T>(session: string, call: () => Promise<T>): Promise<T> {\n return withVippsSecrets(\n () => [{ value: session, label: REDACTED }, ...apiKeySecret()],\n async () => {\n try {\n return await call();\n } catch (error) {\n if (isDeadSession(error)) throw new PortalSessionExpiredError();\n throw error;\n }\n }\n );\n }\n\n function wireFamily(profile: PortalProfileDto) {\n return profile.family.map((member) => ({ name: member.name, birth_year: member.birthYear }));\n }\n\n /** The id the refreshed profile gives the child with this name and year. */\n function idOf(profile: PortalProfileDto, name: string, birthYear: number): string | null {\n const matches = profile.family.filter(\n (member) => member.name === name && member.birthYear === birthYear\n );\n return matches.length === 1 ? matches[0].personId : null;\n }\n\n /** Where a child sits in the list the fallback rewrites: by id, else by index. */\n function familyIndex(profile: PortalProfileDto, target: PersonTarget): number {\n if (target.personId) {\n return profile.family.findIndex((member) => member.personId === target.personId);\n }\n const index = target.index ?? -1;\n return index >= 0 && index < profile.family.length ? index : -1;\n }\n\n async function createPerson(\n session: string,\n input: PortalPersonInput\n ): Promise<PortalPersonResult> {\n const created = await withPersonsCall(session, async () => {\n try {\n const data = unwrap(\n await quickPortal().persons.create(session, personCreate(input)),\n PERSONS_PATH\n );\n const personId = field(data, 'person_id');\n return { personId: typeof personId === 'string' ? personId : null };\n } catch (error) {\n if (isMissingRoute(error)) return null;\n return mapPersonError(error);\n }\n });\n if (created !== null) {\n return { profile: await getMe(session), personId: created.personId, fallback: false };\n }\n const current = await getMe(session);\n const profile = await updateMe(session, {\n family: [...wireFamily(current), { name: input.name, birth_year: input.birthYear }],\n });\n return { profile, personId: idOf(profile, input.name, input.birthYear), fallback: true };\n }\n\n async function updatePerson(\n session: string,\n target: PersonTarget,\n patch: Partial<PortalPersonInput>\n ): Promise<PortalPersonResult> {\n const personId = target.personId ?? null;\n if (personId !== null) {\n const done = await withPersonsCall(session, async () => {\n try {\n await callMedal(`${PERSONS_PATH}/${encodeURIComponent(personId)}`, {\n method: 'PATCH',\n body: personPatch(patch),\n session,\n });\n return true;\n } catch (error) {\n if (isMissingRoute(error)) return false;\n return mapPersonError(error);\n }\n });\n if (done) return { profile: await getMe(session), personId, fallback: false };\n }\n const current = await getMe(session);\n const index = familyIndex(current, target);\n if (index === -1) mapPersonError(new MedalApiError(404, 'NOT_FOUND', 'person not in family'));\n const family = wireFamily(current);\n const name = patch.name ?? family[index].name;\n const birthYear = patch.birthYear ?? family[index].birth_year;\n family[index] = { name, birth_year: birthYear };\n const profile = await updateMe(session, { family });\n return { profile, personId: idOf(profile, name, birthYear), fallback: true };\n }\n\n async function removePerson(session: string, target: PersonTarget): Promise<PortalPersonResult> {\n const personId = target.personId ?? null;\n if (personId !== null) {\n const done = await withPersonsCall(session, async () => {\n try {\n await quickPortal().persons.remove(session, personId);\n return true;\n } catch (error) {\n if (isMissingRoute(error)) return false;\n return mapPersonError(error);\n }\n });\n if (done) return { profile: await getMe(session), personId, fallback: false };\n }\n const current = await getMe(session);\n const index = familyIndex(current, target);\n if (index === -1) mapPersonError(new MedalApiError(404, 'NOT_FOUND', 'person not in family'));\n const profile = await updateMe(session, {\n family: wireFamily(current).filter((_, position) => position !== index),\n });\n return { profile, personId, fallback: true };\n }\n\n /**\n * Confirm a conflicted Vipps login with the six-digit code Medal e-mailed to\n * the address on file (SP10 A5): `POST /api/v1/portal/vipps/link/verify`.\n *\n * `null` for every refusal Medal folds into 401 `PORTAL_CODE_INVALID` — a\n * wrong or burned code, an unknown, expired or spent link, a browser binding\n * that is not the one the login started with — which is one answer for the\n * form. 409 is `PortalVippsConflictError`, 429 `PortalThrottledError`.\n *\n * The link, the code, the binding and the session it buys are all scrubbed\n * from anything thrown, with the API key. Through the SDK's\n * `portal.login.vipps.verifyLink`, which sends it exactly once: the right code\n * consumes the link, so a retry would misreport a completed login.\n */\n async function verifyVippsLink({\n link,\n code,\n browserBinding,\n }: {\n link: string;\n code: string;\n /** Mandatory: Medal refuses a verify without the binding the login started with. */\n browserBinding: string;\n }): Promise<{ sessionToken: string; expiresAt: number } | null> {\n const path = '/api/v1/portal/vipps/link/verify';\n let token = '';\n const secrets = (): Secret[] => [\n { value: link, label: '<link>' },\n { value: code, label: '<code>' },\n { value: browserBinding, label: '<binding>' },\n { value: token, label: REDACTED },\n ...apiKeySecret(),\n ];\n return withVippsSecrets(secrets, async () => {\n let data: unknown;\n try {\n data = unwrap(\n await quickPortal().login.vipps.verifyLink({\n link,\n code,\n browser_binding: browserBinding,\n }),\n path\n );\n } catch (error) {\n return mapLinkError(error);\n }\n const sessionToken = field(data, 'session_token');\n const expiresAt = field(data, 'expires_at');\n if (typeof sessionToken === 'string') token = sessionToken;\n if (token === '' || typeof expiresAt !== 'number') throw malformed(path);\n return { sessionToken: token, expiresAt };\n });\n }\n\n /** The link verify's refusals: `null` for «wrong or expired», the two errors, else rethrown. */\n function mapLinkError(error: unknown): null {\n if (error instanceof MedalApiError) {\n if (error.status === 401 && error.code === 'PORTAL_CODE_INVALID') return null;\n if (error.status === 409 && error.code === 'VIPPS_IDENTITY_CONFLICT') {\n throw new PortalVippsConflictError();\n }\n if (error.status === 429) throw new PortalThrottledError();\n }\n throw error;\n }\n\n /*\n * ---------------------------------------------------------------------------\n * «Koble til Vipps» from Min side → Profil\n *\n * A parent who logged in with e-mail links their Vipps account to the profile\n * they are already in: `POST /api/v1/portal/me/vipps/link/start` (session)\n * answers with the address to send them to, and Medal's callback either says\n * `?vipps=linked` / `?vipps=link_conflict` outright or hands back a one-time\n * link GRANT that this site spends with the same session through\n * `POST /api/v1/portal/me/vipps/link/complete`. Both carry the browser\n * binding from the `__Host-` binding cookie (`core/portal/vipps-link.ts`), so a grant lifted\n * out of one browser's address bar is worthless in another's.\n *\n * FEATURE-DETECTED like the persons routes: a start that answers with a\n * missing route (`isMissingRoute`) is `null`, and the Profil row hides. Raw\n * `callMedal`, because no SDK has these yet (1.12 included).\n * TODO(sdk): use `portal.vipps.link.{start,complete}` once the SDK has them.\n * ---------------------------------------------------------------------------\n */\n\n const VIPPS_LINK_PATH = '/api/v1/portal/me/vipps/link';\n\n /** The session, the binding, the grant when there is one, and the key — scrubbed; a dead session mapped. */\n async function withVippsLinkCall<T>(\n session: string,\n extra: Secret[],\n call: () => Promise<T>\n ): Promise<T> {\n return withVippsSecrets(\n () => [{ value: session, label: REDACTED }, ...extra, ...apiKeySecret()],\n async () => {\n try {\n return await call();\n } catch (error) {\n if (isDeadSession(error)) throw new PortalSessionExpiredError();\n throw error;\n }\n }\n );\n }\n\n function isVippsNotConfigured(error: unknown): boolean {\n return error instanceof MedalApiError && error.code === 'VIPPS_NOT_CONFIGURED';\n }\n\n /**\n * Ask Medal where to send a logged-in parent to link Vipps. `null` against a\n * Medal without the route AND for a workspace without Vipps (503\n * `VIPPS_NOT_CONFIGURED`) — both hide the row, with nothing to say.\n * `PortalThrottledError` for a 429, `PortalSessionExpiredError` for a dead\n * session; the address is checked to be HTTPS.\n */\n async function startVippsLink(\n session: string,\n {\n returnUrl,\n browserBinding,\n locale = 'no',\n }: { returnUrl: string; browserBinding: string; locale?: 'no' | 'en' }\n ): Promise<{ authorizeUrl: string } | null> {\n const path = `${VIPPS_LINK_PATH}/start`;\n return withVippsLinkCall(session, [{ value: browserBinding, label: '<binding>' }], async () => {\n let data: unknown;\n try {\n data = await callMedal(path, {\n method: 'POST',\n body: { return_url: returnUrl, browser_binding: browserBinding, locale },\n session,\n });\n } catch (error) {\n // No route, or a workspace with no Vipps (503 `VIPPS_NOT_CONFIGURED`):\n // either way there is nothing to link, and the Profil row hides.\n if (isMissingRoute(error) || isVippsNotConfigured(error)) return null;\n if (isDeadSession(error)) throw error;\n return mapStartError(error);\n }\n const authorizeUrl = field(data, 'authorize_url');\n if (typeof authorizeUrl !== 'string' || !authorizeUrl.startsWith('https://')) {\n throw malformed(path);\n }\n return { authorizeUrl };\n });\n }\n\n /**\n * Spend the link grant Medal's callback handed back (medal-monorepo #5592).\n *\n * 200 `{ data: { status: 'linked', already_linked } }` is `linked` — a 2xx\n * without that status is malformed, not a success. 409\n * `VIPPS_IDENTITY_CONFLICT` is `conflict` (this Vipps account belongs to\n * another profile). 404 `LINK_GRANT_NOT_FOUND` — an unknown, spent, expired\n * or malformed grant, and (since the #5592 security review, which BURNS the\n * grant on it) a wrong binding or another customer's session — is `invalid`,\n * as are a 400 and a non-session 401. Every one of the three is final for\n * the grant. A dead session and a throttle are the seam's usual errors.\n */\n async function completeVippsLink(\n session: string,\n { grant, browserBinding }: { grant: string; browserBinding: string }\n ): Promise<VippsLinkCompletion> {\n const path = `${VIPPS_LINK_PATH}/complete`;\n const secrets: Secret[] = [\n { value: grant, label: '<grant>' },\n { value: browserBinding, label: '<binding>' },\n ];\n return withVippsLinkCall(session, secrets, async () => {\n try {\n const data = await callMedal(path, {\n method: 'POST',\n body: { grant, browser_binding: browserBinding },\n session,\n });\n if (field(data, 'status') !== 'linked') throw malformed(path);\n return 'linked';\n } catch (error) {\n return mapCompleteError(error);\n }\n });\n }\n\n /** The complete route's refusals, as `VippsLinkCompletion`; anything else is rethrown. */\n function mapCompleteError(error: unknown): VippsLinkCompletion {\n if (!(error instanceof MedalApiError)) throw error;\n // Read before the dead-session guard, which narrows `error` away.\n const { status } = error;\n // Left for `withVippsLinkCall`, which maps it to `PortalSessionExpiredError`.\n if (isDeadSession(error)) throw error;\n if (status === 409) return 'conflict';\n if (status === 400 || status === 401 || status === 404) return 'invalid';\n if (status === 429) throw new PortalThrottledError();\n throw error;\n }\n\n return {\n startLogin,\n verifyLogin,\n logout,\n getMe,\n updateMe,\n getMyBookings,\n exportMyData,\n deleteMe,\n startVippsLogin,\n exchangeVippsGrant,\n createPerson,\n updatePerson,\n removePerson,\n verifyVippsLink,\n startVippsLink,\n completeVippsLink,\n };\n}\n\nexport type PortalSeam = ReturnType<typeof createPortalSeam>;\n"],"mappings":"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"}
@@ -0,0 +1,76 @@
1
+ //#region src/next/redact.ts
2
+ /**
3
+ * Secrets out of thrown errors — the portal session, the Vipps grant and the
4
+ * token it buys, the API key — before anything up the stack logs them. One
5
+ * helper for every server path that sends a secret to Medal: the portal seam
6
+ * and the create route's forwarded session alike.
7
+ */
8
+ /** What a portal session (or a token standing in for one) becomes in an error. */
9
+ const REDACTED = "<session>";
10
+ /**
11
+ * How far below the thrown error the scrub follows `cause` and
12
+ * `AggregateError.errors`. Three is deeper than any chain the SDK or `fetch`
13
+ * builds (undici wraps one `cause` under `fetch failed`), and a bound is
14
+ * needed at all because `cause` may point back up the chain: an error that is
15
+ * its own cause would otherwise recurse until the stack ran out.
16
+ */
17
+ const REDACT_DEPTH = 3;
18
+ function scrub(text, session, label = REDACTED) {
19
+ return text.includes(session) ? text.split(session).join(label) : text;
20
+ }
21
+ /**
22
+ * Scrub `message` and `stack` on `error` and on every `Error` hanging off it.
23
+ *
24
+ * `cause` and `errors` are followed because a logger prints them: Node's
25
+ * `util.inspect`, Pino's error serializer and Sentry all walk the cause chain,
26
+ * so a clean top-level message over an unscrubbed `cause.message` is the same
27
+ * leak one level down. A string `cause` is rewritten in place for the same
28
+ * reason; anything else there (a response object, say) is not a string a
29
+ * logger prints verbatim and is left alone.
30
+ */
31
+ function redactError(error, session, depth, label = REDACTED) {
32
+ if (error.message.includes(session)) try {
33
+ error.message = scrub(error.message, session, label);
34
+ } catch {}
35
+ if (typeof error.stack === "string" && error.stack.includes(session)) error.stack = scrub(error.stack, session, label);
36
+ if (depth >= REDACT_DEPTH) return;
37
+ if (error.cause instanceof Error) redactError(error.cause, session, depth + 1, label);
38
+ else if (typeof error.cause === "string") error.cause = scrub(error.cause, session, label);
39
+ if (error instanceof AggregateError) {
40
+ for (const inner of error.errors) if (inner instanceof Error) redactError(inner, session, depth + 1, label);
41
+ }
42
+ }
43
+ /**
44
+ * Scrub `session` out of whatever was thrown, in place, and hand it back.
45
+ *
46
+ * In place rather than re-wrapped: callers up the stack switch on
47
+ * `instanceof MedalApiError` and read `.status` / `.code`, and a fresh `Error`
48
+ * would erase all three. `message` and `stack` are the two strings a logger
49
+ * prints — on the error and, via `redactError`, on its `cause` chain;
50
+ * `MedalApiError.details` is `readonly` and carries the engine's structured
51
+ * body, which never echoes a request header.
52
+ *
53
+ * A non-`Error` throw that contains the session (a bare string, say) is
54
+ * REPLACED — there is nothing else on it worth keeping, and nothing safe to
55
+ * pass on.
56
+ */
57
+ function redactSession(error, session) {
58
+ return redactSecret(error, {
59
+ value: session,
60
+ label: REDACTED
61
+ });
62
+ }
63
+ /** `redactSession` for any secret: the Vipps grant, the token it buys, the API key. */
64
+ function redactSecret(error, secret) {
65
+ if (secret.value === "") return error;
66
+ if (error instanceof Error) {
67
+ redactError(error, secret.value, 0, secret.label);
68
+ return error;
69
+ }
70
+ if (typeof error === "string" && error.includes(secret.value)) return new Error(scrub(error, secret.value, secret.label));
71
+ return error;
72
+ }
73
+ //#endregion
74
+ export { REDACTED, redactSecret, redactSession };
75
+
76
+ //# sourceMappingURL=redact.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redact.mjs","names":[],"sources":["../../src/next/redact.ts"],"sourcesContent":["/**\n * Secrets out of thrown errors — the portal session, the Vipps grant and the\n * token it buys, the API key — before anything up the stack logs them. One\n * helper for every server path that sends a secret to Medal: the portal seam\n * and the create route's forwarded session alike.\n */\n\n/** What a portal session (or a token standing in for one) becomes in an error. */\nexport const REDACTED = '<session>';\n\n/** A value that must not appear in an error, and the placeholder it becomes. */\nexport type Secret = { value: string; label: string };\n\n/**\n * How far below the thrown error the scrub follows `cause` and\n * `AggregateError.errors`. Three is deeper than any chain the SDK or `fetch`\n * builds (undici wraps one `cause` under `fetch failed`), and a bound is\n * needed at all because `cause` may point back up the chain: an error that is\n * its own cause would otherwise recurse until the stack ran out.\n */\nconst REDACT_DEPTH = 3;\n\nfunction scrub(text: string, session: string, label = REDACTED): string {\n return text.includes(session) ? text.split(session).join(label) : text;\n}\n\n/**\n * Scrub `message` and `stack` on `error` and on every `Error` hanging off it.\n *\n * `cause` and `errors` are followed because a logger prints them: Node's\n * `util.inspect`, Pino's error serializer and Sentry all walk the cause chain,\n * so a clean top-level message over an unscrubbed `cause.message` is the same\n * leak one level down. A string `cause` is rewritten in place for the same\n * reason; anything else there (a response object, say) is not a string a\n * logger prints verbatim and is left alone.\n */\nfunction redactError(error: Error, session: string, depth: number, label = REDACTED): void {\n if (error.message.includes(session)) {\n // A getter-only `message` cannot be rewritten: leave it, scrub the rest.\n try {\n error.message = scrub(error.message, session, label);\n } catch {}\n }\n if (typeof error.stack === 'string' && error.stack.includes(session)) {\n error.stack = scrub(error.stack, session, label);\n }\n if (depth >= REDACT_DEPTH) return;\n if (error.cause instanceof Error) {\n redactError(error.cause, session, depth + 1, label);\n } else if (typeof error.cause === 'string') {\n error.cause = scrub(error.cause, session, label);\n }\n if (error instanceof AggregateError) {\n for (const inner of error.errors) {\n if (inner instanceof Error) redactError(inner, session, depth + 1, label);\n }\n }\n}\n\n/**\n * Scrub `session` out of whatever was thrown, in place, and hand it back.\n *\n * In place rather than re-wrapped: callers up the stack switch on\n * `instanceof MedalApiError` and read `.status` / `.code`, and a fresh `Error`\n * would erase all three. `message` and `stack` are the two strings a logger\n * prints — on the error and, via `redactError`, on its `cause` chain;\n * `MedalApiError.details` is `readonly` and carries the engine's structured\n * body, which never echoes a request header.\n *\n * A non-`Error` throw that contains the session (a bare string, say) is\n * REPLACED — there is nothing else on it worth keeping, and nothing safe to\n * pass on.\n */\nexport function redactSession(error: unknown, session: string): unknown {\n return redactSecret(error, { value: session, label: REDACTED });\n}\n\n/** `redactSession` for any secret: the Vipps grant, the token it buys, the API key. */\nexport function redactSecret(error: unknown, secret: Secret): unknown {\n if (secret.value === '') return error;\n if (error instanceof Error) {\n redactError(error, secret.value, 0, secret.label);\n return error;\n }\n if (typeof error === 'string' && error.includes(secret.value)) {\n return new Error(scrub(error, secret.value, secret.label));\n }\n return error;\n}\n"],"mappings":";;;;;;;;AAQA,MAAa,WAAW;;;;;;;;AAYxB,MAAM,eAAe;AAErB,SAAS,MAAM,MAAc,SAAiB,QAAQ,UAAkB;CACtE,OAAO,KAAK,SAAS,OAAO,IAAI,KAAK,MAAM,OAAO,CAAC,CAAC,KAAK,KAAK,IAAI;AACpE;;;;;;;;;;;AAYA,SAAS,YAAY,OAAc,SAAiB,OAAe,QAAQ,UAAgB;CACzF,IAAI,MAAM,QAAQ,SAAS,OAAO,GAEhC,IAAI;EACF,MAAM,UAAU,MAAM,MAAM,SAAS,SAAS,KAAK;CACrD,QAAQ,CAAC;CAEX,IAAI,OAAO,MAAM,UAAU,YAAY,MAAM,MAAM,SAAS,OAAO,GACjE,MAAM,QAAQ,MAAM,MAAM,OAAO,SAAS,KAAK;CAEjD,IAAI,SAAS,cAAc;CAC3B,IAAI,MAAM,iBAAiB,OACzB,YAAY,MAAM,OAAO,SAAS,QAAQ,GAAG,KAAK;MAC7C,IAAI,OAAO,MAAM,UAAU,UAChC,MAAM,QAAQ,MAAM,MAAM,OAAO,SAAS,KAAK;CAEjD,IAAI,iBAAiB,gBACd;OAAA,MAAM,SAAS,MAAM,QACxB,IAAI,iBAAiB,OAAO,YAAY,OAAO,SAAS,QAAQ,GAAG,KAAK;CAAA;AAG9E;;;;;;;;;;;;;;;AAgBA,SAAgB,cAAc,OAAgB,SAA0B;CACtE,OAAO,aAAa,OAAO;EAAE,OAAO;EAAS,OAAO;CAAS,CAAC;AAChE;;AAGA,SAAgB,aAAa,OAAgB,QAAyB;CACpE,IAAI,OAAO,UAAU,IAAI,OAAO;CAChC,IAAI,iBAAiB,OAAO;EAC1B,YAAY,OAAO,OAAO,OAAO,GAAG,OAAO,KAAK;EAChD,OAAO;CACT;CACA,IAAI,OAAO,UAAU,YAAY,MAAM,SAAS,OAAO,KAAK,GAC1D,OAAO,IAAI,MAAM,MAAM,OAAO,OAAO,OAAO,OAAO,KAAK,CAAC;CAE3D,OAAO;AACT"}