@agentlip/hub 0.1.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.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Auth middleware utilities for HTTP mutations + WebSocket token validation.
3
+ *
4
+ * Security requirements:
5
+ * - All token comparisons use constant-time comparison (prevent timing attacks)
6
+ * - Tokens are NEVER echoed in error responses or logs
7
+ * - Generic error messages that don't leak token info
8
+ */
9
+
10
+ import { constantTimeEqual } from "./authToken";
11
+
12
+ /**
13
+ * Parse Bearer token from Authorization header.
14
+ * Returns the token string if valid "Bearer <token>" format, null otherwise.
15
+ *
16
+ * Does NOT log or include the token in any error state.
17
+ */
18
+ export function parseBearerToken(req: Request): string | null {
19
+ const authHeader = req.headers.get("Authorization");
20
+ if (!authHeader) {
21
+ return null;
22
+ }
23
+
24
+ // Must be "Bearer <token>" format (case-insensitive "Bearer")
25
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
26
+ if (!match) {
27
+ return null;
28
+ }
29
+
30
+ const token = match[1];
31
+ // Reject empty or whitespace-only tokens
32
+ if (!token || token.trim().length === 0) {
33
+ return null;
34
+ }
35
+
36
+ return token;
37
+ }
38
+
39
+ export type AuthOk = { ok: true };
40
+ export type AuthFailure = { ok: false; response: Response };
41
+ export type AuthResult = AuthOk | AuthFailure;
42
+
43
+ /**
44
+ * Require valid Bearer token auth for HTTP requests.
45
+ *
46
+ * Uses constant-time comparison to prevent timing attacks.
47
+ * Returns generic 401 response on failure (no token info leaked).
48
+ *
49
+ * @param req - Incoming HTTP request
50
+ * @param expectedToken - The valid auth token (from server.json)
51
+ * @returns { ok: true } on success, { ok: false, response: Response } on failure
52
+ */
53
+ export function requireAuth(req: Request, expectedToken: string): AuthResult {
54
+ const providedToken = parseBearerToken(req);
55
+
56
+ if (providedToken === null) {
57
+ return {
58
+ ok: false,
59
+ response: new Response(
60
+ JSON.stringify({
61
+ error: "Unauthorized",
62
+ code: "MISSING_AUTH",
63
+ }),
64
+ {
65
+ status: 401,
66
+ headers: { "Content-Type": "application/json" },
67
+ }
68
+ ),
69
+ };
70
+ }
71
+
72
+ // Constant-time comparison prevents timing attacks
73
+ if (!constantTimeEqual(providedToken, expectedToken)) {
74
+ return {
75
+ ok: false,
76
+ response: new Response(
77
+ JSON.stringify({
78
+ error: "Unauthorized",
79
+ code: "INVALID_AUTH",
80
+ }),
81
+ {
82
+ status: 401,
83
+ headers: { "Content-Type": "application/json" },
84
+ }
85
+ ),
86
+ };
87
+ }
88
+
89
+ return { ok: true };
90
+ }
91
+
92
+ export type WsAuthOk = { ok: true };
93
+ export type WsAuthFailure = { ok: false; closeCode: number; closeReason: string };
94
+ export type WsAuthResult = WsAuthOk | WsAuthFailure;
95
+
96
+ /**
97
+ * Require valid token query param for WebSocket connections.
98
+ *
99
+ * WebSocket auth uses ?token=<value> query parameter since
100
+ * browsers cannot set custom headers on WebSocket connections.
101
+ *
102
+ * Uses constant-time comparison to prevent timing attacks.
103
+ * Returns close code/reason on failure (no token info leaked).
104
+ *
105
+ * Close codes:
106
+ * - 4001: Missing token
107
+ * - 4003: Invalid token (Forbidden)
108
+ *
109
+ * @param url - WebSocket connection URL
110
+ * @param expectedToken - The valid auth token (from server.json)
111
+ * @returns { ok: true } on success, { ok: false, closeCode, closeReason } on failure
112
+ */
113
+ export function requireWsToken(url: URL, expectedToken: string): WsAuthResult {
114
+ const providedToken = url.searchParams.get("token");
115
+
116
+ if (providedToken === null || providedToken.trim().length === 0) {
117
+ return {
118
+ ok: false,
119
+ closeCode: 4001,
120
+ closeReason: "Missing authentication token",
121
+ };
122
+ }
123
+
124
+ // Constant-time comparison prevents timing attacks
125
+ if (!constantTimeEqual(providedToken, expectedToken)) {
126
+ return {
127
+ ok: false,
128
+ closeCode: 4003,
129
+ closeReason: "Invalid authentication token",
130
+ };
131
+ }
132
+
133
+ return { ok: true };
134
+ }
@@ -0,0 +1,32 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ /**
4
+ * Generate a cryptographically random auth token.
5
+ *
6
+ * Generates >=128-bit entropy token (32 bytes = 256 bits).
7
+ * Returns 64-character hex string.
8
+ *
9
+ * Never log this token.
10
+ */
11
+ export function generateAuthToken(): string {
12
+ return randomBytes(32).toString("hex");
13
+ }
14
+
15
+ /**
16
+ * Constant-time string comparison helper for auth token validation.
17
+ * Prevents timing attacks.
18
+ *
19
+ * Returns true if strings are equal, false otherwise.
20
+ */
21
+ export function constantTimeEqual(a: string, b: string): boolean {
22
+ if (a.length !== b.length) {
23
+ return false;
24
+ }
25
+
26
+ let result = 0;
27
+ for (let i = 0; i < a.length; i++) {
28
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
29
+ }
30
+
31
+ return result === 0;
32
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Input validation and size limit utilities for HTTP and WebSocket.
3
+ *
4
+ * Provides safe JSON body parsing with:
5
+ * - Configurable byte limits
6
+ * - Proper error handling (no user content echoed in errors)
7
+ * - Validation helpers
8
+ *
9
+ * Size limits from plan (0.1.2 Safe Defaults):
10
+ * - HTTP message content: 64KB
11
+ * - Attachment metadata: 16KB
12
+ * - WS message: 256KB
13
+ */
14
+
15
+ /**
16
+ * Size limit constants (bytes).
17
+ */
18
+ export const SIZE_LIMITS = {
19
+ /** Max message content: 64KB */
20
+ MESSAGE_BODY: 64 * 1024,
21
+ /** Max attachment metadata: 16KB */
22
+ ATTACHMENT: 16 * 1024,
23
+ /** Max WebSocket message: 256KB */
24
+ WS_MESSAGE: 256 * 1024,
25
+ /** Default HTTP body limit: 64KB */
26
+ DEFAULT_HTTP: 64 * 1024,
27
+ } as const;
28
+
29
+ /**
30
+ * Options for reading JSON body.
31
+ */
32
+ export interface ReadJsonBodyOptions {
33
+ /** Maximum bytes to accept (default: 64KB) */
34
+ maxBytes?: number;
35
+ }
36
+
37
+ /**
38
+ * Result of parsing JSON body.
39
+ */
40
+ export type JsonBodyResult<T = unknown> =
41
+ | { ok: true; data: T }
42
+ | { ok: false; response: Response };
43
+
44
+ /**
45
+ * Read and parse JSON body from HTTP request with size validation.
46
+ *
47
+ * Security:
48
+ * - Size checked before parsing (DoS protection)
49
+ * - Invalid JSON errors do not echo user content
50
+ * - Generic error messages
51
+ *
52
+ * @param req - HTTP request
53
+ * @param options - Size limit options
54
+ * @returns Parsed JSON or error response
55
+ */
56
+ export async function readJsonBody<T = unknown>(
57
+ req: Request,
58
+ options: ReadJsonBodyOptions = {}
59
+ ): Promise<JsonBodyResult<T>> {
60
+ const maxBytes = options.maxBytes ?? SIZE_LIMITS.DEFAULT_HTTP;
61
+
62
+ // Check Content-Length header first (fast path rejection)
63
+ const contentLength = req.headers.get("Content-Length");
64
+ if (contentLength) {
65
+ const length = parseInt(contentLength, 10);
66
+ if (!isNaN(length) && length > maxBytes) {
67
+ return {
68
+ ok: false,
69
+ response: payloadTooLargeResponse(maxBytes),
70
+ };
71
+ }
72
+ }
73
+
74
+ // Check Content-Type
75
+ const contentType = req.headers.get("Content-Type");
76
+ if (!contentType || !contentType.includes("application/json")) {
77
+ return {
78
+ ok: false,
79
+ response: invalidContentTypeResponse(),
80
+ };
81
+ }
82
+
83
+ try {
84
+ // Read body as ArrayBuffer to check actual size
85
+ const buffer = await req.arrayBuffer();
86
+
87
+ if (buffer.byteLength > maxBytes) {
88
+ return {
89
+ ok: false,
90
+ response: payloadTooLargeResponse(maxBytes),
91
+ };
92
+ }
93
+
94
+ // Decode and parse JSON
95
+ const text = new TextDecoder().decode(buffer);
96
+
97
+ try {
98
+ const data = JSON.parse(text) as T;
99
+ return { ok: true, data };
100
+ } catch {
101
+ return {
102
+ ok: false,
103
+ response: invalidJsonResponse(),
104
+ };
105
+ }
106
+ } catch {
107
+ // Body read error (connection reset, etc.)
108
+ return {
109
+ ok: false,
110
+ response: bodyReadErrorResponse(),
111
+ };
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Validate a WebSocket message size.
117
+ *
118
+ * @param data - Message data (string or binary)
119
+ * @param maxBytes - Maximum allowed bytes (default: 256KB)
120
+ * @returns true if within limit
121
+ */
122
+ export function validateWsMessageSize(
123
+ data: string | ArrayBuffer | Uint8Array,
124
+ maxBytes: number = SIZE_LIMITS.WS_MESSAGE
125
+ ): boolean {
126
+ let size: number;
127
+
128
+ if (typeof data === "string") {
129
+ // For strings, use byte length (UTF-8)
130
+ size = new TextEncoder().encode(data).length;
131
+ } else if (data instanceof ArrayBuffer) {
132
+ size = data.byteLength;
133
+ } else {
134
+ size = data.length;
135
+ }
136
+
137
+ return size <= maxBytes;
138
+ }
139
+
140
+ /**
141
+ * Parse and validate WebSocket JSON message.
142
+ *
143
+ * @param data - Raw message data
144
+ * @param maxBytes - Maximum allowed bytes
145
+ * @returns Parsed object or null on failure
146
+ */
147
+ export function parseWsMessage<T = unknown>(
148
+ data: string | ArrayBuffer | Uint8Array,
149
+ maxBytes: number = SIZE_LIMITS.WS_MESSAGE
150
+ ): T | null {
151
+ if (!validateWsMessageSize(data, maxBytes)) {
152
+ return null;
153
+ }
154
+
155
+ let text: string;
156
+ if (typeof data === "string") {
157
+ text = data;
158
+ } else if (data instanceof ArrayBuffer) {
159
+ text = new TextDecoder().decode(data);
160
+ } else {
161
+ text = new TextDecoder().decode(data);
162
+ }
163
+
164
+ try {
165
+ return JSON.parse(text) as T;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Validate that serialized JSON stays within size limit.
173
+ * Useful for validating attachment value_json before insertion.
174
+ *
175
+ * @param value - Value to check
176
+ * @param maxBytes - Maximum serialized size
177
+ * @returns true if within limit
178
+ */
179
+ export function validateJsonSize(value: unknown, maxBytes: number): boolean {
180
+ try {
181
+ const serialized = JSON.stringify(value);
182
+ return new TextEncoder().encode(serialized).length <= maxBytes;
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ // ============================================================================
189
+ // Error response helpers (generic messages, no user content echoed)
190
+ // ============================================================================
191
+
192
+ /**
193
+ * Create 413 Payload Too Large response.
194
+ */
195
+ export function payloadTooLargeResponse(maxBytes: number): Response {
196
+ return new Response(
197
+ JSON.stringify({
198
+ error: `Payload too large (max ${Math.floor(maxBytes / 1024)}KB)`,
199
+ code: "PAYLOAD_TOO_LARGE",
200
+ }),
201
+ {
202
+ status: 413,
203
+ headers: { "Content-Type": "application/json" },
204
+ }
205
+ );
206
+ }
207
+
208
+ /**
209
+ * Create 400 Bad Request response for invalid JSON.
210
+ */
211
+ export function invalidJsonResponse(): Response {
212
+ return new Response(
213
+ JSON.stringify({
214
+ error: "Invalid JSON",
215
+ code: "INVALID_INPUT",
216
+ }),
217
+ {
218
+ status: 400,
219
+ headers: { "Content-Type": "application/json" },
220
+ }
221
+ );
222
+ }
223
+
224
+ /**
225
+ * Create 415 Unsupported Media Type response.
226
+ */
227
+ export function invalidContentTypeResponse(): Response {
228
+ return new Response(
229
+ JSON.stringify({
230
+ error: "Content-Type must be application/json",
231
+ code: "INVALID_INPUT",
232
+ }),
233
+ {
234
+ status: 415,
235
+ headers: { "Content-Type": "application/json" },
236
+ }
237
+ );
238
+ }
239
+
240
+ /**
241
+ * Create 400 Bad Request response for body read errors.
242
+ */
243
+ export function bodyReadErrorResponse(): Response {
244
+ return new Response(
245
+ JSON.stringify({
246
+ error: "Failed to read request body",
247
+ code: "INVALID_INPUT",
248
+ }),
249
+ {
250
+ status: 400,
251
+ headers: { "Content-Type": "application/json" },
252
+ }
253
+ );
254
+ }
255
+
256
+ /**
257
+ * Create generic 400 Bad Request response for validation errors.
258
+ *
259
+ * @param message - Error message (should not contain user input)
260
+ */
261
+ export function validationErrorResponse(message: string): Response {
262
+ return new Response(
263
+ JSON.stringify({
264
+ error: message,
265
+ code: "INVALID_INPUT",
266
+ }),
267
+ {
268
+ status: 400,
269
+ headers: { "Content-Type": "application/json" },
270
+ }
271
+ );
272
+ }
package/src/config.ts ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Workspace config loader and schema validation
3
+ *
4
+ * Security requirements:
5
+ * - Only load agentlip.config.ts from workspace root (never traverse upward)
6
+ * - Validate plugin module paths to prevent path traversal
7
+ * - Return null for missing config (optional file)
8
+ */
9
+
10
+ import { join, resolve, relative, normalize } from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+
13
+ /**
14
+ * Plugin configuration
15
+ */
16
+ export interface PluginConfig {
17
+ name: string;
18
+ type: "linkifier" | "extractor";
19
+ enabled: boolean;
20
+ /** Path to custom plugin module (relative to workspace root or absolute). Default: built-in */
21
+ module?: string;
22
+ /** Plugin-specific configuration */
23
+ config?: Record<string, unknown>;
24
+ }
25
+
26
+ /**
27
+ * Workspace configuration schema
28
+ */
29
+ export interface WorkspaceConfig {
30
+ plugins?: PluginConfig[];
31
+ rateLimits?: {
32
+ perConnection?: number;
33
+ global?: number;
34
+ };
35
+ limits?: {
36
+ maxMessageSize?: number;
37
+ maxAttachmentSize?: number;
38
+ maxWsMessageSize?: number;
39
+ maxWsConnections?: number;
40
+ maxWsQueueSize?: number;
41
+ maxEventReplayBatch?: number;
42
+ };
43
+ pluginDefaults?: {
44
+ timeout?: number;
45
+ memoryLimit?: number;
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Result of config loading
51
+ */
52
+ export interface LoadConfigResult {
53
+ config: WorkspaceConfig;
54
+ /** Absolute path to config file (if loaded) */
55
+ configPath?: string;
56
+ }
57
+
58
+ /**
59
+ * Validate that a plugin module path does not escape workspace root.
60
+ *
61
+ * Security: prevents path traversal attacks via plugin.module field.
62
+ *
63
+ * @param modulePath - Plugin module path (relative or absolute)
64
+ * @param workspaceRoot - Workspace root directory (absolute)
65
+ * @returns Absolute path to module if valid
66
+ * @throws Error if path escapes workspace root
67
+ */
68
+ export function validatePluginModulePath(
69
+ modulePath: string,
70
+ workspaceRoot: string
71
+ ): string {
72
+ const absWorkspaceRoot = resolve(workspaceRoot);
73
+
74
+ // Resolve module path relative to workspace root (if relative)
75
+ const absModulePath = resolve(absWorkspaceRoot, modulePath);
76
+
77
+ // Normalize paths to handle '..' and '.' components
78
+ const normalizedWorkspaceRoot = normalize(absWorkspaceRoot);
79
+ const normalizedModulePath = normalize(absModulePath);
80
+
81
+ // Check that resolved path is within workspace root
82
+ const rel = relative(normalizedWorkspaceRoot, normalizedModulePath);
83
+
84
+ // relative() returns a path that:
85
+ // - starts with '..' if target is outside source
86
+ // - is empty string if paths are identical
87
+ // - is a relative path within if target is inside source
88
+
89
+ if (rel.startsWith("..") || resolve(normalizedWorkspaceRoot, rel) !== normalizedModulePath) {
90
+ throw new Error(
91
+ `Plugin module path escapes workspace root: ${modulePath} ` +
92
+ `(resolves to ${normalizedModulePath}, workspace: ${normalizedWorkspaceRoot})`
93
+ );
94
+ }
95
+
96
+ return normalizedModulePath;
97
+ }
98
+
99
+ /**
100
+ * Validate workspace config schema.
101
+ *
102
+ * Performs basic structural validation and security checks.
103
+ *
104
+ * @param config - Config object to validate
105
+ * @param workspaceRoot - Workspace root for plugin path validation
106
+ * @throws Error if validation fails
107
+ */
108
+ export function validateWorkspaceConfig(
109
+ config: unknown,
110
+ workspaceRoot: string
111
+ ): asserts config is WorkspaceConfig {
112
+ if (config === null || typeof config !== "object") {
113
+ throw new Error("Config must be an object");
114
+ }
115
+
116
+ const cfg = config as Record<string, unknown>;
117
+
118
+ // Validate plugins array (if present)
119
+ if (cfg.plugins !== undefined) {
120
+ if (!Array.isArray(cfg.plugins)) {
121
+ throw new Error("plugins must be an array");
122
+ }
123
+
124
+ for (const [idx, plugin] of cfg.plugins.entries()) {
125
+ if (plugin === null || typeof plugin !== "object") {
126
+ throw new Error(`plugins[${idx}] must be an object`);
127
+ }
128
+
129
+ const p = plugin as Record<string, unknown>;
130
+
131
+ // Required fields
132
+ if (typeof p.name !== "string" || p.name.length === 0) {
133
+ throw new Error(`plugins[${idx}].name must be a non-empty string`);
134
+ }
135
+
136
+ if (p.type !== "linkifier" && p.type !== "extractor") {
137
+ throw new Error(`plugins[${idx}].type must be "linkifier" or "extractor"`);
138
+ }
139
+
140
+ if (typeof p.enabled !== "boolean") {
141
+ throw new Error(`plugins[${idx}].enabled must be a boolean`);
142
+ }
143
+
144
+ // Validate module path (if provided)
145
+ if (p.module !== undefined) {
146
+ if (typeof p.module !== "string") {
147
+ throw new Error(`plugins[${idx}].module must be a string`);
148
+ }
149
+
150
+ // Security: validate path does not escape workspace
151
+ try {
152
+ validatePluginModulePath(p.module, workspaceRoot);
153
+ } catch (err: any) {
154
+ throw new Error(`plugins[${idx}].module: ${err.message}`);
155
+ }
156
+ }
157
+
158
+ // Validate config (if provided)
159
+ if (p.config !== undefined) {
160
+ if (p.config === null || typeof p.config !== "object" || Array.isArray(p.config)) {
161
+ throw new Error(`plugins[${idx}].config must be an object`);
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ // Validate rateLimits (if present)
168
+ if (cfg.rateLimits !== undefined) {
169
+ if (cfg.rateLimits === null || typeof cfg.rateLimits !== "object") {
170
+ throw new Error("rateLimits must be an object");
171
+ }
172
+
173
+ const rl = cfg.rateLimits as Record<string, unknown>;
174
+
175
+ if (rl.perConnection !== undefined && typeof rl.perConnection !== "number") {
176
+ throw new Error("rateLimits.perConnection must be a number");
177
+ }
178
+
179
+ if (rl.global !== undefined && typeof rl.global !== "number") {
180
+ throw new Error("rateLimits.global must be a number");
181
+ }
182
+ }
183
+
184
+ // Validate limits (if present)
185
+ if (cfg.limits !== undefined) {
186
+ if (cfg.limits === null || typeof cfg.limits !== "object") {
187
+ throw new Error("limits must be an object");
188
+ }
189
+
190
+ const lim = cfg.limits as Record<string, unknown>;
191
+ const limitFields = [
192
+ "maxMessageSize",
193
+ "maxAttachmentSize",
194
+ "maxWsMessageSize",
195
+ "maxWsConnections",
196
+ "maxWsQueueSize",
197
+ "maxEventReplayBatch",
198
+ ];
199
+
200
+ for (const field of limitFields) {
201
+ if (lim[field] !== undefined && typeof lim[field] !== "number") {
202
+ throw new Error(`limits.${field} must be a number`);
203
+ }
204
+ }
205
+ }
206
+
207
+ // Validate pluginDefaults (if present)
208
+ if (cfg.pluginDefaults !== undefined) {
209
+ if (cfg.pluginDefaults === null || typeof cfg.pluginDefaults !== "object") {
210
+ throw new Error("pluginDefaults must be an object");
211
+ }
212
+
213
+ const pd = cfg.pluginDefaults as Record<string, unknown>;
214
+
215
+ if (pd.timeout !== undefined && typeof pd.timeout !== "number") {
216
+ throw new Error("pluginDefaults.timeout must be a number");
217
+ }
218
+
219
+ if (pd.memoryLimit !== undefined && typeof pd.memoryLimit !== "number") {
220
+ throw new Error("pluginDefaults.memoryLimit must be a number");
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Load and validate workspace config from agentlip.config.ts.
227
+ *
228
+ * Security guarantees:
229
+ * - Only loads from workspace root (never traverses upward)
230
+ * - Validates plugin module paths to prevent path traversal
231
+ * - Returns null if config file doesn't exist (optional file)
232
+ *
233
+ * @param workspaceRoot - Absolute path to workspace root directory
234
+ * @returns Config object or null if file doesn't exist
235
+ * @throws Error if config exists but is invalid
236
+ */
237
+ export async function loadWorkspaceConfig(
238
+ workspaceRoot: string
239
+ ): Promise<LoadConfigResult | null> {
240
+ const absWorkspaceRoot = resolve(workspaceRoot);
241
+ const configPath = join(absWorkspaceRoot, "agentlip.config.ts");
242
+
243
+ // Convert to file:// URL for dynamic import
244
+ const configUrl = pathToFileURL(configPath).href;
245
+
246
+ let configModule: unknown;
247
+ try {
248
+ configModule = await import(configUrl);
249
+ } catch (err: any) {
250
+ // File doesn't exist or has syntax errors
251
+ if (err?.code === "ERR_MODULE_NOT_FOUND" || err?.code === "ENOENT") {
252
+ return null;
253
+ }
254
+
255
+ // Config exists but has errors - propagate
256
+ throw new Error(`Failed to load agentlip.config.ts: ${err.message}`);
257
+ }
258
+
259
+ // Extract default export
260
+ const config = (configModule as any)?.default;
261
+
262
+ if (config === undefined) {
263
+ throw new Error("agentlip.config.ts must have a default export");
264
+ }
265
+
266
+ // Validate config schema
267
+ validateWorkspaceConfig(config, absWorkspaceRoot);
268
+
269
+ return {
270
+ config,
271
+ configPath,
272
+ };
273
+ }