@withpotter/lib 0.13.0-next.64 → 0.13.0-next.66

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.
@@ -2,8 +2,7 @@ import { apiFetch } from "./api";
2
2
  import {
3
3
  ApiRequestError,
4
4
  getAccountId,
5
- getApiUrl,
6
- getAuthToken
5
+ getApiUrl
7
6
  } from "./api/core";
8
7
  const BASE = "/platform-admin/v1/compliance/cases";
9
8
  const caseUrl = (tenantId) => `${BASE}/${encodeURIComponent(tenantId)}`;
@@ -12,9 +11,7 @@ const photoPath = (tenantId, personId, kind) => kind === "selfie" ? `${personUrl
12
11
  async function fetchImage(path) {
13
12
  const headers = {};
14
13
  const account = getAccountId();
15
- const token = getAuthToken();
16
14
  if (account) headers["x-account-id"] = account;
17
- if (token) headers.Authorization = `Bearer ${token}`;
18
15
  const res = await fetch(`${getApiUrl()}${path}`, {
19
16
  headers,
20
17
  credentials: "include",
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/admin-case-api.ts"],"sourcesContent":["import { apiFetch } from './api';\nimport type {\n CaseList,\n CaseStatus,\n DecisionBody,\n DocumentLink,\n VerificationCase,\n} from './admin-case-types';\nimport {\n ApiRequestError,\n getAccountId,\n getApiUrl,\n getAuthToken,\n} from './api/core';\n\n/**\n * One business's wallet verification case, as a platform admin reviews it.\n * Same `apiFetch` and cookie session as the rest of the console; the routes\n * answer with the record itself, no envelope. Shapes: `admin-case-types.ts`.\n */\n\nexport type PhotoKind = 'selfie' | 'bvn' | 'nin';\n\nconst BASE = '/platform-admin/v1/compliance/cases';\n\nconst caseUrl = (tenantId: string) => `${BASE}/${encodeURIComponent(tenantId)}`;\n\nconst personUrl = (tenantId: string, personId: string) =>\n `${caseUrl(tenantId)}/people/${encodeURIComponent(personId)}`;\n\nexport const photoPath = (\n tenantId: string,\n personId: string,\n kind: PhotoKind,\n) =>\n kind === 'selfie'\n ? `${personUrl(tenantId, personId)}/photo`\n : `${personUrl(tenantId, personId)}/reference-photo?source=${kind}`;\n\n/**\n * Image bytes, not JSON, so not `apiFetch`: the same proxy, cookies and\n * headers, read as a blob. Never cached; the caller makes the object URL.\n */\nasync function fetchImage(path: string): Promise<Blob> {\n const headers: Record<string, string> = {};\n const account = getAccountId();\n const token = getAuthToken();\n\n if (account) headers['x-account-id'] = account;\n\n if (token) headers.Authorization = `Bearer ${token}`;\n\n const res = await fetch(`${getApiUrl()}${path}`, {\n headers,\n credentials: 'include',\n cache: 'no-store',\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => null);\n\n throw new ApiRequestError(\n body?.message || `HTTP ${res.status}`,\n res.status,\n body,\n );\n }\n\n return res.blob();\n}\n\nconst post = <T>(path: string, body: unknown) =>\n apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) });\n\nexport const adminCaseApi = {\n /** 404 when the business has not started verification. */\n get: (tenantId: string) => apiFetch<VerificationCase>(caseUrl(tenantId)),\n\n /** Oldest wait first. */\n list: (q: { status?: CaseStatus; page?: number; limit?: number } = {}) => {\n const params = new URLSearchParams();\n\n if (q.status) params.set('status', q.status);\n\n if (q.page) params.set('page', String(q.page));\n\n if (q.limit) params.set('limit', String(q.limit));\n\n const qs = params.toString();\n\n return apiFetch<CaseList>(qs ? `${BASE}?${qs}` : BASE);\n },\n\n /** `total` is all the queue needs; one row keeps the answer small. */\n waitingCount: () =>\n apiFetch<{ total: number }>(`${BASE}?status=WAITING_REVIEW&limit=1`),\n\n photo: (tenantId: string, personId: string, kind: PhotoKind) =>\n fetchImage(photoPath(tenantId, personId, kind)),\n\n reveal: (tenantId: string, personId: string, field: 'bvn' | 'nin') =>\n post<{ value: string }>(`${personUrl(tenantId, personId)}/reveal`, {\n field,\n }),\n\n /** A short-lived link to an ID photo or certificate; 502 when it could not be opened. */\n viewDocument: (tenantId: string, documentId: string) =>\n post<DocumentLink>(\n `${caseUrl(tenantId)}/documents/${encodeURIComponent(documentId)}/view`,\n {},\n ),\n\n /** 409 when the case is not waiting for review. */\n decide: (tenantId: string, body: DecisionBody) =>\n post<VerificationCase>(`${caseUrl(tenantId)}/decision`, body),\n\n /** 403 for the admin who decided; 409 when there is nothing to reverse. */\n reverse: (tenantId: string, note: string) =>\n post<VerificationCase>(`${caseUrl(tenantId)}/reverse`, { note }),\n};\n"],"mappings":"AAAA,SAAS,gBAAgB;AAQzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAUP,MAAM,OAAO;AAEb,MAAM,UAAU,CAAC,aAAqB,GAAG,IAAI,IAAI,mBAAmB,QAAQ,CAAC;AAE7E,MAAM,YAAY,CAAC,UAAkB,aACnC,GAAG,QAAQ,QAAQ,CAAC,WAAW,mBAAmB,QAAQ,CAAC;AAEtD,MAAM,YAAY,CACvB,UACA,UACA,SAEA,SAAS,WACL,GAAG,UAAU,UAAU,QAAQ,CAAC,WAChC,GAAG,UAAU,UAAU,QAAQ,CAAC,2BAA2B,IAAI;AAMrE,eAAe,WAAW,MAA6B;AACrD,QAAM,UAAkC,CAAC;AACzC,QAAM,UAAU,aAAa;AAC7B,QAAM,QAAQ,aAAa;AAE3B,MAAI,QAAS,SAAQ,cAAc,IAAI;AAEvC,MAAI,MAAO,SAAQ,gBAAgB,UAAU,KAAK;AAElD,QAAM,MAAM,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,IAAI,IAAI;AAAA,IAC/C;AAAA,IACA,aAAa;AAAA,IACb,OAAO;AAAA,EACT,CAAC;AAED,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI;AAE9C,UAAM,IAAI;AAAA,MACR,MAAM,WAAW,QAAQ,IAAI,MAAM;AAAA,MACnC,IAAI;AAAA,MACJ;AAAA,IACF;AAAA,EACF;AAEA,SAAO,IAAI,KAAK;AAClB;AAEA,MAAM,OAAO,CAAI,MAAc,SAC7B,SAAY,MAAM,EAAE,QAAQ,QAAQ,MAAM,KAAK,UAAU,IAAI,EAAE,CAAC;AAE3D,MAAM,eAAe;AAAA;AAAA,EAE1B,KAAK,CAAC,aAAqB,SAA2B,QAAQ,QAAQ,CAAC;AAAA;AAAA,EAGvE,MAAM,CAAC,IAA4D,CAAC,MAAM;AACxE,UAAM,SAAS,IAAI,gBAAgB;AAEnC,QAAI,EAAE,OAAQ,QAAO,IAAI,UAAU,EAAE,MAAM;AAE3C,QAAI,EAAE,KAAM,QAAO,IAAI,QAAQ,OAAO,EAAE,IAAI,CAAC;AAE7C,QAAI,EAAE,MAAO,QAAO,IAAI,SAAS,OAAO,EAAE,KAAK,CAAC;AAEhD,UAAM,KAAK,OAAO,SAAS;AAE3B,WAAO,SAAmB,KAAK,GAAG,IAAI,IAAI,EAAE,KAAK,IAAI;AAAA,EACvD;AAAA;AAAA,EAGA,cAAc,MACZ,SAA4B,GAAG,IAAI,gCAAgC;AAAA,EAErE,OAAO,CAAC,UAAkB,UAAkB,SAC1C,WAAW,UAAU,UAAU,UAAU,IAAI,CAAC;AAAA,EAEhD,QAAQ,CAAC,UAAkB,UAAkB,UAC3C,KAAwB,GAAG,UAAU,UAAU,QAAQ,CAAC,WAAW;AAAA,IACjE;AAAA,EACF,CAAC;AAAA;AAAA,EAGH,cAAc,CAAC,UAAkB,eAC/B;AAAA,IACE,GAAG,QAAQ,QAAQ,CAAC,cAAc,mBAAmB,UAAU,CAAC;AAAA,IAChE,CAAC;AAAA,EACH;AAAA;AAAA,EAGF,QAAQ,CAAC,UAAkB,SACzB,KAAuB,GAAG,QAAQ,QAAQ,CAAC,aAAa,IAAI;AAAA;AAAA,EAG9D,SAAS,CAAC,UAAkB,SAC1B,KAAuB,GAAG,QAAQ,QAAQ,CAAC,YAAY,EAAE,KAAK,CAAC;AACnE;","names":[]}
1
+ {"version":3,"sources":["../src/admin-case-api.ts"],"sourcesContent":["import { apiFetch } from './api';\nimport type {\n CaseList,\n CaseStatus,\n DecisionBody,\n DocumentLink,\n VerificationCase,\n} from './admin-case-types';\nimport {\n ApiRequestError,\n getAccountId,\n getApiUrl,\n} from './api/core';\n\n/**\n * One business's wallet verification case, as a platform admin reviews it.\n * Same `apiFetch` and cookie session as the rest of the console; the routes\n * answer with the record itself, no envelope. Shapes: `admin-case-types.ts`.\n */\n\nexport type PhotoKind = 'selfie' | 'bvn' | 'nin';\n\nconst BASE = '/platform-admin/v1/compliance/cases';\n\nconst caseUrl = (tenantId: string) => `${BASE}/${encodeURIComponent(tenantId)}`;\n\nconst personUrl = (tenantId: string, personId: string) =>\n `${caseUrl(tenantId)}/people/${encodeURIComponent(personId)}`;\n\nexport const photoPath = (\n tenantId: string,\n personId: string,\n kind: PhotoKind,\n) =>\n kind === 'selfie'\n ? `${personUrl(tenantId, personId)}/photo`\n : `${personUrl(tenantId, personId)}/reference-photo?source=${kind}`;\n\n/**\n * Image bytes, not JSON, so not `apiFetch`: the same proxy, cookies and\n * headers, read as a blob. Never cached; the caller makes the object URL.\n */\nasync function fetchImage(path: string): Promise<Blob> {\n const headers: Record<string, string> = {};\n const account = getAccountId();\n\n if (account) headers['x-account-id'] = account;\n\n const res = await fetch(`${getApiUrl()}${path}`, {\n headers,\n credentials: 'include',\n cache: 'no-store',\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => null);\n\n throw new ApiRequestError(\n body?.message || `HTTP ${res.status}`,\n res.status,\n body,\n );\n }\n\n return res.blob();\n}\n\nconst post = <T>(path: string, body: unknown) =>\n apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) });\n\nexport const adminCaseApi = {\n /** 404 when the business has not started verification. */\n get: (tenantId: string) => apiFetch<VerificationCase>(caseUrl(tenantId)),\n\n /** Oldest wait first. */\n list: (q: { status?: CaseStatus; page?: number; limit?: number } = {}) => {\n const params = new URLSearchParams();\n\n if (q.status) params.set('status', q.status);\n\n if (q.page) params.set('page', String(q.page));\n\n if (q.limit) params.set('limit', String(q.limit));\n\n const qs = params.toString();\n\n return apiFetch<CaseList>(qs ? `${BASE}?${qs}` : BASE);\n },\n\n /** `total` is all the queue needs; one row keeps the answer small. */\n waitingCount: () =>\n apiFetch<{ total: number }>(`${BASE}?status=WAITING_REVIEW&limit=1`),\n\n photo: (tenantId: string, personId: string, kind: PhotoKind) =>\n fetchImage(photoPath(tenantId, personId, kind)),\n\n reveal: (tenantId: string, personId: string, field: 'bvn' | 'nin') =>\n post<{ value: string }>(`${personUrl(tenantId, personId)}/reveal`, {\n field,\n }),\n\n /** A short-lived link to an ID photo or certificate; 502 when it could not be opened. */\n viewDocument: (tenantId: string, documentId: string) =>\n post<DocumentLink>(\n `${caseUrl(tenantId)}/documents/${encodeURIComponent(documentId)}/view`,\n {},\n ),\n\n /** 409 when the case is not waiting for review. */\n decide: (tenantId: string, body: DecisionBody) =>\n post<VerificationCase>(`${caseUrl(tenantId)}/decision`, body),\n\n /** 403 for the admin who decided; 409 when there is nothing to reverse. */\n reverse: (tenantId: string, note: string) =>\n post<VerificationCase>(`${caseUrl(tenantId)}/reverse`, { note }),\n};\n"],"mappings":"AAAA,SAAS,gBAAgB;AAQzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAUP,MAAM,OAAO;AAEb,MAAM,UAAU,CAAC,aAAqB,GAAG,IAAI,IAAI,mBAAmB,QAAQ,CAAC;AAE7E,MAAM,YAAY,CAAC,UAAkB,aACnC,GAAG,QAAQ,QAAQ,CAAC,WAAW,mBAAmB,QAAQ,CAAC;AAEtD,MAAM,YAAY,CACvB,UACA,UACA,SAEA,SAAS,WACL,GAAG,UAAU,UAAU,QAAQ,CAAC,WAChC,GAAG,UAAU,UAAU,QAAQ,CAAC,2BAA2B,IAAI;AAMrE,eAAe,WAAW,MAA6B;AACrD,QAAM,UAAkC,CAAC;AACzC,QAAM,UAAU,aAAa;AAE7B,MAAI,QAAS,SAAQ,cAAc,IAAI;AAEvC,QAAM,MAAM,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,IAAI,IAAI;AAAA,IAC/C;AAAA,IACA,aAAa;AAAA,IACb,OAAO;AAAA,EACT,CAAC;AAED,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI;AAE9C,UAAM,IAAI;AAAA,MACR,MAAM,WAAW,QAAQ,IAAI,MAAM;AAAA,MACnC,IAAI;AAAA,MACJ;AAAA,IACF;AAAA,EACF;AAEA,SAAO,IAAI,KAAK;AAClB;AAEA,MAAM,OAAO,CAAI,MAAc,SAC7B,SAAY,MAAM,EAAE,QAAQ,QAAQ,MAAM,KAAK,UAAU,IAAI,EAAE,CAAC;AAE3D,MAAM,eAAe;AAAA;AAAA,EAE1B,KAAK,CAAC,aAAqB,SAA2B,QAAQ,QAAQ,CAAC;AAAA;AAAA,EAGvE,MAAM,CAAC,IAA4D,CAAC,MAAM;AACxE,UAAM,SAAS,IAAI,gBAAgB;AAEnC,QAAI,EAAE,OAAQ,QAAO,IAAI,UAAU,EAAE,MAAM;AAE3C,QAAI,EAAE,KAAM,QAAO,IAAI,QAAQ,OAAO,EAAE,IAAI,CAAC;AAE7C,QAAI,EAAE,MAAO,QAAO,IAAI,SAAS,OAAO,EAAE,KAAK,CAAC;AAEhD,UAAM,KAAK,OAAO,SAAS;AAE3B,WAAO,SAAmB,KAAK,GAAG,IAAI,IAAI,EAAE,KAAK,IAAI;AAAA,EACvD;AAAA;AAAA,EAGA,cAAc,MACZ,SAA4B,GAAG,IAAI,gCAAgC;AAAA,EAErE,OAAO,CAAC,UAAkB,UAAkB,SAC1C,WAAW,UAAU,UAAU,UAAU,IAAI,CAAC;AAAA,EAEhD,QAAQ,CAAC,UAAkB,UAAkB,UAC3C,KAAwB,GAAG,UAAU,UAAU,QAAQ,CAAC,WAAW;AAAA,IACjE;AAAA,EACF,CAAC;AAAA;AAAA,EAGH,cAAc,CAAC,UAAkB,eAC/B;AAAA,IACE,GAAG,QAAQ,QAAQ,CAAC,cAAc,mBAAmB,UAAU,CAAC;AAAA,IAChE,CAAC;AAAA,EACH;AAAA;AAAA,EAGF,QAAQ,CAAC,UAAkB,SACzB,KAAuB,GAAG,QAAQ,QAAQ,CAAC,aAAa,IAAI;AAAA;AAAA,EAG9D,SAAS,CAAC,UAAkB,SAC1B,KAAuB,GAAG,QAAQ,QAAQ,CAAC,YAAY,EAAE,KAAK,CAAC;AACnE;","names":[]}
@@ -10,9 +10,9 @@ export declare const accountsApi: {
10
10
  password: string;
11
11
  }) => Promise<AuthSessionPayload>;
12
12
  /**
13
- * Get current account (requires auth token)
13
+ * Get current account (the session cookie, or an explicit token)
14
14
  */
15
- getMe: (token: string) => Promise<{
15
+ getMe: (token?: string) => Promise<{
16
16
  id: string;
17
17
  email: string;
18
18
  name: string;
@@ -54,7 +54,7 @@ export declare const accountsApi: {
54
54
  /**
55
55
  * List organizations I belong to
56
56
  */
57
- getMyOrganizations: (token: string) => Promise<{
57
+ getMyOrganizations: (token?: string) => Promise<{
58
58
  id: string;
59
59
  name: string;
60
60
  slug: string;
@@ -12,7 +12,7 @@ const accountsApi = {
12
12
  body: JSON.stringify(data)
13
13
  }),
14
14
  /**
15
- * Get current account (requires auth token)
15
+ * Get current account (the session cookie, or an explicit token)
16
16
  */
17
17
  getMe: (token) => apiFetch("/accounts/me", { token }),
18
18
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/api/accounts.ts"],"sourcesContent":["import { apiFetch } from './core';\nimport type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser';\nimport type {\n SignupInput,\n SignupResult,\n UpdateAccountInput,\n ChangePasswordInput,\n ChangeEmailInput,\n AuthSessionPayload,\n GoogleSignInCallbackResult,\n GoogleLinkInput,\n AccountConsent,\n ConsentInput,\n MfaSetupResult,\n MfaVerifyResult,\n SendLoginCodeResult,\n VerifyLoginCodeResult,\n Passkey,\n BeginPasskeyLoginResult,\n PasskeyLoginResult,\n} from './types';\n\nexport const accountsApi = {\n /**\n * Create a new account (signup)\n */\n signup: (data: SignupInput) =>\n apiFetch<SignupResult>('/accounts', {\n method: 'POST',\n body: JSON.stringify(data),\n }),\n\n registerTeamInvite: (\n token: string,\n data: { name: string; password: string },\n ) =>\n apiFetch<AuthSessionPayload>(`/accounts/team-invites/${token}/register`, {\n method: 'POST',\n body: JSON.stringify(data),\n }),\n\n /**\n * Get current account (requires auth token)\n */\n getMe: (token: string) =>\n apiFetch<SignupResult['account']>('/accounts/me', { token }),\n\n /**\n * Update current account profile\n */\n updateMe: (data: UpdateAccountInput) =>\n apiFetch<SignupResult['account']>('/accounts/me', {\n method: 'PUT',\n body: JSON.stringify(data),\n }),\n\n /**\n * Change password\n */\n changePassword: (data: ChangePasswordInput) =>\n apiFetch<{ success: boolean }>('/accounts/me/password', {\n method: 'PUT',\n body: JSON.stringify(data),\n }),\n\n /**\n * Change email address\n */\n changeEmail: (data: ChangeEmailInput) =>\n apiFetch<{ success: boolean }>('/accounts/me/email', {\n method: 'PUT',\n body: JSON.stringify(data),\n }),\n\n /**\n * Delete account\n */\n deleteAccount: () =>\n apiFetch<{ success: boolean }>('/accounts/me', {\n method: 'DELETE',\n }),\n\n /**\n * List organizations I belong to\n */\n getMyOrganizations: (token: string) =>\n apiFetch<{ id: string; name: string; slug: string }[]>(\n '/accounts/me/organizations',\n { token },\n ),\n\n /**\n * Verify email address using token from verification email\n */\n verifyEmail: (token: string) =>\n apiFetch<{ message: string }>('/accounts/verify-email', {\n method: 'POST',\n body: JSON.stringify({ token }),\n }),\n\n /**\n * Resend verification email to the current account\n */\n resendVerification: () =>\n apiFetch<{ message: string }>('/accounts/resend-verification', {\n method: 'POST',\n }),\n\n forgotPassword: (email: string) =>\n apiFetch<{ message: string }>('/accounts/forgot-password', {\n method: 'POST',\n body: JSON.stringify({ email }),\n }),\n\n resetPassword: (token: string, newPassword: string) =>\n apiFetch<{ message: string }>('/accounts/reset-password', {\n method: 'POST',\n body: JSON.stringify({ token, newPassword }),\n }),\n\n /**\n * Get the Google OAuth consent URL. Caller navigates to `url`; Google\n * redirects back to the configured callback with `?code=&state=`.\n */\n googleAuthUrl: () =>\n apiFetch<{ url: string; state: string }>('/accounts/google/url'),\n\n /**\n * Exchange the OAuth code for an account + session. Returns one of:\n * - `signed-in` : existing google account, session issued\n * - `created` : brand-new account, session issued\n * - `collision` : email matches a password account; prompt for password\n * then call `googleLink` with `collision.accountId`.\n */\n googleCallback: (input: { code: string; state: string }) =>\n apiFetch<GoogleSignInCallbackResult>('/accounts/google/callback', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n /**\n * Confirm password ownership on collision and link Google to the account.\n * Returns a normal signed-in session payload.\n */\n googleLink: (input: GoogleLinkInput) =>\n apiFetch<AuthSessionPayload>('/accounts/google/link', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n /**\n * Passwordless: send a one-time login code to the email. Always resolves the\n * same way whether or not an account exists (no enumeration).\n */\n sendLoginCode: (email: string) =>\n apiFetch<SendLoginCodeResult>('/accounts/code/send', {\n method: 'POST',\n body: JSON.stringify({ email }),\n }),\n\n /**\n * Verify the code. On success the account is signed in (created first if it\n * didn't exist), and the same session payload as login is returned.\n * `isNewAccount` tells the client whether to route into onboarding.\n */\n verifyLoginCode: (input: { email: string; code: string }) =>\n apiFetch<VerifyLoginCodeResult>('/accounts/code/verify', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n // ── Communication & privacy consent ────────────────────────────────\n /** Current consent preferences for the signed-in account. */\n getConsent: () => apiFetch<AccountConsent>('/accounts/me/consent'),\n\n /** Patch one or more consent switches. Server merges + re-stamps. */\n updateConsent: (data: Partial<ConsentInput>) =>\n apiFetch<AccountConsent>('/accounts/me/consent', {\n method: 'PATCH',\n body: JSON.stringify(data),\n }),\n\n /**\n * The second factor, for a sign-in that cleared its first.\n *\n * Takes a live authenticator code or one of the ten backup codes — those had\n * never been usable before, because nothing ever asked.\n */\n completeMfaChallenge: (input: { mfaToken: string; code: string }) =>\n apiFetch<AuthSessionPayload>('/accounts/mfa/challenge', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n // ── Passkeys ───────────────────────────────────────────────────────\n //\n // Enrolment is authenticated, signing in is not. You prove who you are with\n // an emailed code once, then attach a faster way back in from the device in\n // your hand.\n\n /** Options for creating a passkey. Requires a signed-in session. */\n beginPasskeyRegistration: () =>\n apiFetch<PublicKeyCredentialCreationOptionsJSON>(\n '/accounts/passkeys/register/begin',\n { method: 'POST' },\n ),\n\n /** Hand the created credential back to be verified and kept. */\n finishPasskeyRegistration: (input: { response: unknown; name?: string }) =>\n apiFetch<Passkey>('/accounts/passkeys/register/finish', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n /**\n * Start a passkey sign-in.\n *\n * `email` is optional and usually omitted: without it the browser offers\n * whatever it holds for this site, which is how the login gets to be one tap\n * with nothing typed. Passing it narrows the prompt to one account, which is\n * what the Welcome back card wants.\n */\n beginPasskeyLogin: (email?: string) =>\n apiFetch<BeginPasskeyLoginResult>('/accounts/passkeys/login/begin', {\n method: 'POST',\n body: JSON.stringify(email ? { email } : {}),\n }),\n\n finishPasskeyLogin: (input: { handle: string; response: unknown }) =>\n apiFetch<PasskeyLoginResult>('/accounts/passkeys/login/finish', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n listPasskeys: () => apiFetch<Passkey[]>('/accounts/passkeys'),\n\n renamePasskey: (id: string, name: string) =>\n apiFetch<{ success: true }>(`/accounts/passkeys/${id}`, {\n method: 'PATCH',\n body: JSON.stringify({ name }),\n }),\n\n removePasskey: (id: string) =>\n apiFetch<{ success: true }>(`/accounts/passkeys/${id}`, {\n method: 'DELETE',\n }),\n\n // ── Two-factor authentication (TOTP) ───────────────────────────────\n /**\n * Begin enrolment. Stores an encrypted secret with mfaEnabled=false and\n * returns the QR + secret to display. Not active until `verifyMfa` succeeds.\n */\n setupMfa: () =>\n apiFetch<MfaSetupResult>('/accounts/me/mfa/setup', { method: 'POST' }),\n\n /**\n * Confirm the first code to switch 2FA on. Returns the one-time backup\n * codes — shown ONCE; only their hashes are stored server-side.\n */\n verifyMfa: (code: string) =>\n apiFetch<MfaVerifyResult>('/accounts/me/mfa/verify', {\n method: 'POST',\n body: JSON.stringify({ code }),\n }),\n\n /** Turn 2FA off. Requires a valid current code (or a backup code). */\n disableMfa: (code: string) =>\n apiFetch<{ enabled: false }>('/accounts/me/mfa/disable', {\n method: 'POST',\n body: JSON.stringify({ code }),\n }),\n};\n\n/** Per-account communication + analytics consent. */\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/api/accounts.ts"],"sourcesContent":["import { apiFetch } from './core';\nimport type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser';\nimport type {\n SignupInput,\n SignupResult,\n UpdateAccountInput,\n ChangePasswordInput,\n ChangeEmailInput,\n AuthSessionPayload,\n GoogleSignInCallbackResult,\n GoogleLinkInput,\n AccountConsent,\n ConsentInput,\n MfaSetupResult,\n MfaVerifyResult,\n SendLoginCodeResult,\n VerifyLoginCodeResult,\n Passkey,\n BeginPasskeyLoginResult,\n PasskeyLoginResult,\n} from './types';\n\nexport const accountsApi = {\n /**\n * Create a new account (signup)\n */\n signup: (data: SignupInput) =>\n apiFetch<SignupResult>('/accounts', {\n method: 'POST',\n body: JSON.stringify(data),\n }),\n\n registerTeamInvite: (\n token: string,\n data: { name: string; password: string },\n ) =>\n apiFetch<AuthSessionPayload>(`/accounts/team-invites/${token}/register`, {\n method: 'POST',\n body: JSON.stringify(data),\n }),\n\n /**\n * Get current account (the session cookie, or an explicit token)\n */\n getMe: (token?: string) =>\n apiFetch<SignupResult['account']>('/accounts/me', { token }),\n\n /**\n * Update current account profile\n */\n updateMe: (data: UpdateAccountInput) =>\n apiFetch<SignupResult['account']>('/accounts/me', {\n method: 'PUT',\n body: JSON.stringify(data),\n }),\n\n /**\n * Change password\n */\n changePassword: (data: ChangePasswordInput) =>\n apiFetch<{ success: boolean }>('/accounts/me/password', {\n method: 'PUT',\n body: JSON.stringify(data),\n }),\n\n /**\n * Change email address\n */\n changeEmail: (data: ChangeEmailInput) =>\n apiFetch<{ success: boolean }>('/accounts/me/email', {\n method: 'PUT',\n body: JSON.stringify(data),\n }),\n\n /**\n * Delete account\n */\n deleteAccount: () =>\n apiFetch<{ success: boolean }>('/accounts/me', {\n method: 'DELETE',\n }),\n\n /**\n * List organizations I belong to\n */\n getMyOrganizations: (token?: string) =>\n apiFetch<{ id: string; name: string; slug: string }[]>(\n '/accounts/me/organizations',\n { token },\n ),\n\n /**\n * Verify email address using token from verification email\n */\n verifyEmail: (token: string) =>\n apiFetch<{ message: string }>('/accounts/verify-email', {\n method: 'POST',\n body: JSON.stringify({ token }),\n }),\n\n /**\n * Resend verification email to the current account\n */\n resendVerification: () =>\n apiFetch<{ message: string }>('/accounts/resend-verification', {\n method: 'POST',\n }),\n\n forgotPassword: (email: string) =>\n apiFetch<{ message: string }>('/accounts/forgot-password', {\n method: 'POST',\n body: JSON.stringify({ email }),\n }),\n\n resetPassword: (token: string, newPassword: string) =>\n apiFetch<{ message: string }>('/accounts/reset-password', {\n method: 'POST',\n body: JSON.stringify({ token, newPassword }),\n }),\n\n /**\n * Get the Google OAuth consent URL. Caller navigates to `url`; Google\n * redirects back to the configured callback with `?code=&state=`.\n */\n googleAuthUrl: () =>\n apiFetch<{ url: string; state: string }>('/accounts/google/url'),\n\n /**\n * Exchange the OAuth code for an account + session. Returns one of:\n * - `signed-in` : existing google account, session issued\n * - `created` : brand-new account, session issued\n * - `collision` : email matches a password account; prompt for password\n * then call `googleLink` with `collision.accountId`.\n */\n googleCallback: (input: { code: string; state: string }) =>\n apiFetch<GoogleSignInCallbackResult>('/accounts/google/callback', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n /**\n * Confirm password ownership on collision and link Google to the account.\n * Returns a normal signed-in session payload.\n */\n googleLink: (input: GoogleLinkInput) =>\n apiFetch<AuthSessionPayload>('/accounts/google/link', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n /**\n * Passwordless: send a one-time login code to the email. Always resolves the\n * same way whether or not an account exists (no enumeration).\n */\n sendLoginCode: (email: string) =>\n apiFetch<SendLoginCodeResult>('/accounts/code/send', {\n method: 'POST',\n body: JSON.stringify({ email }),\n }),\n\n /**\n * Verify the code. On success the account is signed in (created first if it\n * didn't exist), and the same session payload as login is returned.\n * `isNewAccount` tells the client whether to route into onboarding.\n */\n verifyLoginCode: (input: { email: string; code: string }) =>\n apiFetch<VerifyLoginCodeResult>('/accounts/code/verify', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n // ── Communication & privacy consent ────────────────────────────────\n /** Current consent preferences for the signed-in account. */\n getConsent: () => apiFetch<AccountConsent>('/accounts/me/consent'),\n\n /** Patch one or more consent switches. Server merges + re-stamps. */\n updateConsent: (data: Partial<ConsentInput>) =>\n apiFetch<AccountConsent>('/accounts/me/consent', {\n method: 'PATCH',\n body: JSON.stringify(data),\n }),\n\n /**\n * The second factor, for a sign-in that cleared its first.\n *\n * Takes a live authenticator code or one of the ten backup codes — those had\n * never been usable before, because nothing ever asked.\n */\n completeMfaChallenge: (input: { mfaToken: string; code: string }) =>\n apiFetch<AuthSessionPayload>('/accounts/mfa/challenge', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n // ── Passkeys ───────────────────────────────────────────────────────\n //\n // Enrolment is authenticated, signing in is not. You prove who you are with\n // an emailed code once, then attach a faster way back in from the device in\n // your hand.\n\n /** Options for creating a passkey. Requires a signed-in session. */\n beginPasskeyRegistration: () =>\n apiFetch<PublicKeyCredentialCreationOptionsJSON>(\n '/accounts/passkeys/register/begin',\n { method: 'POST' },\n ),\n\n /** Hand the created credential back to be verified and kept. */\n finishPasskeyRegistration: (input: { response: unknown; name?: string }) =>\n apiFetch<Passkey>('/accounts/passkeys/register/finish', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n /**\n * Start a passkey sign-in.\n *\n * `email` is optional and usually omitted: without it the browser offers\n * whatever it holds for this site, which is how the login gets to be one tap\n * with nothing typed. Passing it narrows the prompt to one account, which is\n * what the Welcome back card wants.\n */\n beginPasskeyLogin: (email?: string) =>\n apiFetch<BeginPasskeyLoginResult>('/accounts/passkeys/login/begin', {\n method: 'POST',\n body: JSON.stringify(email ? { email } : {}),\n }),\n\n finishPasskeyLogin: (input: { handle: string; response: unknown }) =>\n apiFetch<PasskeyLoginResult>('/accounts/passkeys/login/finish', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n listPasskeys: () => apiFetch<Passkey[]>('/accounts/passkeys'),\n\n renamePasskey: (id: string, name: string) =>\n apiFetch<{ success: true }>(`/accounts/passkeys/${id}`, {\n method: 'PATCH',\n body: JSON.stringify({ name }),\n }),\n\n removePasskey: (id: string) =>\n apiFetch<{ success: true }>(`/accounts/passkeys/${id}`, {\n method: 'DELETE',\n }),\n\n // ── Two-factor authentication (TOTP) ───────────────────────────────\n /**\n * Begin enrolment. Stores an encrypted secret with mfaEnabled=false and\n * returns the QR + secret to display. Not active until `verifyMfa` succeeds.\n */\n setupMfa: () =>\n apiFetch<MfaSetupResult>('/accounts/me/mfa/setup', { method: 'POST' }),\n\n /**\n * Confirm the first code to switch 2FA on. Returns the one-time backup\n * codes — shown ONCE; only their hashes are stored server-side.\n */\n verifyMfa: (code: string) =>\n apiFetch<MfaVerifyResult>('/accounts/me/mfa/verify', {\n method: 'POST',\n body: JSON.stringify({ code }),\n }),\n\n /** Turn 2FA off. Requires a valid current code (or a backup code). */\n disableMfa: (code: string) =>\n apiFetch<{ enabled: false }>('/accounts/me/mfa/disable', {\n method: 'POST',\n body: JSON.stringify({ code }),\n }),\n};\n\n/** Per-account communication + analytics consent. */\n"],"mappings":"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","names":[]}
@@ -1,4 +1,4 @@
1
- import { getApiUrl, getAccountId, getAuthToken, apiFetch } from "./core";
1
+ import { getApiUrl, getAccountId, apiFetch } from "./core";
2
2
  import { scopeParams } from "./analytics-patterns";
3
3
  const analyticsApi = {
4
4
  /**
@@ -365,8 +365,6 @@ const analyticsApi = {
365
365
  const headers = { "X-Tenant-Slug": tenantSlug };
366
366
  const accountId = getAccountId();
367
367
  if (accountId) headers["x-account-id"] = accountId;
368
- const token = getAuthToken();
369
- if (token) headers.Authorization = `Bearer ${token}`;
370
368
  const res = await fetch(`${getApiUrl()}/analytics/export?${params}`, {
371
369
  headers,
372
370
  credentials: "include"
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/api/analytics.ts"],"sourcesContent":["import { getApiUrl, getAccountId, getAuthToken, apiFetch } from './core';\nimport { scopeParams } from './analytics-patterns';\nimport type {\n CohortRow,\n SalesLedger,\n OrderReport,\n RevenueSplit,\n OverviewStats,\n SalesStats,\n TopContent,\n TopProduct,\n TrafficSource,\n DeviceStats,\n FunnelStats,\n BookingAnalytics,\n EventAnalytics,\n ProductPerformance,\n HomeSummary,\n OutstandingInvoices,\n PaymentsMix,\n Insight,\n MetricTree,\n Utilization,\n Rebooking,\n WinBack,\n LoyaltyEffect,\n DashboardStats,\n SearchTermStat,\n AttributionReport,\n AnalyticsQuery,\n RecentEvent,\n StockCover,\n BookingPerformance,\n Fulfilment,\n CollectionSpeed,\n DineIn,\n EventSales,\n Benchmarks,\n WeeklyDigest,\n} from './types';\n\nexport const analyticsApi = {\n /**\n * One event's ticketing: bookings, heads, revenue, check-in rate and the\n * split by ticket tier.\n *\n * The route has existed since event-tickets shipped and had no wrapper here\n * at all, so nine measurements the server computes on request reached no\n * screen. `contentId` is the event's Content row.\n */\n getEventAnalytics: (tenantSlug: string, contentId: string) =>\n apiFetch<EventAnalytics>(`/analytics/events/${contentId}`, { tenantSlug }),\n\n /**\n * What one item did, and where its buyers came from.\n *\n * The ordinary-product counterpart to `getEventAnalytics` above. Until this\n * existed the return leg of the loop was built for exactly one archetype: a\n * venue could see how a night went, and everybody else could open an item\n * they had created and find nothing that said what it earned.\n *\n * Its own request rather than a field on the product, because it is scoped\n * to a WINDOW and the product record is not — and because the item screen\n * should render its record immediately and let the money arrive after.\n */\n getProductPerformance: (\n tenantSlug: string,\n contentId: string,\n query: { startDate: string; endDate: string },\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<ProductPerformance>(\n `/analytics/products/${contentId}?${params}`,\n { tenantSlug },\n );\n },\n\n /**\n * One measure, cut by one dimension, over a window.\n *\n * Grouped in the database. The view this replaces fetched 250 orders and\n * grouped them in the browser, so a busy quarter drew a chart from a slice.\n */\n getOrderReport: (\n tenantSlug: string,\n query: AnalyticsQuery & {\n dimension: string;\n measure: string;\n },\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n dimension: query.dimension,\n measure: query.measure,\n ...(query.granularity && { granularity: query.granularity }),\n });\n return apiFetch<OrderReport>(`/analytics/order-report?${params}`, {\n tenantSlug,\n });\n },\n\n /** New against returning revenue, by first-paid-order. */\n getRevenueSplit: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<RevenueSplit>(`/analytics/revenue-split?${params}`, {\n tenantSlug,\n });\n },\n\n /** Gross to net over one window, from `orders` rather than from beacons. */\n getSalesLedger: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.granularity && { granularity: query.granularity }),\n ...scopeParams(query),\n });\n return apiFetch<SalesLedger>(`/analytics/sales-ledger?${params}`, {\n tenantSlug,\n });\n },\n\n getDashboard: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.granularity && { granularity: query.granularity }),\n ...(query.limit && { limit: String(query.limit) }),\n });\n return apiFetch<DashboardStats>(`/analytics/dashboard?${params}`, {\n tenantSlug,\n });\n },\n\n getOverview: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<OverviewStats>(`/analytics/overview?${params}`, {\n tenantSlug,\n });\n },\n\n /**\n * Retention by first-purchase month.\n *\n * No date range, deliberately: a cohort table reads backwards from today by\n * definition, and handing it the hub's period picker would let a merchant\n * ask for the six months ending last March, which is a different question\n * with the same shape. `months` is how many cohorts to draw, clamped 3 to 12\n * server-side because the grid is a triangle nobody can read at sixty.\n */\n getCohorts: (tenantSlug: string, months = 6) =>\n apiFetch<{ cohorts: CohortRow[] }>(`/analytics/cohorts?months=${months}`, {\n tenantSlug,\n }),\n\n getSales: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<SalesStats>(`/analytics/sales?${params}`, { tenantSlug });\n },\n\n /**\n * The last N events, newest first.\n *\n * The route has existed since analytics shipped and nothing in the dashboard\n * called it. `useLivePresence` does now: an event carries `sessionId` and\n * `createdAt`, so \"who is on the site this minute\" is a filter over this\n * payload rather than an endpoint of its own.\n *\n * `limit` matters more than it looks. The window is counted client-side, so\n * a busy storefront can have more events in five minutes than the limit\n * returns, and the count would silently cap. The caller detects that by\n * checking whether the OLDEST event returned is still inside the window.\n */\n getRecentEvents: (tenantSlug: string, limit = 60) =>\n apiFetch<RecentEvent[]>(`/analytics/recent-events?limit=${limit}`, {\n tenantSlug,\n }),\n\n getTopContent: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.limit && { limit: String(query.limit) }),\n });\n return apiFetch<TopContent[]>(`/analytics/top-content?${params}`, {\n tenantSlug,\n });\n },\n\n getTopProducts: (\n tenantSlug: string,\n query: AnalyticsQuery & { sortBy?: 'revenue' | 'units' },\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.limit && { limit: String(query.limit) }),\n // Server-side, because it has to be the ORDER BY: re-sorting a\n // revenue-ranked page gives the units leaders AMONG the revenue leaders.\n ...(query.sortBy && { sortBy: query.sortBy }),\n });\n return apiFetch<TopProduct[]>(`/analytics/top-products?${params}`, {\n tenantSlug,\n });\n },\n\n getTrafficSources: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<TrafficSource[]>(`/analytics/traffic-sources?${params}`, {\n tenantSlug,\n });\n },\n\n getDevices: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<DeviceStats[]>(`/analytics/devices?${params}`, {\n tenantSlug,\n });\n },\n\n // Not part of `/analytics/dashboard`. That payload is already twenty-odd\n // aggregates fetched on every view of the hub, and only the traffic report\n // asks this question.\n getSearchTerms: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n limit: String(query.limit ?? 20),\n });\n return apiFetch<SearchTermStat[]>(`/analytics/search-terms?${params}`, {\n tenantSlug,\n });\n },\n\n /**\n * Revenue by where the orders came from.\n *\n * Its own request for the same reason search-terms is: `/analytics/dashboard`\n * is already twenty-odd aggregates on every view of the hub, and this is four\n * grouped queries only one screen asks for.\n *\n * `model` is sendable again, and this time the API whitelists it.\n *\n * It was dropped from this signature because the endpoint's DTO rejected it\n * outright — `property model should not exist` — so every request carrying\n * one 400d, which was every request, because the parameter defaulted. The\n * controller read it and Swagger documented the enum; only the DTO had never\n * heard of it.\n *\n * Sent only when a model is actually chosen, so the default request is\n * byte-identical to what a build without the fix expects. Callers must still\n * gate the CHOICE on `features.attributionModels` — an old API will 400 on\n * `first_touch`, and a control that errors is worse than no control.\n */\n getAttribution: (\n tenantSlug: string,\n query: AnalyticsQuery,\n model?: 'last_touch' | 'first_touch',\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(model ? { model } : {}),\n });\n return apiFetch<AttributionReport>(`/analytics/attribution?${params}`, {\n tenantSlug,\n });\n },\n\n getFunnel: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<FunnelStats>(`/analytics/funnel?${params}`, { tenantSlug });\n },\n\n getBookingAnalytics: (tenantSlug: string) => {\n return apiFetch<BookingAnalytics>(`/analytics/booking-stats`, {\n tenantSlug,\n });\n },\n\n /**\n * The home money band in one request.\n *\n * Replaces three: two full `/analytics/dashboard` payloads (current and\n * prior) plus an `/analytics/outstanding` route that does not exist on the\n * API and had been 404ing silently on every load. The band renders four\n * numbers and a series; this returns exactly that.\n */\n getHomeSummary: (\n tenantSlug: string,\n params: { startDate: string; endDate: string },\n ) =>\n apiFetch<HomeSummary>(\n `/analytics/home-summary?startDate=${params.startDate}&endDate=${params.endDate}`,\n { tenantSlug },\n ),\n\n /** When each tracked product runs out, and which are not moving. */\n getStockCover: (tenantSlug: string) =>\n apiFetch<StockCover>('/analytics/stock-cover', { tenantSlug }),\n\n /** No-show and cancellation rates, and by who was booked. */\n getBookingPerformance: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<BookingPerformance>(\n `/analytics/booking-performance?${params}`,\n { tenantSlug },\n );\n },\n\n /** Paid orders still to send, and how long sending takes. */\n getFulfilment: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Fulfilment>(`/analytics/fulfilment?${params}`, {\n tenantSlug,\n });\n },\n\n /** How many days invoices take to be paid. */\n getCollectionSpeed: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<CollectionSpeed>(`/analytics/collection-speed?${params}`, {\n tenantSlug,\n });\n },\n\n /** Tabs open now, average bill and how long tables are held. */\n getDineIn: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<DineIn>(`/analytics/dine-in?${params}`, { tenantSlug });\n },\n\n /** Tickets by type for each ticketed event, and who checked in. */\n getEventSales: (tenantSlug: string) =>\n apiFetch<EventSales>('/analytics/event-sales', { tenantSlug }),\n\n /** How this business compares with similar ones. Aggregates only. */\n getBenchmarks: (tenantSlug: string) =>\n apiFetch<Benchmarks>('/analytics/benchmarks', { tenantSlug }),\n\n /** The last seven days in a few plain lines, ready to read or send. */\n getWeeklyDigest: (tenantSlug: string) =>\n apiFetch<WeeklyDigest>('/analytics/weekly-digest', { tenantSlug }),\n\n getOutstanding: (tenantSlug: string, limit?: number) => {\n const params = new URLSearchParams(limit ? { limit: String(limit) } : {});\n const qs = params.toString();\n return apiFetch<OutstandingInvoices>(\n `/analytics/outstanding${qs ? `?${qs}` : ''}`,\n { tenantSlug },\n );\n },\n\n getPaymentsMix: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<PaymentsMix>(`/analytics/payments-mix?${params}`, {\n tenantSlug,\n });\n },\n\n getInsights: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Insight[]>(`/analytics/insights?${params}`, {\n tenantSlug,\n });\n },\n\n /**\n * The prior window is not sent: the API derives the equally-long stretch\n * immediately before this one, so the comparison cannot drift between\n * callers.\n */\n getMetricTree: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<MetricTree>(`/analytics/metric-tree?${params}`, {\n tenantSlug,\n });\n },\n\n getLoyaltyEffect: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<LoyaltyEffect>(`/analytics/loyalty-effect?${params}`, {\n tenantSlug,\n });\n },\n\n getRebooking: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Rebooking>(`/analytics/rebooking?${params}`, {\n tenantSlug,\n });\n },\n\n getWinBack: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<WinBack>(`/analytics/win-back?${params}`, { tenantSlug });\n },\n\n getUtilization: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Utilization>(`/analytics/utilization?${params}`, {\n tenantSlug,\n });\n },\n\n // CSV, not JSON — so it bypasses apiFetch's JSON parsing and returns raw text.\n exportCsv: async (\n tenantSlug: string,\n query: AnalyticsQuery,\n ): Promise<string> => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n const headers: Record<string, string> = { 'X-Tenant-Slug': tenantSlug };\n const accountId = getAccountId();\n if (accountId) headers['x-account-id'] = accountId;\n const token = getAuthToken();\n if (token) headers.Authorization = `Bearer ${token}`;\n\n const res = await fetch(`${getApiUrl()}/analytics/export?${params}`, {\n headers,\n credentials: 'include',\n });\n if (!res.ok) throw new Error(`Export failed (HTTP ${res.status})`);\n return res.text();\n },\n};\n\n// ============================================\n// ORDERS API\n// ============================================\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/api/analytics.ts"],"sourcesContent":["import { getApiUrl, getAccountId, apiFetch } from './core';\nimport { scopeParams } from './analytics-patterns';\nimport type {\n CohortRow,\n SalesLedger,\n OrderReport,\n RevenueSplit,\n OverviewStats,\n SalesStats,\n TopContent,\n TopProduct,\n TrafficSource,\n DeviceStats,\n FunnelStats,\n BookingAnalytics,\n EventAnalytics,\n ProductPerformance,\n HomeSummary,\n OutstandingInvoices,\n PaymentsMix,\n Insight,\n MetricTree,\n Utilization,\n Rebooking,\n WinBack,\n LoyaltyEffect,\n DashboardStats,\n SearchTermStat,\n AttributionReport,\n AnalyticsQuery,\n RecentEvent,\n StockCover,\n BookingPerformance,\n Fulfilment,\n CollectionSpeed,\n DineIn,\n EventSales,\n Benchmarks,\n WeeklyDigest,\n} from './types';\n\nexport const analyticsApi = {\n /**\n * One event's ticketing: bookings, heads, revenue, check-in rate and the\n * split by ticket tier.\n *\n * The route has existed since event-tickets shipped and had no wrapper here\n * at all, so nine measurements the server computes on request reached no\n * screen. `contentId` is the event's Content row.\n */\n getEventAnalytics: (tenantSlug: string, contentId: string) =>\n apiFetch<EventAnalytics>(`/analytics/events/${contentId}`, { tenantSlug }),\n\n /**\n * What one item did, and where its buyers came from.\n *\n * The ordinary-product counterpart to `getEventAnalytics` above. Until this\n * existed the return leg of the loop was built for exactly one archetype: a\n * venue could see how a night went, and everybody else could open an item\n * they had created and find nothing that said what it earned.\n *\n * Its own request rather than a field on the product, because it is scoped\n * to a WINDOW and the product record is not — and because the item screen\n * should render its record immediately and let the money arrive after.\n */\n getProductPerformance: (\n tenantSlug: string,\n contentId: string,\n query: { startDate: string; endDate: string },\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<ProductPerformance>(\n `/analytics/products/${contentId}?${params}`,\n { tenantSlug },\n );\n },\n\n /**\n * One measure, cut by one dimension, over a window.\n *\n * Grouped in the database. The view this replaces fetched 250 orders and\n * grouped them in the browser, so a busy quarter drew a chart from a slice.\n */\n getOrderReport: (\n tenantSlug: string,\n query: AnalyticsQuery & {\n dimension: string;\n measure: string;\n },\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n dimension: query.dimension,\n measure: query.measure,\n ...(query.granularity && { granularity: query.granularity }),\n });\n return apiFetch<OrderReport>(`/analytics/order-report?${params}`, {\n tenantSlug,\n });\n },\n\n /** New against returning revenue, by first-paid-order. */\n getRevenueSplit: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<RevenueSplit>(`/analytics/revenue-split?${params}`, {\n tenantSlug,\n });\n },\n\n /** Gross to net over one window, from `orders` rather than from beacons. */\n getSalesLedger: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.granularity && { granularity: query.granularity }),\n ...scopeParams(query),\n });\n return apiFetch<SalesLedger>(`/analytics/sales-ledger?${params}`, {\n tenantSlug,\n });\n },\n\n getDashboard: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.granularity && { granularity: query.granularity }),\n ...(query.limit && { limit: String(query.limit) }),\n });\n return apiFetch<DashboardStats>(`/analytics/dashboard?${params}`, {\n tenantSlug,\n });\n },\n\n getOverview: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<OverviewStats>(`/analytics/overview?${params}`, {\n tenantSlug,\n });\n },\n\n /**\n * Retention by first-purchase month.\n *\n * No date range, deliberately: a cohort table reads backwards from today by\n * definition, and handing it the hub's period picker would let a merchant\n * ask for the six months ending last March, which is a different question\n * with the same shape. `months` is how many cohorts to draw, clamped 3 to 12\n * server-side because the grid is a triangle nobody can read at sixty.\n */\n getCohorts: (tenantSlug: string, months = 6) =>\n apiFetch<{ cohorts: CohortRow[] }>(`/analytics/cohorts?months=${months}`, {\n tenantSlug,\n }),\n\n getSales: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<SalesStats>(`/analytics/sales?${params}`, { tenantSlug });\n },\n\n /**\n * The last N events, newest first.\n *\n * The route has existed since analytics shipped and nothing in the dashboard\n * called it. `useLivePresence` does now: an event carries `sessionId` and\n * `createdAt`, so \"who is on the site this minute\" is a filter over this\n * payload rather than an endpoint of its own.\n *\n * `limit` matters more than it looks. The window is counted client-side, so\n * a busy storefront can have more events in five minutes than the limit\n * returns, and the count would silently cap. The caller detects that by\n * checking whether the OLDEST event returned is still inside the window.\n */\n getRecentEvents: (tenantSlug: string, limit = 60) =>\n apiFetch<RecentEvent[]>(`/analytics/recent-events?limit=${limit}`, {\n tenantSlug,\n }),\n\n getTopContent: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.limit && { limit: String(query.limit) }),\n });\n return apiFetch<TopContent[]>(`/analytics/top-content?${params}`, {\n tenantSlug,\n });\n },\n\n getTopProducts: (\n tenantSlug: string,\n query: AnalyticsQuery & { sortBy?: 'revenue' | 'units' },\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.limit && { limit: String(query.limit) }),\n // Server-side, because it has to be the ORDER BY: re-sorting a\n // revenue-ranked page gives the units leaders AMONG the revenue leaders.\n ...(query.sortBy && { sortBy: query.sortBy }),\n });\n return apiFetch<TopProduct[]>(`/analytics/top-products?${params}`, {\n tenantSlug,\n });\n },\n\n getTrafficSources: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<TrafficSource[]>(`/analytics/traffic-sources?${params}`, {\n tenantSlug,\n });\n },\n\n getDevices: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<DeviceStats[]>(`/analytics/devices?${params}`, {\n tenantSlug,\n });\n },\n\n // Not part of `/analytics/dashboard`. That payload is already twenty-odd\n // aggregates fetched on every view of the hub, and only the traffic report\n // asks this question.\n getSearchTerms: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n limit: String(query.limit ?? 20),\n });\n return apiFetch<SearchTermStat[]>(`/analytics/search-terms?${params}`, {\n tenantSlug,\n });\n },\n\n /**\n * Revenue by where the orders came from.\n *\n * Its own request for the same reason search-terms is: `/analytics/dashboard`\n * is already twenty-odd aggregates on every view of the hub, and this is four\n * grouped queries only one screen asks for.\n *\n * `model` is sendable again, and this time the API whitelists it.\n *\n * It was dropped from this signature because the endpoint's DTO rejected it\n * outright — `property model should not exist` — so every request carrying\n * one 400d, which was every request, because the parameter defaulted. The\n * controller read it and Swagger documented the enum; only the DTO had never\n * heard of it.\n *\n * Sent only when a model is actually chosen, so the default request is\n * byte-identical to what a build without the fix expects. Callers must still\n * gate the CHOICE on `features.attributionModels` — an old API will 400 on\n * `first_touch`, and a control that errors is worse than no control.\n */\n getAttribution: (\n tenantSlug: string,\n query: AnalyticsQuery,\n model?: 'last_touch' | 'first_touch',\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(model ? { model } : {}),\n });\n return apiFetch<AttributionReport>(`/analytics/attribution?${params}`, {\n tenantSlug,\n });\n },\n\n getFunnel: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<FunnelStats>(`/analytics/funnel?${params}`, { tenantSlug });\n },\n\n getBookingAnalytics: (tenantSlug: string) => {\n return apiFetch<BookingAnalytics>(`/analytics/booking-stats`, {\n tenantSlug,\n });\n },\n\n /**\n * The home money band in one request.\n *\n * Replaces three: two full `/analytics/dashboard` payloads (current and\n * prior) plus an `/analytics/outstanding` route that does not exist on the\n * API and had been 404ing silently on every load. The band renders four\n * numbers and a series; this returns exactly that.\n */\n getHomeSummary: (\n tenantSlug: string,\n params: { startDate: string; endDate: string },\n ) =>\n apiFetch<HomeSummary>(\n `/analytics/home-summary?startDate=${params.startDate}&endDate=${params.endDate}`,\n { tenantSlug },\n ),\n\n /** When each tracked product runs out, and which are not moving. */\n getStockCover: (tenantSlug: string) =>\n apiFetch<StockCover>('/analytics/stock-cover', { tenantSlug }),\n\n /** No-show and cancellation rates, and by who was booked. */\n getBookingPerformance: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<BookingPerformance>(\n `/analytics/booking-performance?${params}`,\n { tenantSlug },\n );\n },\n\n /** Paid orders still to send, and how long sending takes. */\n getFulfilment: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Fulfilment>(`/analytics/fulfilment?${params}`, {\n tenantSlug,\n });\n },\n\n /** How many days invoices take to be paid. */\n getCollectionSpeed: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<CollectionSpeed>(`/analytics/collection-speed?${params}`, {\n tenantSlug,\n });\n },\n\n /** Tabs open now, average bill and how long tables are held. */\n getDineIn: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<DineIn>(`/analytics/dine-in?${params}`, { tenantSlug });\n },\n\n /** Tickets by type for each ticketed event, and who checked in. */\n getEventSales: (tenantSlug: string) =>\n apiFetch<EventSales>('/analytics/event-sales', { tenantSlug }),\n\n /** How this business compares with similar ones. Aggregates only. */\n getBenchmarks: (tenantSlug: string) =>\n apiFetch<Benchmarks>('/analytics/benchmarks', { tenantSlug }),\n\n /** The last seven days in a few plain lines, ready to read or send. */\n getWeeklyDigest: (tenantSlug: string) =>\n apiFetch<WeeklyDigest>('/analytics/weekly-digest', { tenantSlug }),\n\n getOutstanding: (tenantSlug: string, limit?: number) => {\n const params = new URLSearchParams(limit ? { limit: String(limit) } : {});\n const qs = params.toString();\n return apiFetch<OutstandingInvoices>(\n `/analytics/outstanding${qs ? `?${qs}` : ''}`,\n { tenantSlug },\n );\n },\n\n getPaymentsMix: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<PaymentsMix>(`/analytics/payments-mix?${params}`, {\n tenantSlug,\n });\n },\n\n getInsights: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Insight[]>(`/analytics/insights?${params}`, {\n tenantSlug,\n });\n },\n\n /**\n * The prior window is not sent: the API derives the equally-long stretch\n * immediately before this one, so the comparison cannot drift between\n * callers.\n */\n getMetricTree: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<MetricTree>(`/analytics/metric-tree?${params}`, {\n tenantSlug,\n });\n },\n\n getLoyaltyEffect: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<LoyaltyEffect>(`/analytics/loyalty-effect?${params}`, {\n tenantSlug,\n });\n },\n\n getRebooking: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Rebooking>(`/analytics/rebooking?${params}`, {\n tenantSlug,\n });\n },\n\n getWinBack: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<WinBack>(`/analytics/win-back?${params}`, { tenantSlug });\n },\n\n getUtilization: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Utilization>(`/analytics/utilization?${params}`, {\n tenantSlug,\n });\n },\n\n // CSV, not JSON — so it bypasses apiFetch's JSON parsing and returns raw text.\n exportCsv: async (\n tenantSlug: string,\n query: AnalyticsQuery,\n ): Promise<string> => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n const headers: Record<string, string> = { 'X-Tenant-Slug': tenantSlug };\n const accountId = getAccountId();\n if (accountId) headers['x-account-id'] = accountId;\n\n const res = await fetch(`${getApiUrl()}/analytics/export?${params}`, {\n headers,\n credentials: 'include',\n });\n if (!res.ok) throw new Error(`Export failed (HTTP ${res.status})`);\n return res.text();\n },\n};\n\n// ============================================\n// ORDERS API\n// ============================================\n"],"mappings":"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","names":[]}
@@ -10,7 +10,6 @@ export declare const IS_BROWSER: boolean;
10
10
  import type { FetchOptions } from './types';
11
11
  export declare const getApiUrl: () => string;
12
12
  export declare function getAccountId(): string | null;
13
- export declare function getAuthToken(): string | null;
14
13
  export declare function getCsrfToken(): string | null;
15
14
  /**
16
15
  * Thrown when the API rejects the session (401). Typed so a caller can tell an
package/dist/api/core.js CHANGED
@@ -3,6 +3,7 @@ import posthog from "posthog-js";
3
3
  import { notifyWrite } from "./cache-bus";
4
4
  import { announceFeatureLocked, featureNotInPlan } from "./feature-locked";
5
5
  import { isUnservedRoute, unservedMessage } from "./unserved-routes";
6
+ import { refreshSession, wantsRefresh } from "./session-refresh";
6
7
  import {
7
8
  isSessionExpiryResponse,
8
9
  merchantIdentity,
@@ -27,10 +28,6 @@ function getAccountId() {
27
28
  }
28
29
  return null;
29
30
  }
30
- function getAuthToken() {
31
- if (typeof window === "undefined") return null;
32
- return localStorage.getItem("pb_token");
33
- }
34
31
  function getCsrfToken() {
35
32
  if (typeof window === "undefined" || typeof document === "undefined")
36
33
  return null;
@@ -90,7 +87,7 @@ async function apiFetch(endpoint, options = {}) {
90
87
  if (tenantSlug) {
91
88
  headers["X-Tenant-Slug"] = tenantSlug;
92
89
  }
93
- const merchant = merchantIdentity(endpoint, getAccountId(), getAuthToken());
90
+ const merchant = merchantIdentity(endpoint, getAccountId(), null);
94
91
  const effectiveAccountId = accountId || merchant.accountId;
95
92
  if (effectiveAccountId) {
96
93
  headers["x-account-id"] = effectiveAccountId;
@@ -130,7 +127,7 @@ async function apiFetch(endpoint, options = {}) {
130
127
  }
131
128
  return send(url, fetchOptions, headers, isWrite && !onlyReads(endpoint));
132
129
  }
133
- async function send(url, fetchOptions, headers, isWrite) {
130
+ async function send(url, fetchOptions, headers, isWrite, retried = false) {
134
131
  const res = await fetch(url, {
135
132
  ...fetchOptions,
136
133
  headers,
@@ -138,6 +135,12 @@ async function send(url, fetchOptions, headers, isWrite) {
138
135
  // Include cookies for CSRF
139
136
  });
140
137
  if (!res.ok) {
138
+ if (res.status === 401 && !retried && wantsRefresh(url)) {
139
+ const body = await res.clone().json().catch(() => null);
140
+ if (wantsRefresh(url, body) && await refreshSession(getApiUrl())) {
141
+ return send(url, fetchOptions, headers, isWrite, true);
142
+ }
143
+ }
141
144
  const lockedBody = res.status === 402 ? await res.clone().json().catch(() => null) : null;
142
145
  const locked = featureNotInPlan(lockedBody);
143
146
  if (locked) {
@@ -203,7 +206,6 @@ export {
203
206
  bouncedRecently,
204
207
  getAccountId,
205
208
  getApiUrl,
206
- getAuthToken,
207
209
  getCsrfToken,
208
210
  isSessionExpiryResponse2 as isSessionExpiryResponse,
209
211
  recordBounce
@@ -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 {\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
+ {"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,4 +1,4 @@
1
- import { apiFetch, getAccountId, getAuthToken, getCsrfToken } from "./core";
1
+ import { apiFetch, getAccountId, 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,8 +29,6 @@ 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}`;
34
32
  const accountId = getAccountId();
35
33
  if (accountId) headers["x-account-id"] = accountId;
36
34
  const csrfToken = getCsrfToken();
@@ -38,6 +36,7 @@ async function askPotterStream(question, history, on, signal, tenantSlug) {
38
36
  const res = await fetch("/api/proxy/potter/ask/stream", {
39
37
  method: "POST",
40
38
  headers,
39
+ credentials: "include",
41
40
  body: JSON.stringify({ question, history }),
42
41
  signal
43
42
  });
@@ -1 +1 @@
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
+ {"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":[]}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Renewing the session when its short-lived access token has run out.
3
+ *
4
+ * The engine keeps the session in HttpOnly cookies: a 15-minute access token
5
+ * and a rotating refresh token. This code cannot read either; it only asks
6
+ * `POST /accounts/refresh` to swap them when a request comes back "expired".
7
+ */
8
+ /**
9
+ * Whether a 401 from `url` could be an expired access token. Called once with
10
+ * the URL alone (to skip the body read) and again with the body.
11
+ */
12
+ export declare function wantsRefresh(url: string, body?: unknown): boolean;
13
+ /**
14
+ * Ask the engine for a new session. Every request that failed together shares
15
+ * one call. False when it cannot be renewed (refused, or an engine without the
16
+ * route), so the caller falls back to ending the session.
17
+ */
18
+ export declare function refreshSession(apiBase: string): Promise<boolean>;
@@ -0,0 +1,37 @@
1
+ const REFRESH_PATH = "/accounts/refresh";
2
+ const NEVER_REFRESH = [REFRESH_PATH, "/accounts/login", "/accounts/logout"];
3
+ const ADMIN_API = "/platform-admin/";
4
+ const EXPIRED = /expired|no authentication token/i;
5
+ const IDLE = /session_idle/i;
6
+ let inFlight = null;
7
+ function wantsRefresh(url, body) {
8
+ const path = new URL(url, "http://localhost").pathname;
9
+ if (path.includes(ADMIN_API)) return false;
10
+ if (NEVER_REFRESH.some((never) => path.endsWith(never))) return false;
11
+ if (body === void 0) return true;
12
+ const { message, code } = body ?? {};
13
+ const text = [code, ...Array.isArray(message) ? message : [message]].filter((part) => typeof part === "string").join(" ");
14
+ return EXPIRED.test(text) && !IDLE.test(text);
15
+ }
16
+ function refreshSession(apiBase) {
17
+ inFlight ??= renew(apiBase).finally(() => {
18
+ inFlight = null;
19
+ });
20
+ return inFlight;
21
+ }
22
+ async function renew(apiBase) {
23
+ try {
24
+ const res = await fetch(`${apiBase}${REFRESH_PATH}`, {
25
+ method: "POST",
26
+ credentials: "include"
27
+ });
28
+ return res.ok;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ export {
34
+ refreshSession,
35
+ wantsRefresh
36
+ };
37
+ //# sourceMappingURL=session-refresh.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/api/session-refresh.ts"],"sourcesContent":["/**\n * Renewing the session when its short-lived access token has run out.\n *\n * The engine keeps the session in HttpOnly cookies: a 15-minute access token\n * and a rotating refresh token. This code cannot read either; it only asks\n * `POST /accounts/refresh` to swap them when a request comes back \"expired\".\n */\n\nconst REFRESH_PATH = '/accounts/refresh';\n/** Calls whose 401 means something else (wrong details, nothing to end). */\nconst NEVER_REFRESH = [REFRESH_PATH, '/accounts/login', '/accounts/logout'];\nconst ADMIN_API = '/platform-admin/';\nconst EXPIRED = /expired|no authentication token/i;\nconst IDLE = /session_idle/i;\n\nlet inFlight: Promise<boolean> | null = null;\n\n/**\n * Whether a 401 from `url` could be an expired access token. Called once with\n * the URL alone (to skip the body read) and again with the body.\n */\nexport function wantsRefresh(url: string, body?: unknown): boolean {\n const path = new URL(url, 'http://localhost').pathname;\n if (path.includes(ADMIN_API)) return false;\n if (NEVER_REFRESH.some((never) => path.endsWith(never))) return false;\n if (body === undefined) return true;\n\n const { message, code } = (body ?? {}) as { message?: unknown; code?: unknown };\n const text = [code, ...(Array.isArray(message) ? message : [message])]\n .filter((part): part is string => typeof part === 'string')\n .join(' ');\n return EXPIRED.test(text) && !IDLE.test(text);\n}\n\n/**\n * Ask the engine for a new session. Every request that failed together shares\n * one call. False when it cannot be renewed (refused, or an engine without the\n * route), so the caller falls back to ending the session.\n */\nexport function refreshSession(apiBase: string): Promise<boolean> {\n inFlight ??= renew(apiBase).finally(() => {\n inFlight = null;\n });\n return inFlight;\n}\n\nasync function renew(apiBase: string): Promise<boolean> {\n try {\n const res = await fetch(`${apiBase}${REFRESH_PATH}`, {\n method: 'POST',\n credentials: 'include',\n });\n return res.ok;\n } catch {\n return false;\n }\n}\n"],"mappings":"AAQA,MAAM,eAAe;AAErB,MAAM,gBAAgB,CAAC,cAAc,mBAAmB,kBAAkB;AAC1E,MAAM,YAAY;AAClB,MAAM,UAAU;AAChB,MAAM,OAAO;AAEb,IAAI,WAAoC;AAMjC,SAAS,aAAa,KAAa,MAAyB;AACjE,QAAM,OAAO,IAAI,IAAI,KAAK,kBAAkB,EAAE;AAC9C,MAAI,KAAK,SAAS,SAAS,EAAG,QAAO;AACrC,MAAI,cAAc,KAAK,CAAC,UAAU,KAAK,SAAS,KAAK,CAAC,EAAG,QAAO;AAChE,MAAI,SAAS,OAAW,QAAO;AAE/B,QAAM,EAAE,SAAS,KAAK,IAAK,QAAQ,CAAC;AACpC,QAAM,OAAO,CAAC,MAAM,GAAI,MAAM,QAAQ,OAAO,IAAI,UAAU,CAAC,OAAO,CAAE,EAClE,OAAO,CAAC,SAAyB,OAAO,SAAS,QAAQ,EACzD,KAAK,GAAG;AACX,SAAO,QAAQ,KAAK,IAAI,KAAK,CAAC,KAAK,KAAK,IAAI;AAC9C;AAOO,SAAS,eAAe,SAAmC;AAChE,eAAa,MAAM,OAAO,EAAE,QAAQ,MAAM;AACxC,eAAW;AAAA,EACb,CAAC;AACD,SAAO;AACT;AAEA,eAAe,MAAM,SAAmC;AACtD,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,GAAG,OAAO,GAAG,YAAY,IAAI;AAAA,MACnD,QAAQ;AAAA,MACR,aAAa;AAAA,IACf,CAAC;AACD,WAAO,IAAI;AAAA,EACb,QAAQ;AACN,WAAO;AAAA,EACT;AACF;","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 (`pb_token`, sent as a
5
- * bearer) and the platform admin (`pb_admin_token`, an HttpOnly cookie). They
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
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