🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

otterkit

Package Overview
Dependencies
Maintainers
1
Versions
40
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

otterkit - npm Package Compare versions

Comparing version
0.27.0
to
0.29.0
+72
dist/deliver.d.ts
/**
* Local delivery (--deliver / --deliver-exec)
*
* Mirrors server-answered captures to the developer's own machine: an HTTP
* POST to a local server, a shell command per request, or both. The cloud
* endpoint has already answered the provider by the time an entry lands
* here, so delivery is strictly best-effort - a dead local server or a
* crashing command never affects the endpoint, and every capture stays in
* the log for `otterkit replay`.
*/
import { RequestEntry } from './request-log.js';
/**
* Normalize a --deliver target to a base URL. Accepts a bare port ("3000"),
* host:port, or a full http(s) URL with an optional path prefix.
* Returns null when the value parses to none of those.
*/
export declare function parseDeliverTarget(raw: string): string | null;
export type DeliveryResult = {
kind: 'http';
status: number;
durationMs: number;
} | {
kind: 'exec';
exitCode: number | null;
durationMs: number;
timedOut?: boolean;
} | {
kind: 'skipped';
reason: 'unverified';
} | {
kind: 'dropped';
count: number;
} | {
kind: 'failed';
via: 'http' | 'exec';
error: string;
suppressedSinceLastNotice?: number;
};
interface DelivererConfig {
/** Base URL from parseDeliverTarget - HTTP delivery when set. */
target?: string;
/** Shell command run per capture - exec delivery when set. */
exec?: string;
/** Only deliver captures whose signature verified valid. */
verifiedOnly?: boolean;
/** Endpoint subdomain, sent along as X-OtterKit-Endpoint / OTTERKIT_ENDPOINT. */
endpoint: string;
onResult: (entry: RequestEntry, result: DeliveryResult) => void;
}
/**
* Sequential delivery queue: one capture fully delivered (HTTP, then exec)
* before the next starts. Ordering matches arrival, and a replay burst can
* never stampede the local server.
*/
export declare class Deliverer {
private config;
private queue;
private pumping;
private droppedSinceNotice;
private consecutiveHttpFailures;
private suppressedSinceNotice;
private lastFailureNoticeAt;
constructor(config: DelivererConfig);
push(entry: RequestEntry): void;
/** Let in-flight work finish, up to graceMs. Pending queue is abandoned. */
drain(graceMs: number): Promise<void>;
private pump;
private buildMetadata;
private deliverHttp;
private deliverExec;
}
export {};
/**
* Local delivery (--deliver / --deliver-exec)
*
* Mirrors server-answered captures to the developer's own machine: an HTTP
* POST to a local server, a shell command per request, or both. The cloud
* endpoint has already answered the provider by the time an entry lands
* here, so delivery is strictly best-effort - a dead local server or a
* crashing command never affects the endpoint, and every capture stays in
* the log for `otterkit replay`.
*/
import { spawn } from 'node:child_process';
/** Headers recomputed by fetch or meaningless outside the original hop. */
const STRIP_HEADERS = new Set(['host', 'connection', 'upgrade', 'transfer-encoding', 'content-length']);
const HTTP_TIMEOUT_MS = 10_000;
const HTTP_RETRY_DELAY_MS = 500;
const EXEC_TIMEOUT_MS = 30_000;
/** Oldest entries are dropped past this - bounds a standby-replay burst. */
const QUEUE_CAP = 500;
/** After this many consecutive failures, collapse per-line errors... */
const FAILURE_SUPPRESS_AFTER = 3;
/** ...into one notice per this window. */
const FAILURE_NOTICE_INTERVAL_MS = 30_000;
/**
* Normalize a --deliver target to a base URL. Accepts a bare port ("3000"),
* host:port, or a full http(s) URL with an optional path prefix.
* Returns null when the value parses to none of those.
*/
export function parseDeliverTarget(raw) {
const value = raw.trim();
if (/^\d+$/.test(value)) {
const port = parseInt(value, 10);
if (port < 1 || port > 65535)
return null;
return `http://127.0.0.1:${port}`;
}
const withScheme = /^https?:\/\//i.test(value) ? value : `http://${value}`;
try {
const url = new URL(withScheme);
if (url.search || url.hash || url.username || url.password)
return null;
const prefix = url.pathname === '/' ? '' : url.pathname.replace(/\/$/, '');
return `${url.origin}${prefix}`;
}
catch {
return null;
}
}
/**
* Sequential delivery queue: one capture fully delivered (HTTP, then exec)
* before the next starts. Ordering matches arrival, and a replay burst can
* never stampede the local server.
*/
export class Deliverer {
config;
queue = [];
pumping = false;
droppedSinceNotice = 0;
consecutiveHttpFailures = 0;
suppressedSinceNotice = 0;
lastFailureNoticeAt = 0;
constructor(config) {
this.config = config;
}
push(entry) {
if (this.config.verifiedOnly && entry.headers['x-otterkit-verified'] !== 'valid') {
this.config.onResult(entry, { kind: 'skipped', reason: 'unverified' });
return;
}
if (this.queue.length >= QUEUE_CAP) {
this.queue.shift();
this.droppedSinceNotice++;
}
else if (this.droppedSinceNotice > 0) {
this.config.onResult(entry, { kind: 'dropped', count: this.droppedSinceNotice });
this.droppedSinceNotice = 0;
}
this.queue.push(entry);
void this.pump();
}
/** Let in-flight work finish, up to graceMs. Pending queue is abandoned. */
async drain(graceMs) {
const deadline = Date.now() + graceMs;
while (this.pumping && Date.now() < deadline) {
await sleep(50);
}
}
async pump() {
if (this.pumping)
return;
this.pumping = true;
try {
let entry;
while ((entry = this.queue.shift()) !== undefined) {
if (this.config.target)
await this.deliverHttp(entry);
if (this.config.exec)
await this.deliverExec(entry);
}
}
finally {
this.pumping = false;
}
}
buildMetadata(entry) {
const verified = entry.headers['x-otterkit-verified'];
return {
'X-OtterKit-Request-Id': entry.id ?? '',
'X-OtterKit-Received-At': entry.ts,
'X-OtterKit-Endpoint': this.config.endpoint,
...(verified ? { 'X-OtterKit-Verified': verified } : {}),
...(entry.replayed ? { 'X-OtterKit-Replayed': 'true' } : {}),
};
}
async deliverHttp(entry) {
const start = Date.now();
const url = `${this.config.target}${entry.path}`;
const headers = new Headers();
for (const [key, value] of Object.entries(entry.headers)) {
if (STRIP_HEADERS.has(key.toLowerCase()))
continue;
headers.set(key, value);
}
for (const [key, value] of Object.entries(this.buildMetadata(entry))) {
if (value)
headers.set(key, value);
}
const body = entry.body && !['GET', 'HEAD'].includes(entry.method)
? Buffer.from(entry.body, 'base64')
: undefined;
let lastError = '';
for (let attempt = 0; attempt < 2; attempt++) {
if (attempt > 0)
await sleep(HTTP_RETRY_DELAY_MS);
const abort = new AbortController();
const timer = setTimeout(() => abort.abort(), HTTP_TIMEOUT_MS);
try {
const response = await fetch(url, {
method: entry.method,
headers,
body,
signal: abort.signal,
});
// Any HTTP status is a delivery - the local server saw the request.
await response.arrayBuffer().catch(() => { });
this.consecutiveHttpFailures = 0;
this.config.onResult(entry, {
kind: 'http',
status: response.status,
durationMs: Date.now() - start,
});
return;
}
catch (err) {
lastError = describeFetchError(err);
}
finally {
clearTimeout(timer);
}
}
this.consecutiveHttpFailures++;
if (this.consecutiveHttpFailures <= FAILURE_SUPPRESS_AFTER ||
Date.now() - this.lastFailureNoticeAt >= FAILURE_NOTICE_INTERVAL_MS) {
this.lastFailureNoticeAt = Date.now();
this.config.onResult(entry, {
kind: 'failed',
via: 'http',
error: lastError,
suppressedSinceLastNotice: this.suppressedSinceNotice || undefined,
});
this.suppressedSinceNotice = 0;
}
else {
this.suppressedSinceNotice++;
}
}
deliverExec(entry) {
const start = Date.now();
return new Promise(resolve => {
const verified = entry.headers['x-otterkit-verified'] ?? '';
let child;
try {
child = spawn(this.config.exec, {
shell: true,
stdio: ['pipe', 'ignore', 'ignore'],
env: {
...process.env,
OTTERKIT_METHOD: entry.method,
OTTERKIT_PATH: entry.path,
OTTERKIT_REQUEST_ID: entry.id ?? '',
OTTERKIT_VERIFIED: verified,
OTTERKIT_RECEIVED_AT: entry.ts,
OTTERKIT_ENDPOINT: this.config.endpoint,
OTTERKIT_HEADERS: JSON.stringify(entry.headers),
},
});
}
catch (err) {
this.config.onResult(entry, {
kind: 'failed',
via: 'exec',
error: err instanceof Error ? err.message : 'spawn failed',
});
resolve();
return;
}
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGKILL');
}, EXEC_TIMEOUT_MS);
child.on('error', err => {
clearTimeout(timer);
this.config.onResult(entry, { kind: 'failed', via: 'exec', error: err.message });
resolve();
});
child.on('close', exitCode => {
clearTimeout(timer);
this.config.onResult(entry, {
kind: 'exec',
exitCode,
durationMs: Date.now() - start,
timedOut: timedOut || undefined,
});
resolve();
});
if (entry.body) {
child.stdin?.write(Buffer.from(entry.body, 'base64'));
}
child.stdin?.end();
});
}
}
function describeFetchError(err) {
if (err instanceof Error) {
if (err.name === 'AbortError')
return `timed out after ${HTTP_TIMEOUT_MS / 1000}s`;
const cause = err.cause;
return cause?.code ?? err.message;
}
return 'delivery failed';
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
+6
-0

@@ -20,2 +20,8 @@ export interface DaemonLaunchOptions {

store?: boolean;
/** Local delivery (webhook only): mirror captures to this base URL. */
deliver?: string;
/** Local delivery (webhook only): run this command per capture. */
deliverExec?: string;
/** Only deliver captures whose signature verified valid. */
verifiedOnly?: boolean;
/** Config profile name (`otterkit up`). */

@@ -22,0 +28,0 @@ profile?: string;

@@ -35,2 +35,11 @@ /**

}
// Delivery config via env like the credentials - exec commands may embed
// secrets and must never show up in `ps`.
if (mode === 'webhook' && options.deliver)
env.OTTERKIT_DELIVER = options.deliver;
if (mode === 'webhook' && options.deliverExec)
env.OTTERKIT_DELIVER_EXEC = options.deliverExec;
if (mode === 'webhook' && options.verifiedOnly && (options.deliver || options.deliverExec)) {
env.OTTERKIT_DELIVER_VERIFIED_ONLY = '1';
}
// Webhook endpoints are server-answered: the response config is endpoint

@@ -71,2 +80,3 @@ // config, applied via the agent API before the daemon even spawns.

auth: options.auth ? true : undefined,
deliver: mode === 'webhook' && (options.deliver || options.deliverExec) ? true : undefined,
profile: options.profile,

@@ -73,0 +83,0 @@ });

+4
-0

@@ -13,3 +13,7 @@ #!/usr/bin/env node

* verification - via env like the auth credentials, never argv.
* Env: OTTERKIT_DELIVER / OTTERKIT_DELIVER_EXEC / OTTERKIT_DELIVER_VERIFIED_ONLY
* arm local delivery - via env because exec commands may embed secrets.
* Delivery outcomes are silent here (stdio is detached); the capture log
* remains the record.
*/
export {};

@@ -13,2 +13,6 @@ #!/usr/bin/env node

* verification - via env like the auth credentials, never argv.
* Env: OTTERKIT_DELIVER / OTTERKIT_DELIVER_EXEC / OTTERKIT_DELIVER_VERIFIED_ONLY
* arm local delivery - via env because exec commands may embed secrets.
* Delivery outcomes are silent here (stdio is detached); the capture log
* remains the record.
*/

@@ -18,2 +22,3 @@ import { TunnelClient } from './tunnel-client.js';

import { logRequest } from './request-log.js';
import { Deliverer } from './deliver.js';
const args = process.argv.slice(2);

@@ -33,2 +38,13 @@ const captureMode = args.includes('--capture');

: undefined;
const deliverTarget = process.env.OTTERKIT_DELIVER;
const deliverExec = process.env.OTTERKIT_DELIVER_EXEC;
const deliverer = captureMode && (deliverTarget || deliverExec)
? new Deliverer({
target: deliverTarget,
exec: deliverExec,
verifiedOnly: process.env.OTTERKIT_DELIVER_VERIFIED_ONLY === '1',
endpoint: subdomain ?? '',
onResult: () => { },
})
: null;
async function run() {

@@ -53,2 +69,3 @@ const client = new TunnelClient({

logRequest(subdomain, entry).catch(() => { });
deliverer?.push(entry);
}

@@ -73,2 +90,3 @@ : undefined,

clearTimeout(ttlTimer);
await deliverer?.drain(5000);
await client.disconnect();

@@ -75,0 +93,0 @@ removeDaemon(subdomain);

@@ -21,2 +21,4 @@ /**

auth?: boolean;
/** Local delivery is armed. The target/command live only in the worker env. */
deliver?: boolean;
/** Config profile name this daemon was started from (`otterkit up`). */

@@ -23,0 +25,0 @@ profile?: string;

@@ -38,3 +38,29 @@ export interface StoredRequest {

}>;
export interface SchemaInferResult {
subdomain: string;
analyzed: number;
skipped: {
nonJson: number;
binary: number;
empty: number;
truncated: number;
overBudget: number;
};
groups: {
path: string;
discriminator?: string;
discriminatorValue?: string;
count: number;
name: string;
jsonSchema: unknown;
typescript: string;
}[];
}
/**
* Infer JSON Schema + TypeScript types from a session's stored request
* bodies. Same token-authed surface and error mapping as fetchHistory; the
* inference runs server-side over the stored history.
*/
export declare function fetchSchema(subdomain: string, filters?: Omit<HistoryFilters, 'before'>): Promise<SchemaInferResult>;
/**
* Toggle email-on-arrival for a webhook session the account owns. Same

@@ -41,0 +67,0 @@ * token-authed API surface as fetchHistory; the throttling (1 email/15min,

@@ -52,2 +52,31 @@ /**

/**
* Infer JSON Schema + TypeScript types from a session's stored request
* bodies. Same token-authed surface and error mapping as fetchHistory; the
* inference runs server-side over the stored history.
*/
export async function fetchSchema(subdomain, filters = {}) {
const token = getToken();
if (!token)
throw new HistoryError('not_logged_in', 'Run `otterkit login` first.');
const params = new URLSearchParams();
if (filters.limit)
params.set('limit', String(filters.limit));
if (filters.method)
params.set('method', filters.method);
if (filters.path)
params.set('q', filters.path);
const qs = params.toString();
const resp = await fetch(`${API_SERVER}/api/me/webhooks/${encodeURIComponent(subdomain)}/schema${qs ? `?${qs}` : ''}`, { headers: { Authorization: `Bearer ${token}` } });
const json = (await resp.json().catch(() => ({})));
if (resp.status === 401)
throw new HistoryError('invalid_token', 'Run `otterkit login` again.');
if (resp.status === 404) {
throw new HistoryError('not_found', `No webhook session "${subdomain}" on this account. CLI sessions store history only with --store.`);
}
if (!resp.ok || !json.data) {
throw new HistoryError(json.error ?? `schema_infer_failed_${resp.status}`);
}
return json.data;
}
/**
* Toggle email-on-arrival for a webhook session the account owns. Same

@@ -54,0 +83,0 @@ * token-authed API surface as fetchHistory; the throttling (1 email/15min,

@@ -28,3 +28,3 @@ /**

import { createWait, listWaits, cancelWait, sessionToken } from './waits.js';
import { fetchHistory } from './history.js';
import { fetchHistory, fetchSchema } from './history.js';
const { version } = createRequire(import.meta.url)('../package.json');

@@ -498,2 +498,33 @@ const API_SERVER = process.env.OTTERKIT_API_URL || 'https://api.otterkit.com';

});
server.registerTool('schema_infer', {
title: 'Infer request types from stored history',
description: 'Generate JSON Schema (draft-07) and TypeScript types from the request bodies OtterKit ' +
'stored server-side for a webhook endpoint - "what does this provider actually send". ' +
'Groups by path (and by a detected event field like type/event), reports field ' +
'optionality as present-in-N/M counts. Needs stored history: portal endpoints always ' +
'store; CLI webhooks only with --store.',
inputSchema: {
subdomain: z.string().describe('Webhook endpoint whose stored bodies to analyze'),
limit: z
.number()
.int()
.min(1)
.max(1000)
.optional()
.describe('Analyze up to N newest requests (default 500)'),
method: z.string().optional().describe('Only this HTTP method'),
path: z.string().optional().describe('Only paths containing this substring'),
},
}, async (args) => {
try {
return ok(await fetchSchema(args.subdomain, {
limit: args.limit,
method: args.method,
path: args.path,
}));
}
catch (e) {
return mapError(e);
}
});
server.registerTool('account_status', {

@@ -500,0 +531,0 @@ title: 'Account and credit balance',

@@ -10,2 +10,4 @@ /**

ts: string;
/** Server-minted request id (absent in logs from older CLI versions). */
id?: string;
method: string;

@@ -25,2 +27,4 @@ path: string;

buffered?: boolean;
/** Standby replay (arrived while disconnected), as opposed to a live mirror. */
replayed?: boolean;
}

@@ -27,0 +31,0 @@ export declare function ensureRequestsDir(): Promise<void>;

@@ -254,2 +254,3 @@ /**

ts: new Date(req.receivedAt ?? Date.now()).toISOString(),
id: req.requestId,
method: req.method,

@@ -262,2 +263,3 @@ path: req.path,

buffered: true,
replayed: req.buffered === true,
// The server produced the response; the log records what it served.

@@ -275,2 +277,3 @@ response: { headers: {}, body: null },

ts: new Date().toISOString(),
id: req.requestId,
method: req.method,

@@ -277,0 +280,0 @@ path: req.path,

+0
-7

@@ -65,9 +65,2 @@ export interface WaitView {

/**
* Enable the session's public read-only inspector link (idempotent - an
* existing link is returned, not rotated). Auth is the connect token.
*/
export declare function enableShare(subdomain: string, token: string): Promise<{
shareUrl: string;
}>;
/**
* Replace the endpoint's dynamic response rules (full array; null clears).

@@ -74,0 +67,0 @@ * The default response is the no-match fallback.

@@ -51,14 +51,2 @@ /**

/**
* Enable the session's public read-only inspector link (idempotent - an
* existing link is returned, not rotated). Auth is the connect token.
*/
export async function enableShare(subdomain, token) {
const resp = await fetch(`${TUNNEL_SERVER}/api/agent/tunnels/${subdomain}/share?token=${encodeURIComponent(token)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
const data = (await resp.json().catch(() => ({})));
if (!resp.ok || !data.shareUrl) {
throw new WaitError(data.error || `share failed (${resp.status})`, resp.status);
}
return { shareUrl: data.shareUrl };
}
/**
* Replace the endpoint's dynamic response rules (full array; null clears).

@@ -65,0 +53,0 @@ * The default response is the no-match fallback.

{
"name": "otterkit",
"version": "0.27.0",
"version": "0.29.0",
"description": "OtterKit CLI - provision and connect tunnels for AI agents",

@@ -5,0 +5,0 @@ "mcpName": "io.github.useotterkit/otterkit",

Sorry, the diff of this file is too big to display