@clivly/sdk 0.10.8 → 0.10.9

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.
@@ -12,8 +12,18 @@ function jsonResponse(body, status) {
12
12
  function normalizeApiUrl(apiUrl) {
13
13
  return apiUrl.replace(TRAILING_SLASH, "");
14
14
  }
15
- function resolveOrigin(req) {
16
- const headerOrigin = req.headers.get("origin");
15
+ /**
16
+ * The origin *claimed* by the request, for reporting upstream.
17
+ *
18
+ * Falls back to the request URL so Clivly still receives an origin for a
19
+ * non-browser caller. This value is reported, never trusted: only a real
20
+ * `Origin` header is evidence of where a request came from, so the access
21
+ * decision uses {@link trustedOrigin} instead. Keeping the two apart is the
22
+ * point — conflating them let a caller that simply omitted the header be
23
+ * judged against the server's own origin, which is usually in the allowlist.
24
+ */
25
+ function reportedOrigin(req) {
26
+ const headerOrigin = trustedOrigin(req);
17
27
  if (headerOrigin) return headerOrigin;
18
28
  try {
19
29
  return new URL(req.url).origin;
@@ -21,6 +31,15 @@ function resolveOrigin(req) {
21
31
  return null;
22
32
  }
23
33
  }
34
+ /**
35
+ * The only origin worth an access decision: one the browser set itself.
36
+ *
37
+ * A missing header means "not a browser request we can vouch for", which is
38
+ * untrusted — never the server's own origin.
39
+ */
40
+ function trustedOrigin(req) {
41
+ return req.headers.get("origin");
42
+ }
24
43
  function isChatSessionRequestBody(value) {
25
44
  if (typeof value !== "object" || value === null) return false;
26
45
  const body = value;
@@ -31,6 +50,17 @@ function isChatSessionRequestBody(value) {
31
50
  if (typeof visitor.email !== "string" || visitor.email.trim().length === 0) return false;
32
51
  return body.visitorToken === void 0 || body.visitorToken === null || typeof body.visitorToken === "string";
33
52
  }
53
+ /**
54
+ * Judge a request against the configured allowlist.
55
+ *
56
+ * `origin` must be a *trusted* origin (see {@link trustedOrigin}) — passing
57
+ * the reported one would reinstate the bypass this guards against.
58
+ *
59
+ * An unset allowlist still admits every origin, because requiring it would
60
+ * break every existing deployment on upgrade. The unset case is instead
61
+ * warned about once at construction, so the permissive default is loud
62
+ * rather than silent.
63
+ */
34
64
  function originAllowed(origin, allowedOrigins) {
35
65
  if (!allowedOrigins || allowedOrigins.length === 0) return true;
36
66
  if (!origin) return false;
@@ -38,6 +68,7 @@ function originAllowed(origin, allowedOrigins) {
38
68
  }
39
69
  function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl = DEFAULT_API_URL, fetchImpl = fetch, resolveUser }) {
40
70
  const sessionUrl = `${normalizeApiUrl(apiUrl)}/clivly/widget/session`;
71
+ if (!allowedOrigins || allowedOrigins.length === 0) console.warn("[clivly] createChatSessionHandler has no allowedOrigins; every origin can open a chat session against this workspace. Pass allowedOrigins to restrict it.");
41
72
  return async (request) => {
42
73
  if (request.method !== "POST") return new Response(JSON.stringify({ error: "method_not_allowed" }), {
43
74
  status: 405,
@@ -46,8 +77,8 @@ function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl = DEFAULT_API
46
77
  allow: "POST"
47
78
  }
48
79
  });
49
- const origin = resolveOrigin(request);
50
- if (!originAllowed(origin, allowedOrigins)) return jsonResponse({ error: "origin_not_allowed" }, 403);
80
+ if (!originAllowed(trustedOrigin(request), allowedOrigins)) return jsonResponse({ error: "origin_not_allowed" }, 403);
81
+ const origin = reportedOrigin(request);
51
82
  let body;
52
83
  try {
53
84
  body = await request.json();
@@ -11,8 +11,18 @@ function jsonResponse(body, status) {
11
11
  function normalizeApiUrl(apiUrl) {
12
12
  return apiUrl.replace(TRAILING_SLASH, "");
13
13
  }
14
- function resolveOrigin(req) {
15
- const headerOrigin = req.headers.get("origin");
14
+ /**
15
+ * The origin *claimed* by the request, for reporting upstream.
16
+ *
17
+ * Falls back to the request URL so Clivly still receives an origin for a
18
+ * non-browser caller. This value is reported, never trusted: only a real
19
+ * `Origin` header is evidence of where a request came from, so the access
20
+ * decision uses {@link trustedOrigin} instead. Keeping the two apart is the
21
+ * point — conflating them let a caller that simply omitted the header be
22
+ * judged against the server's own origin, which is usually in the allowlist.
23
+ */
24
+ function reportedOrigin(req) {
25
+ const headerOrigin = trustedOrigin(req);
16
26
  if (headerOrigin) return headerOrigin;
17
27
  try {
18
28
  return new URL(req.url).origin;
@@ -20,6 +30,15 @@ function resolveOrigin(req) {
20
30
  return null;
21
31
  }
22
32
  }
33
+ /**
34
+ * The only origin worth an access decision: one the browser set itself.
35
+ *
36
+ * A missing header means "not a browser request we can vouch for", which is
37
+ * untrusted — never the server's own origin.
38
+ */
39
+ function trustedOrigin(req) {
40
+ return req.headers.get("origin");
41
+ }
23
42
  function isChatSessionRequestBody(value) {
24
43
  if (typeof value !== "object" || value === null) return false;
25
44
  const body = value;
@@ -30,6 +49,17 @@ function isChatSessionRequestBody(value) {
30
49
  if (typeof visitor.email !== "string" || visitor.email.trim().length === 0) return false;
31
50
  return body.visitorToken === void 0 || body.visitorToken === null || typeof body.visitorToken === "string";
32
51
  }
52
+ /**
53
+ * Judge a request against the configured allowlist.
54
+ *
55
+ * `origin` must be a *trusted* origin (see {@link trustedOrigin}) — passing
56
+ * the reported one would reinstate the bypass this guards against.
57
+ *
58
+ * An unset allowlist still admits every origin, because requiring it would
59
+ * break every existing deployment on upgrade. The unset case is instead
60
+ * warned about once at construction, so the permissive default is loud
61
+ * rather than silent.
62
+ */
33
63
  function originAllowed(origin, allowedOrigins) {
34
64
  if (!allowedOrigins || allowedOrigins.length === 0) return true;
35
65
  if (!origin) return false;
@@ -37,6 +67,7 @@ function originAllowed(origin, allowedOrigins) {
37
67
  }
38
68
  function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl = DEFAULT_API_URL, fetchImpl = fetch, resolveUser }) {
39
69
  const sessionUrl = `${normalizeApiUrl(apiUrl)}/clivly/widget/session`;
70
+ if (!allowedOrigins || allowedOrigins.length === 0) console.warn("[clivly] createChatSessionHandler has no allowedOrigins; every origin can open a chat session against this workspace. Pass allowedOrigins to restrict it.");
40
71
  return async (request) => {
41
72
  if (request.method !== "POST") return new Response(JSON.stringify({ error: "method_not_allowed" }), {
42
73
  status: 405,
@@ -45,8 +76,8 @@ function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl = DEFAULT_API
45
76
  allow: "POST"
46
77
  }
47
78
  });
48
- const origin = resolveOrigin(request);
49
- if (!originAllowed(origin, allowedOrigins)) return jsonResponse({ error: "origin_not_allowed" }, 403);
79
+ if (!originAllowed(trustedOrigin(request), allowedOrigins)) return jsonResponse({ error: "origin_not_allowed" }, 403);
80
+ const origin = reportedOrigin(request);
50
81
  let body;
51
82
  try {
52
83
  body = await request.json();
package/dist/index.cjs CHANGED
@@ -394,11 +394,15 @@ function errorMessage(error) {
394
394
  /**
395
395
  * The entities to ask about, in the exact terms the sync pushes them.
396
396
  *
397
- * `source` is the resolved `SyncSource.entity` — the value that arrives at
397
+ * `source` is whatever the push will actually send — the value that arrives at
398
398
  * `ingestPush` as `input.source` and is compared against `mapping.sourceTable`.
399
+ * Usually that is `SyncSource.entity`, but a projection pushes under its base
400
+ * table, so this asks `customPushSource` rather than reading `entity` directly.
401
+ *
399
402
  * It is NOT the entity config's own `source` field, which names a host table;
400
- * the two are routinely conflated, and using the wrong one would report every
401
- * correctly mapped workspace as unmapped.
403
+ * the two are routinely conflated, and using the wrong one reports a correctly
404
+ * mapped workspace as unmapped — which is exactly what happened to every
405
+ * projection until this used the push label.
402
406
  */
403
407
  function readinessEntitiesFromConfig(config) {
404
408
  const entities = [];
@@ -407,7 +411,7 @@ function readinessEntitiesFromConfig(config) {
407
411
  if (!resolved) continue;
408
412
  const objectTypeSlug = resolved.source.objectType;
409
413
  entities.push({
410
- source: resolved.source.entity,
414
+ source: customPushSource(config, resolved.source),
411
415
  concept: resolved.concept,
412
416
  ...resolved.concept === "custom" && objectTypeSlug ? { objectTypeSlug } : {}
413
417
  });
@@ -1614,6 +1618,18 @@ var TriggerRequestError = class extends Error {
1614
1618
  function triggerErrorStatus(error) {
1615
1619
  return error instanceof TriggerRequestError ? error.status : 500;
1616
1620
  }
1621
+ const SENTENCE_END_RE = /[.!?]$/;
1622
+ async function readPushFailureMessage(response) {
1623
+ if (!response || typeof response.json !== "function") return null;
1624
+ try {
1625
+ const body = await response.json();
1626
+ if (typeof body !== "object" || body === null) return null;
1627
+ const { message } = body;
1628
+ return typeof message === "string" && message.trim().length > 0 ? message : null;
1629
+ } catch {
1630
+ return null;
1631
+ }
1632
+ }
1617
1633
  const SIGNATURE_TOLERANCE_SECONDS = 300;
1618
1634
  const SIGNATURE_HEADER_RE = /^t=(\d+),v1=([0-9a-f]+)$/;
1619
1635
  const SECONDS_PER_MS = 1e3;
@@ -1836,17 +1852,27 @@ function createClivlySDK(config) {
1836
1852
  }, response);
1837
1853
  return true;
1838
1854
  }
1839
- lastPushFailure ??= { status: response?.status ?? null };
1855
+ lastPushFailure ??= {
1856
+ status: response?.status ?? null,
1857
+ message: await readPushFailureMessage(response)
1858
+ };
1840
1859
  return false;
1841
1860
  };
1842
1861
  const describePushFailure = (entities) => {
1843
1862
  const base = `Clivly sync: push failed for ${entities.join(", ")}`;
1844
1863
  const status = lastPushFailure?.status ?? null;
1864
+ const serverMessage = lastPushFailure?.message ?? null;
1845
1865
  let cause;
1846
1866
  if (status === 401 || status === 403) cause = "the API key was rejected — check CLIVLY_API_KEY is set to a valid key for this org";
1847
1867
  else if (status === null) cause = `couldn't reach Clivly at ${apiUrl} — check network access and CLIVLY_API_URL`;
1868
+ else if (serverMessage) cause = `Clivly returned HTTP ${status}: ${serverMessage}`;
1848
1869
  else cause = `Clivly returned HTTP ${status}`;
1849
- return `${base}: ${cause}. Run \`clivly doctor\` to diagnose.`;
1870
+ const separator = SENTENCE_END_RE.test(cause) ? "" : ".";
1871
+ return `${base}: ${cause}${separator} Run \`clivly doctor\` to diagnose.`;
1872
+ };
1873
+ const pushFailureError = (message) => {
1874
+ const status = lastPushFailure?.status ?? null;
1875
+ return status !== null && status >= CLIENT_ERROR_FLOOR && status < RETRYABLE_STATUS_FLOOR ? new TriggerRequestError(status, message) : new Error(message);
1850
1876
  };
1851
1877
  const syncOne = async (source, path, mode, objectType, runId, batchSizeOverride, maxRowsOverride, persistCursor = true) => {
1852
1878
  const since = mode === "full" ? null : await cursorStore.get(source.entity) ?? null;
@@ -1936,7 +1962,7 @@ function createClivlySDK(config) {
1936
1962
  failed: true,
1937
1963
  error: message
1938
1964
  };
1939
- throw new Error(message);
1965
+ throw pushFailureError(message);
1940
1966
  }
1941
1967
  lastPush = { failed: false };
1942
1968
  };
@@ -1944,7 +1970,7 @@ function createClivlySDK(config) {
1944
1970
  const resolved = resolveSampleSource(config, entityKey);
1945
1971
  if (!resolved) throw new TriggerRequestError(400, `Clivly sync: no source is configured for entity "${entityKey}". The dashboard maps this table, but clivly.config declares no matching entity — add it to \`entities\`, or remove the mapping in Integrations → Mapping.`);
1946
1972
  lastPushFailure = null;
1947
- if (!await syncOne(resolved.source, resolved.path, "delta", resolved.objectType, runId, SAMPLE_BATCH_SIZE, SAMPLE_BATCH_SIZE, false)) throw new Error(describePushFailure([resolved.source.entity]));
1973
+ if (!await syncOne(resolved.source, resolved.path, "delta", resolved.objectType, runId, SAMPLE_BATCH_SIZE, SAMPLE_BATCH_SIZE, false)) throw pushFailureError(describePushFailure([resolved.source.entity]));
1948
1974
  };
1949
1975
  const describeSyncTrigger = () => ({
1950
1976
  configured: config.syncTrigger?.url,
package/dist/index.mjs CHANGED
@@ -393,11 +393,15 @@ function errorMessage(error) {
393
393
  /**
394
394
  * The entities to ask about, in the exact terms the sync pushes them.
395
395
  *
396
- * `source` is the resolved `SyncSource.entity` — the value that arrives at
396
+ * `source` is whatever the push will actually send — the value that arrives at
397
397
  * `ingestPush` as `input.source` and is compared against `mapping.sourceTable`.
398
+ * Usually that is `SyncSource.entity`, but a projection pushes under its base
399
+ * table, so this asks `customPushSource` rather than reading `entity` directly.
400
+ *
398
401
  * It is NOT the entity config's own `source` field, which names a host table;
399
- * the two are routinely conflated, and using the wrong one would report every
400
- * correctly mapped workspace as unmapped.
402
+ * the two are routinely conflated, and using the wrong one reports a correctly
403
+ * mapped workspace as unmapped — which is exactly what happened to every
404
+ * projection until this used the push label.
401
405
  */
402
406
  function readinessEntitiesFromConfig(config) {
403
407
  const entities = [];
@@ -406,7 +410,7 @@ function readinessEntitiesFromConfig(config) {
406
410
  if (!resolved) continue;
407
411
  const objectTypeSlug = resolved.source.objectType;
408
412
  entities.push({
409
- source: resolved.source.entity,
413
+ source: customPushSource(config, resolved.source),
410
414
  concept: resolved.concept,
411
415
  ...resolved.concept === "custom" && objectTypeSlug ? { objectTypeSlug } : {}
412
416
  });
@@ -1613,6 +1617,18 @@ var TriggerRequestError = class extends Error {
1613
1617
  function triggerErrorStatus(error) {
1614
1618
  return error instanceof TriggerRequestError ? error.status : 500;
1615
1619
  }
1620
+ const SENTENCE_END_RE = /[.!?]$/;
1621
+ async function readPushFailureMessage(response) {
1622
+ if (!response || typeof response.json !== "function") return null;
1623
+ try {
1624
+ const body = await response.json();
1625
+ if (typeof body !== "object" || body === null) return null;
1626
+ const { message } = body;
1627
+ return typeof message === "string" && message.trim().length > 0 ? message : null;
1628
+ } catch {
1629
+ return null;
1630
+ }
1631
+ }
1616
1632
  const SIGNATURE_TOLERANCE_SECONDS = 300;
1617
1633
  const SIGNATURE_HEADER_RE = /^t=(\d+),v1=([0-9a-f]+)$/;
1618
1634
  const SECONDS_PER_MS = 1e3;
@@ -1835,17 +1851,27 @@ function createClivlySDK(config) {
1835
1851
  }, response);
1836
1852
  return true;
1837
1853
  }
1838
- lastPushFailure ??= { status: response?.status ?? null };
1854
+ lastPushFailure ??= {
1855
+ status: response?.status ?? null,
1856
+ message: await readPushFailureMessage(response)
1857
+ };
1839
1858
  return false;
1840
1859
  };
1841
1860
  const describePushFailure = (entities) => {
1842
1861
  const base = `Clivly sync: push failed for ${entities.join(", ")}`;
1843
1862
  const status = lastPushFailure?.status ?? null;
1863
+ const serverMessage = lastPushFailure?.message ?? null;
1844
1864
  let cause;
1845
1865
  if (status === 401 || status === 403) cause = "the API key was rejected — check CLIVLY_API_KEY is set to a valid key for this org";
1846
1866
  else if (status === null) cause = `couldn't reach Clivly at ${apiUrl} — check network access and CLIVLY_API_URL`;
1867
+ else if (serverMessage) cause = `Clivly returned HTTP ${status}: ${serverMessage}`;
1847
1868
  else cause = `Clivly returned HTTP ${status}`;
1848
- return `${base}: ${cause}. Run \`clivly doctor\` to diagnose.`;
1869
+ const separator = SENTENCE_END_RE.test(cause) ? "" : ".";
1870
+ return `${base}: ${cause}${separator} Run \`clivly doctor\` to diagnose.`;
1871
+ };
1872
+ const pushFailureError = (message) => {
1873
+ const status = lastPushFailure?.status ?? null;
1874
+ return status !== null && status >= CLIENT_ERROR_FLOOR && status < RETRYABLE_STATUS_FLOOR ? new TriggerRequestError(status, message) : new Error(message);
1849
1875
  };
1850
1876
  const syncOne = async (source, path, mode, objectType, runId, batchSizeOverride, maxRowsOverride, persistCursor = true) => {
1851
1877
  const since = mode === "full" ? null : await cursorStore.get(source.entity) ?? null;
@@ -1935,7 +1961,7 @@ function createClivlySDK(config) {
1935
1961
  failed: true,
1936
1962
  error: message
1937
1963
  };
1938
- throw new Error(message);
1964
+ throw pushFailureError(message);
1939
1965
  }
1940
1966
  lastPush = { failed: false };
1941
1967
  };
@@ -1943,7 +1969,7 @@ function createClivlySDK(config) {
1943
1969
  const resolved = resolveSampleSource(config, entityKey);
1944
1970
  if (!resolved) throw new TriggerRequestError(400, `Clivly sync: no source is configured for entity "${entityKey}". The dashboard maps this table, but clivly.config declares no matching entity — add it to \`entities\`, or remove the mapping in Integrations → Mapping.`);
1945
1971
  lastPushFailure = null;
1946
- if (!await syncOne(resolved.source, resolved.path, "delta", resolved.objectType, runId, SAMPLE_BATCH_SIZE, SAMPLE_BATCH_SIZE, false)) throw new Error(describePushFailure([resolved.source.entity]));
1972
+ if (!await syncOne(resolved.source, resolved.path, "delta", resolved.objectType, runId, SAMPLE_BATCH_SIZE, SAMPLE_BATCH_SIZE, false)) throw pushFailureError(describePushFailure([resolved.source.entity]));
1947
1973
  };
1948
1974
  const describeSyncTrigger = () => ({
1949
1975
  configured: config.syncTrigger?.url,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clivly/sdk",
3
- "version": "0.10.8",
3
+ "version": "0.10.9",
4
4
  "private": false,
5
5
  "description": "Clivly SDK — connect your app to Clivly Cloud (heartbeat, discovery, auth bridge).",
6
6
  "license": "MIT",
@@ -49,10 +49,11 @@
49
49
  }
50
50
  },
51
51
  "files": [
52
- "dist"
52
+ "dist",
53
+ "README.md"
53
54
  ],
54
55
  "dependencies": {
55
- "@clivly/core": "0.10.8"
56
+ "@clivly/core": "0.10.9"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@electric-sql/pglite": "^0.5.4",