@withpotter/lib 0.13.0-next.124 → 0.13.0-next.59

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 (79) hide show
  1. package/dist/admin-account-types.d.ts +0 -21
  2. package/dist/admin-api.d.ts +2 -224
  3. package/dist/admin-api.js +0 -41
  4. package/dist/admin-api.js.map +1 -1
  5. package/dist/admin-case-api.js +4 -1
  6. package/dist/admin-case-api.js.map +1 -1
  7. package/dist/admin-pos-api.d.ts +1 -25
  8. package/dist/admin-pos-api.js +0 -24
  9. package/dist/admin-pos-api.js.map +1 -1
  10. package/dist/admin-pos-types.d.ts +0 -43
  11. package/dist/api/accounts.d.ts +3 -5
  12. package/dist/api/accounts.js +1 -1
  13. package/dist/api/accounts.js.map +1 -1
  14. package/dist/api/analytics.js +3 -1
  15. package/dist/api/analytics.js.map +1 -1
  16. package/dist/api/core.d.ts +1 -0
  17. package/dist/api/core.js +7 -19
  18. package/dist/api/core.js.map +1 -1
  19. package/dist/api/potter.js +3 -2
  20. package/dist/api/potter.js.map +1 -1
  21. package/dist/api/session-scope.d.ts +2 -2
  22. package/dist/api/session-scope.js.map +1 -1
  23. package/dist/api/types/auth.d.ts +1 -7
  24. package/dist/api/types/auth.js.map +1 -1
  25. package/dist/api/types/crm.d.ts +0 -2
  26. package/dist/api/types/templates.d.ts +0 -6
  27. package/dist/auth-context.d.ts +0 -2
  28. package/dist/auth-context.js +37 -12
  29. package/dist/auth-context.js.map +1 -1
  30. package/dist/mobile-routes.js +2 -4
  31. package/dist/mobile-routes.js.map +1 -1
  32. package/dist/plan-price.d.ts +2 -16
  33. package/dist/plan-price.js +0 -9
  34. package/dist/plan-price.js.map +1 -1
  35. package/dist/resolve-enabled-behaviors.d.ts +0 -2
  36. package/dist/resolve-enabled-behaviors.js +1 -2
  37. package/dist/resolve-enabled-behaviors.js.map +1 -1
  38. package/dist/template-types.d.ts +0 -8
  39. package/dist/timeline-copy.d.ts +1 -1
  40. package/dist/timeline-copy.js +2 -6
  41. package/dist/timeline-copy.js.map +1 -1
  42. package/dist/use-nav-groups.js +5 -5
  43. package/dist/use-nav-groups.js.map +1 -1
  44. package/dist/utils.js +4 -4
  45. package/dist/utils.js.map +1 -1
  46. package/dist/wallet-format.js +0 -1
  47. package/dist/wallet-format.js.map +1 -1
  48. package/package.json +2 -2
  49. package/dist/__fixtures__/demo-content-server.json +0 -484
  50. package/dist/admin-earnings-api.d.ts +0 -90
  51. package/dist/admin-earnings-api.js +0 -15
  52. package/dist/admin-earnings-api.js.map +0 -1
  53. package/dist/admin-earnings-model.d.ts +0 -30
  54. package/dist/admin-earnings-model.js +0 -47
  55. package/dist/admin-earnings-model.js.map +0 -1
  56. package/dist/admin-pos-olive.d.ts +0 -44
  57. package/dist/admin-pos-olive.js +0 -93
  58. package/dist/admin-pos-olive.js.map +0 -1
  59. package/dist/api/demo-content.d.ts +0 -104
  60. package/dist/api/demo-content.js +0 -36
  61. package/dist/api/demo-content.js.map +0 -1
  62. package/dist/api/plan-refusal.d.ts +0 -21
  63. package/dist/api/plan-refusal.js +0 -28
  64. package/dist/api/plan-refusal.js.map +0 -1
  65. package/dist/api/session-refresh.d.ts +0 -18
  66. package/dist/api/session-refresh.js +0 -37
  67. package/dist/api/session-refresh.js.map +0 -1
  68. package/dist/demo-content-mock.d.ts +0 -22
  69. package/dist/demo-content-mock.js +0 -70
  70. package/dist/demo-content-mock.js.map +0 -1
  71. package/dist/demo-content-poller.d.ts +0 -33
  72. package/dist/demo-content-poller.js +0 -80
  73. package/dist/demo-content-poller.js.map +0 -1
  74. package/dist/demo-content.d.ts +0 -43
  75. package/dist/demo-content.js +0 -78
  76. package/dist/demo-content.js.map +0 -1
  77. package/dist/stored-token.d.ts +0 -1
  78. package/dist/stored-token.js +0 -11
  79. package/dist/stored-token.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/api/core.ts"],"sourcesContent":["/**\n * The shared plumbing every API module needs: auth token, CSRF, the bounce\n * guard, and apiFetch itself.\n *\n * Split out of the old 7,657-line src/lib/api.ts. That file was imported by\n * 405 files, so touching any endpoint invalidated all of them, and a route\n * that wanted one call pulled the whole surface.\n */\n// Always use proxy for cookie-based auth to work (same-origin required for cookies)\nexport const IS_BROWSER = typeof window !== 'undefined';\n\nimport posthog from 'posthog-js';\nimport type { FetchOptions } from './types';\nimport { notifyWrite } from './cache-bus';\nimport { announceFeatureLocked, featureNotInPlan } from './feature-locked';\nimport { announcePlanRefusal, planRefusal } from './plan-refusal';\nimport { isUnservedRoute, unservedMessage } from './unserved-routes';\nimport { refreshSession, wantsRefresh } from './session-refresh';\nimport {\n isSessionExpiryResponse,\n merchantIdentity,\n sessionExpiryTarget,\n} from './session-scope';\n\n// Use proxy for all browser requests to ensure cookies are sent correctly\n\n// Use proxy for all browser requests to ensure cookies are sent correctly\nexport const getApiUrl = () => {\n if (IS_BROWSER) {\n // In browser, route through our proxy for same-origin cookie handling\n return '/api/proxy';\n }\n // Server-side, use direct backend URL\n return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';\n};\n\n// Helper to get account ID from localStorage\n\n// Helper to get account ID from localStorage\nexport function getAccountId(): string | null {\n if (typeof window === 'undefined') return null;\n const pbUser = localStorage.getItem('pb_user');\n if (pbUser) {\n try {\n const user = JSON.parse(pbUser);\n return user.id || null;\n } catch {\n return null;\n }\n }\n return null;\n}\n\n// Helper to get CSRF token from cookie\n\n// Helper to get CSRF token from cookie\nexport function getCsrfToken(): string | null {\n if (typeof window === 'undefined' || typeof document === 'undefined')\n return null;\n const match = document.cookie.match(/pb_csrf_token=([^;]+)/);\n return match ? match[1] : null;\n}\n\n/**\n * Thrown when the API rejects the session (401). Typed so a caller can tell an\n * expired session apart from a genuine empty result — the distinction the whole\n * dashboard was previously unable to make.\n */\nexport class SessionExpiredError extends Error {\n constructor() {\n super('Your session has expired');\n this.name = 'SessionExpiredError';\n }\n}\n\nexport class ApiRequestError extends Error {\n constructor(\n message: string,\n readonly status: number,\n readonly details?: unknown,\n ) {\n super(message);\n this.name = 'ApiRequestError';\n }\n}\n\nexport { AUTH_ROUTES, isSessionExpiryResponse } from './session-scope';\n\n/** One redirect per page load, however many requests fail together. */\nlet sessionExpiryHandled = false;\n\n/**\n * Where we last sent someone to log in, and when. Bouncing to login is a full\n * page load, so the in-memory guard above resets every time — it cannot see a\n * loop. This can.\n */\n\n/**\n * Where we last sent someone to log in, and when. Bouncing to login is a full\n * page load, so the in-memory guard above resets every time — it cannot see a\n * loop. This can.\n */\nexport const BOUNCE_KEY = 'pb_session_bounce';\n/** Two bounces off the same path inside this window is a loop, not a login. */\n\n/** Two bounces off the same path inside this window is a loop, not a login. */\nexport const BOUNCE_WINDOW_MS = 15_000;\n\n/**\n * True when we already bounced this path to login and are right back on it.\n *\n * That combination means the session is fine — login only returns you here if\n * it recognises you — and some single endpoint is answering 401 for its own\n * reasons (a missing tenant header, say). Bouncing again just repeats the\n * round trip forever, which the merchant sees as a page that reloads and\n * reloads. Better to let that one panel fail and leave the page standing.\n */\n\n/**\n * True when we already bounced this path to login and are right back on it.\n *\n * That combination means the session is fine — login only returns you here if\n * it recognises you — and some single endpoint is answering 401 for its own\n * reasons (a missing tenant header, say). Bouncing again just repeats the\n * round trip forever, which the merchant sees as a page that reloads and\n * reloads. Better to let that one panel fail and leave the page standing.\n */\nexport function bouncedRecently(path: string): boolean {\n try {\n const raw = sessionStorage.getItem(BOUNCE_KEY);\n if (!raw) return false;\n const { path: last, at } = JSON.parse(raw) as { path: string; at: number };\n return last === path && Date.now() - at < BOUNCE_WINDOW_MS;\n } catch {\n return false;\n }\n}\n\nexport function recordBounce(path: string): void {\n try {\n sessionStorage.setItem(\n BOUNCE_KEY,\n JSON.stringify({ path, at: Date.now() }),\n );\n } catch {\n // Private mode / storage full. The in-memory guard still holds for this\n // page load; we just lose the cross-reload half of the protection.\n }\n}\n\n/**\n * GETS THAT OVERLAP IN TIME ARE ONE REQUEST.\n *\n * Not a cache — an entry lives only while its request is in the air, so this\n * can never hand back a stale answer. It collapses the case the dashboard\n * actually has: several components asking the same question in the same tick,\n * each with its own `useEffect` and nothing to dedupe against.\n *\n * Measured per page load before this, on a real workspace: `/pages` six times\n * on the editor, `products` five on the catalogue, `analytics/dashboard` four,\n * `orders` three. React Query dedupes what goes through it; roughly forty files\n * still fetch in a raw effect and those are all of the above.\n *\n * GET only, and keyed by the things that change the ANSWER — the URL, the\n * workspace and the account. A write is never collapsed: two POSTs that look\n * identical are two things happening.\n */\nconst inFlightGets = new Map<string, Promise<unknown>>();\n\n/**\n * POSTs that ask a question and change nothing: the filters are too large for a\n * query string. They must not announce a write. A query built on one would\n * invalidate every query on success, itself included, and so refetch for ever\n * (the New campaign screen counted its audience a few hundred times a minute).\n */\nconst READ_ONLY_POSTS = [/^\\/crm\\/segments\\/preview(\\?|$)/];\nconst onlyReads = (endpoint: string) =>\n READ_ONLY_POSTS.some((pattern) => pattern.test(endpoint));\n\nexport async function apiFetch<T = unknown>(\n endpoint: string,\n options: FetchOptions = {},\n): Promise<T> {\n const { tenantSlug, token, accountId, ...fetchOptions } = options;\n\n const headers: Record<string, string> = {\n ...(fetchOptions.headers as Record<string, string>),\n };\n\n // Let the runtime add the multipart boundary for FormData uploads.\n if (fetchOptions.body && !(fetchOptions.body instanceof FormData)) {\n headers['Content-Type'] = 'application/json';\n }\n\n if (tenantSlug) {\n headers['X-Tenant-Slug'] = tenantSlug;\n }\n\n // The merchant's account goes on merchant calls only. The session itself is\n // the HttpOnly pb_auth_token cookie, which the browser sends; only a caller\n // holding a fresh one-time token (an invite link) passes `token` here.\n const merchant = merchantIdentity(endpoint, getAccountId(), null);\n const effectiveAccountId = accountId || merchant.accountId;\n if (effectiveAccountId) {\n headers['x-account-id'] = effectiveAccountId;\n }\n\n const effectiveToken = token || merchant.token;\n if (effectiveToken) {\n headers.Authorization = `Bearer ${effectiveToken}`;\n }\n\n // Add CSRF token for state-changing requests (POST, PUT, PATCH, DELETE)\n const method = (fetchOptions.method || 'GET').toUpperCase();\n // Reused below to announce the write. The list is the same one CSRF cares\n // about for the same reason: these are the methods that change something.\n const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);\n if (isWrite) {\n const csrfToken = getCsrfToken();\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n }\n\n // Request tracing: unique ID per API call + PostHog session correlation\n if (IS_BROWSER) {\n headers['x-request-id'] =\n crypto.randomUUID?.() ??\n `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;\n const sessionId = posthog.get_session_id?.();\n if (sessionId) {\n headers['x-posthog-session-id'] = sessionId;\n }\n }\n\n // A route this box's engine does not serve fails here, as the engine would.\n if (isUnservedRoute(endpoint)) {\n throw new ApiRequestError(unservedMessage(method, endpoint), 404);\n }\n\n const url = `${getApiUrl()}${endpoint}`;\n\n if (method === 'GET') {\n const key = `${url}|${tenantSlug ?? ''}|${effectiveAccountId ?? ''}`;\n const inFlight = inFlightGets.get(key);\n if (inFlight) return inFlight as Promise<T>;\n const started = send<T>(url, fetchOptions, headers, isWrite && !onlyReads(endpoint)).finally(() => {\n inFlightGets.delete(key);\n });\n inFlightGets.set(key, started);\n return started;\n }\n\n return send<T>(url, fetchOptions, headers, isWrite && !onlyReads(endpoint));\n}\n\n/** The request itself, once `apiFetch` has decided who is making it. */\nasync function send<T>(\n url: string,\n fetchOptions: Omit<FetchOptions, 'tenantSlug' | 'token' | 'accountId'>,\n headers: Record<string, string>,\n isWrite: boolean,\n retried = false,\n): Promise<T> {\n const res = await fetch(url, {\n ...fetchOptions,\n headers,\n credentials: 'include', // Include cookies for CSRF\n });\n\n if (!res.ok) {\n // The 15-minute access token ran out but the refresh cookie may still be\n // good: renew once, shared by every request that failed together, and\n // send this one again. Anything else falls through to the old handling.\n if (res.status === 401 && !retried && wantsRefresh(url)) {\n const body = await res.clone().json().catch(() => null);\n if (wantsRefresh(url, body) && (await refreshSession(getApiUrl()))) {\n return send<T>(url, fetchOptions, headers, isWrite, true);\n }\n }\n\n // 402 Payment Required — subscription inactive, redirect to billing\n // ...unless the plan guard sent it: a page the plan leaves out shows the\n // locked-feature card, and the merchant stays where they are.\n const lockedBody =\n res.status === 402\n ? await res.clone().json().catch(() => null)\n : null;\n const locked = featureNotInPlan(lockedBody);\n if (locked) {\n announceFeatureLocked(locked);\n throw new ApiRequestError(lockedBody.message ?? 'Not in your plan', 402, lockedBody);\n }\n const refusal = planRefusal(lockedBody);\n if (refusal) {\n announcePlanRefusal(refusal);\n throw new ApiRequestError(\n refusal.code === 'LIMIT_REACHED'\n ? 'You have reached the limit on your plan'\n : 'This is not available on your plan',\n 402,\n lockedBody,\n );\n }\n if (res.status === 402 && IS_BROWSER) {\n window.location.href = '/dashboard/settings/billing';\n throw new Error('Payment required');\n }\n\n /**\n * 401 — the session is gone, not the data.\n *\n * Handled here because it cannot be handled well anywhere else: every panel\n * catches its own failure and renders an empty state, so an expired session\n * was being drawn as £0 revenue, 0 bookings and \"nothing yet\" across the\n * whole dashboard. That tells a merchant their business made nothing, when\n * in fact the app cannot talk to the server. A 401 is never an empty state.\n *\n * 403 is deliberately NOT caught: that is a real permission answer for a\n * valid session, and panels are right to hide themselves for it.\n *\n * The guard matters — a dashboard fires a dozen requests at once, and all\n * of them 401 together. Without it they'd each kick off a navigation.\n */\n if (isSessionExpiryResponse(res.status, url) && IS_BROWSER) {\n // An admin 401 goes to the admin sign-in, never the merchant one: that\n // forwards a signed-in merchant to /dashboard.\n const path = window.location.pathname;\n const target = sessionExpiryTarget(url, path, window.location.search);\n if (target && !sessionExpiryHandled && !bouncedRecently(path)) {\n sessionExpiryHandled = true;\n recordBounce(path);\n window.location.href = target;\n }\n throw new SessionExpiredError();\n }\n\n const error = await res.json().catch(() => ({ message: 'Request failed' }));\n // NestJS validation pipes return message as an array of strings\n let msg: string;\n if (Array.isArray(error.message)) {\n const items = error.message\n .map((m: string) => {\n // Strip field prefix like \"maxDiscountAmount: maxDiscountAmount must...\"\n const colonIdx = m.indexOf(': ');\n return colonIdx > -1 ? m.slice(colonIdx + 2) : m;\n })\n .filter((m: string, i: number, arr: string[]) => arr.indexOf(m) === i);\n // When NestJS returns an empty message array, fall back to error.error\n msg =\n items.length > 0\n ? '• ' + items.join('\\n• ')\n : error.error || `HTTP ${res.status}`;\n } else {\n msg = error.message || error.error || `HTTP ${res.status}`;\n }\n // Carry the status. Callers were reduced to matching on message text to\n // tell \"the server refused this\" from \"the server could not be reached\",\n // and those need opposite handling: one means the session is dead, the\n // other means fall back to cache and carry on.\n //\n // `details` carries the parsed body for the same reason, one level in. An\n // API that answers a refusal with a machine-readable code and the id of the\n // thing that caused it was having all of that thrown away here, leaving the\n // caller to substring-match the human sentence — which is how a recoverable\n // refusal (\"you already have one of these, connect it\") becomes a dead end.\n throw new ApiRequestError(msg, res.status, error);\n }\n\n /**\n * THE WRITE LANDED, SO WHAT IS ON SCREEN IS NOW OUT OF DATE.\n *\n * Announced here rather than at a hundred call sites, because this is the\n * only place that knows a write happened at all — see `cache-bus.ts` for why\n * that matters and what it fixes. Only after `res.ok`: a refused write has\n * changed nothing and refetching over it would be noise.\n */\n if (isWrite) {\n notifyWrite();\n }\n\n // 204 No Content has no body — return early to avoid JSON parse error\n if (res.status === 204) {\n return undefined as unknown as T;\n }\n\n // Handle empty response body (e.g., 200 OK with no content)\n const contentType = res.headers.get('content-type');\n if (!contentType || !contentType.includes('application/json')) {\n // Check if response has content\n const text = await res.text();\n if (!text) {\n return undefined as unknown as T;\n }\n return text as T;\n // Try to parse as JSON in case content-type header is missing\n try {\n return JSON.parse(text) as T;\n } catch {\n return undefined as unknown as T;\n }\n }\n\n return res.json();\n}\n\n// ============================================\n// AUTH / ACCOUNTS API\n// ============================================\n"],"mappings":"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","names":["isSessionExpiryResponse"]}
1
+ {"version":3,"sources":["../../src/api/core.ts"],"sourcesContent":["/**\n * The shared plumbing every API module needs: auth token, CSRF, the bounce\n * guard, and apiFetch itself.\n *\n * Split out of the old 7,657-line src/lib/api.ts. That file was imported by\n * 405 files, so touching any endpoint invalidated all of them, and a route\n * that wanted one call pulled the whole surface.\n */\n// Always use proxy for cookie-based auth to work (same-origin required for cookies)\nexport const IS_BROWSER = typeof window !== 'undefined';\n\nimport posthog from 'posthog-js';\nimport type { FetchOptions } from './types';\nimport { notifyWrite } from './cache-bus';\nimport { announceFeatureLocked, featureNotInPlan } from './feature-locked';\nimport { isUnservedRoute, unservedMessage } from './unserved-routes';\nimport {\n isSessionExpiryResponse,\n merchantIdentity,\n sessionExpiryTarget,\n} from './session-scope';\n\n// Use proxy for all browser requests to ensure cookies are sent correctly\n\n// Use proxy for all browser requests to ensure cookies are sent correctly\nexport const getApiUrl = () => {\n if (IS_BROWSER) {\n // In browser, route through our proxy for same-origin cookie handling\n return '/api/proxy';\n }\n // Server-side, use direct backend URL\n return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';\n};\n\n// Helper to get account ID from localStorage\n\n// Helper to get account ID from localStorage\nexport function getAccountId(): string | null {\n if (typeof window === 'undefined') return null;\n const pbUser = localStorage.getItem('pb_user');\n if (pbUser) {\n try {\n const user = JSON.parse(pbUser);\n return user.id || null;\n } catch {\n return null;\n }\n }\n return null;\n}\n\n// Helper to get auth token from localStorage\n\n// Helper to get auth token from localStorage\nexport function getAuthToken(): string | null {\n if (typeof window === 'undefined') return null;\n return localStorage.getItem('pb_token');\n}\n\n// Helper to get CSRF token from cookie\n\n// Helper to get CSRF token from cookie\nexport function getCsrfToken(): string | null {\n if (typeof window === 'undefined' || typeof document === 'undefined')\n return null;\n const match = document.cookie.match(/pb_csrf_token=([^;]+)/);\n return match ? match[1] : null;\n}\n\n/**\n * Thrown when the API rejects the session (401). Typed so a caller can tell an\n * expired session apart from a genuine empty result — the distinction the whole\n * dashboard was previously unable to make.\n */\nexport class SessionExpiredError extends Error {\n constructor() {\n super('Your session has expired');\n this.name = 'SessionExpiredError';\n }\n}\n\nexport class ApiRequestError extends Error {\n constructor(\n message: string,\n readonly status: number,\n readonly details?: unknown,\n ) {\n super(message);\n this.name = 'ApiRequestError';\n }\n}\n\nexport { AUTH_ROUTES, isSessionExpiryResponse } from './session-scope';\n\n/** One redirect per page load, however many requests fail together. */\nlet sessionExpiryHandled = false;\n\n/**\n * Where we last sent someone to log in, and when. Bouncing to login is a full\n * page load, so the in-memory guard above resets every time — it cannot see a\n * loop. This can.\n */\n\n/**\n * Where we last sent someone to log in, and when. Bouncing to login is a full\n * page load, so the in-memory guard above resets every time — it cannot see a\n * loop. This can.\n */\nexport const BOUNCE_KEY = 'pb_session_bounce';\n/** Two bounces off the same path inside this window is a loop, not a login. */\n\n/** Two bounces off the same path inside this window is a loop, not a login. */\nexport const BOUNCE_WINDOW_MS = 15_000;\n\n/**\n * True when we already bounced this path to login and are right back on it.\n *\n * That combination means the session is fine — login only returns you here if\n * it recognises you — and some single endpoint is answering 401 for its own\n * reasons (a missing tenant header, say). Bouncing again just repeats the\n * round trip forever, which the merchant sees as a page that reloads and\n * reloads. Better to let that one panel fail and leave the page standing.\n */\n\n/**\n * True when we already bounced this path to login and are right back on it.\n *\n * That combination means the session is fine — login only returns you here if\n * it recognises you — and some single endpoint is answering 401 for its own\n * reasons (a missing tenant header, say). Bouncing again just repeats the\n * round trip forever, which the merchant sees as a page that reloads and\n * reloads. Better to let that one panel fail and leave the page standing.\n */\nexport function bouncedRecently(path: string): boolean {\n try {\n const raw = sessionStorage.getItem(BOUNCE_KEY);\n if (!raw) return false;\n const { path: last, at } = JSON.parse(raw) as { path: string; at: number };\n return last === path && Date.now() - at < BOUNCE_WINDOW_MS;\n } catch {\n return false;\n }\n}\n\nexport function recordBounce(path: string): void {\n try {\n sessionStorage.setItem(\n BOUNCE_KEY,\n JSON.stringify({ path, at: Date.now() }),\n );\n } catch {\n // Private mode / storage full. The in-memory guard still holds for this\n // page load; we just lose the cross-reload half of the protection.\n }\n}\n\n/**\n * GETS THAT OVERLAP IN TIME ARE ONE REQUEST.\n *\n * Not a cache — an entry lives only while its request is in the air, so this\n * can never hand back a stale answer. It collapses the case the dashboard\n * actually has: several components asking the same question in the same tick,\n * each with its own `useEffect` and nothing to dedupe against.\n *\n * Measured per page load before this, on a real workspace: `/pages` six times\n * on the editor, `products` five on the catalogue, `analytics/dashboard` four,\n * `orders` three. React Query dedupes what goes through it; roughly forty files\n * still fetch in a raw effect and those are all of the above.\n *\n * GET only, and keyed by the things that change the ANSWER — the URL, the\n * workspace and the account. A write is never collapsed: two POSTs that look\n * identical are two things happening.\n */\nconst inFlightGets = new Map<string, Promise<unknown>>();\n\n/**\n * POSTs that ask a question and change nothing: the filters are too large for a\n * query string. They must not announce a write. A query built on one would\n * invalidate every query on success, itself included, and so refetch for ever\n * (the New campaign screen counted its audience a few hundred times a minute).\n */\nconst READ_ONLY_POSTS = [/^\\/crm\\/segments\\/preview(\\?|$)/];\nconst onlyReads = (endpoint: string) =>\n READ_ONLY_POSTS.some((pattern) => pattern.test(endpoint));\n\nexport async function apiFetch<T = unknown>(\n endpoint: string,\n options: FetchOptions = {},\n): Promise<T> {\n const { tenantSlug, token, accountId, ...fetchOptions } = options;\n\n const headers: Record<string, string> = {\n ...(fetchOptions.headers as Record<string, string>),\n };\n\n // Let the runtime add the multipart boundary for FormData uploads.\n if (fetchOptions.body && !(fetchOptions.body instanceof FormData)) {\n headers['Content-Type'] = 'application/json';\n }\n\n if (tenantSlug) {\n headers['X-Tenant-Slug'] = tenantSlug;\n }\n\n // The merchant's account and token go on merchant calls only: the admin\n // guard reads a bearer before its own cookie, so a merchant one would be\n // taken for a bad admin session.\n const merchant = merchantIdentity(endpoint, getAccountId(), getAuthToken());\n const effectiveAccountId = accountId || merchant.accountId;\n if (effectiveAccountId) {\n headers['x-account-id'] = effectiveAccountId;\n }\n\n const effectiveToken = token || merchant.token;\n if (effectiveToken) {\n headers.Authorization = `Bearer ${effectiveToken}`;\n }\n\n // Add CSRF token for state-changing requests (POST, PUT, PATCH, DELETE)\n const method = (fetchOptions.method || 'GET').toUpperCase();\n // Reused below to announce the write. The list is the same one CSRF cares\n // about for the same reason: these are the methods that change something.\n const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);\n if (isWrite) {\n const csrfToken = getCsrfToken();\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n }\n\n // Request tracing: unique ID per API call + PostHog session correlation\n if (IS_BROWSER) {\n headers['x-request-id'] =\n crypto.randomUUID?.() ??\n `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;\n const sessionId = posthog.get_session_id?.();\n if (sessionId) {\n headers['x-posthog-session-id'] = sessionId;\n }\n }\n\n // A route this box's engine does not serve fails here, as the engine would.\n if (isUnservedRoute(endpoint)) {\n throw new ApiRequestError(unservedMessage(method, endpoint), 404);\n }\n\n const url = `${getApiUrl()}${endpoint}`;\n\n if (method === 'GET') {\n const key = `${url}|${tenantSlug ?? ''}|${effectiveAccountId ?? ''}`;\n const inFlight = inFlightGets.get(key);\n if (inFlight) return inFlight as Promise<T>;\n const started = send<T>(url, fetchOptions, headers, isWrite && !onlyReads(endpoint)).finally(() => {\n inFlightGets.delete(key);\n });\n inFlightGets.set(key, started);\n return started;\n }\n\n return send<T>(url, fetchOptions, headers, isWrite && !onlyReads(endpoint));\n}\n\n/** The request itself, once `apiFetch` has decided who is making it. */\nasync function send<T>(\n url: string,\n fetchOptions: Omit<FetchOptions, 'tenantSlug' | 'token' | 'accountId'>,\n headers: Record<string, string>,\n isWrite: boolean,\n): Promise<T> {\n const res = await fetch(url, {\n ...fetchOptions,\n headers,\n credentials: 'include', // Include cookies for CSRF\n });\n\n if (!res.ok) {\n // 402 Payment Required — subscription inactive, redirect to billing\n // ...unless the plan guard sent it: a page the plan leaves out shows the\n // locked-feature card, and the merchant stays where they are.\n const lockedBody =\n res.status === 402\n ? await res.clone().json().catch(() => null)\n : null;\n const locked = featureNotInPlan(lockedBody);\n if (locked) {\n announceFeatureLocked(locked);\n throw new ApiRequestError(lockedBody.message ?? 'Not in your plan', 402, lockedBody);\n }\n if (res.status === 402 && IS_BROWSER) {\n window.location.href = '/dashboard/settings/billing';\n throw new Error('Payment required');\n }\n\n /**\n * 401 — the session is gone, not the data.\n *\n * Handled here because it cannot be handled well anywhere else: every panel\n * catches its own failure and renders an empty state, so an expired session\n * was being drawn as £0 revenue, 0 bookings and \"nothing yet\" across the\n * whole dashboard. That tells a merchant their business made nothing, when\n * in fact the app cannot talk to the server. A 401 is never an empty state.\n *\n * 403 is deliberately NOT caught: that is a real permission answer for a\n * valid session, and panels are right to hide themselves for it.\n *\n * The guard matters — a dashboard fires a dozen requests at once, and all\n * of them 401 together. Without it they'd each kick off a navigation.\n */\n if (isSessionExpiryResponse(res.status, url) && IS_BROWSER) {\n // An admin 401 goes to the admin sign-in, never the merchant one: that\n // forwards a signed-in merchant to /dashboard.\n const path = window.location.pathname;\n const target = sessionExpiryTarget(url, path, window.location.search);\n if (target && !sessionExpiryHandled && !bouncedRecently(path)) {\n sessionExpiryHandled = true;\n recordBounce(path);\n window.location.href = target;\n }\n throw new SessionExpiredError();\n }\n\n const error = await res.json().catch(() => ({ message: 'Request failed' }));\n // NestJS validation pipes return message as an array of strings\n let msg: string;\n if (Array.isArray(error.message)) {\n const items = error.message\n .map((m: string) => {\n // Strip field prefix like \"maxDiscountAmount: maxDiscountAmount must...\"\n const colonIdx = m.indexOf(': ');\n return colonIdx > -1 ? m.slice(colonIdx + 2) : m;\n })\n .filter((m: string, i: number, arr: string[]) => arr.indexOf(m) === i);\n // When NestJS returns an empty message array, fall back to error.error\n msg =\n items.length > 0\n ? '• ' + items.join('\\n• ')\n : error.error || `HTTP ${res.status}`;\n } else {\n msg = error.message || error.error || `HTTP ${res.status}`;\n }\n // Carry the status. Callers were reduced to matching on message text to\n // tell \"the server refused this\" from \"the server could not be reached\",\n // and those need opposite handling: one means the session is dead, the\n // other means fall back to cache and carry on.\n //\n // `details` carries the parsed body for the same reason, one level in. An\n // API that answers a refusal with a machine-readable code and the id of the\n // thing that caused it was having all of that thrown away here, leaving the\n // caller to substring-match the human sentence — which is how a recoverable\n // refusal (\"you already have one of these, connect it\") becomes a dead end.\n throw new ApiRequestError(msg, res.status, error);\n }\n\n /**\n * THE WRITE LANDED, SO WHAT IS ON SCREEN IS NOW OUT OF DATE.\n *\n * Announced here rather than at a hundred call sites, because this is the\n * only place that knows a write happened at all — see `cache-bus.ts` for why\n * that matters and what it fixes. Only after `res.ok`: a refused write has\n * changed nothing and refetching over it would be noise.\n */\n if (isWrite) {\n notifyWrite();\n }\n\n // 204 No Content has no body — return early to avoid JSON parse error\n if (res.status === 204) {\n return undefined as unknown as T;\n }\n\n // Handle empty response body (e.g., 200 OK with no content)\n const contentType = res.headers.get('content-type');\n if (!contentType || !contentType.includes('application/json')) {\n // Check if response has content\n const text = await res.text();\n if (!text) {\n return undefined as unknown as T;\n }\n return text as T;\n // Try to parse as JSON in case content-type header is missing\n try {\n return JSON.parse(text) as T;\n } catch {\n return undefined as unknown as T;\n }\n }\n\n return res.json();\n}\n\n// ============================================\n// AUTH / ACCOUNTS API\n// ============================================\n"],"mappings":"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","names":["isSessionExpiryResponse"]}
@@ -1,4 +1,4 @@
1
- import { apiFetch, getAccountId, getCsrfToken } from "./core";
1
+ import { apiFetch, getAccountId, getAuthToken, getCsrfToken } from "./core";
2
2
  const potterApi = {
3
3
  /**
4
4
  * `tenantSlug` IS NOT OPTIONAL IN PRACTICE, and leaving it off is what broke
@@ -29,6 +29,8 @@ async function askPotterStream(question, history, on, signal, tenantSlug) {
29
29
  "Content-Type": "application/json"
30
30
  };
31
31
  if (tenantSlug) headers["X-Tenant-Slug"] = tenantSlug;
32
+ const token = getAuthToken();
33
+ if (token) headers.Authorization = `Bearer ${token}`;
32
34
  const accountId = getAccountId();
33
35
  if (accountId) headers["x-account-id"] = accountId;
34
36
  const csrfToken = getCsrfToken();
@@ -36,7 +38,6 @@ async function askPotterStream(question, history, on, signal, tenantSlug) {
36
38
  const res = await fetch("/api/proxy/potter/ask/stream", {
37
39
  method: "POST",
38
40
  headers,
39
- credentials: "include",
40
41
  body: JSON.stringify({ question, history }),
41
42
  signal
42
43
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/api/potter.ts"],"sourcesContent":["import { apiFetch, getAccountId, getCsrfToken } from './core';\n\n/**\n * Box's answering route — the model, not the regex.\n *\n * `potter-answer.ts` is still here and still answers when this cannot: a\n * keyword router over a help corpus that reaches four hand-written lookups. It\n * could only ever answer sentences somebody had already thought of, which is\n * why \"which items are running low\" matched and \"am I about to run out of\n * anything\" did not.\n *\n * This calls `POST /potter/ask`, which decides which question a sentence IS,\n * in whatever words it arrived in, and answers from the merchant's own rows\n * through the same usecases the analytics screens read.\n */\n\nexport interface PotterTurn {\n role: 'you' | 'potter';\n text: string;\n}\n\nexport interface PotterAnswer {\n text: string;\n /** Which lookups ran. Nothing renders this yet; it is here so an answer can\n * be audited against the rows it claims to have read. */\n toolsUsed: string[];\n}\n\n/**\n * NULL MEANS \"NOT AVAILABLE\", NOT \"NO ANSWER\", and the caller must tell those\n * apart. The route answers 503 wherever `ANTHROPIC_API_KEY` is unset — which is\n * every local checkout and staging until the key is deployed — and a merchant\n * must not see an error for a deployment state they cannot act on. The caller\n * falls back to the local router, which is exactly what shipped before this\n * existed.\n *\n * A thrown error is different and is NOT swallowed here: a 500 from a route\n * that is configured is a real failure and the surface should say so.\n */\nexport const potterApi = {\n /**\n * `tenantSlug` IS NOT OPTIONAL IN PRACTICE, and leaving it off is what broke\n * this route. `/potter/ask` reads `@TenantId()` behind `PermissionsGuard`,\n * which throws `Tenant context required` — a 401 — when no workspace was\n * named. `apiFetch` only sends `X-Tenant-Slug` when it is handed one, so\n * every question from the browser took a 401, which is not 503 and so was\n * rethrown rather than falling back. The surface said \"I could not reach\n * that just now. Try again in a moment.\", which was untrue in the only way\n * that matters: trying again could never work.\n */\n async ask(\n question: string,\n history: PotterTurn[] = [],\n tenantSlug?: string,\n ): Promise<PotterAnswer | null> {\n try {\n return await apiFetch<PotterAnswer>('/potter/ask', {\n method: 'POST',\n tenantSlug,\n body: JSON.stringify({ question, history }),\n });\n } catch (err) {\n const status = (err as { status?: number } | null)?.status;\n // 503: not configured here. 404: the route is older than this client.\n if (status === 503 || status === 404) return null;\n throw err;\n }\n },\n};\n\n/** One frame of a streamed turn, mirroring the API's own union. */\nexport type PotterStreamEvent =\n | { type: 'text'; text: string }\n | { type: 'tool'; name: string }\n | { type: 'done'; toolsUsed: string[] }\n | { type: 'error'; message: string };\n\n/**\n * The same answer, read as it is written.\n *\n * NOT `EventSource`, and not because of a preference: it only speaks GET, and a\n * question plus ten turns of history does not belong in a URL. So this posts and\n * reads the body stream, which is a few lines of framing here and nothing at all\n * on the server.\n *\n * SSE FRAMING IS LINE-ORIENTED AND A CHUNK IS NOT A FRAME. A read can end\n * mid-event, so the tail of the buffer is kept until a blank line proves the\n * frame is whole. Parsing per chunk instead is the bug that shows up only under\n * load, when messages are long enough to split.\n *\n * FALSE MEANS \"NOT AVAILABLE HERE\", the same contract `ask` uses: an\n * unconfigured environment cannot answer 503 once the headers are out, so it\n * says so in an `error` frame instead, and both paths return the caller to the\n * keyword router rather than to an error a merchant cannot act on.\n */\n/**\n * A RAW `fetch` HAS TO CARRY WHAT `apiFetch` WOULD HAVE CARRIED.\n *\n * This posted `Content-Type` and nothing else, and a body stream is the one\n * thing `apiFetch` cannot return — so the route lost the four headers every\n * other call in the app gets for free. Two of them were load-bearing:\n * `X-CSRF-Token`, without which the api's global `CsrfGuard` refuses every\n * POST, and `X-Tenant-Slug`, without which `PermissionsGuard` has no workspace\n * to check a permission against. The stream therefore answered 403 on every\n * environment since it shipped, silently, because a failed stream falls back\n * to the whole-turn route and that route was broken the same way.\n *\n * They are read off the same helpers `apiFetch` uses rather than passed in, so\n * the two paths cannot drift on where a token lives.\n */\nexport async function askPotterStream(\n question: string,\n history: PotterTurn[],\n on: (event: PotterStreamEvent) => void,\n signal?: AbortSignal,\n tenantSlug?: string,\n): Promise<boolean> {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n if (tenantSlug) headers['X-Tenant-Slug'] = tenantSlug;\n const accountId = getAccountId();\n if (accountId) headers['x-account-id'] = accountId;\n const csrfToken = getCsrfToken();\n if (csrfToken) headers['X-CSRF-Token'] = csrfToken;\n\n const res = await fetch('/api/proxy/potter/ask/stream', {\n method: 'POST',\n headers,\n credentials: 'include',\n body: JSON.stringify({ question, history }),\n signal,\n });\n\n if (res.status === 503 || res.status === 404 || !res.body) return false;\n if (!res.ok) throw new Error(`Box answered ${res.status}`);\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let available = true;\n\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n\n const { events, rest } = takeSseEvents(\n buffer + decoder.decode(value, { stream: true }),\n );\n buffer = rest;\n\n for (const event of events) {\n /* An unconfigured environment says so in-band, because a 503 cannot be\n sent once the headers are out. Treated as \"not available\" rather than\n as a failure, so the caller falls back exactly as it would on a 503. */\n if (event.type === 'error' && event.message.includes('not configured')) {\n available = false;\n } else {\n on(event);\n }\n }\n }\n\n return available;\n}\n\n/**\n * Whole SSE events out of a buffer, and whatever is left over.\n *\n * SEPARATED FROM THE FETCH SO IT CAN BE TESTED, because this is the part that\n * is quietly wrong in most hand-rolled SSE clients and the part that cannot be\n * checked by looking at it. A chunk is not a frame: a read can end anywhere,\n * including halfway through a JSON payload, so anything after the last blank\n * line is held back rather than parsed. Parsing per chunk works perfectly until\n * answers get long enough to split across reads — which is exactly when nobody\n * is watching.\n *\n * A frame that is not JSON is dropped rather than thrown: that is a proxy or a\n * gateway talking, not Box, and crashing a merchant's turn on somebody\n * else's error page helps nobody.\n */\nexport function takeSseEvents(buffer: string): {\n events: PotterStreamEvent[];\n rest: string;\n} {\n const events: PotterStreamEvent[] = [];\n let rest = buffer;\n\n for (;;) {\n const cut = rest.indexOf('\\n\\n');\n if (cut === -1) break;\n\n const frame = rest.slice(0, cut);\n rest = rest.slice(cut + 2);\n\n const line = frame.split('\\n').find((l) => l.startsWith('data:'));\n if (!line) continue;\n\n try {\n events.push(JSON.parse(line.slice(5).trim()) as PotterStreamEvent);\n } catch {\n /* Not ours. See above. */\n }\n }\n\n return { events, rest };\n}\n"],"mappings":"AAAA,SAAS,UAAU,cAAc,oBAAoB;AAuC9C,MAAM,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWvB,MAAM,IACJ,UACA,UAAwB,CAAC,GACzB,YAC8B;AAC9B,QAAI;AACF,aAAO,MAAM,SAAuB,eAAe;AAAA,QACjD,QAAQ;AAAA,QACR;AAAA,QACA,MAAM,KAAK,UAAU,EAAE,UAAU,QAAQ,CAAC;AAAA,MAC5C,CAAC;AAAA,IACH,SAAS,KAAK;AACZ,YAAM,SAAU,KAAoC;AAEpD,UAAI,WAAW,OAAO,WAAW,IAAK,QAAO;AAC7C,YAAM;AAAA,IACR;AAAA,EACF;AACF;AA0CA,eAAsB,gBACpB,UACA,SACA,IACA,QACA,YACkB;AAClB,QAAM,UAAkC;AAAA,IACtC,gBAAgB;AAAA,EAClB;AACA,MAAI,WAAY,SAAQ,eAAe,IAAI;AAC3C,QAAM,YAAY,aAAa;AAC/B,MAAI,UAAW,SAAQ,cAAc,IAAI;AACzC,QAAM,YAAY,aAAa;AAC/B,MAAI,UAAW,SAAQ,cAAc,IAAI;AAEzC,QAAM,MAAM,MAAM,MAAM,gCAAgC;AAAA,IACtD,QAAQ;AAAA,IACR;AAAA,IACA,aAAa;AAAA,IACb,MAAM,KAAK,UAAU,EAAE,UAAU,QAAQ,CAAC;AAAA,IAC1C;AAAA,EACF,CAAC;AAED,MAAI,IAAI,WAAW,OAAO,IAAI,WAAW,OAAO,CAAC,IAAI,KAAM,QAAO;AAClE,MAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,gBAAgB,IAAI,MAAM,EAAE;AAEzD,QAAM,SAAS,IAAI,KAAK,UAAU;AAClC,QAAM,UAAU,IAAI,YAAY;AAChC,MAAI,SAAS;AACb,MAAI,YAAY;AAEhB,aAAS;AACP,UAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,QAAI,KAAM;AAEV,UAAM,EAAE,QAAQ,KAAK,IAAI;AAAA,MACvB,SAAS,QAAQ,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC;AAAA,IACjD;AACA,aAAS;AAET,eAAW,SAAS,QAAQ;AAI1B,UAAI,MAAM,SAAS,WAAW,MAAM,QAAQ,SAAS,gBAAgB,GAAG;AACtE,oBAAY;AAAA,MACd,OAAO;AACL,WAAG,KAAK;AAAA,MACV;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAiBO,SAAS,cAAc,QAG5B;AACA,QAAM,SAA8B,CAAC;AACrC,MAAI,OAAO;AAEX,aAAS;AACP,UAAM,MAAM,KAAK,QAAQ,MAAM;AAC/B,QAAI,QAAQ,GAAI;AAEhB,UAAM,QAAQ,KAAK,MAAM,GAAG,GAAG;AAC/B,WAAO,KAAK,MAAM,MAAM,CAAC;AAEzB,UAAM,OAAO,MAAM,MAAM,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,WAAW,OAAO,CAAC;AAChE,QAAI,CAAC,KAAM;AAEX,QAAI;AACF,aAAO,KAAK,KAAK,MAAM,KAAK,MAAM,CAAC,EAAE,KAAK,CAAC,CAAsB;AAAA,IACnE,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,SAAO,EAAE,QAAQ,KAAK;AACxB;","names":[]}
1
+ {"version":3,"sources":["../../src/api/potter.ts"],"sourcesContent":["import { apiFetch, getAccountId, getAuthToken, getCsrfToken } from './core';\n\n/**\n * Box's answering route — the model, not the regex.\n *\n * `potter-answer.ts` is still here and still answers when this cannot: a\n * keyword router over a help corpus that reaches four hand-written lookups. It\n * could only ever answer sentences somebody had already thought of, which is\n * why \"which items are running low\" matched and \"am I about to run out of\n * anything\" did not.\n *\n * This calls `POST /potter/ask`, which decides which question a sentence IS,\n * in whatever words it arrived in, and answers from the merchant's own rows\n * through the same usecases the analytics screens read.\n */\n\nexport interface PotterTurn {\n role: 'you' | 'potter';\n text: string;\n}\n\nexport interface PotterAnswer {\n text: string;\n /** Which lookups ran. Nothing renders this yet; it is here so an answer can\n * be audited against the rows it claims to have read. */\n toolsUsed: string[];\n}\n\n/**\n * NULL MEANS \"NOT AVAILABLE\", NOT \"NO ANSWER\", and the caller must tell those\n * apart. The route answers 503 wherever `ANTHROPIC_API_KEY` is unset — which is\n * every local checkout and staging until the key is deployed — and a merchant\n * must not see an error for a deployment state they cannot act on. The caller\n * falls back to the local router, which is exactly what shipped before this\n * existed.\n *\n * A thrown error is different and is NOT swallowed here: a 500 from a route\n * that is configured is a real failure and the surface should say so.\n */\nexport const potterApi = {\n /**\n * `tenantSlug` IS NOT OPTIONAL IN PRACTICE, and leaving it off is what broke\n * this route. `/potter/ask` reads `@TenantId()` behind `PermissionsGuard`,\n * which throws `Tenant context required` — a 401 — when no workspace was\n * named. `apiFetch` only sends `X-Tenant-Slug` when it is handed one, so\n * every question from the browser took a 401, which is not 503 and so was\n * rethrown rather than falling back. The surface said \"I could not reach\n * that just now. Try again in a moment.\", which was untrue in the only way\n * that matters: trying again could never work.\n */\n async ask(\n question: string,\n history: PotterTurn[] = [],\n tenantSlug?: string,\n ): Promise<PotterAnswer | null> {\n try {\n return await apiFetch<PotterAnswer>('/potter/ask', {\n method: 'POST',\n tenantSlug,\n body: JSON.stringify({ question, history }),\n });\n } catch (err) {\n const status = (err as { status?: number } | null)?.status;\n // 503: not configured here. 404: the route is older than this client.\n if (status === 503 || status === 404) return null;\n throw err;\n }\n },\n};\n\n/** One frame of a streamed turn, mirroring the API's own union. */\nexport type PotterStreamEvent =\n | { type: 'text'; text: string }\n | { type: 'tool'; name: string }\n | { type: 'done'; toolsUsed: string[] }\n | { type: 'error'; message: string };\n\n/**\n * The same answer, read as it is written.\n *\n * NOT `EventSource`, and not because of a preference: it only speaks GET, and a\n * question plus ten turns of history does not belong in a URL. So this posts and\n * reads the body stream, which is a few lines of framing here and nothing at all\n * on the server.\n *\n * SSE FRAMING IS LINE-ORIENTED AND A CHUNK IS NOT A FRAME. A read can end\n * mid-event, so the tail of the buffer is kept until a blank line proves the\n * frame is whole. Parsing per chunk instead is the bug that shows up only under\n * load, when messages are long enough to split.\n *\n * FALSE MEANS \"NOT AVAILABLE HERE\", the same contract `ask` uses: an\n * unconfigured environment cannot answer 503 once the headers are out, so it\n * says so in an `error` frame instead, and both paths return the caller to the\n * keyword router rather than to an error a merchant cannot act on.\n */\n/**\n * A RAW `fetch` HAS TO CARRY WHAT `apiFetch` WOULD HAVE CARRIED.\n *\n * This posted `Content-Type` and nothing else, and a body stream is the one\n * thing `apiFetch` cannot return — so the route lost the four headers every\n * other call in the app gets for free. Two of them were load-bearing:\n * `X-CSRF-Token`, without which the api's global `CsrfGuard` refuses every\n * POST, and `X-Tenant-Slug`, without which `PermissionsGuard` has no workspace\n * to check a permission against. The stream therefore answered 403 on every\n * environment since it shipped, silently, because a failed stream falls back\n * to the whole-turn route and that route was broken the same way.\n *\n * They are read off the same helpers `apiFetch` uses rather than passed in, so\n * the two paths cannot drift on where a token lives.\n */\nexport async function askPotterStream(\n question: string,\n history: PotterTurn[],\n on: (event: PotterStreamEvent) => void,\n signal?: AbortSignal,\n tenantSlug?: string,\n): Promise<boolean> {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n if (tenantSlug) headers['X-Tenant-Slug'] = tenantSlug;\n const token = getAuthToken();\n if (token) headers.Authorization = `Bearer ${token}`;\n const accountId = getAccountId();\n if (accountId) headers['x-account-id'] = accountId;\n const csrfToken = getCsrfToken();\n if (csrfToken) headers['X-CSRF-Token'] = csrfToken;\n\n const res = await fetch('/api/proxy/potter/ask/stream', {\n method: 'POST',\n headers,\n body: JSON.stringify({ question, history }),\n signal,\n });\n\n if (res.status === 503 || res.status === 404 || !res.body) return false;\n if (!res.ok) throw new Error(`Box answered ${res.status}`);\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let available = true;\n\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n\n const { events, rest } = takeSseEvents(\n buffer + decoder.decode(value, { stream: true }),\n );\n buffer = rest;\n\n for (const event of events) {\n /* An unconfigured environment says so in-band, because a 503 cannot be\n sent once the headers are out. Treated as \"not available\" rather than\n as a failure, so the caller falls back exactly as it would on a 503. */\n if (event.type === 'error' && event.message.includes('not configured')) {\n available = false;\n } else {\n on(event);\n }\n }\n }\n\n return available;\n}\n\n/**\n * Whole SSE events out of a buffer, and whatever is left over.\n *\n * SEPARATED FROM THE FETCH SO IT CAN BE TESTED, because this is the part that\n * is quietly wrong in most hand-rolled SSE clients and the part that cannot be\n * checked by looking at it. A chunk is not a frame: a read can end anywhere,\n * including halfway through a JSON payload, so anything after the last blank\n * line is held back rather than parsed. Parsing per chunk works perfectly until\n * answers get long enough to split across reads — which is exactly when nobody\n * is watching.\n *\n * A frame that is not JSON is dropped rather than thrown: that is a proxy or a\n * gateway talking, not Box, and crashing a merchant's turn on somebody\n * else's error page helps nobody.\n */\nexport function takeSseEvents(buffer: string): {\n events: PotterStreamEvent[];\n rest: string;\n} {\n const events: PotterStreamEvent[] = [];\n let rest = buffer;\n\n for (;;) {\n const cut = rest.indexOf('\\n\\n');\n if (cut === -1) break;\n\n const frame = rest.slice(0, cut);\n rest = rest.slice(cut + 2);\n\n const line = frame.split('\\n').find((l) => l.startsWith('data:'));\n if (!line) continue;\n\n try {\n events.push(JSON.parse(line.slice(5).trim()) as PotterStreamEvent);\n } catch {\n /* Not ours. See above. */\n }\n }\n\n return { events, rest };\n}\n"],"mappings":"AAAA,SAAS,UAAU,cAAc,cAAc,oBAAoB;AAuC5D,MAAM,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWvB,MAAM,IACJ,UACA,UAAwB,CAAC,GACzB,YAC8B;AAC9B,QAAI;AACF,aAAO,MAAM,SAAuB,eAAe;AAAA,QACjD,QAAQ;AAAA,QACR;AAAA,QACA,MAAM,KAAK,UAAU,EAAE,UAAU,QAAQ,CAAC;AAAA,MAC5C,CAAC;AAAA,IACH,SAAS,KAAK;AACZ,YAAM,SAAU,KAAoC;AAEpD,UAAI,WAAW,OAAO,WAAW,IAAK,QAAO;AAC7C,YAAM;AAAA,IACR;AAAA,EACF;AACF;AA0CA,eAAsB,gBACpB,UACA,SACA,IACA,QACA,YACkB;AAClB,QAAM,UAAkC;AAAA,IACtC,gBAAgB;AAAA,EAClB;AACA,MAAI,WAAY,SAAQ,eAAe,IAAI;AAC3C,QAAM,QAAQ,aAAa;AAC3B,MAAI,MAAO,SAAQ,gBAAgB,UAAU,KAAK;AAClD,QAAM,YAAY,aAAa;AAC/B,MAAI,UAAW,SAAQ,cAAc,IAAI;AACzC,QAAM,YAAY,aAAa;AAC/B,MAAI,UAAW,SAAQ,cAAc,IAAI;AAEzC,QAAM,MAAM,MAAM,MAAM,gCAAgC;AAAA,IACtD,QAAQ;AAAA,IACR;AAAA,IACA,MAAM,KAAK,UAAU,EAAE,UAAU,QAAQ,CAAC;AAAA,IAC1C;AAAA,EACF,CAAC;AAED,MAAI,IAAI,WAAW,OAAO,IAAI,WAAW,OAAO,CAAC,IAAI,KAAM,QAAO;AAClE,MAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,gBAAgB,IAAI,MAAM,EAAE;AAEzD,QAAM,SAAS,IAAI,KAAK,UAAU;AAClC,QAAM,UAAU,IAAI,YAAY;AAChC,MAAI,SAAS;AACb,MAAI,YAAY;AAEhB,aAAS;AACP,UAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,QAAI,KAAM;AAEV,UAAM,EAAE,QAAQ,KAAK,IAAI;AAAA,MACvB,SAAS,QAAQ,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC;AAAA,IACjD;AACA,aAAS;AAET,eAAW,SAAS,QAAQ;AAI1B,UAAI,MAAM,SAAS,WAAW,MAAM,QAAQ,SAAS,gBAAgB,GAAG;AACtE,oBAAY;AAAA,MACd,OAAO;AACL,WAAG,KAAK;AAAA,MACV;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAiBO,SAAS,cAAc,QAG5B;AACA,QAAM,SAA8B,CAAC;AACrC,MAAI,OAAO;AAEX,aAAS;AACP,UAAM,MAAM,KAAK,QAAQ,MAAM;AAC/B,QAAI,QAAQ,GAAI;AAEhB,UAAM,QAAQ,KAAK,MAAM,GAAG,GAAG;AAC/B,WAAO,KAAK,MAAM,MAAM,CAAC;AAEzB,UAAM,OAAO,MAAM,MAAM,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,WAAW,OAAO,CAAC;AAChE,QAAI,CAAC,KAAM;AAEX,QAAI;AACF,aAAO,KAAK,KAAK,MAAM,KAAK,MAAM,CAAC,EAAE,KAAK,CAAC,CAAsB;AAAA,IACnE,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,SAAO,EAAE,QAAQ,KAAK;AACxB;","names":[]}
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Which session a request belongs to, and where a 401 sends the person.
3
3
  *
4
- * One console serves two sign-ins: the merchant dashboard (the HttpOnly `pb_auth_token`
5
- * cookie) and the platform admin (`pb_admin_token`, an HttpOnly cookie). They
4
+ * One console serves two sign-ins: the merchant dashboard (`pb_token`, sent as a
5
+ * bearer) and the platform admin (`pb_admin_token`, an HttpOnly cookie). They
6
6
  * must never mix. The admin guard reads a bearer before its cookie, so a merchant
7
7
  * bearer on an admin call is read as a bad admin session; and a 401 from the
8
8
  * admin must not send the person to the merchant sign-in, which forwards a
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/api/session-scope.ts"],"sourcesContent":["/**\n * Which session a request belongs to, and where a 401 sends the person.\n *\n * One console serves two sign-ins: the merchant dashboard (the HttpOnly `pb_auth_token`\n * cookie) and the platform admin (`pb_admin_token`, an HttpOnly cookie). They\n * must never mix. The admin guard reads a bearer before its cookie, so a merchant\n * bearer on an admin call is read as a bad admin session; and a 401 from the\n * admin must not send the person to the merchant sign-in, which forwards a\n * signed-in merchant to `/dashboard`.\n */\n\nconst ADMIN_API = '/platform-admin/';\nconst ADMIN_LOGIN_API = '/platform-admin/v1/auth/login';\nconst ADMIN_PAGE = '/admin';\nexport const ADMIN_LOGIN_PAGE = '/admin/login';\n/** Admin pages that run without an admin session; a 401 there must not bounce. */\nconst ADMIN_AUTH_PAGES = [ADMIN_LOGIN_PAGE, '/admin/change-password'];\n/** Merchant pages that must never bounce to login, or the redirect loops. */\nexport const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];\n\nfunction pathOf(url: string): string {\n return new URL(url, 'http://localhost').pathname;\n}\n\n/** True for a call to the platform-admin API. */\nexport function isAdminRequest(url: string): boolean {\n return pathOf(url).includes(ADMIN_API);\n}\n\n/** True for a page of the admin console. */\nexport function isAdminPage(pagePath: string): boolean {\n return pagePath === ADMIN_PAGE || pagePath.startsWith(`${ADMIN_PAGE}/`);\n}\n\n/** A 401 means the session is gone, except wrong admin sign-in details. */\nexport function isSessionExpiryResponse(status: number, url: string): boolean {\n return status === 401 && !pathOf(url).endsWith(ADMIN_LOGIN_API);\n}\n\n/**\n * Where to send someone whose session has gone, or null to stay put.\n * Admin calls and admin pages go to the admin sign-in; everything else to the\n * merchant sign-in. Either way they come back to the page they were on.\n */\nexport function sessionExpiryTarget(\n requestUrl: string,\n pagePath: string,\n search = '',\n): string | null {\n const back = encodeURIComponent(pagePath + search);\n if (isAdminRequest(requestUrl) || isAdminPage(pagePath)) {\n if (ADMIN_AUTH_PAGES.includes(pagePath)) return null;\n return isAdminPage(pagePath)\n ? `${ADMIN_LOGIN_PAGE}?redirect=${back}`\n : ADMIN_LOGIN_PAGE;\n }\n if (AUTH_ROUTES.some((route) => pagePath.startsWith(route))) return null;\n return `/login?redirect=${back}`;\n}\n\n/**\n * The admin page to open after admin sign-in: the `redirect` it was sent with\n * when that is an admin page on this site, otherwise Overview.\n */\nexport function adminReturnPath(redirect: string | null | undefined): string {\n if (!redirect || !redirect.startsWith('/') || redirect.startsWith('//')) {\n return ADMIN_PAGE;\n }\n const url = new URL(redirect, 'http://localhost');\n if (url.origin !== 'http://localhost' || !isAdminPage(url.pathname)) {\n return ADMIN_PAGE;\n }\n if (ADMIN_AUTH_PAGES.includes(url.pathname)) return ADMIN_PAGE;\n return url.pathname + url.search;\n}\n\n/** The merchant's own headers go only on merchant calls. */\nexport function merchantIdentity(\n requestUrl: string,\n accountId: string | null,\n token: string | null,\n): { accountId: string | null; token: string | null } {\n if (isAdminRequest(requestUrl)) return { accountId: null, token: null };\n return { accountId, token };\n}\n"],"mappings":"AAWA,MAAM,YAAY;AAClB,MAAM,kBAAkB;AACxB,MAAM,aAAa;AACZ,MAAM,mBAAmB;AAEhC,MAAM,mBAAmB,CAAC,kBAAkB,wBAAwB;AAE7D,MAAM,cAAc,CAAC,UAAU,WAAW,SAAS,SAAS;AAEnE,SAAS,OAAO,KAAqB;AACnC,SAAO,IAAI,IAAI,KAAK,kBAAkB,EAAE;AAC1C;AAGO,SAAS,eAAe,KAAsB;AACnD,SAAO,OAAO,GAAG,EAAE,SAAS,SAAS;AACvC;AAGO,SAAS,YAAY,UAA2B;AACrD,SAAO,aAAa,cAAc,SAAS,WAAW,GAAG,UAAU,GAAG;AACxE;AAGO,SAAS,wBAAwB,QAAgB,KAAsB;AAC5E,SAAO,WAAW,OAAO,CAAC,OAAO,GAAG,EAAE,SAAS,eAAe;AAChE;AAOO,SAAS,oBACd,YACA,UACA,SAAS,IACM;AACf,QAAM,OAAO,mBAAmB,WAAW,MAAM;AACjD,MAAI,eAAe,UAAU,KAAK,YAAY,QAAQ,GAAG;AACvD,QAAI,iBAAiB,SAAS,QAAQ,EAAG,QAAO;AAChD,WAAO,YAAY,QAAQ,IACvB,GAAG,gBAAgB,aAAa,IAAI,KACpC;AAAA,EACN;AACA,MAAI,YAAY,KAAK,CAAC,UAAU,SAAS,WAAW,KAAK,CAAC,EAAG,QAAO;AACpE,SAAO,mBAAmB,IAAI;AAChC;AAMO,SAAS,gBAAgB,UAA6C;AAC3E,MAAI,CAAC,YAAY,CAAC,SAAS,WAAW,GAAG,KAAK,SAAS,WAAW,IAAI,GAAG;AACvE,WAAO;AAAA,EACT;AACA,QAAM,MAAM,IAAI,IAAI,UAAU,kBAAkB;AAChD,MAAI,IAAI,WAAW,sBAAsB,CAAC,YAAY,IAAI,QAAQ,GAAG;AACnE,WAAO;AAAA,EACT;AACA,MAAI,iBAAiB,SAAS,IAAI,QAAQ,EAAG,QAAO;AACpD,SAAO,IAAI,WAAW,IAAI;AAC5B;AAGO,SAAS,iBACd,YACA,WACA,OACoD;AACpD,MAAI,eAAe,UAAU,EAAG,QAAO,EAAE,WAAW,MAAM,OAAO,KAAK;AACtE,SAAO,EAAE,WAAW,MAAM;AAC5B;","names":[]}
1
+ {"version":3,"sources":["../../src/api/session-scope.ts"],"sourcesContent":["/**\n * Which session a request belongs to, and where a 401 sends the person.\n *\n * One console serves two sign-ins: the merchant dashboard (`pb_token`, sent as a\n * bearer) and the platform admin (`pb_admin_token`, an HttpOnly cookie). They\n * must never mix. The admin guard reads a bearer before its cookie, so a merchant\n * bearer on an admin call is read as a bad admin session; and a 401 from the\n * admin must not send the person to the merchant sign-in, which forwards a\n * signed-in merchant to `/dashboard`.\n */\n\nconst ADMIN_API = '/platform-admin/';\nconst ADMIN_LOGIN_API = '/platform-admin/v1/auth/login';\nconst ADMIN_PAGE = '/admin';\nexport const ADMIN_LOGIN_PAGE = '/admin/login';\n/** Admin pages that run without an admin session; a 401 there must not bounce. */\nconst ADMIN_AUTH_PAGES = [ADMIN_LOGIN_PAGE, '/admin/change-password'];\n/** Merchant pages that must never bounce to login, or the redirect loops. */\nexport const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];\n\nfunction pathOf(url: string): string {\n return new URL(url, 'http://localhost').pathname;\n}\n\n/** True for a call to the platform-admin API. */\nexport function isAdminRequest(url: string): boolean {\n return pathOf(url).includes(ADMIN_API);\n}\n\n/** True for a page of the admin console. */\nexport function isAdminPage(pagePath: string): boolean {\n return pagePath === ADMIN_PAGE || pagePath.startsWith(`${ADMIN_PAGE}/`);\n}\n\n/** A 401 means the session is gone, except wrong admin sign-in details. */\nexport function isSessionExpiryResponse(status: number, url: string): boolean {\n return status === 401 && !pathOf(url).endsWith(ADMIN_LOGIN_API);\n}\n\n/**\n * Where to send someone whose session has gone, or null to stay put.\n * Admin calls and admin pages go to the admin sign-in; everything else to the\n * merchant sign-in. Either way they come back to the page they were on.\n */\nexport function sessionExpiryTarget(\n requestUrl: string,\n pagePath: string,\n search = '',\n): string | null {\n const back = encodeURIComponent(pagePath + search);\n if (isAdminRequest(requestUrl) || isAdminPage(pagePath)) {\n if (ADMIN_AUTH_PAGES.includes(pagePath)) return null;\n return isAdminPage(pagePath)\n ? `${ADMIN_LOGIN_PAGE}?redirect=${back}`\n : ADMIN_LOGIN_PAGE;\n }\n if (AUTH_ROUTES.some((route) => pagePath.startsWith(route))) return null;\n return `/login?redirect=${back}`;\n}\n\n/**\n * The admin page to open after admin sign-in: the `redirect` it was sent with\n * when that is an admin page on this site, otherwise Overview.\n */\nexport function adminReturnPath(redirect: string | null | undefined): string {\n if (!redirect || !redirect.startsWith('/') || redirect.startsWith('//')) {\n return ADMIN_PAGE;\n }\n const url = new URL(redirect, 'http://localhost');\n if (url.origin !== 'http://localhost' || !isAdminPage(url.pathname)) {\n return ADMIN_PAGE;\n }\n if (ADMIN_AUTH_PAGES.includes(url.pathname)) return ADMIN_PAGE;\n return url.pathname + url.search;\n}\n\n/** The merchant's own headers go only on merchant calls. */\nexport function merchantIdentity(\n requestUrl: string,\n accountId: string | null,\n token: string | null,\n): { accountId: string | null; token: string | null } {\n if (isAdminRequest(requestUrl)) return { accountId: null, token: null };\n return { accountId, token };\n}\n"],"mappings":"AAWA,MAAM,YAAY;AAClB,MAAM,kBAAkB;AACxB,MAAM,aAAa;AACZ,MAAM,mBAAmB;AAEhC,MAAM,mBAAmB,CAAC,kBAAkB,wBAAwB;AAE7D,MAAM,cAAc,CAAC,UAAU,WAAW,SAAS,SAAS;AAEnE,SAAS,OAAO,KAAqB;AACnC,SAAO,IAAI,IAAI,KAAK,kBAAkB,EAAE;AAC1C;AAGO,SAAS,eAAe,KAAsB;AACnD,SAAO,OAAO,GAAG,EAAE,SAAS,SAAS;AACvC;AAGO,SAAS,YAAY,UAA2B;AACrD,SAAO,aAAa,cAAc,SAAS,WAAW,GAAG,UAAU,GAAG;AACxE;AAGO,SAAS,wBAAwB,QAAgB,KAAsB;AAC5E,SAAO,WAAW,OAAO,CAAC,OAAO,GAAG,EAAE,SAAS,eAAe;AAChE;AAOO,SAAS,oBACd,YACA,UACA,SAAS,IACM;AACf,QAAM,OAAO,mBAAmB,WAAW,MAAM;AACjD,MAAI,eAAe,UAAU,KAAK,YAAY,QAAQ,GAAG;AACvD,QAAI,iBAAiB,SAAS,QAAQ,EAAG,QAAO;AAChD,WAAO,YAAY,QAAQ,IACvB,GAAG,gBAAgB,aAAa,IAAI,KACpC;AAAA,EACN;AACA,MAAI,YAAY,KAAK,CAAC,UAAU,SAAS,WAAW,KAAK,CAAC,EAAG,QAAO;AACpE,SAAO,mBAAmB,IAAI;AAChC;AAMO,SAAS,gBAAgB,UAA6C;AAC3E,MAAI,CAAC,YAAY,CAAC,SAAS,WAAW,GAAG,KAAK,SAAS,WAAW,IAAI,GAAG;AACvE,WAAO;AAAA,EACT;AACA,QAAM,MAAM,IAAI,IAAI,UAAU,kBAAkB;AAChD,MAAI,IAAI,WAAW,sBAAsB,CAAC,YAAY,IAAI,QAAQ,GAAG;AACnE,WAAO;AAAA,EACT;AACA,MAAI,iBAAiB,SAAS,IAAI,QAAQ,EAAG,QAAO;AACpD,SAAO,IAAI,WAAW,IAAI;AAC5B;AAGO,SAAS,iBACd,YACA,WACA,OACoD;AACpD,MAAI,eAAe,UAAU,EAAG,QAAO,EAAE,WAAW,MAAM,OAAO,KAAK;AACtE,SAAO,EAAE,WAAW,MAAM;AAC5B;","names":[]}
@@ -29,8 +29,6 @@ export interface SignupResult {
29
29
  email: string;
30
30
  name: string;
31
31
  avatarUrl?: string;
32
- /** Optional; null or absent when the account has none. */
33
- phone?: string | null;
34
32
  authProvider: string;
35
33
  status: string;
36
34
  mfaEnabled?: boolean;
@@ -41,8 +39,6 @@ export interface SignupResult {
41
39
  export interface UpdateAccountInput {
42
40
  name?: string;
43
41
  avatarUrl?: string;
44
- /** Optional. An empty string clears it. */
45
- phone?: string;
46
42
  }
47
43
  export interface ChangePasswordInput {
48
44
  currentPassword: string;
@@ -73,7 +69,6 @@ export interface AuthAccount {
73
69
  email: string;
74
70
  name: string;
75
71
  avatarUrl?: string | null;
76
- phone?: string | null;
77
72
  authProvider: string;
78
73
  status: string;
79
74
  emailVerified?: boolean;
@@ -83,8 +78,7 @@ export interface AuthAccount {
83
78
  /** Successful session payload (Google sign-in `signed-in`/`created`, and login). */
84
79
  export interface AuthSessionPayload {
85
80
  account: AuthAccount;
86
- /** Only a native client gets one; the browser session is a cookie. Never stored. */
87
- token?: string;
81
+ token: string;
88
82
  tenants: LoginTenantInfo[];
89
83
  organizationId: string | null;
90
84
  sessionId?: string;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/api/types/auth.ts"],"sourcesContent":["import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser';\n\n/**\n * AUTH / ACCOUNTS API — as the API sends it.\n *\n * One slice of what was a 4,164-line `types.ts`. The sections were already\n * here as comment fences; they are files now, so a type is found by the\n * endpoint it belongs to rather than by scrolling.\n */\n\n/**\n * Every request/response shape the API layer uses.\n *\n * Kept in one module on purpose: interfaces and types are erased at build\n * time, so grouping them costs nothing at runtime while keeping the domain\n * modules free of cross-imports for shared shapes.\n */\nexport interface FetchOptions extends RequestInit {\n tenantSlug?: string;\n token?: string;\n accountId?: string;\n}\n\nexport interface SignupInput {\n email: string;\n name: string;\n password?: string;\n}\n\nexport interface SignupResult {\n account: {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string;\n /** Optional; null or absent when the account has none. */\n phone?: string | null;\n authProvider: string;\n status: string;\n mfaEnabled?: boolean;\n };\n organizationId: string;\n token?: string;\n}\n\nexport interface UpdateAccountInput {\n name?: string;\n avatarUrl?: string;\n /** Optional. An empty string clears it. */\n phone?: string;\n}\n\nexport interface ChangePasswordInput {\n currentPassword: string;\n newPassword: string;\n}\n\nexport interface ChangeEmailInput {\n newEmail: string;\n password: string;\n}\n\nexport interface LoginTenantInfo {\n id: string;\n name: string;\n slug: string;\n templateId: string;\n role: string;\n permissions?: Record<string, boolean>;\n organizationId: string | null;\n businessLogo: string | null;\n contactEmail: string | null;\n contactPhone: string | null;\n customDomain: string | null;\n status: string;\n settings: Record<string, unknown> | null;\n}\n\n/** Account payload from authenticated endpoints. */\nexport interface AuthAccount {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string | null;\n phone?: string | null;\n authProvider: string;\n status: string;\n emailVerified?: boolean;\n mfaEnabled?: boolean;\n organizationId?: string | null;\n}\n\n/** Successful session payload (Google sign-in `signed-in`/`created`, and login). */\nexport interface AuthSessionPayload {\n account: AuthAccount;\n /** Only a native client gets one; the browser session is a cookie. Never stored. */\n token?: string;\n tenants: LoginTenantInfo[];\n organizationId: string | null;\n sessionId?: string;\n}\n\n/** Returned when a Google email matches an existing password-only account. */\nexport interface GoogleSignInCollisionPayload {\n email: string;\n accountId: string;\n googleSub: string;\n name: string | null;\n avatarUrl: string | null;\n}\n\n/** /accounts/google/callback response. `kind` discriminates the next step. */\nexport interface GoogleSignInCallbackResult extends Partial<AuthSessionPayload> {\n kind: 'signed-in' | 'created' | 'collision';\n collision?: GoogleSignInCollisionPayload;\n}\n\nexport interface GoogleLinkInput {\n accountId: string;\n password: string;\n googleSub: string;\n}\n\n/** Per-account communication + analytics consent. */\nexport interface AccountConsent {\n productEmails: boolean;\n productSms: boolean;\n analytics: boolean;\n /** ISO timestamp of the last change, when the account has ever set it. */\n updatedAt?: string;\n}\n\nexport type ConsentInput = Pick<\n AccountConsent,\n 'productEmails' | 'productSms' | 'analytics'\n>;\n\nexport interface MfaSetupResult {\n /** Base32 secret, for manual entry when a QR can't be scanned. */\n secret: string;\n /** otpauth:// URI encoded in the QR. */\n otpauthUrl: string;\n /** Ready-to-render data: URL of the QR image. */\n qrDataUri: string;\n}\n\nexport interface MfaVerifyResult {\n enabled: true;\n /** One-time backup codes — display once, never retrievable again. */\n backupCodes: string[];\n}\n\nexport interface SendLoginCodeResult {\n message: string;\n}\n\n/** One enrolled authenticator, as the settings list sees it. */\nexport interface Passkey {\n id: string;\n /** What the owner named it. Null until they do. */\n name: string | null;\n /** `singleDevice` keys die with the hardware; `multiDevice` ones sync. */\n deviceType: string | null;\n backedUp: boolean;\n createdAt: string;\n lastUsedAt: string | null;\n}\n\nexport interface BeginPasskeyLoginResult {\n /** Ties the response back to the challenge the server issued. */\n handle: string;\n options: PublicKeyCredentialRequestOptionsJSON;\n}\n\nexport interface PasskeyLoginResult extends AuthSessionPayload {\n /**\n * Whether the authenticator verified the person, not just held the key.\n *\n * True means the gesture already carried both factors, so an MFA-enabled\n * account must NOT then be asked for a TOTP code — that would make the safer\n * route the more annoying one.\n */\n mfaSatisfied: boolean;\n}\n\nexport interface VerifyLoginCodeSession extends AuthSessionPayload {\n /** True when this verify created the account (route to onboarding). */\n isNewAccount: boolean;\n}\n\n/**\n * What a door returns when the first factor passed but the account owes a\n * second.\n *\n * No token and no account: nothing here authenticates anything. The `mfaToken`\n * is single-use, short-lived and attempt-capped, and is accepted only by\n * /accounts/mfa/challenge.\n */\nexport interface MfaChallengeRequired {\n mfaRequired: true;\n mfaToken: string;\n}\n\n/**\n * A union rather than an optional field, so a caller cannot read `.token` off a\n * challenge by accident. It caught a real mismatch on the API side.\n */\nexport type VerifyLoginCodeResult =\n | VerifyLoginCodeSession\n | MfaChallengeRequired;\n\nexport function isMfaChallenge(\n result: AuthSessionPayload | MfaChallengeRequired,\n): result is MfaChallengeRequired {\n return (result as MfaChallengeRequired).mfaRequired === true;\n}\n\nexport type DeviceKind = 'mobile' | 'tablet' | 'desktop' | 'web';\n\nexport interface ConnectedDeviceRecord {\n id: string;\n name: string;\n kind: DeviceKind;\n location?: string;\n lastSeen: string;\n isCurrent?: boolean;\n}\n\nexport interface ProvisionTenantInput {\n organizationId: string;\n /** Omit it — the API derives the starting look from `intents`. Send one only\n * when the merchant deliberately chose a template. */\n templateId?: string;\n businessName: string;\n slug?: string;\n email?: string;\n phone?: string;\n category?: string;\n /** ISO 4217 default currency, derived from country. */\n currency?: string;\n intents?: string[];\n isSolo?: boolean;\n}\n"],"mappings":"AAmNO,SAAS,eACd,QACgC;AAChC,SAAQ,OAAgC,gBAAgB;AAC1D;","names":[]}
1
+ {"version":3,"sources":["../../../src/api/types/auth.ts"],"sourcesContent":["import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser';\n\n/**\n * AUTH / ACCOUNTS API — as the API sends it.\n *\n * One slice of what was a 4,164-line `types.ts`. The sections were already\n * here as comment fences; they are files now, so a type is found by the\n * endpoint it belongs to rather than by scrolling.\n */\n\n/**\n * Every request/response shape the API layer uses.\n *\n * Kept in one module on purpose: interfaces and types are erased at build\n * time, so grouping them costs nothing at runtime while keeping the domain\n * modules free of cross-imports for shared shapes.\n */\nexport interface FetchOptions extends RequestInit {\n tenantSlug?: string;\n token?: string;\n accountId?: string;\n}\n\nexport interface SignupInput {\n email: string;\n name: string;\n password?: string;\n}\n\nexport interface SignupResult {\n account: {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string;\n authProvider: string;\n status: string;\n mfaEnabled?: boolean;\n };\n organizationId: string;\n token?: string;\n}\n\nexport interface UpdateAccountInput {\n name?: string;\n avatarUrl?: string;\n}\n\nexport interface ChangePasswordInput {\n currentPassword: string;\n newPassword: string;\n}\n\nexport interface ChangeEmailInput {\n newEmail: string;\n password: string;\n}\n\nexport interface LoginTenantInfo {\n id: string;\n name: string;\n slug: string;\n templateId: string;\n role: string;\n permissions?: Record<string, boolean>;\n organizationId: string | null;\n businessLogo: string | null;\n contactEmail: string | null;\n contactPhone: string | null;\n customDomain: string | null;\n status: string;\n settings: Record<string, unknown> | null;\n}\n\n/** Account payload from authenticated endpoints. */\nexport interface AuthAccount {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string | null;\n authProvider: string;\n status: string;\n emailVerified?: boolean;\n mfaEnabled?: boolean;\n organizationId?: string | null;\n}\n\n/** Successful session payload (Google sign-in `signed-in`/`created`, and login). */\nexport interface AuthSessionPayload {\n account: AuthAccount;\n token: string;\n tenants: LoginTenantInfo[];\n organizationId: string | null;\n sessionId?: string;\n}\n\n/** Returned when a Google email matches an existing password-only account. */\nexport interface GoogleSignInCollisionPayload {\n email: string;\n accountId: string;\n googleSub: string;\n name: string | null;\n avatarUrl: string | null;\n}\n\n/** /accounts/google/callback response. `kind` discriminates the next step. */\nexport interface GoogleSignInCallbackResult extends Partial<AuthSessionPayload> {\n kind: 'signed-in' | 'created' | 'collision';\n collision?: GoogleSignInCollisionPayload;\n}\n\nexport interface GoogleLinkInput {\n accountId: string;\n password: string;\n googleSub: string;\n}\n\n/** Per-account communication + analytics consent. */\nexport interface AccountConsent {\n productEmails: boolean;\n productSms: boolean;\n analytics: boolean;\n /** ISO timestamp of the last change, when the account has ever set it. */\n updatedAt?: string;\n}\n\nexport type ConsentInput = Pick<\n AccountConsent,\n 'productEmails' | 'productSms' | 'analytics'\n>;\n\nexport interface MfaSetupResult {\n /** Base32 secret, for manual entry when a QR can't be scanned. */\n secret: string;\n /** otpauth:// URI encoded in the QR. */\n otpauthUrl: string;\n /** Ready-to-render data: URL of the QR image. */\n qrDataUri: string;\n}\n\nexport interface MfaVerifyResult {\n enabled: true;\n /** One-time backup codes — display once, never retrievable again. */\n backupCodes: string[];\n}\n\nexport interface SendLoginCodeResult {\n message: string;\n}\n\n/** One enrolled authenticator, as the settings list sees it. */\nexport interface Passkey {\n id: string;\n /** What the owner named it. Null until they do. */\n name: string | null;\n /** `singleDevice` keys die with the hardware; `multiDevice` ones sync. */\n deviceType: string | null;\n backedUp: boolean;\n createdAt: string;\n lastUsedAt: string | null;\n}\n\nexport interface BeginPasskeyLoginResult {\n /** Ties the response back to the challenge the server issued. */\n handle: string;\n options: PublicKeyCredentialRequestOptionsJSON;\n}\n\nexport interface PasskeyLoginResult extends AuthSessionPayload {\n /**\n * Whether the authenticator verified the person, not just held the key.\n *\n * True means the gesture already carried both factors, so an MFA-enabled\n * account must NOT then be asked for a TOTP code — that would make the safer\n * route the more annoying one.\n */\n mfaSatisfied: boolean;\n}\n\nexport interface VerifyLoginCodeSession extends AuthSessionPayload {\n /** True when this verify created the account (route to onboarding). */\n isNewAccount: boolean;\n}\n\n/**\n * What a door returns when the first factor passed but the account owes a\n * second.\n *\n * No token and no account: nothing here authenticates anything. The `mfaToken`\n * is single-use, short-lived and attempt-capped, and is accepted only by\n * /accounts/mfa/challenge.\n */\nexport interface MfaChallengeRequired {\n mfaRequired: true;\n mfaToken: string;\n}\n\n/**\n * A union rather than an optional field, so a caller cannot read `.token` off a\n * challenge by accident. It caught a real mismatch on the API side.\n */\nexport type VerifyLoginCodeResult =\n | VerifyLoginCodeSession\n | MfaChallengeRequired;\n\nexport function isMfaChallenge(\n result: AuthSessionPayload | MfaChallengeRequired,\n): result is MfaChallengeRequired {\n return (result as MfaChallengeRequired).mfaRequired === true;\n}\n\nexport type DeviceKind = 'mobile' | 'tablet' | 'desktop' | 'web';\n\nexport interface ConnectedDeviceRecord {\n id: string;\n name: string;\n kind: DeviceKind;\n location?: string;\n lastSeen: string;\n isCurrent?: boolean;\n}\n\nexport interface ProvisionTenantInput {\n organizationId: string;\n /** Omit it — the API derives the starting look from `intents`. Send one only\n * when the merchant deliberately chose a template. */\n templateId?: string;\n businessName: string;\n slug?: string;\n email?: string;\n phone?: string;\n category?: string;\n /** ISO 4217 default currency, derived from country. */\n currency?: string;\n intents?: string[];\n isSolo?: boolean;\n}\n"],"mappings":"AA6MO,SAAS,eACd,QACgC;AAChC,SAAQ,OAAgC,gBAAgB;AAC1D;","names":[]}
@@ -111,8 +111,6 @@ export interface MerchantNotification {
111
111
  link: string | null;
112
112
  readAt: string | null;
113
113
  createdAt: string;
114
- /** What the sender attached: for a plan notice, its tone and the next step offered on the row. */
115
- metadata?: Record<string, unknown> | null;
116
114
  }
117
115
  export interface MerchantNotificationList {
118
116
  notifications: MerchantNotification[];
@@ -264,12 +264,6 @@ export interface TenantSettings {
264
264
  perHours?: number;
265
265
  applyToCampaigns?: boolean;
266
266
  };
267
- /**
268
- * Moves a paid order from Pending to Confirmed by itself. Off unless this says
269
- * `true`; in-person orders and cash or transfer orders confirmed by hand are
270
- * never touched by it.
271
- */
272
- autoConfirmPaidOrders?: boolean;
273
267
  businessName?: string;
274
268
  tagline?: string;
275
269
  logo?: {
@@ -11,8 +11,6 @@ export interface User {
11
11
  email: string;
12
12
  name: string;
13
13
  avatarUrl?: string;
14
- /** The account's own phone number, when they gave one. */
15
- phone?: string | null;
16
14
  emailVerified: boolean;
17
15
  /** Two-factor authentication is active on this account. */
18
16
  mfaEnabled?: boolean;
@@ -13,7 +13,6 @@ import {
13
13
  accountsApi,
14
14
  tenantApi
15
15
  } from "./api";
16
- import { removeLegacyToken } from "./stored-token";
17
16
  import { resetPostHogIdentity } from "@withpotter/components/analytics";
18
17
  import { uploadImage } from "@withpotter/lib/media-service";
19
18
  import { endSession } from "@withpotter/lib/end-session";
@@ -225,10 +224,32 @@ function AuthProvider({ children }) {
225
224
  setLoading(false);
226
225
  return;
227
226
  }
228
- removeLegacyToken();
227
+ const token = localStorage.getItem("pb_token");
229
228
  const pbUser = localStorage.getItem("pb_user");
230
229
  const storedOrgId = localStorage.getItem("organizationId");
230
+ if (!token) {
231
+ setLoading(false);
232
+ return;
233
+ }
234
+ try {
235
+ const parts = token.split(".");
236
+ if (parts.length === 3) {
237
+ const payload = JSON.parse(atob(parts[1]));
238
+ if (payload.exp && payload.exp <= Math.floor(Date.now() / 1e3)) {
239
+ clearStoredAuth();
240
+ setLoading(false);
241
+ return;
242
+ }
243
+ }
244
+ } catch {
245
+ clearStoredAuth();
246
+ setLoading(false);
247
+ return;
248
+ }
231
249
  if (!pbUser) {
250
+ if (process.env.NODE_ENV === "development") {
251
+ console.warn("No user data found, clearing auth state");
252
+ }
232
253
  clearStoredAuth();
233
254
  setLoading(false);
234
255
  return;
@@ -259,7 +280,6 @@ function AuthProvider({ children }) {
259
280
  email: userData.email,
260
281
  name: userData.name || userData.email.split("@")[0],
261
282
  avatarUrl: userData.avatarUrl || void 0,
262
- phone: userData.phone ?? null,
263
283
  emailVerified: userData.emailVerified ?? false,
264
284
  mfaEnabled: userData.mfaEnabled ?? false,
265
285
  organizationId: storedOrgId || userData?.organizationId || cachedWorkspace.tenant.organizationId || "",
@@ -269,7 +289,9 @@ function AuthProvider({ children }) {
269
289
  setLoading(false);
270
290
  }
271
291
  try {
272
- const freshAccount = await apiFetch("/accounts/me");
292
+ const freshAccount = await apiFetch("/accounts/me", {
293
+ token
294
+ });
273
295
  if (freshAccount) {
274
296
  userData = freshAccount;
275
297
  localStorage.setItem("pb_user", JSON.stringify(freshAccount));
@@ -303,7 +325,6 @@ function AuthProvider({ children }) {
303
325
  email: userData.email,
304
326
  name: userData.name || userData.email.split("@")[0],
305
327
  avatarUrl: userData.avatarUrl || void 0,
306
- phone: userData.phone ?? null,
307
328
  emailVerified: userData.emailVerified ?? false,
308
329
  mfaEnabled: userData.mfaEnabled ?? false,
309
330
  organizationId: userOrgId ?? "",
@@ -319,7 +340,7 @@ function AuthProvider({ children }) {
319
340
  };
320
341
  }, [fetchTenant, loadWorkspaces]);
321
342
  const establishSession = async (data, method = "email", connection) => {
322
- const { account, tenants, organizationId } = data;
343
+ const { account, token, tenants, organizationId } = data;
323
344
  const workspaces = [];
324
345
  for (const t of tenants || []) {
325
346
  workspaces.push({
@@ -350,6 +371,7 @@ function AuthProvider({ children }) {
350
371
  }
351
372
  }
352
373
  const userOrgId = organizationId || tenants?.[0]?.organizationId || currentWorkspace?.tenant?.organizationId || null;
374
+ localStorage.setItem("pb_token", token);
353
375
  localStorage.setItem("pb_account_id", account.id);
354
376
  localStorage.setItem("pb_user", JSON.stringify(account));
355
377
  localStorage.setItem("userEmail", account.email);
@@ -377,7 +399,6 @@ function AuthProvider({ children }) {
377
399
  email: account.email,
378
400
  name: account.name || account.email.split("@")[0],
379
401
  avatarUrl: account.avatarUrl || void 0,
380
- phone: account.phone ?? null,
381
402
  emailVerified: account.emailVerified ?? false,
382
403
  mfaEnabled: account.mfaEnabled ?? false,
383
404
  organizationId: userOrgId ?? "",
@@ -474,7 +495,8 @@ function AuthProvider({ children }) {
474
495
  const slug = input.slug || businessName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
475
496
  let organizationId = user?.organizationId || "";
476
497
  if (!organizationId) {
477
- const orgs = await accountsApi.getMyOrganizations();
498
+ const token = localStorage.getItem("pb_token") || "";
499
+ const orgs = await accountsApi.getMyOrganizations(token);
478
500
  if (!orgs || orgs.length === 0) {
479
501
  throw new Error("No organization found. Please sign up first.");
480
502
  }
@@ -599,11 +621,13 @@ function AuthProvider({ children }) {
599
621
  const refreshUser = useCallback(async () => {
600
622
  if (!user) return;
601
623
  try {
624
+ const token = localStorage.getItem("pb_token");
602
625
  let userData = null;
603
- {
626
+ if (token) {
604
627
  try {
605
628
  const freshAccount = await apiFetch(
606
- "/accounts/me"
629
+ "/accounts/me",
630
+ { token }
607
631
  );
608
632
  if (freshAccount) {
609
633
  userData = freshAccount;
@@ -619,6 +643,9 @@ function AuthProvider({ children }) {
619
643
  const pbUser = localStorage.getItem("pb_user");
620
644
  if (pbUser) userData = JSON.parse(pbUser);
621
645
  }
646
+ } else {
647
+ const pbUser = localStorage.getItem("pb_user");
648
+ if (pbUser) userData = JSON.parse(pbUser);
622
649
  }
623
650
  if (!userData) return;
624
651
  const result = await loadWorkspaces();
@@ -636,8 +663,6 @@ function AuthProvider({ children }) {
636
663
  name: userData?.name || prev.name,
637
664
  email: userData?.email || prev.email,
638
665
  avatarUrl: userData?.avatarUrl || prev.avatarUrl,
639
- // Absent from an older API response: keep what we had.
640
- phone: userData && "phone" in userData ? userData.phone ?? null : prev.phone,
641
666
  emailVerified: userData?.emailVerified ?? prev.emailVerified,
642
667
  mfaEnabled: userData?.mfaEnabled ?? prev.mfaEnabled,
643
668
  workspaces,