khotan-data 0.1.1 → 0.3.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,50 @@ 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`. A rejected request gets a
576
+ * `401` whose JSON body includes `code: "authorize_rejected"` and a `hint`
577
+ * describing the auth model (useful for programmatic callers).
578
+ *
579
+ * NOTE: `KHOTAN_SECRET` is an encryption key, NOT an HTTP credential. Sending it
580
+ * as a `Bearer` token does not authenticate a request — only `authorize` (and
581
+ * the dev-only `KhotanCLI` HMAC token used by the local CLI) can. To trigger a
582
+ * flow from outside the app, either call `khotanData.flow(name).start()` from
583
+ * server code, or send a credential your `authorize` hook accepts.
584
+ *
585
+ * The following routes are intentionally exempt and are NOT passed to
586
+ * `authorize` (they have their own protection):
587
+ * - Inbound webhooks (`POST .../webhook/:plug`) — verified per-plug via `onVerify`.
588
+ * - The cron dispatcher (`.../cron`) — protected by `CRON_SECRET`.
589
+ * - Debug routes (`.../debug...`) — gated by `KHOTAN_DEBUG` and disabled in production.
590
+ */
591
+ type KhotanAuthorize = (request: Request) => boolean | Promise<boolean>;
554
592
  interface KhotanConfig {
555
593
  adapter: KhotanAdapter;
556
594
  plugs: PlugRegistration[];
557
595
  resources?: ResourceRegistration[];
558
596
  caches?: CacheRegistration[];
559
597
  secret?: string;
598
+ /**
599
+ * Gate every management route (plugs, variables, flows, runs, wires,
600
+ * mappings, caches, resources, webhook handlers/events) behind a custom
601
+ * authorization check. Strongly recommended for any deployed app — without
602
+ * it the management API is publicly accessible. See {@link KhotanAuthorize}.
603
+ */
604
+ authorize?: KhotanAuthorize;
560
605
  }
561
606
  type KhotanHandler = (request: Request) => Promise<Response>;
562
607
  interface WireInstance {
@@ -659,4 +704,4 @@ interface NextJsRouteHandlers {
659
704
  }
660
705
  declare function toNextJsHandler(factoryHandler: KhotanHandler): NextJsRouteHandlers;
661
706
 
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 };
707
+ 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,50 @@ 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`. A rejected request gets a
576
+ * `401` whose JSON body includes `code: "authorize_rejected"` and a `hint`
577
+ * describing the auth model (useful for programmatic callers).
578
+ *
579
+ * NOTE: `KHOTAN_SECRET` is an encryption key, NOT an HTTP credential. Sending it
580
+ * as a `Bearer` token does not authenticate a request — only `authorize` (and
581
+ * the dev-only `KhotanCLI` HMAC token used by the local CLI) can. To trigger a
582
+ * flow from outside the app, either call `khotanData.flow(name).start()` from
583
+ * server code, or send a credential your `authorize` hook accepts.
584
+ *
585
+ * The following routes are intentionally exempt and are NOT passed to
586
+ * `authorize` (they have their own protection):
587
+ * - Inbound webhooks (`POST .../webhook/:plug`) — verified per-plug via `onVerify`.
588
+ * - The cron dispatcher (`.../cron`) — protected by `CRON_SECRET`.
589
+ * - Debug routes (`.../debug...`) — gated by `KHOTAN_DEBUG` and disabled in production.
590
+ */
591
+ type KhotanAuthorize = (request: Request) => boolean | Promise<boolean>;
554
592
  interface KhotanConfig {
555
593
  adapter: KhotanAdapter;
556
594
  plugs: PlugRegistration[];
557
595
  resources?: ResourceRegistration[];
558
596
  caches?: CacheRegistration[];
559
597
  secret?: string;
598
+ /**
599
+ * Gate every management route (plugs, variables, flows, runs, wires,
600
+ * mappings, caches, resources, webhook handlers/events) behind a custom
601
+ * authorization check. Strongly recommended for any deployed app — without
602
+ * it the management API is publicly accessible. See {@link KhotanAuthorize}.
603
+ */
604
+ authorize?: KhotanAuthorize;
560
605
  }
561
606
  type KhotanHandler = (request: Request) => Promise<Response>;
562
607
  interface WireInstance {
@@ -659,4 +704,4 @@ interface NextJsRouteHandlers {
659
704
  }
660
705
  declare function toNextJsHandler(factoryHandler: KhotanHandler): NextJsRouteHandlers;
661
706
 
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 };
707
+ 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,31 @@ 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(
2303
+ {
2304
+ error: "Unauthorized",
2305
+ code: "authorize_rejected",
2306
+ hint: "Management routes (/api/khotan/*) require your `authorize` hook to pass. KHOTAN_SECRET is an encryption key, not an HTTP credential \u2014 sending it as a Bearer token will not authenticate the request. To trigger a flow: call khotanData.flow(name).start() from server code (no HTTP/auth needed), or send a credential your authorize hook accepts (e.g. a session cookie or your own token). The khotan CLI authenticates automatically via a dev-only token derived from KHOTAN_SECRET."
2307
+ },
2308
+ { status: 401 }
2309
+ );
2310
+ }
2311
+ }
2232
2312
  const limit = Math.min(
2233
2313
  Math.max(
2234
2314
  Number.parseInt(url.searchParams.get("limit") ?? "20", 10) || 20,
@@ -2270,15 +2350,13 @@ function khotan(config) {
2270
2350
  return Response.json(result);
2271
2351
  }
2272
2352
  if (debugIdx !== -1 && debugIdx === segments.length - 1) {
2273
- const debugActive = process?.env?.["KHOTAN_DEBUG"];
2274
- if (!debugActive) {
2353
+ if (!isDebugEnabled()) {
2275
2354
  return Response.json({ error: "Not found" }, { status: 404 });
2276
2355
  }
2277
2356
  return Response.json({ enabled: true });
2278
2357
  }
2279
2358
  if (debugIdx !== -1 && debugIdx === segments.length - 2) {
2280
- const debugActive = process?.env?.["KHOTAN_DEBUG"];
2281
- if (!debugActive) {
2359
+ if (!isDebugEnabled()) {
2282
2360
  return Response.json({ error: "Not found" }, { status: 404 });
2283
2361
  }
2284
2362
  const plugName = segments[debugIdx + 1];
@@ -2580,7 +2658,6 @@ function khotan(config) {
2580
2658
  const result = await dispatchScheduledFlows({ runType });
2581
2659
  return Response.json(result);
2582
2660
  }
2583
- const webhookIdx = segments.indexOf("webhook");
2584
2661
  if (webhookIdx !== -1 && webhookIdx === segments.length - 2) {
2585
2662
  const plugName = segments[webhookIdx + 1];
2586
2663
  const plugReg = plugs.find((p) => p.name === plugName);
@@ -2797,8 +2874,7 @@ function khotan(config) {
2797
2874
  return Response.json({ received: true }, { status: 202 });
2798
2875
  }
2799
2876
  if (debugIdx !== -1 && debugIdx === segments.length - 2) {
2800
- const debugActive = process?.env?.["KHOTAN_DEBUG"];
2801
- if (!debugActive) {
2877
+ if (!isDebugEnabled()) {
2802
2878
  return Response.json({ error: "Not found" }, { status: 404 });
2803
2879
  }
2804
2880
  const plugName = segments[debugIdx + 1];
@@ -3287,6 +3363,6 @@ function toNextJsHandler(factoryHandler) {
3287
3363
  };
3288
3364
  }
3289
3365
 
3290
- export { __setWorkflowGetRunForTests, __setWorkflowGetWritableForTests, __setWorkflowStartForTests, bindWorkflowPlug, drizzleAdapter, khotan, khotanCache, khotanMappings, sendUpdate, toNextJsHandler };
3366
+ export { __setWorkflowGetRunForTests, __setWorkflowGetWritableForTests, __setWorkflowStartForTests, bindWorkflowPlug, deriveCliToken, drizzleAdapter, khotan, khotanCache, khotanMappings, sendUpdate, toNextJsHandler };
3291
3367
  //# sourceMappingURL=factory.js.map
3292
3368
  //# sourceMappingURL=factory.js.map