@withpotter/lib 0.13.0-next.97 → 0.13.0-next.99

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.
package/dist/api/core.js CHANGED
@@ -2,6 +2,7 @@ const IS_BROWSER = typeof window !== "undefined";
2
2
  import posthog from "posthog-js";
3
3
  import { notifyWrite } from "./cache-bus";
4
4
  import { announceFeatureLocked, featureNotInPlan } from "./feature-locked";
5
+ import { announcePlanRefusal, planRefusal } from "./plan-refusal";
5
6
  import { isUnservedRoute, unservedMessage } from "./unserved-routes";
6
7
  import { refreshSession, wantsRefresh } from "./session-refresh";
7
8
  import {
@@ -147,6 +148,15 @@ async function send(url, fetchOptions, headers, isWrite, retried = false) {
147
148
  announceFeatureLocked(locked);
148
149
  throw new ApiRequestError(lockedBody.message ?? "Not in your plan", 402, lockedBody);
149
150
  }
151
+ const refusal = planRefusal(lockedBody);
152
+ if (refusal) {
153
+ announcePlanRefusal(refusal);
154
+ throw new ApiRequestError(
155
+ refusal.code === "LIMIT_REACHED" ? "You have reached the limit on your plan" : "This is not available on your plan",
156
+ 402,
157
+ lockedBody
158
+ );
159
+ }
150
160
  if (res.status === 402 && IS_BROWSER) {
151
161
  window.location.href = "/dashboard/settings/billing";
152
162
  throw new Error("Payment required");
@@ -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 { 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 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 { 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"]}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A 402 from Olive's subscription guard: `{ code, feature, requiredPlan }` with
3
+ * `code` one of the four below. It is the engine saying "this plan does not
4
+ * allow that", not "your subscription has lapsed", so it must not send the
5
+ * merchant to billing: the dashboard shows an upgrade prompt where they are and
6
+ * the merchant stays in what they were doing.
7
+ *
8
+ * The event name is also written out in `@withpotter/olive-subscription`
9
+ * (`PlanRefusalModal`), which listens for it and does not import this package.
10
+ */
11
+ export declare const PLAN_REFUSAL_EVENT = "olive:plan-refusal";
12
+ export declare const PLAN_REFUSAL_CODES: readonly ["LIMIT_REACHED", "FEATURE_LOCKED", "SUBSCRIPTION_REQUIRED", "VIEW_ONLY"];
13
+ export type PlanRefusalCode = (typeof PLAN_REFUSAL_CODES)[number];
14
+ export interface PlanRefusal {
15
+ code: PlanRefusalCode;
16
+ feature: string | null;
17
+ requiredPlan: string | null;
18
+ }
19
+ /** The refusal a 402 body carries, or null when it is some other 402. */
20
+ export declare function planRefusal(body: unknown): PlanRefusal | null;
21
+ export declare function announcePlanRefusal(refusal: PlanRefusal): void;
@@ -0,0 +1,28 @@
1
+ const PLAN_REFUSAL_EVENT = "olive:plan-refusal";
2
+ const PLAN_REFUSAL_CODES = [
3
+ "LIMIT_REACHED",
4
+ "FEATURE_LOCKED",
5
+ "SUBSCRIPTION_REQUIRED",
6
+ "VIEW_ONLY"
7
+ ];
8
+ function planRefusal(body) {
9
+ if (!body || typeof body !== "object") return null;
10
+ const { code, feature, requiredPlan } = body;
11
+ if (!PLAN_REFUSAL_CODES.some((c) => c === code)) return null;
12
+ return {
13
+ code,
14
+ feature: typeof feature === "string" ? feature : null,
15
+ requiredPlan: typeof requiredPlan === "string" ? requiredPlan : null
16
+ };
17
+ }
18
+ function announcePlanRefusal(refusal) {
19
+ if (typeof window === "undefined") return;
20
+ window.dispatchEvent(new CustomEvent(PLAN_REFUSAL_EVENT, { detail: refusal }));
21
+ }
22
+ export {
23
+ PLAN_REFUSAL_CODES,
24
+ PLAN_REFUSAL_EVENT,
25
+ announcePlanRefusal,
26
+ planRefusal
27
+ };
28
+ //# sourceMappingURL=plan-refusal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/api/plan-refusal.ts"],"sourcesContent":["/**\n * A 402 from Olive's subscription guard: `{ code, feature, requiredPlan }` with\n * `code` one of the four below. It is the engine saying \"this plan does not\n * allow that\", not \"your subscription has lapsed\", so it must not send the\n * merchant to billing: the dashboard shows an upgrade prompt where they are and\n * the merchant stays in what they were doing.\n *\n * The event name is also written out in `@withpotter/olive-subscription`\n * (`PlanRefusalModal`), which listens for it and does not import this package.\n */\nexport const PLAN_REFUSAL_EVENT = \"olive:plan-refusal\";\n\nexport const PLAN_REFUSAL_CODES = [\n \"LIMIT_REACHED\",\n \"FEATURE_LOCKED\",\n \"SUBSCRIPTION_REQUIRED\",\n \"VIEW_ONLY\",\n] as const;\n\nexport type PlanRefusalCode = (typeof PLAN_REFUSAL_CODES)[number];\n\nexport interface PlanRefusal {\n code: PlanRefusalCode;\n feature: string | null;\n requiredPlan: string | null;\n}\n\n/** The refusal a 402 body carries, or null when it is some other 402. */\nexport function planRefusal(body: unknown): PlanRefusal | null {\n if (!body || typeof body !== \"object\") return null;\n\n const { code, feature, requiredPlan } = body as {\n code?: unknown;\n feature?: unknown;\n requiredPlan?: unknown;\n };\n\n if (!PLAN_REFUSAL_CODES.some((c) => c === code)) return null;\n\n return {\n code: code as PlanRefusalCode,\n feature: typeof feature === \"string\" ? feature : null,\n requiredPlan: typeof requiredPlan === \"string\" ? requiredPlan : null,\n };\n}\n\nexport function announcePlanRefusal(refusal: PlanRefusal): void {\n if (typeof window === \"undefined\") return;\n\n window.dispatchEvent(new CustomEvent(PLAN_REFUSAL_EVENT, { detail: refusal }));\n}\n"],"mappings":"AAUO,MAAM,qBAAqB;AAE3B,MAAM,qBAAqB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAWO,SAAS,YAAY,MAAmC;AAC7D,MAAI,CAAC,QAAQ,OAAO,SAAS,SAAU,QAAO;AAE9C,QAAM,EAAE,MAAM,SAAS,aAAa,IAAI;AAMxC,MAAI,CAAC,mBAAmB,KAAK,CAAC,MAAM,MAAM,IAAI,EAAG,QAAO;AAExD,SAAO;AAAA,IACL;AAAA,IACA,SAAS,OAAO,YAAY,WAAW,UAAU;AAAA,IACjD,cAAc,OAAO,iBAAiB,WAAW,eAAe;AAAA,EAClE;AACF;AAEO,SAAS,oBAAoB,SAA4B;AAC9D,MAAI,OAAO,WAAW,YAAa;AAEnC,SAAO,cAAc,IAAI,YAAY,oBAAoB,EAAE,QAAQ,QAAQ,CAAC,CAAC;AAC/E;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withpotter/lib",
3
- "version": "0.13.0-next.97",
3
+ "version": "0.13.0-next.99",
4
4
  "description": "Box dashboard foundation: API client, contexts, types, intent config, and utilities.",
5
5
  "license": "UNLICENSED",
6
6
  "sideEffects": false,