khotan-data 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,6 +53,11 @@ import { shopifyProductsSnapshotCache } from "@/lib/khotan/caches/shopify-produc
53
53
 
54
54
  const khotanData = khotan({
55
55
  adapter: drizzleAdapter(db),
56
+ // Gate the management API behind your auth layer (see "Security" below).
57
+ authorize: async (request) => {
58
+ const session = await auth.api.getSession({ headers: request.headers });
59
+ return Boolean(session?.user);
60
+ },
56
61
  resources: [
57
62
  { name: "products", mapping: { connectField: "sku" } },
58
63
  ],
@@ -79,6 +84,30 @@ await khotanData.flow("products-inflow", { plugName: "shopify" }).start({
79
84
  });
80
85
  ```
81
86
 
87
+ ## Security
88
+
89
+ The management API (`/api/khotan/*`) exposes plug credentials and operational
90
+ controls. It is **public unless you gate it**. Pass an `authorize` hook — it
91
+ receives the raw `Request` and returns `true`/`false`, so it composes directly
92
+ with session libraries like better-auth:
93
+
94
+ ```typescript
95
+ authorize: async (request) => {
96
+ const session = await auth.api.getSession({ headers: request.headers });
97
+ return session?.user?.role === "admin";
98
+ },
99
+ ```
100
+
101
+ - `KHOTAN_SECRET` encrypts plug credentials **at rest** (AES-256-GCM). It is not
102
+ an auth credential — it never gates requests. Set it to a high-entropy value.
103
+ - Inbound webhooks (verified via per-plug `onVerify`), the cron dispatcher
104
+ (`CRON_SECRET`), and debug routes (`KHOTAN_DEBUG`, non-production only) are
105
+ exempt from `authorize` automatically.
106
+ - `KHOTAN_DEBUG` is force-disabled when `NODE_ENV=production`. The cron route
107
+ fails closed in production when `CRON_SECRET` is unset.
108
+ - Protect the Hub dashboard page (e.g. `/config`) with your app's middleware —
109
+ `authorize` only guards the API.
110
+
82
111
  ## Caches
83
112
 
84
113
  Use first-class caches when a flow, relay, catch, or pass needs durable state between runs.
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import path2 from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { execSync } from 'child_process';
7
7
  import prompts2 from 'prompts';
8
+ import crypto from 'crypto';
8
9
 
9
10
  // src/cli/config-template.ts
10
11
  function configTemplate(outputDir) {
@@ -152,6 +153,11 @@ var COMPONENTS = {
152
153
  outputFile: "wires/wire.ts",
153
154
  outputBase: "outputDir"
154
155
  },
156
+ {
157
+ templatePath: path2.resolve(__dirname$1, "templates", "api-state.tsx"),
158
+ outputFile: "api-state.tsx",
159
+ outputBase: "components"
160
+ },
155
161
  {
156
162
  templatePath: path2.resolve(__dirname$1, "templates", "wire-panel.tsx"),
157
163
  outputFile: "wire.tsx",
@@ -202,6 +208,11 @@ var COMPONENTS = {
202
208
  ]
203
209
  },
204
210
  files: [
211
+ {
212
+ templatePath: path2.resolve(__dirname$1, "templates", "api-state.tsx"),
213
+ outputFile: "api-state.tsx",
214
+ outputBase: "components"
215
+ },
205
216
  {
206
217
  templatePath: path2.resolve(__dirname$1, "templates", "hub.tsx"),
207
218
  outputFile: "hub.tsx",
@@ -227,6 +238,11 @@ var COMPONENTS = {
227
238
  shadcnComponents: ["card", "table", "badge", "button"]
228
239
  },
229
240
  files: [
241
+ {
242
+ templatePath: path2.resolve(__dirname$1, "templates", "api-state.tsx"),
243
+ outputFile: "api-state.tsx",
244
+ outputBase: "components"
245
+ },
230
246
  {
231
247
  templatePath: path2.resolve(__dirname$1, "templates", "logs.tsx"),
232
248
  outputFile: "logs.tsx",
@@ -256,6 +272,11 @@ var COMPONENTS = {
256
272
  shadcnComponents: ["card", "table", "button", "input", "label"]
257
273
  },
258
274
  files: [
275
+ {
276
+ templatePath: path2.resolve(__dirname$1, "templates", "api-state.tsx"),
277
+ outputFile: "api-state.tsx",
278
+ outputBase: "components"
279
+ },
259
280
  {
260
281
  templatePath: path2.resolve(
261
282
  __dirname$1,
@@ -276,6 +297,11 @@ var COMPONENTS = {
276
297
  shadcnComponents: ["card", "badge", "button", "input", "label"]
277
298
  },
278
299
  files: [
300
+ {
301
+ templatePath: path2.resolve(__dirname$1, "templates", "api-state.tsx"),
302
+ outputFile: "api-state.tsx",
303
+ outputBase: "components"
304
+ },
279
305
  {
280
306
  templatePath: path2.resolve(__dirname$1, "templates", "plug-debugger.tsx"),
281
307
  outputFile: "plug-debugger.tsx",
@@ -523,6 +549,11 @@ var BLOCKS = {
523
549
  shadcnComponents: ["card", "badge"]
524
550
  },
525
551
  files: [
552
+ {
553
+ templatePath: path2.resolve(__dirname$1, "templates", "api-state.tsx"),
554
+ outputFile: "api-state.tsx",
555
+ outputBase: "components"
556
+ },
526
557
  {
527
558
  templatePath: path2.resolve(
528
559
  __dirname$1,
@@ -1774,13 +1805,13 @@ function diffSchemas(expected, actual, basePath = "$") {
1774
1805
  }
1775
1806
  return diffObjectSchema(expected, actual, basePath);
1776
1807
  }
1777
- function diffTypedNode(expected, actual, path15) {
1808
+ function diffTypedNode(expected, actual, path16) {
1778
1809
  const expectedType = expected["_type"];
1779
1810
  if (expectedType === "array") {
1780
1811
  if (actual.type !== "array") {
1781
1812
  return [
1782
1813
  {
1783
- path: path15,
1814
+ path: path16,
1784
1815
  issue: "type_mismatch",
1785
1816
  note: `expected array, got ${actual.type}`
1786
1817
  }
@@ -1788,13 +1819,13 @@ function diffTypedNode(expected, actual, path15) {
1788
1819
  }
1789
1820
  const itemSchema = expected["items"];
1790
1821
  if (!itemSchema || !actual.items) return [];
1791
- return diffSchemas(itemSchema, actual.items, `${path15}[]`);
1822
+ return diffSchemas(itemSchema, actual.items, `${path16}[]`);
1792
1823
  }
1793
1824
  const normalizedExpected = normalizeType(expectedType);
1794
1825
  if (normalizedExpected !== actual.type && actual.type !== "null") {
1795
1826
  return [
1796
1827
  {
1797
- path: path15,
1828
+ path: path16,
1798
1829
  issue: "type_mismatch",
1799
1830
  note: `expected ${expectedType}, got ${actual.type}`
1800
1831
  }
@@ -1802,11 +1833,11 @@ function diffTypedNode(expected, actual, path15) {
1802
1833
  }
1803
1834
  return [];
1804
1835
  }
1805
- function diffObjectSchema(expected, actual, path15) {
1836
+ function diffObjectSchema(expected, actual, path16) {
1806
1837
  if (actual.type !== "object") {
1807
1838
  return [
1808
1839
  {
1809
- path: path15,
1840
+ path: path16,
1810
1841
  issue: "type_mismatch",
1811
1842
  note: `expected object, got ${actual.type}`
1812
1843
  }
@@ -1815,7 +1846,7 @@ function diffObjectSchema(expected, actual, path15) {
1815
1846
  const mismatches = [];
1816
1847
  const actualProps = actual.properties;
1817
1848
  for (const [key, typeDesc] of Object.entries(expected)) {
1818
- const childPath = path15 === "$" ? `$.${key}` : `${path15}.${key}`;
1849
+ const childPath = path16 === "$" ? `$.${key}` : `${path16}.${key}`;
1819
1850
  const typeStr = typeof typeDesc === "string" ? typeDesc : null;
1820
1851
  const isOptional = typeStr?.endsWith("?") ?? false;
1821
1852
  if (!(key in actualProps)) {
@@ -1853,7 +1884,7 @@ function diffObjectSchema(expected, actual, path15) {
1853
1884
  }
1854
1885
  for (const key of Object.keys(actualProps)) {
1855
1886
  if (!(key in expected)) {
1856
- const childPath = path15 === "$" ? `$.${key}` : `${path15}.${key}`;
1887
+ const childPath = path16 === "$" ? `$.${key}` : `${path16}.${key}`;
1857
1888
  mismatches.push({ path: childPath, issue: "extra" });
1858
1889
  }
1859
1890
  }
@@ -1874,6 +1905,48 @@ function normalizeType(typeStr) {
1874
1905
  return lower;
1875
1906
  }
1876
1907
  }
1908
+ var CLI_TOKEN_SCHEME = "KhotanCLI";
1909
+ function readSecretFromEnvFile(filePath) {
1910
+ try {
1911
+ const content = fs4.readFileSync(filePath, "utf-8");
1912
+ for (const line of content.split("\n")) {
1913
+ const trimmed = line.trim();
1914
+ if (!trimmed || trimmed.startsWith("#")) continue;
1915
+ const match = /^KHOTAN_SECRET\s*=\s*["']?(.*?)["']?\s*$/.exec(trimmed);
1916
+ if (match) return match[1] ?? null;
1917
+ }
1918
+ } catch {
1919
+ }
1920
+ return null;
1921
+ }
1922
+ function resolveKhotanSecret(cwd = process.cwd()) {
1923
+ const fromEnv = process.env["KHOTAN_SECRET"]?.trim();
1924
+ if (fromEnv) return fromEnv;
1925
+ return readSecretFromEnvFile(path2.join(cwd, ".env.local")) ?? readSecretFromEnvFile(path2.join(cwd, ".env")) ?? null;
1926
+ }
1927
+ function cliAuthHeader() {
1928
+ const secret = resolveKhotanSecret();
1929
+ if (!secret) return null;
1930
+ const timestamp = String(Date.now());
1931
+ const sig = crypto.createHmac("sha256", secret).update(`khotan-cli:${timestamp}`).digest("hex");
1932
+ return `${CLI_TOKEN_SCHEME} ${timestamp}.${sig}`;
1933
+ }
1934
+ function cliFetch(url, init) {
1935
+ const auth = cliAuthHeader();
1936
+ if (!auth) {
1937
+ return init === void 0 ? fetch(url) : fetch(url, init);
1938
+ }
1939
+ const headers = {
1940
+ ...init?.headers ?? {},
1941
+ Authorization: auth
1942
+ };
1943
+ return fetch(url, { ...init, headers });
1944
+ }
1945
+ function unauthorizedHint() {
1946
+ return resolveKhotanSecret() ? "Unauthorized. The dev server rejected the CLI token \u2014 confirm KHOTAN_SECRET matches the value the server is running with." : "Unauthorized. Set KHOTAN_SECRET in your environment (or .env.local) so the CLI can authenticate against your authorize() hook.";
1947
+ }
1948
+
1949
+ // src/cli/commands/plug-vars.ts
1877
1950
  function output(obj) {
1878
1951
  process.stdout.write(JSON.stringify(obj, null, 2) + "\n");
1879
1952
  }
@@ -1911,13 +1984,14 @@ function resolvePort(portFlag) {
1911
1984
  async function checkConnectivity(baseUrl) {
1912
1985
  let res;
1913
1986
  try {
1914
- res = await fetch(`${baseUrl}/plugs`);
1987
+ res = await cliFetch(`${baseUrl}/plugs`);
1915
1988
  } catch {
1916
1989
  fail(
1917
1990
  "connect_failed",
1918
1991
  `Could not connect to dev server at ${baseUrl.replace("http://", "")}. Is it running?`
1919
1992
  );
1920
1993
  }
1994
+ if (res.status === 401) fail("unauthorized", unauthorizedHint());
1921
1995
  if (!res.ok) {
1922
1996
  fail(
1923
1997
  "api_unavailable",
@@ -1931,11 +2005,11 @@ var varsCommand = new Command("vars").description("View and manage plug variable
1931
2005
  const baseUrl = `http://localhost:${port}${opts.basePath}`;
1932
2006
  await checkConnectivity(baseUrl);
1933
2007
  if (opts.list) {
1934
- const plugsRes = await fetch(`${baseUrl}/plugs`);
2008
+ const plugsRes = await cliFetch(`${baseUrl}/plugs`);
1935
2009
  const plugs = await plugsRes.json();
1936
2010
  const variables = await Promise.all(
1937
2011
  plugs.map(async (plug) => {
1938
- const res = await fetch(`${baseUrl}/variables/${plug.name}`);
2012
+ const res = await cliFetch(`${baseUrl}/variables/${plug.name}`);
1939
2013
  if (res.status === 404) {
1940
2014
  return {
1941
2015
  plugName: plug.name,
@@ -1964,7 +2038,7 @@ var varsCommand = new Command("vars").description("View and manage plug variable
1964
2038
  }
1965
2039
  const resolvedAction = action ?? "show";
1966
2040
  if (resolvedAction === "show") {
1967
- const res = await fetch(`${baseUrl}/variables/${plugName}`);
2041
+ const res = await cliFetch(`${baseUrl}/variables/${plugName}`);
1968
2042
  if (res.status === 404) {
1969
2043
  fail("plug_not_found", `Plug "${plugName}" not found.`);
1970
2044
  }
@@ -1991,7 +2065,7 @@ var varsCommand = new Command("vars").description("View and manage plug variable
1991
2065
  } catch {
1992
2066
  fail("invalid_json", "Could not parse --json as JSON.");
1993
2067
  }
1994
- const res = await fetch(`${baseUrl}/variables/${plugName}`, {
2068
+ const res = await cliFetch(`${baseUrl}/variables/${plugName}`, {
1995
2069
  method: "POST",
1996
2070
  headers: { "Content-Type": "application/json" },
1997
2071
  body: JSON.stringify(payload)
@@ -2007,7 +2081,7 @@ var varsCommand = new Command("vars").description("View and manage plug variable
2007
2081
  return;
2008
2082
  }
2009
2083
  if (resolvedAction === "clear") {
2010
- const res = await fetch(`${baseUrl}/variables/${plugName}`, {
2084
+ const res = await cliFetch(`${baseUrl}/variables/${plugName}`, {
2011
2085
  method: "DELETE"
2012
2086
  });
2013
2087
  if (!res.ok && res.status !== 204) {
@@ -2075,7 +2149,7 @@ function tryParseJson(value) {
2075
2149
  async function checkConnectivity2(baseUrl) {
2076
2150
  let res;
2077
2151
  try {
2078
- res = await fetch(`${baseUrl}/debug`);
2152
+ res = await cliFetch(`${baseUrl}/debug`);
2079
2153
  } catch {
2080
2154
  fail2(
2081
2155
  "connect_failed",
@@ -2098,7 +2172,8 @@ var plugCommand = new Command("plug").alias("probe").description(
2098
2172
  if (opts.list) {
2099
2173
  await checkConnectivity2(baseUrl);
2100
2174
  try {
2101
- const res = await fetch(`${baseUrl}/plugs`);
2175
+ const res = await cliFetch(`${baseUrl}/plugs`);
2176
+ if (res.status === 401) fail2("unauthorized", unauthorizedHint());
2102
2177
  const data = await res.json();
2103
2178
  const raw = Array.isArray(data) ? data : data.plugs ?? [];
2104
2179
  const plugs = raw.map((p) => ({
@@ -2122,7 +2197,7 @@ var plugCommand = new Command("plug").alias("probe").description(
2122
2197
  await checkConnectivity2(baseUrl);
2123
2198
  if (opts.info) {
2124
2199
  try {
2125
- const res = await fetch(`${baseUrl}/debug/${plugName}`);
2200
+ const res = await cliFetch(`${baseUrl}/debug/${plugName}`);
2126
2201
  if (res.status === 404) {
2127
2202
  fail2(
2128
2203
  "plug_not_found",
@@ -2141,7 +2216,7 @@ var plugCommand = new Command("plug").alias("probe").description(
2141
2216
  let allEndpoints = null;
2142
2217
  if (opts.endpoint) {
2143
2218
  try {
2144
- const res = await fetch(`${baseUrl}/debug/${plugName}`);
2219
+ const res = await cliFetch(`${baseUrl}/debug/${plugName}`);
2145
2220
  if (res.status === 404) {
2146
2221
  fail2(
2147
2222
  "plug_not_found",
@@ -2205,7 +2280,7 @@ var plugCommand = new Command("plug").alias("probe").description(
2205
2280
  if (extraHeaders) debugPayload["headers"] = extraHeaders;
2206
2281
  let debugRes;
2207
2282
  try {
2208
- debugRes = await fetch(`${baseUrl}/debug/${plugName}`, {
2283
+ debugRes = await cliFetch(`${baseUrl}/debug/${plugName}`, {
2209
2284
  method: "POST",
2210
2285
  headers: { "Content-Type": "application/json" },
2211
2286
  body: JSON.stringify(debugPayload)
@@ -2252,7 +2327,7 @@ var plugCommand = new Command("plug").alias("probe").description(
2252
2327
  } else {
2253
2328
  if (!allEndpoints) {
2254
2329
  try {
2255
- const metaRes = await fetch(`${baseUrl}/debug/${plugName}`);
2330
+ const metaRes = await cliFetch(`${baseUrl}/debug/${plugName}`);
2256
2331
  const metaData = await metaRes.json();
2257
2332
  allEndpoints = metaData.endpoints ?? null;
2258
2333
  } catch {
@@ -2327,13 +2402,14 @@ function resolveWebhookOrigin(originFlag) {
2327
2402
  async function checkConnectivity3(baseUrl) {
2328
2403
  let res;
2329
2404
  try {
2330
- res = await fetch(`${baseUrl}/plugs`);
2405
+ res = await cliFetch(`${baseUrl}/plugs`);
2331
2406
  } catch {
2332
2407
  fail3(
2333
2408
  "connect_failed",
2334
2409
  `Could not connect to dev server at ${baseUrl.replace("http://", "")}. Is it running?`
2335
2410
  );
2336
2411
  }
2412
+ if (res.status === 401) fail3("unauthorized", unauthorizedHint());
2337
2413
  if (!res.ok) {
2338
2414
  fail3(
2339
2415
  "api_unavailable",
@@ -2355,11 +2431,11 @@ var wireCommand = new Command("wire").description(
2355
2431
  const baseUrl = `http://localhost:${port}${opts.basePath}`;
2356
2432
  await checkConnectivity3(baseUrl);
2357
2433
  if (opts.list) {
2358
- const plugsRes = await fetch(`${baseUrl}/plugs`);
2434
+ const plugsRes = await cliFetch(`${baseUrl}/plugs`);
2359
2435
  const plugs = await plugsRes.json();
2360
2436
  const wires = await Promise.all(
2361
2437
  plugs.map(async (plug) => {
2362
- const res = await fetch(`${baseUrl}/wires/${plug.name}`);
2438
+ const res = await cliFetch(`${baseUrl}/wires/${plug.name}`);
2363
2439
  const data = await res.json();
2364
2440
  return {
2365
2441
  plugName: plug.name,
@@ -2380,7 +2456,7 @@ var wireCommand = new Command("wire").description(
2380
2456
  }
2381
2457
  const resolvedAction = opts.info ? "info" : action ?? "info";
2382
2458
  if (resolvedAction === "info") {
2383
- const res = await fetch(`${baseUrl}/wires/${plugName}`);
2459
+ const res = await cliFetch(`${baseUrl}/wires/${plugName}`);
2384
2460
  if (res.status === 404) {
2385
2461
  fail3("plug_not_found", `Plug "${plugName}" not found.`);
2386
2462
  }
@@ -2395,7 +2471,7 @@ var wireCommand = new Command("wire").description(
2395
2471
  }
2396
2472
  if (resolvedAction === "connect") {
2397
2473
  const callbackUrl = opts.callbackUrl ?? `${resolveWebhookOrigin(opts.webhookOrigin)}/api/khotan/webhook/${plugName}`;
2398
- const res = await fetch(`${baseUrl}/wires/${plugName}`, {
2474
+ const res = await cliFetch(`${baseUrl}/wires/${plugName}`, {
2399
2475
  method: "POST",
2400
2476
  headers: { "Content-Type": "application/json" },
2401
2477
  body: JSON.stringify({ callbackUrl })
@@ -2419,7 +2495,7 @@ var wireCommand = new Command("wire").description(
2419
2495
  if (resolvedAction === "disconnect") {
2420
2496
  let wireId = opts.wireId;
2421
2497
  if (!wireId) {
2422
- const currentRes = await fetch(`${baseUrl}/wires/${plugName}`);
2498
+ const currentRes = await cliFetch(`${baseUrl}/wires/${plugName}`);
2423
2499
  const current = await currentRes.json();
2424
2500
  wireId = current.wire?.id;
2425
2501
  }
@@ -2429,7 +2505,7 @@ var wireCommand = new Command("wire").description(
2429
2505
  `No active wire found for "${plugName}". Use --wire-id to override.`
2430
2506
  );
2431
2507
  }
2432
- const res = await fetch(`${baseUrl}/wires/${plugName}`, {
2508
+ const res = await cliFetch(`${baseUrl}/wires/${plugName}`, {
2433
2509
  method: "DELETE",
2434
2510
  headers: { "Content-Type": "application/json" },
2435
2511
  body: JSON.stringify({ wireId })
@@ -2487,9 +2563,10 @@ function resolveBaseUrl(opts) {
2487
2563
  return `http://localhost:${resolvePort4(opts.port)}${opts.basePath}`;
2488
2564
  }
2489
2565
  async function fetchJson(url, init) {
2490
- const res = await fetch(url, init);
2566
+ const res = await cliFetch(url, init);
2491
2567
  const data = await res.json().catch(() => ({}));
2492
2568
  if (!res.ok) {
2569
+ if (res.status === 401) fail4("unauthorized", unauthorizedHint());
2493
2570
  fail4(
2494
2571
  "request_failed",
2495
2572
  data.error ?? `Request to ${url} failed with status ${res.status}`
@@ -2498,20 +2575,22 @@ async function fetchJson(url, init) {
2498
2575
  return data;
2499
2576
  }
2500
2577
  async function checkConnectivity4(baseUrl) {
2578
+ let res;
2501
2579
  try {
2502
- const res = await fetch(`${baseUrl}/flows`);
2503
- if (!res.ok) {
2504
- fail4(
2505
- "api_unavailable",
2506
- `Could not reach Khotan flows API at ${baseUrl}. Check your base path and dev server.`
2507
- );
2508
- }
2580
+ res = await cliFetch(`${baseUrl}/flows`);
2509
2581
  } catch {
2510
2582
  fail4(
2511
2583
  "connect_failed",
2512
2584
  `Could not connect to dev server at ${baseUrl.replace("http://", "")}. Is it running?`
2513
2585
  );
2514
2586
  }
2587
+ if (res.status === 401) fail4("unauthorized", unauthorizedHint());
2588
+ if (!res.ok) {
2589
+ fail4(
2590
+ "api_unavailable",
2591
+ `Could not reach Khotan flows API at ${baseUrl}. Check your base path and dev server.`
2592
+ );
2593
+ }
2515
2594
  }
2516
2595
  function parseJsonOption(value, label) {
2517
2596
  if (!value) return void 0;
@@ -2660,9 +2739,10 @@ function resolveBaseUrl2(opts) {
2660
2739
  return `http://localhost:${resolvePort5(opts.port)}${opts.basePath}`;
2661
2740
  }
2662
2741
  async function fetchJson2(url, init) {
2663
- const res = await fetch(url, init);
2742
+ const res = await cliFetch(url, init);
2664
2743
  const data = await res.json().catch(() => ({}));
2665
2744
  if (!res.ok) {
2745
+ if (res.status === 401) fail5("unauthorized", unauthorizedHint());
2666
2746
  fail5(
2667
2747
  "request_failed",
2668
2748
  data.error ?? `Request to ${url} failed with status ${res.status}`
@@ -2671,20 +2751,22 @@ async function fetchJson2(url, init) {
2671
2751
  return data;
2672
2752
  }
2673
2753
  async function checkConnectivity5(baseUrl) {
2754
+ let res;
2674
2755
  try {
2675
- const res = await fetch(`${baseUrl}/flows`);
2676
- if (!res.ok) {
2677
- fail5(
2678
- "api_unavailable",
2679
- `Could not reach Khotan flows API at ${baseUrl}. Check your base path and dev server.`
2680
- );
2681
- }
2756
+ res = await cliFetch(`${baseUrl}/flows`);
2682
2757
  } catch {
2683
2758
  fail5(
2684
2759
  "connect_failed",
2685
2760
  `Could not connect to dev server at ${baseUrl.replace("http://", "")}. Is it running?`
2686
2761
  );
2687
2762
  }
2763
+ if (res.status === 401) fail5("unauthorized", unauthorizedHint());
2764
+ if (!res.ok) {
2765
+ fail5(
2766
+ "api_unavailable",
2767
+ `Could not reach Khotan flows API at ${baseUrl}. Check your base path and dev server.`
2768
+ );
2769
+ }
2688
2770
  }
2689
2771
  function parseJsonObjectOption(value, label) {
2690
2772
  if (!value) return void 0;
@@ -2842,10 +2924,14 @@ withApiOptions2(
2842
2924
  ).action(async (mappingId, opts) => {
2843
2925
  const baseUrl = resolveBaseUrl2(opts);
2844
2926
  await checkConnectivity5(baseUrl);
2845
- const res = await fetch(`${baseUrl}/mappings/${encodeURIComponent(mappingId)}`, {
2846
- method: "DELETE"
2847
- });
2927
+ const res = await cliFetch(
2928
+ `${baseUrl}/mappings/${encodeURIComponent(mappingId)}`,
2929
+ {
2930
+ method: "DELETE"
2931
+ }
2932
+ );
2848
2933
  if (!res.ok) {
2934
+ if (res.status === 401) fail5("unauthorized", unauthorizedHint());
2849
2935
  const data = await res.json().catch(() => ({}));
2850
2936
  fail5(
2851
2937
  "request_failed",
package/dist/factory.cjs CHANGED
@@ -261,6 +261,47 @@ function hexToBytes(hex) {
261
261
  function bytesToHex(bytes) {
262
262
  return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
263
263
  }
264
+ var CLI_TOKEN_SCHEME = "KhotanCLI";
265
+ var CLI_TOKEN_WINDOW_MS = 6e4;
266
+ async function deriveCliToken(secret, timestamp2) {
267
+ const key = await crypto.subtle.importKey(
268
+ "raw",
269
+ new TextEncoder().encode(secret),
270
+ { name: "HMAC", hash: "SHA-256" },
271
+ false,
272
+ ["sign"]
273
+ );
274
+ const sig = await crypto.subtle.sign(
275
+ "HMAC",
276
+ key,
277
+ new TextEncoder().encode(`khotan-cli:${timestamp2}`)
278
+ );
279
+ return bytesToHex(new Uint8Array(sig));
280
+ }
281
+ function timingSafeEqualHex(a, b) {
282
+ if (a.length !== b.length) return false;
283
+ let diff = 0;
284
+ for (let i = 0; i < a.length; i++) {
285
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
286
+ }
287
+ return diff === 0;
288
+ }
289
+ async function isCliRequestAuthorized(request, secret) {
290
+ if (process.env["NODE_ENV"] === "production") return false;
291
+ if (!secret) return false;
292
+ const header = request.headers.get("authorization");
293
+ if (!header?.startsWith(`${CLI_TOKEN_SCHEME} `)) return false;
294
+ const token = header.slice(CLI_TOKEN_SCHEME.length + 1).trim();
295
+ const dotIdx = token.indexOf(".");
296
+ if (dotIdx === -1) return false;
297
+ const timestamp2 = token.slice(0, dotIdx);
298
+ const provided = token.slice(dotIdx + 1);
299
+ const ts = Number.parseInt(timestamp2, 10);
300
+ if (!Number.isFinite(ts)) return false;
301
+ if (Math.abs(Date.now() - ts) > CLI_TOKEN_WINDOW_MS) return false;
302
+ const expected = await deriveCliToken(secret, timestamp2);
303
+ return timingSafeEqualHex(provided, expected);
304
+ }
264
305
  function bindPlugWithVars(plug, vars, setVars) {
265
306
  const opts = (extra) => ({
266
307
  ...extra,
@@ -1106,9 +1147,14 @@ function coerceDate(value) {
1106
1147
  }
1107
1148
  function isCronRequestAuthorized(request) {
1108
1149
  const secret = process.env["CRON_SECRET"]?.trim();
1109
- if (!secret) return true;
1150
+ if (!secret) {
1151
+ return process.env["NODE_ENV"] !== "production";
1152
+ }
1110
1153
  return request.headers.get("authorization") === `Bearer ${secret}`;
1111
1154
  }
1155
+ function isDebugEnabled() {
1156
+ return Boolean(process?.env?.["KHOTAN_DEBUG"]) && process?.env?.["NODE_ENV"] !== "production";
1157
+ }
1112
1158
  function isWorkflowCancelledError(error) {
1113
1159
  if (!error || typeof error !== "object") return false;
1114
1160
  const record = error;
@@ -1402,8 +1448,18 @@ function canonicalizeConnectValue(resource, connectValue) {
1402
1448
  );
1403
1449
  }
1404
1450
  function khotan(config) {
1405
- const { adapter, plugs, resources = [], caches = [] } = config;
1451
+ const { adapter, plugs, resources = [], caches = [], authorize } = config;
1406
1452
  const instanceId = crypto.randomUUID();
1453
+ if (!authorize) {
1454
+ console.warn(
1455
+ "[khotan] No `authorize` hook configured: the management API (/api/khotan/*) is publicly accessible. Pass `authorize` to gate it behind your auth layer (e.g. better-auth). This is required for any deployed environment."
1456
+ );
1457
+ }
1458
+ if (!(config.secret ?? process.env["KHOTAN_SECRET"])) {
1459
+ console.warn(
1460
+ "[khotan] No `secret`/`KHOTAN_SECRET` configured: plug credentials and wire metadata will not be encrypted at rest. Set KHOTAN_SECRET to a high-entropy value."
1461
+ );
1462
+ }
1407
1463
  const plugNames = /* @__PURE__ */ new Set();
1408
1464
  for (const plug of plugs) {
1409
1465
  if (plugNames.has(plug.name)) {
@@ -2230,7 +2286,24 @@ function khotan(config) {
2230
2286
  const webhookEventsIdx = segments.indexOf("webhook-events");
2231
2287
  const variablesIdx = segments.indexOf("variables");
2232
2288
  const cronIdx = segments.indexOf("cron");
2289
+ const webhookIdx = segments.indexOf("webhook");
2233
2290
  const debugIdx = segments.indexOf("debug");
2291
+ const isInboundWebhook = webhookIdx !== -1 && webhookIdx === segments.length - 2;
2292
+ const isCronRoute = cronIdx !== -1 && cronIdx === segments.length - 1;
2293
+ const isDebugRoute = debugIdx !== -1;
2294
+ if (authorize && !isInboundWebhook && !isCronRoute && !isDebugRoute) {
2295
+ let allowed = await isCliRequestAuthorized(request, secret);
2296
+ if (!allowed) {
2297
+ try {
2298
+ allowed = await authorize(request);
2299
+ } catch {
2300
+ allowed = false;
2301
+ }
2302
+ }
2303
+ if (!allowed) {
2304
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
2305
+ }
2306
+ }
2234
2307
  const limit = Math.min(
2235
2308
  Math.max(
2236
2309
  Number.parseInt(url.searchParams.get("limit") ?? "20", 10) || 20,
@@ -2272,15 +2345,13 @@ function khotan(config) {
2272
2345
  return Response.json(result);
2273
2346
  }
2274
2347
  if (debugIdx !== -1 && debugIdx === segments.length - 1) {
2275
- const debugActive = process?.env?.["KHOTAN_DEBUG"];
2276
- if (!debugActive) {
2348
+ if (!isDebugEnabled()) {
2277
2349
  return Response.json({ error: "Not found" }, { status: 404 });
2278
2350
  }
2279
2351
  return Response.json({ enabled: true });
2280
2352
  }
2281
2353
  if (debugIdx !== -1 && debugIdx === segments.length - 2) {
2282
- const debugActive = process?.env?.["KHOTAN_DEBUG"];
2283
- if (!debugActive) {
2354
+ if (!isDebugEnabled()) {
2284
2355
  return Response.json({ error: "Not found" }, { status: 404 });
2285
2356
  }
2286
2357
  const plugName = segments[debugIdx + 1];
@@ -2582,7 +2653,6 @@ function khotan(config) {
2582
2653
  const result = await dispatchScheduledFlows({ runType });
2583
2654
  return Response.json(result);
2584
2655
  }
2585
- const webhookIdx = segments.indexOf("webhook");
2586
2656
  if (webhookIdx !== -1 && webhookIdx === segments.length - 2) {
2587
2657
  const plugName = segments[webhookIdx + 1];
2588
2658
  const plugReg = plugs.find((p) => p.name === plugName);
@@ -2799,8 +2869,7 @@ function khotan(config) {
2799
2869
  return Response.json({ received: true }, { status: 202 });
2800
2870
  }
2801
2871
  if (debugIdx !== -1 && debugIdx === segments.length - 2) {
2802
- const debugActive = process?.env?.["KHOTAN_DEBUG"];
2803
- if (!debugActive) {
2872
+ if (!isDebugEnabled()) {
2804
2873
  return Response.json({ error: "Not found" }, { status: 404 });
2805
2874
  }
2806
2875
  const plugName = segments[debugIdx + 1];
@@ -3293,6 +3362,7 @@ exports.__setWorkflowGetRunForTests = __setWorkflowGetRunForTests;
3293
3362
  exports.__setWorkflowGetWritableForTests = __setWorkflowGetWritableForTests;
3294
3363
  exports.__setWorkflowStartForTests = __setWorkflowStartForTests;
3295
3364
  exports.bindWorkflowPlug = bindWorkflowPlug;
3365
+ exports.deriveCliToken = deriveCliToken;
3296
3366
  exports.drizzleAdapter = drizzleAdapter;
3297
3367
  exports.khotan = khotan;
3298
3368
  exports.khotanCache = khotanCache;