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.
@@ -1,5 +1,12 @@
1
1
  import { PgDatabase } from 'drizzle-orm/pg-core';
2
2
 
3
+ /**
4
+ * Derives the CLI auth token: HMAC-SHA256 over the timestamp, keyed by the
5
+ * KHOTAN_SECRET. One-way, so the raw secret (the encryption key) never travels
6
+ * over the wire — even a token captured from a dev log can't be reversed into
7
+ * the secret. Exported so the CLI can compute the same value.
8
+ */
9
+ declare function deriveCliToken(secret: string, timestamp: string): Promise<string>;
3
10
  type ResourceConnectField = string | [string, ...string[]];
4
11
  interface ResourcePlugParticipation {
5
12
  uniqueIdentifier: string;
@@ -551,12 +558,42 @@ interface KhotanAdapter {
551
558
  lastRunStatus: KhotanTerminalRunStatus;
552
559
  }): Promise<void>;
553
560
  }
561
+ /**
562
+ * Authorize an incoming request to the khotan management API.
563
+ *
564
+ * Return `true` to allow the request, `false` to reject it with `401`.
565
+ * The function receives the raw `Request`, so it composes directly with
566
+ * session libraries such as better-auth:
567
+ *
568
+ * ```ts
569
+ * authorize: async (request) => {
570
+ * const session = await auth.api.getSession({ headers: request.headers });
571
+ * return session?.user?.role === "admin";
572
+ * }
573
+ * ```
574
+ *
575
+ * Throwing is treated the same as returning `false`.
576
+ *
577
+ * The following routes are intentionally exempt and are NOT passed to
578
+ * `authorize` (they have their own protection):
579
+ * - Inbound webhooks (`POST .../webhook/:plug`) — verified per-plug via `onVerify`.
580
+ * - The cron dispatcher (`.../cron`) — protected by `CRON_SECRET`.
581
+ * - Debug routes (`.../debug...`) — gated by `KHOTAN_DEBUG` and disabled in production.
582
+ */
583
+ type KhotanAuthorize = (request: Request) => boolean | Promise<boolean>;
554
584
  interface KhotanConfig {
555
585
  adapter: KhotanAdapter;
556
586
  plugs: PlugRegistration[];
557
587
  resources?: ResourceRegistration[];
558
588
  caches?: CacheRegistration[];
559
589
  secret?: string;
590
+ /**
591
+ * Gate every management route (plugs, variables, flows, runs, wires,
592
+ * mappings, caches, resources, webhook handlers/events) behind a custom
593
+ * authorization check. Strongly recommended for any deployed app — without
594
+ * it the management API is publicly accessible. See {@link KhotanAuthorize}.
595
+ */
596
+ authorize?: KhotanAuthorize;
560
597
  }
561
598
  type KhotanHandler = (request: Request) => Promise<Response>;
562
599
  interface WireInstance {
@@ -659,4 +696,4 @@ interface NextJsRouteHandlers {
659
696
  }
660
697
  declare function toNextJsHandler(factoryHandler: KhotanHandler): NextJsRouteHandlers;
661
698
 
662
- export { type BindablePlug, type BoundPlug, type CacheEntryRecord, type CacheInstance, type CacheRegistration, type CacheScope, type CatchRegistration, type CatchWorkflowContext, type FlowInstance, type FlowRegistration, type FlowRunContext, type FlowRunResult, type FlowSelectorOptions, type FlowStartOptions, type FlowType, type FlowWorkflowContext, type KhotanAdapter, type KhotanConfig, type KhotanHandler, type KhotanInstance, type KhotanRunStatus, type KhotanRunUpdate, type KhotanTerminalRunStatus, type PassRegistration, type PassWorkflowContext, type PlugRegistration, type ResourceConnectField, type ResourceMappingRegistration, type ResourcePlugParticipation, type ResourceRegistration, type VarField, type WebhookRegistration, type WireInstance, type WireRegistration, type WireSubscribeContext, type WireUnsubscribeContext, type WireVerifyContext, __setWorkflowGetRunForTests, __setWorkflowGetWritableForTests, __setWorkflowStartForTests, bindWorkflowPlug, drizzleAdapter, khotan, khotanCache, khotanMappings, sendUpdate, toNextJsHandler };
699
+ export { type BindablePlug, type BoundPlug, type CacheEntryRecord, type CacheInstance, type CacheRegistration, type CacheScope, type CatchRegistration, type CatchWorkflowContext, type FlowInstance, type FlowRegistration, type FlowRunContext, type FlowRunResult, type FlowSelectorOptions, type FlowStartOptions, type FlowType, type FlowWorkflowContext, type KhotanAdapter, type KhotanAuthorize, type KhotanConfig, type KhotanHandler, type KhotanInstance, type KhotanRunStatus, type KhotanRunUpdate, type KhotanTerminalRunStatus, type PassRegistration, type PassWorkflowContext, type PlugRegistration, type ResourceConnectField, type ResourceMappingRegistration, type ResourcePlugParticipation, type ResourceRegistration, type VarField, type WebhookRegistration, type WireInstance, type WireRegistration, type WireSubscribeContext, type WireUnsubscribeContext, type WireVerifyContext, __setWorkflowGetRunForTests, __setWorkflowGetWritableForTests, __setWorkflowStartForTests, bindWorkflowPlug, deriveCliToken, drizzleAdapter, khotan, khotanCache, khotanMappings, sendUpdate, toNextJsHandler };
package/dist/factory.d.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import { PgDatabase } from 'drizzle-orm/pg-core';
2
2
 
3
+ /**
4
+ * Derives the CLI auth token: HMAC-SHA256 over the timestamp, keyed by the
5
+ * KHOTAN_SECRET. One-way, so the raw secret (the encryption key) never travels
6
+ * over the wire — even a token captured from a dev log can't be reversed into
7
+ * the secret. Exported so the CLI can compute the same value.
8
+ */
9
+ declare function deriveCliToken(secret: string, timestamp: string): Promise<string>;
3
10
  type ResourceConnectField = string | [string, ...string[]];
4
11
  interface ResourcePlugParticipation {
5
12
  uniqueIdentifier: string;
@@ -551,12 +558,42 @@ interface KhotanAdapter {
551
558
  lastRunStatus: KhotanTerminalRunStatus;
552
559
  }): Promise<void>;
553
560
  }
561
+ /**
562
+ * Authorize an incoming request to the khotan management API.
563
+ *
564
+ * Return `true` to allow the request, `false` to reject it with `401`.
565
+ * The function receives the raw `Request`, so it composes directly with
566
+ * session libraries such as better-auth:
567
+ *
568
+ * ```ts
569
+ * authorize: async (request) => {
570
+ * const session = await auth.api.getSession({ headers: request.headers });
571
+ * return session?.user?.role === "admin";
572
+ * }
573
+ * ```
574
+ *
575
+ * Throwing is treated the same as returning `false`.
576
+ *
577
+ * The following routes are intentionally exempt and are NOT passed to
578
+ * `authorize` (they have their own protection):
579
+ * - Inbound webhooks (`POST .../webhook/:plug`) — verified per-plug via `onVerify`.
580
+ * - The cron dispatcher (`.../cron`) — protected by `CRON_SECRET`.
581
+ * - Debug routes (`.../debug...`) — gated by `KHOTAN_DEBUG` and disabled in production.
582
+ */
583
+ type KhotanAuthorize = (request: Request) => boolean | Promise<boolean>;
554
584
  interface KhotanConfig {
555
585
  adapter: KhotanAdapter;
556
586
  plugs: PlugRegistration[];
557
587
  resources?: ResourceRegistration[];
558
588
  caches?: CacheRegistration[];
559
589
  secret?: string;
590
+ /**
591
+ * Gate every management route (plugs, variables, flows, runs, wires,
592
+ * mappings, caches, resources, webhook handlers/events) behind a custom
593
+ * authorization check. Strongly recommended for any deployed app — without
594
+ * it the management API is publicly accessible. See {@link KhotanAuthorize}.
595
+ */
596
+ authorize?: KhotanAuthorize;
560
597
  }
561
598
  type KhotanHandler = (request: Request) => Promise<Response>;
562
599
  interface WireInstance {
@@ -659,4 +696,4 @@ interface NextJsRouteHandlers {
659
696
  }
660
697
  declare function toNextJsHandler(factoryHandler: KhotanHandler): NextJsRouteHandlers;
661
698
 
662
- export { type BindablePlug, type BoundPlug, type CacheEntryRecord, type CacheInstance, type CacheRegistration, type CacheScope, type CatchRegistration, type CatchWorkflowContext, type FlowInstance, type FlowRegistration, type FlowRunContext, type FlowRunResult, type FlowSelectorOptions, type FlowStartOptions, type FlowType, type FlowWorkflowContext, type KhotanAdapter, type KhotanConfig, type KhotanHandler, type KhotanInstance, type KhotanRunStatus, type KhotanRunUpdate, type KhotanTerminalRunStatus, type PassRegistration, type PassWorkflowContext, type PlugRegistration, type ResourceConnectField, type ResourceMappingRegistration, type ResourcePlugParticipation, type ResourceRegistration, type VarField, type WebhookRegistration, type WireInstance, type WireRegistration, type WireSubscribeContext, type WireUnsubscribeContext, type WireVerifyContext, __setWorkflowGetRunForTests, __setWorkflowGetWritableForTests, __setWorkflowStartForTests, bindWorkflowPlug, drizzleAdapter, khotan, khotanCache, khotanMappings, sendUpdate, toNextJsHandler };
699
+ export { type BindablePlug, type BoundPlug, type CacheEntryRecord, type CacheInstance, type CacheRegistration, type CacheScope, type CatchRegistration, type CatchWorkflowContext, type FlowInstance, type FlowRegistration, type FlowRunContext, type FlowRunResult, type FlowSelectorOptions, type FlowStartOptions, type FlowType, type FlowWorkflowContext, type KhotanAdapter, type KhotanAuthorize, type KhotanConfig, type KhotanHandler, type KhotanInstance, type KhotanRunStatus, type KhotanRunUpdate, type KhotanTerminalRunStatus, type PassRegistration, type PassWorkflowContext, type PlugRegistration, type ResourceConnectField, type ResourceMappingRegistration, type ResourcePlugParticipation, type ResourceRegistration, type VarField, type WebhookRegistration, type WireInstance, type WireRegistration, type WireSubscribeContext, type WireUnsubscribeContext, type WireVerifyContext, __setWorkflowGetRunForTests, __setWorkflowGetWritableForTests, __setWorkflowStartForTests, bindWorkflowPlug, deriveCliToken, drizzleAdapter, khotan, khotanCache, khotanMappings, sendUpdate, toNextJsHandler };
package/dist/factory.js CHANGED
@@ -259,6 +259,47 @@ function hexToBytes(hex) {
259
259
  function bytesToHex(bytes) {
260
260
  return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
261
261
  }
262
+ var CLI_TOKEN_SCHEME = "KhotanCLI";
263
+ var CLI_TOKEN_WINDOW_MS = 6e4;
264
+ async function deriveCliToken(secret, timestamp2) {
265
+ const key = await crypto.subtle.importKey(
266
+ "raw",
267
+ new TextEncoder().encode(secret),
268
+ { name: "HMAC", hash: "SHA-256" },
269
+ false,
270
+ ["sign"]
271
+ );
272
+ const sig = await crypto.subtle.sign(
273
+ "HMAC",
274
+ key,
275
+ new TextEncoder().encode(`khotan-cli:${timestamp2}`)
276
+ );
277
+ return bytesToHex(new Uint8Array(sig));
278
+ }
279
+ function timingSafeEqualHex(a, b) {
280
+ if (a.length !== b.length) return false;
281
+ let diff = 0;
282
+ for (let i = 0; i < a.length; i++) {
283
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
284
+ }
285
+ return diff === 0;
286
+ }
287
+ async function isCliRequestAuthorized(request, secret) {
288
+ if (process.env["NODE_ENV"] === "production") return false;
289
+ if (!secret) return false;
290
+ const header = request.headers.get("authorization");
291
+ if (!header?.startsWith(`${CLI_TOKEN_SCHEME} `)) return false;
292
+ const token = header.slice(CLI_TOKEN_SCHEME.length + 1).trim();
293
+ const dotIdx = token.indexOf(".");
294
+ if (dotIdx === -1) return false;
295
+ const timestamp2 = token.slice(0, dotIdx);
296
+ const provided = token.slice(dotIdx + 1);
297
+ const ts = Number.parseInt(timestamp2, 10);
298
+ if (!Number.isFinite(ts)) return false;
299
+ if (Math.abs(Date.now() - ts) > CLI_TOKEN_WINDOW_MS) return false;
300
+ const expected = await deriveCliToken(secret, timestamp2);
301
+ return timingSafeEqualHex(provided, expected);
302
+ }
262
303
  function bindPlugWithVars(plug, vars, setVars) {
263
304
  const opts = (extra) => ({
264
305
  ...extra,
@@ -1104,9 +1145,14 @@ function coerceDate(value) {
1104
1145
  }
1105
1146
  function isCronRequestAuthorized(request) {
1106
1147
  const secret = process.env["CRON_SECRET"]?.trim();
1107
- if (!secret) return true;
1148
+ if (!secret) {
1149
+ return process.env["NODE_ENV"] !== "production";
1150
+ }
1108
1151
  return request.headers.get("authorization") === `Bearer ${secret}`;
1109
1152
  }
1153
+ function isDebugEnabled() {
1154
+ return Boolean(process?.env?.["KHOTAN_DEBUG"]) && process?.env?.["NODE_ENV"] !== "production";
1155
+ }
1110
1156
  function isWorkflowCancelledError(error) {
1111
1157
  if (!error || typeof error !== "object") return false;
1112
1158
  const record = error;
@@ -1400,8 +1446,18 @@ function canonicalizeConnectValue(resource, connectValue) {
1400
1446
  );
1401
1447
  }
1402
1448
  function khotan(config) {
1403
- const { adapter, plugs, resources = [], caches = [] } = config;
1449
+ const { adapter, plugs, resources = [], caches = [], authorize } = config;
1404
1450
  const instanceId = crypto.randomUUID();
1451
+ if (!authorize) {
1452
+ console.warn(
1453
+ "[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."
1454
+ );
1455
+ }
1456
+ if (!(config.secret ?? process.env["KHOTAN_SECRET"])) {
1457
+ console.warn(
1458
+ "[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."
1459
+ );
1460
+ }
1405
1461
  const plugNames = /* @__PURE__ */ new Set();
1406
1462
  for (const plug of plugs) {
1407
1463
  if (plugNames.has(plug.name)) {
@@ -2228,7 +2284,24 @@ function khotan(config) {
2228
2284
  const webhookEventsIdx = segments.indexOf("webhook-events");
2229
2285
  const variablesIdx = segments.indexOf("variables");
2230
2286
  const cronIdx = segments.indexOf("cron");
2287
+ const webhookIdx = segments.indexOf("webhook");
2231
2288
  const debugIdx = segments.indexOf("debug");
2289
+ const isInboundWebhook = webhookIdx !== -1 && webhookIdx === segments.length - 2;
2290
+ const isCronRoute = cronIdx !== -1 && cronIdx === segments.length - 1;
2291
+ const isDebugRoute = debugIdx !== -1;
2292
+ if (authorize && !isInboundWebhook && !isCronRoute && !isDebugRoute) {
2293
+ let allowed = await isCliRequestAuthorized(request, secret);
2294
+ if (!allowed) {
2295
+ try {
2296
+ allowed = await authorize(request);
2297
+ } catch {
2298
+ allowed = false;
2299
+ }
2300
+ }
2301
+ if (!allowed) {
2302
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
2303
+ }
2304
+ }
2232
2305
  const limit = Math.min(
2233
2306
  Math.max(
2234
2307
  Number.parseInt(url.searchParams.get("limit") ?? "20", 10) || 20,
@@ -2270,15 +2343,13 @@ function khotan(config) {
2270
2343
  return Response.json(result);
2271
2344
  }
2272
2345
  if (debugIdx !== -1 && debugIdx === segments.length - 1) {
2273
- const debugActive = process?.env?.["KHOTAN_DEBUG"];
2274
- if (!debugActive) {
2346
+ if (!isDebugEnabled()) {
2275
2347
  return Response.json({ error: "Not found" }, { status: 404 });
2276
2348
  }
2277
2349
  return Response.json({ enabled: true });
2278
2350
  }
2279
2351
  if (debugIdx !== -1 && debugIdx === segments.length - 2) {
2280
- const debugActive = process?.env?.["KHOTAN_DEBUG"];
2281
- if (!debugActive) {
2352
+ if (!isDebugEnabled()) {
2282
2353
  return Response.json({ error: "Not found" }, { status: 404 });
2283
2354
  }
2284
2355
  const plugName = segments[debugIdx + 1];
@@ -2580,7 +2651,6 @@ function khotan(config) {
2580
2651
  const result = await dispatchScheduledFlows({ runType });
2581
2652
  return Response.json(result);
2582
2653
  }
2583
- const webhookIdx = segments.indexOf("webhook");
2584
2654
  if (webhookIdx !== -1 && webhookIdx === segments.length - 2) {
2585
2655
  const plugName = segments[webhookIdx + 1];
2586
2656
  const plugReg = plugs.find((p) => p.name === plugName);
@@ -2797,8 +2867,7 @@ function khotan(config) {
2797
2867
  return Response.json({ received: true }, { status: 202 });
2798
2868
  }
2799
2869
  if (debugIdx !== -1 && debugIdx === segments.length - 2) {
2800
- const debugActive = process?.env?.["KHOTAN_DEBUG"];
2801
- if (!debugActive) {
2870
+ if (!isDebugEnabled()) {
2802
2871
  return Response.json({ error: "Not found" }, { status: 404 });
2803
2872
  }
2804
2873
  const plugName = segments[debugIdx + 1];
@@ -3287,6 +3356,6 @@ function toNextJsHandler(factoryHandler) {
3287
3356
  };
3288
3357
  }
3289
3358
 
3290
- export { __setWorkflowGetRunForTests, __setWorkflowGetWritableForTests, __setWorkflowStartForTests, bindWorkflowPlug, drizzleAdapter, khotan, khotanCache, khotanMappings, sendUpdate, toNextJsHandler };
3359
+ export { __setWorkflowGetRunForTests, __setWorkflowGetWritableForTests, __setWorkflowStartForTests, bindWorkflowPlug, deriveCliToken, drizzleAdapter, khotan, khotanCache, khotanMappings, sendUpdate, toNextJsHandler };
3291
3360
  //# sourceMappingURL=factory.js.map
3292
3361
  //# sourceMappingURL=factory.js.map