@dayby/mcp-server 0.1.0 → 0.1.1

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 CHANGED
@@ -56,41 +56,20 @@ Create `~/.dayby/sanitizer.json`:
56
56
  }
57
57
  ```
58
58
 
59
- ### 3. Install
59
+ ### 3. Add to Claude Desktop / Cursor
60
60
 
61
- ```bash
62
- npm install -g @dayby/mcp-server
63
- ```
64
-
65
- ### 4. Add to Claude Code / Claude Desktop / Cursor
66
-
67
- **Claude Code (simplest):**
68
- ```bash
69
- claude mcp add dayby -- dayby-mcp
70
- ```
71
-
72
- **Claude Desktop** (`claude_desktop_config.json`):
73
- ```json
74
- {
75
- "mcpServers": {
76
- "dayby": {
77
- "command": "dayby-mcp",
78
- "env": {
79
- "DAYBY_API_KEY": "your-api-key-here"
80
- }
81
- }
82
- }
83
- }
84
- ```
61
+ In your MCP config (`claude_desktop_config.json` or Cursor settings):
85
62
 
86
- **Cursor** (`.cursor/mcp.json`):
87
63
  ```json
88
64
  {
89
65
  "mcpServers": {
90
66
  "dayby": {
91
- "command": "dayby-mcp",
67
+ "command": "node",
68
+ "args": ["/path/to/mcp-server/dist/index.js"],
92
69
  "env": {
93
- "DAYBY_API_KEY": "your-api-key-here"
70
+ "DAYBY_API_KEY": "your-api-key-here",
71
+ "DAYBY_API_URL": "https://dayby.dev",
72
+ "DAYBY_BLOCKED_TERMS": "CompanyName,SecretProject"
94
73
  }
95
74
  }
96
75
  }
package/dist/index.js CHANGED
@@ -116,6 +116,7 @@ async function main() {
116
116
  originalContent: content,
117
117
  sanitizedTitle: titleResult.clean,
118
118
  sanitizedContent: contentResult.clean,
119
+ visibility,
119
120
  strippedItems: allStripped,
120
121
  createdAt: new Date(),
121
122
  };
@@ -149,7 +150,8 @@ async function main() {
149
150
  draft_id: zod_1.z.string().describe('The draft ID from draft_post'),
150
151
  title: zod_1.z.string().optional().describe('Updated title (will be re-sanitized)'),
151
152
  content: zod_1.z.string().optional().describe('Updated content (will be re-sanitized)'),
152
- }, async ({ draft_id, title, content }) => {
153
+ visibility: zod_1.z.enum(['published', 'draft']).optional().describe('Updated visibility'),
154
+ }, async ({ draft_id, title, content, visibility }) => {
153
155
  const draft = drafts.get(draft_id);
154
156
  if (!draft) {
155
157
  return {
@@ -167,6 +169,9 @@ async function main() {
167
169
  draft.originalContent = content;
168
170
  draft.strippedItems.push(...result.stripped);
169
171
  }
172
+ if (visibility) {
173
+ draft.visibility = visibility;
174
+ }
170
175
  let response = `✏️ **Draft Updated** (ID: ${draft_id})\n\n`;
171
176
  response += `**Title:** ${draft.sanitizedTitle}\n\n`;
172
177
  response += `**Content:**\n${draft.sanitizedContent}\n\n`;
@@ -200,6 +205,7 @@ async function main() {
200
205
  const result = await client.createPost({
201
206
  title: draft.sanitizedTitle,
202
207
  content: draft.sanitizedContent,
208
+ visibility: draft.visibility,
203
209
  });
204
210
  let response = `✅ **Published to DayBy!**\n\n`;
205
211
  response += `**Title:** ${result.post.title}\n`;
package/package.json CHANGED
@@ -1,15 +1,11 @@
1
1
  {
2
2
  "name": "@dayby/mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "DayBy MCP Server — Post your dev progress from Claude, Cursor, or any MCP client. Local sanitization built in.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
7
  "dayby-mcp": "dist/index.js"
8
8
  },
9
- "files": [
10
- "dist/",
11
- "README.md"
12
- ],
13
9
  "scripts": {
14
10
  "build": "tsc",
15
11
  "dev": "tsc --watch",
@@ -0,0 +1,6 @@
1
+ {
2
+ "blockedTerms": ["AcmeCorp", "Project Phoenix", "internal-tool-name"],
3
+ "blockedDomains": ["internal.acme.com", "jira.acme.com", "confluence.acme.com"],
4
+ "blockedNames": ["John Boss", "Jane CTO"],
5
+ "customPatterns": ["ACME-\\d{4,}", "PROJ-\\d+"]
6
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * DayBy API v2 client — thin wrapper around the REST API.
3
+ */
4
+
5
+ export interface DayByConfig {
6
+ apiUrl: string;
7
+ apiKey: string;
8
+ }
9
+
10
+ export interface DayByPost {
11
+ id: number;
12
+ title: string;
13
+ slug: string;
14
+ content: string;
15
+ visibility: string;
16
+ has_article: boolean;
17
+ url: string;
18
+ created_at: string;
19
+ updated_at: string;
20
+ }
21
+
22
+ export interface PostsListResponse {
23
+ posts: DayByPost[];
24
+ meta: {
25
+ total: number;
26
+ page: number;
27
+ per_page: number;
28
+ total_pages: number;
29
+ };
30
+ }
31
+
32
+ export class DayByClient {
33
+ private apiUrl: string;
34
+ private apiKey: string;
35
+
36
+ constructor(config: DayByConfig) {
37
+ this.apiUrl = config.apiUrl.replace(/\/$/, '');
38
+ this.apiKey = config.apiKey;
39
+ }
40
+
41
+ private async request<T>(
42
+ method: string,
43
+ path: string,
44
+ body?: Record<string, unknown>
45
+ ): Promise<T> {
46
+ const url = `${this.apiUrl}${path}`;
47
+ const headers: Record<string, string> = {
48
+ 'Authorization': `Bearer ${this.apiKey}`,
49
+ 'Content-Type': 'application/json',
50
+ 'Accept': 'application/json',
51
+ };
52
+
53
+ const res = await fetch(url, {
54
+ method,
55
+ headers,
56
+ body: body ? JSON.stringify(body) : undefined,
57
+ });
58
+
59
+ if (!res.ok) {
60
+ const error = await res.json().catch(() => ({ error: res.statusText }));
61
+ throw new Error(`DayBy API error (${res.status}): ${JSON.stringify(error)}`);
62
+ }
63
+
64
+ return res.json() as Promise<T>;
65
+ }
66
+
67
+ async listPosts(page = 1, perPage = 10): Promise<PostsListResponse> {
68
+ return this.request<PostsListResponse>(
69
+ 'GET',
70
+ `/api/v2/posts?page=${page}&per_page=${perPage}`
71
+ );
72
+ }
73
+
74
+ async getPost(slug: string): Promise<{ post: DayByPost }> {
75
+ return this.request<{ post: DayByPost }>('GET', `/api/v2/posts/${slug}`);
76
+ }
77
+
78
+ async createPost(params: {
79
+ title: string;
80
+ content: string;
81
+ visibility?: string;
82
+ }): Promise<{ post: DayByPost }> {
83
+ return this.request<{ post: DayByPost }>('POST', '/api/v2/posts', {
84
+ post: params,
85
+ });
86
+ }
87
+
88
+ async updatePost(
89
+ slug: string,
90
+ params: { title?: string; content?: string; visibility?: string }
91
+ ): Promise<{ post: DayByPost }> {
92
+ return this.request<{ post: DayByPost }>('PUT', `/api/v2/posts/${slug}`, {
93
+ post: params,
94
+ });
95
+ }
96
+
97
+ async deletePost(slug: string): Promise<{ message: string }> {
98
+ return this.request<{ message: string }>('DELETE', `/api/v2/posts/${slug}`);
99
+ }
100
+
101
+ async generateArticle(slug: string): Promise<{ post: DayByPost; message: string }> {
102
+ return this.request<{ post: DayByPost; message: string }>(
103
+ 'POST',
104
+ `/api/v2/posts/${slug}/generate_article`
105
+ );
106
+ }
107
+ }
package/src/index.ts ADDED
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * DayBy MCP Server
5
+ *
6
+ * Post your dev progress from Claude, Cursor, or any MCP client.
7
+ * All content is sanitized locally before it ever touches the network.
8
+ *
9
+ * Flow: draft_post → review → publish_post (nothing leaves without approval)
10
+ */
11
+
12
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import { z } from 'zod';
15
+ import { Sanitizer, type SanitizerConfig } from './sanitizer.js';
16
+ import { DayByClient } from './dayby-client.js';
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+
20
+ // --- Configuration ---
21
+
22
+ interface Config {
23
+ apiUrl: string;
24
+ apiKey: string;
25
+ sanitizer: Partial<SanitizerConfig>;
26
+ }
27
+
28
+ function loadConfig(): Config {
29
+ // 1. Check env vars
30
+ const apiUrl = process.env.DAYBY_API_URL || 'https://dayby.dev';
31
+ const apiKey = process.env.DAYBY_API_KEY || '';
32
+
33
+ // 2. Load sanitizer config from file if it exists
34
+ let sanitizerConfig: Partial<SanitizerConfig> = {};
35
+ const configPaths = [
36
+ path.join(process.env.HOME || '~', '.dayby', 'sanitizer.json'),
37
+ path.join(process.cwd(), '.dayby-sanitizer.json'),
38
+ ];
39
+
40
+ for (const configPath of configPaths) {
41
+ try {
42
+ const raw = fs.readFileSync(configPath, 'utf-8');
43
+ sanitizerConfig = JSON.parse(raw);
44
+ break;
45
+ } catch {
46
+ // File doesn't exist, that's fine
47
+ }
48
+ }
49
+
50
+ // 3. Also load from env (comma-separated)
51
+ if (process.env.DAYBY_BLOCKED_TERMS) {
52
+ sanitizerConfig.blockedTerms = [
53
+ ...(sanitizerConfig.blockedTerms || []),
54
+ ...process.env.DAYBY_BLOCKED_TERMS.split(',').map(s => s.trim()),
55
+ ];
56
+ }
57
+ if (process.env.DAYBY_BLOCKED_DOMAINS) {
58
+ sanitizerConfig.blockedDomains = [
59
+ ...(sanitizerConfig.blockedDomains || []),
60
+ ...process.env.DAYBY_BLOCKED_DOMAINS.split(',').map(s => s.trim()),
61
+ ];
62
+ }
63
+
64
+ return { apiUrl, apiKey, sanitizer: sanitizerConfig };
65
+ }
66
+
67
+ // --- Draft Store (in-memory, local only) ---
68
+
69
+ interface Draft {
70
+ id: string;
71
+ originalContent: string;
72
+ sanitizedTitle: string;
73
+ sanitizedContent: string;
74
+ visibility: 'published' | 'draft';
75
+ strippedItems: string[];
76
+ createdAt: Date;
77
+ }
78
+
79
+ const drafts = new Map<string, Draft>();
80
+
81
+ function generateDraftId(): string {
82
+ return `draft_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
83
+ }
84
+
85
+ // --- Main ---
86
+
87
+ async function main() {
88
+ const config = loadConfig();
89
+ const sanitizer = new Sanitizer(config.sanitizer);
90
+ const client = new DayByClient({ apiUrl: config.apiUrl, apiKey: config.apiKey });
91
+
92
+ const server = new McpServer({
93
+ name: 'dayby',
94
+ version: '0.1.0',
95
+ });
96
+
97
+ // ========================================
98
+ // Tool: draft_post
99
+ // Sanitizes content locally, returns preview. NOTHING leaves the machine.
100
+ // ========================================
101
+ server.tool(
102
+ 'draft_post',
103
+ 'Create a sanitized draft of a dev progress post. Content is cleaned locally — nothing is sent to the network. Returns a preview for the user to review before publishing.',
104
+ {
105
+ title: z.string().describe('Post title — focus on the technology/skill learned'),
106
+ content: z.string().describe('Post content — describe what you learned, built, or solved. The sanitizer will strip any sensitive data automatically.'),
107
+ visibility: z.enum(['published', 'draft']).default('published').describe('Post visibility on DayBy'),
108
+ },
109
+ async ({ title, content, visibility }) => {
110
+ // Sanitize both title and content locally
111
+ const titleResult = sanitizer.sanitize(title);
112
+ const contentResult = sanitizer.sanitize(content);
113
+
114
+ const allStripped = [...titleResult.stripped, ...contentResult.stripped];
115
+ const draftId = generateDraftId();
116
+
117
+ const draft: Draft = {
118
+ id: draftId,
119
+ originalContent: content,
120
+ sanitizedTitle: titleResult.clean,
121
+ sanitizedContent: contentResult.clean,
122
+ visibility,
123
+ strippedItems: allStripped,
124
+ createdAt: new Date(),
125
+ };
126
+ drafts.set(draftId, draft);
127
+
128
+ // Build response
129
+ let response = `📝 **Draft Preview** (ID: ${draftId})\n\n`;
130
+ response += `**Title:** ${draft.sanitizedTitle}\n\n`;
131
+ response += `**Content:**\n${draft.sanitizedContent}\n\n`;
132
+ response += `**Visibility:** ${visibility}\n`;
133
+
134
+ if (allStripped.length > 0) {
135
+ response += `\n⚠️ **Sanitizer removed ${allStripped.length} sensitive item(s):**\n`;
136
+ for (const item of allStripped.slice(0, 10)) {
137
+ response += ` • ${item}\n`;
138
+ }
139
+ if (allStripped.length > 10) {
140
+ response += ` • ...and ${allStripped.length - 10} more\n`;
141
+ }
142
+ } else {
143
+ response += `\n✅ No sensitive data detected.\n`;
144
+ }
145
+
146
+ response += `\n👉 Review the above. If it looks good, use **publish_post** with draft ID: \`${draftId}\``;
147
+ response += `\n💡 You can also use **edit_draft** to modify before publishing.`;
148
+
149
+ return { content: [{ type: 'text' as const, text: response }] };
150
+ }
151
+ );
152
+
153
+ // ========================================
154
+ // Tool: edit_draft
155
+ // Modify a draft before publishing
156
+ // ========================================
157
+ server.tool(
158
+ 'edit_draft',
159
+ 'Edit a draft post before publishing. Provide updated title and/or content — they will be re-sanitized locally.',
160
+ {
161
+ draft_id: z.string().describe('The draft ID from draft_post'),
162
+ title: z.string().optional().describe('Updated title (will be re-sanitized)'),
163
+ content: z.string().optional().describe('Updated content (will be re-sanitized)'),
164
+ visibility: z.enum(['published', 'draft']).optional().describe('Updated visibility'),
165
+ },
166
+ async ({ draft_id, title, content, visibility }) => {
167
+ const draft = drafts.get(draft_id);
168
+ if (!draft) {
169
+ return {
170
+ content: [{ type: 'text' as const, text: `❌ Draft not found: ${draft_id}. Use draft_post to create a new one.` }],
171
+ };
172
+ }
173
+
174
+ if (title) {
175
+ const result = sanitizer.sanitize(title);
176
+ draft.sanitizedTitle = result.clean;
177
+ draft.strippedItems.push(...result.stripped);
178
+ }
179
+
180
+ if (content) {
181
+ const result = sanitizer.sanitize(content);
182
+ draft.sanitizedContent = result.clean;
183
+ draft.originalContent = content;
184
+ draft.strippedItems.push(...result.stripped);
185
+ }
186
+
187
+ if (visibility) {
188
+ draft.visibility = visibility;
189
+ }
190
+
191
+ let response = `✏️ **Draft Updated** (ID: ${draft_id})\n\n`;
192
+ response += `**Title:** ${draft.sanitizedTitle}\n\n`;
193
+ response += `**Content:**\n${draft.sanitizedContent}\n\n`;
194
+ response += `\n👉 Use **publish_post** with draft ID: \`${draft_id}\` when ready.`;
195
+
196
+ return { content: [{ type: 'text' as const, text: response }] };
197
+ }
198
+ );
199
+
200
+ // ========================================
201
+ // Tool: publish_post
202
+ // Only NOW does data leave the machine — and only the sanitized version.
203
+ // ========================================
204
+ server.tool(
205
+ 'publish_post',
206
+ 'Publish a previously drafted post to DayBy. Only the sanitized version is sent — the original content never leaves your machine.',
207
+ {
208
+ draft_id: z.string().describe('The draft ID from draft_post'),
209
+ generate_article: z.boolean().default(false).describe('Also generate an AI-formatted article on DayBy after publishing'),
210
+ },
211
+ async ({ draft_id, generate_article }) => {
212
+ const draft = drafts.get(draft_id);
213
+ if (!draft) {
214
+ return {
215
+ content: [{ type: 'text' as const, text: `❌ Draft not found: ${draft_id}. Use draft_post to create a new one.` }],
216
+ };
217
+ }
218
+
219
+ if (!config.apiKey) {
220
+ return {
221
+ content: [{
222
+ type: 'text' as const,
223
+ text: '❌ No API key configured. Set DAYBY_API_KEY environment variable or add it to your MCP config.',
224
+ }],
225
+ };
226
+ }
227
+
228
+ try {
229
+ // Only the sanitized content is sent
230
+ const result = await client.createPost({
231
+ title: draft.sanitizedTitle,
232
+ content: draft.sanitizedContent,
233
+ visibility: draft.visibility,
234
+ });
235
+
236
+ let response = `✅ **Published to DayBy!**\n\n`;
237
+ response += `**Title:** ${result.post.title}\n`;
238
+ response += `**URL:** ${result.post.url}\n`;
239
+ response += `**Slug:** ${result.post.slug}\n`;
240
+
241
+ if (generate_article && result.post.slug) {
242
+ try {
243
+ await client.generateArticle(result.post.slug);
244
+ response += `\n🤖 AI article generation triggered.`;
245
+ } catch (e) {
246
+ response += `\n⚠️ Article generation failed: ${e instanceof Error ? e.message : 'Unknown error'}`;
247
+ }
248
+ }
249
+
250
+ // Clean up draft
251
+ drafts.delete(draft_id);
252
+
253
+ return { content: [{ type: 'text' as const, text: response }] };
254
+ } catch (e) {
255
+ return {
256
+ content: [{
257
+ type: 'text' as const,
258
+ text: `❌ Failed to publish: ${e instanceof Error ? e.message : 'Unknown error'}`,
259
+ }],
260
+ };
261
+ }
262
+ }
263
+ );
264
+
265
+ // ========================================
266
+ // Tool: list_posts
267
+ // ========================================
268
+ server.tool(
269
+ 'list_posts',
270
+ 'List your recent DayBy posts.',
271
+ {
272
+ page: z.number().default(1).describe('Page number'),
273
+ per_page: z.number().default(10).describe('Posts per page'),
274
+ },
275
+ async ({ page, per_page }) => {
276
+ if (!config.apiKey) {
277
+ return {
278
+ content: [{ type: 'text' as const, text: '❌ No API key configured.' }],
279
+ };
280
+ }
281
+
282
+ try {
283
+ const result = await client.listPosts(page, per_page);
284
+ let response = `📋 **Your DayBy Posts** (page ${result.meta.page}/${result.meta.total_pages}, ${result.meta.total} total)\n\n`;
285
+
286
+ for (const post of result.posts) {
287
+ response += `• **${post.title}** — ${post.url}\n`;
288
+ response += ` ${post.content.slice(0, 100)}${post.content.length > 100 ? '...' : ''}\n`;
289
+ response += ` _${post.created_at.slice(0, 10)} · ${post.visibility}_\n\n`;
290
+ }
291
+
292
+ return { content: [{ type: 'text' as const, text: response }] };
293
+ } catch (e) {
294
+ return {
295
+ content: [{
296
+ type: 'text' as const,
297
+ text: `❌ Failed to list posts: ${e instanceof Error ? e.message : 'Unknown error'}`,
298
+ }],
299
+ };
300
+ }
301
+ }
302
+ );
303
+
304
+ // ========================================
305
+ // Tool: check_content
306
+ // Dry-run sanitization — see what would get stripped without creating a draft.
307
+ // ========================================
308
+ server.tool(
309
+ 'check_content',
310
+ 'Check if content contains sensitive data without creating a draft. Useful for quick checks before writing a post.',
311
+ {
312
+ text: z.string().describe('Text to check for sensitive content'),
313
+ },
314
+ async ({ text }) => {
315
+ const result = sanitizer.check(text);
316
+
317
+ if (result.safe) {
318
+ return {
319
+ content: [{ type: 'text' as const, text: '✅ No sensitive data detected. Safe to post.' }],
320
+ };
321
+ }
322
+
323
+ let response = `⚠️ **Found ${result.issues.length} sensitive item(s):**\n\n`;
324
+ for (const issue of result.issues) {
325
+ response += ` • ${issue}\n`;
326
+ }
327
+ response += `\nUse **draft_post** to create a sanitized version.`;
328
+
329
+ return { content: [{ type: 'text' as const, text: response }] };
330
+ }
331
+ );
332
+
333
+ // --- Start server ---
334
+ const transport = new StdioServerTransport();
335
+ await server.connect(transport);
336
+ }
337
+
338
+ main().catch(console.error);
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Local sanitization layer — strips sensitive data BEFORE anything leaves the machine.
3
+ * Runs entirely offline. No network calls.
4
+ */
5
+
6
+ export interface SanitizerConfig {
7
+ /** Company/org names to strip */
8
+ blockedTerms: string[];
9
+ /** Internal domains (e.g., "internal.corp.com") */
10
+ blockedDomains: string[];
11
+ /** Teammate/people names to strip */
12
+ blockedNames: string[];
13
+ /** Custom regex patterns to strip */
14
+ customPatterns: string[];
15
+ }
16
+
17
+ const DEFAULT_PATTERNS: RegExp[] = [
18
+ // API keys & tokens
19
+ /(?:api[_-]?key|token|secret|password|bearer)\s*[:=]\s*['"]?[A-Za-z0-9_\-/.+]{16,}['"]?/gi,
20
+ // AWS ARNs
21
+ /arn:aws:[a-z0-9\-]+:[a-z0-9\-]*:\d{12}:[a-zA-Z0-9\-_/:.]+/g,
22
+ // AWS access keys
23
+ /(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}/g,
24
+ // Private IPs
25
+ /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g,
26
+ // Email addresses
27
+ /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
28
+ // SSH keys
29
+ /ssh-(?:rsa|ed25519|ecdsa)\s+[A-Za-z0-9+/=]{40,}/g,
30
+ // JWT tokens
31
+ /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/g,
32
+ // GitHub tokens
33
+ /gh[ps]_[A-Za-z0-9_]{36,}/g,
34
+ // Generic secrets in env-like format
35
+ /[A-Z_]{3,}=["']?[A-Za-z0-9+/=_\-]{20,}["']?/g,
36
+ // Database URLs
37
+ /(?:postgres|mysql|mongodb|redis):\/\/[^\s"']+/gi,
38
+ // File paths with usernames
39
+ /\/(?:home|Users)\/[a-zA-Z0-9._-]+/g,
40
+ ];
41
+
42
+ export class Sanitizer {
43
+ private config: SanitizerConfig;
44
+ private compiledBlockedTerms: RegExp[];
45
+ private compiledCustomPatterns: RegExp[];
46
+
47
+ constructor(config: Partial<SanitizerConfig> = {}) {
48
+ this.config = {
49
+ blockedTerms: config.blockedTerms || [],
50
+ blockedDomains: config.blockedDomains || [],
51
+ blockedNames: config.blockedNames || [],
52
+ customPatterns: config.customPatterns || [],
53
+ };
54
+
55
+ // Compile blocked terms into case-insensitive regexes with word boundaries
56
+ this.compiledBlockedTerms = [
57
+ ...this.config.blockedTerms,
58
+ ...this.config.blockedNames,
59
+ ]
60
+ .filter(t => t.length > 0)
61
+ .map(term => new RegExp(`\\b${escapeRegex(term)}\\b`, 'gi'));
62
+
63
+ this.compiledCustomPatterns = this.config.customPatterns
64
+ .filter(p => p.length > 0)
65
+ .map(p => new RegExp(p, 'gi'));
66
+ }
67
+
68
+ /**
69
+ * Sanitize text locally. Returns the cleaned text and a list of what was stripped.
70
+ */
71
+ sanitize(text: string): { clean: string; stripped: string[] } {
72
+ const stripped: string[] = [];
73
+ let clean = text;
74
+
75
+ // 1. Strip default sensitive patterns
76
+ for (const pattern of DEFAULT_PATTERNS) {
77
+ const matches = clean.match(pattern);
78
+ if (matches) {
79
+ stripped.push(...matches.map(m => `[pattern: ${m.slice(0, 20)}...]`));
80
+ clean = clean.replace(pattern, '[REDACTED]');
81
+ }
82
+ }
83
+
84
+ // 2. Strip blocked domains
85
+ for (const domain of this.config.blockedDomains) {
86
+ const domainRegex = new RegExp(
87
+ `(?:https?://)?(?:[a-z0-9-]+\\.)*${escapeRegex(domain)}[/\\w.-]*`,
88
+ 'gi'
89
+ );
90
+ const matches = clean.match(domainRegex);
91
+ if (matches) {
92
+ stripped.push(...matches.map(m => `[domain: ${domain}]`));
93
+ clean = clean.replace(domainRegex, '[REDACTED-URL]');
94
+ }
95
+ }
96
+
97
+ // 3. Strip blocked terms and names
98
+ for (const termRegex of this.compiledBlockedTerms) {
99
+ const matches = clean.match(termRegex);
100
+ if (matches) {
101
+ stripped.push(...matches.map(m => `[term: ${m}]`));
102
+ clean = clean.replace(termRegex, '[REDACTED]');
103
+ }
104
+ }
105
+
106
+ // 4. Strip custom patterns
107
+ for (const pattern of this.compiledCustomPatterns) {
108
+ const matches = clean.match(pattern);
109
+ if (matches) {
110
+ stripped.push(...matches.map(m => `[custom: ${m.slice(0, 20)}...]`));
111
+ clean = clean.replace(pattern, '[REDACTED]');
112
+ }
113
+ }
114
+
115
+ // 5. Clean up multiple consecutive [REDACTED] tags
116
+ clean = clean.replace(/(\[REDACTED(?:-URL)?\]\s*){2,}/g, '[REDACTED] ');
117
+
118
+ return { clean: clean.trim(), stripped };
119
+ }
120
+
121
+ /**
122
+ * Check if text contains any blocked content without modifying it.
123
+ */
124
+ check(text: string): { safe: boolean; issues: string[] } {
125
+ const { stripped } = this.sanitize(text);
126
+ return { safe: stripped.length === 0, issues: stripped };
127
+ }
128
+ }
129
+
130
+ function escapeRegex(str: string): string {
131
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
132
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "node16",
5
+ "moduleResolution": "node16",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src/**/*"]
14
+ }