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 +29 -0
- package/dist/cli.js +132 -46
- package/dist/factory.cjs +79 -9
- package/dist/factory.cjs.map +1 -1
- package/dist/factory.d.cts +38 -1
- package/dist/factory.d.ts +38 -1
- package/dist/factory.js +79 -10
- package/dist/factory.js.map +1 -1
- package/dist/templates/api-state.tsx +249 -0
- package/dist/templates/debug-index-page.tsx +56 -36
- package/dist/templates/hub.tsx +9 -23
- package/dist/templates/khotan-config.ts +17 -0
- package/dist/templates/mapping-browser.tsx +56 -44
- package/dist/templates/plug-debugger.tsx +15 -7
- package/dist/templates/runs-table.tsx +133 -130
- package/dist/templates/skill-setup.md +37 -2
- package/dist/templates/topology-canvas.tsx +19 -30
- package/dist/templates/var-panel.tsx +33 -10
- package/dist/templates/webhook-events-table.tsx +105 -102
- package/dist/templates/wire-panel.tsx +30 -8
- package/package.json +1 -1
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,
|
|
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:
|
|
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, `${
|
|
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:
|
|
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,
|
|
1836
|
+
function diffObjectSchema(expected, actual, path16) {
|
|
1806
1837
|
if (actual.type !== "object") {
|
|
1807
1838
|
return [
|
|
1808
1839
|
{
|
|
1809
|
-
path:
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2846
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|