@telvok/librarian-mcp 2.0.0 → 2.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.
- package/dist/library/sensitive-scanner.d.ts +20 -0
- package/dist/library/sensitive-scanner.js +56 -0
- package/dist/server.js +2 -0
- package/dist/tools/audit.d.ts +27 -0
- package/dist/tools/audit.js +126 -0
- package/dist/tools/library-publish.d.ts +5 -0
- package/dist/tools/library-publish.js +76 -16
- package/package.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface SensitiveFinding {
|
|
2
|
+
entry: string;
|
|
3
|
+
file?: string;
|
|
4
|
+
matches: string[];
|
|
5
|
+
}
|
|
6
|
+
interface ScannableEntry {
|
|
7
|
+
title: string;
|
|
8
|
+
content: string;
|
|
9
|
+
intent?: string;
|
|
10
|
+
context?: string;
|
|
11
|
+
reasoning?: string;
|
|
12
|
+
example?: string;
|
|
13
|
+
originalPath?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Scan entries for sensitive data patterns.
|
|
17
|
+
* Returns findings grouped by entry.
|
|
18
|
+
*/
|
|
19
|
+
export declare function scanForSensitiveData(entries: ScannableEntry[]): SensitiveFinding[];
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Sensitive Data Scanner
|
|
3
|
+
// Shared module for scanning entries before they leave the user's machine.
|
|
4
|
+
// Used by: library_publish (mandatory), audit tool (on-demand)
|
|
5
|
+
// ============================================================================
|
|
6
|
+
const SENSITIVE_PATTERNS = [
|
|
7
|
+
// API keys and tokens
|
|
8
|
+
{ pattern: /sk_(live|test)_[a-zA-Z0-9]{10,}/g, label: 'Stripe secret key' },
|
|
9
|
+
{ pattern: /whsec_[a-zA-Z0-9]{10,}/g, label: 'Stripe webhook secret' },
|
|
10
|
+
{ pattern: /tvk_[a-zA-Z0-9]{20,}/g, label: 'Telvok API key' },
|
|
11
|
+
{ pattern: /ghp_[a-zA-Z0-9]{36}/g, label: 'GitHub personal access token' },
|
|
12
|
+
{ pattern: /xoxb-[a-zA-Z0-9-]+/g, label: 'Slack bot token' },
|
|
13
|
+
{ pattern: /eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}/g, label: 'JWT token' },
|
|
14
|
+
{ pattern: /AKIA[A-Z0-9]{16}/g, label: 'AWS access key' },
|
|
15
|
+
{ pattern: /npm_[a-zA-Z0-9]{36}/g, label: 'npm token' },
|
|
16
|
+
// Credentials in assignments
|
|
17
|
+
{ pattern: /password\s*[:=]\s*['"][^'"]+['"]/gi, label: 'password value' },
|
|
18
|
+
{ pattern: /secret\s*[:=]\s*['"][^'"]+['"]/gi, label: 'secret value' },
|
|
19
|
+
{ pattern: /api[_-]?key\s*[:=]\s*['"][^'"]+['"]/gi, label: 'API key value' },
|
|
20
|
+
// Personal data
|
|
21
|
+
{ pattern: /\b[a-zA-Z0-9._%+-]+@(?!example\.com)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/g, label: 'email address' },
|
|
22
|
+
// Connection strings with credentials
|
|
23
|
+
{ pattern: /:\/\/[^:]+:[^@]+@[^/\s]+/g, label: 'URL with embedded credentials' },
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Scan entries for sensitive data patterns.
|
|
27
|
+
* Returns findings grouped by entry.
|
|
28
|
+
*/
|
|
29
|
+
export function scanForSensitiveData(entries) {
|
|
30
|
+
const findings = [];
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
const textToScan = [
|
|
33
|
+
entry.title,
|
|
34
|
+
entry.content,
|
|
35
|
+
entry.intent,
|
|
36
|
+
entry.context,
|
|
37
|
+
entry.reasoning,
|
|
38
|
+
entry.example,
|
|
39
|
+
].filter(Boolean).join('\n');
|
|
40
|
+
const matches = [];
|
|
41
|
+
for (const { pattern, label } of SENSITIVE_PATTERNS) {
|
|
42
|
+
pattern.lastIndex = 0;
|
|
43
|
+
if (pattern.test(textToScan)) {
|
|
44
|
+
matches.push(label);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (matches.length > 0) {
|
|
48
|
+
findings.push({
|
|
49
|
+
entry: entry.title,
|
|
50
|
+
file: entry.originalPath,
|
|
51
|
+
matches,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return findings;
|
|
56
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -27,6 +27,7 @@ import { bountySubmitTool } from './tools/bounty-submit.js';
|
|
|
27
27
|
import { myBountiesTool } from './tools/my-bounties.js';
|
|
28
28
|
import { deleteTool } from './tools/delete.js';
|
|
29
29
|
import { unsubscribeTool } from './tools/unsubscribe.js';
|
|
30
|
+
import { auditTool } from './tools/audit.js';
|
|
30
31
|
const allTools = [
|
|
31
32
|
// Core tools — local knowledge management
|
|
32
33
|
{ tool: briefTool, group: 'core' },
|
|
@@ -36,6 +37,7 @@ const allTools = [
|
|
|
36
37
|
{ tool: importMemoriesTool, group: 'core' },
|
|
37
38
|
{ tool: rebuildIndexTool, group: 'core' },
|
|
38
39
|
{ tool: deleteTool, group: 'core' },
|
|
40
|
+
{ tool: auditTool, group: 'core' },
|
|
39
41
|
// Marketplace tools — cloud features
|
|
40
42
|
{ tool: librarySearchTool, group: 'marketplace' },
|
|
41
43
|
{ tool: libraryBuyTool, group: 'marketplace' },
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type SensitiveFinding } from '../library/sensitive-scanner.js';
|
|
2
|
+
interface AuditResult {
|
|
3
|
+
success: boolean;
|
|
4
|
+
message: string;
|
|
5
|
+
total_scanned: number;
|
|
6
|
+
findings: SensitiveFinding[];
|
|
7
|
+
clean: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare const auditTool: {
|
|
10
|
+
name: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description: string;
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: "object";
|
|
15
|
+
properties: {
|
|
16
|
+
entries: {
|
|
17
|
+
type: string;
|
|
18
|
+
items: {
|
|
19
|
+
type: string;
|
|
20
|
+
};
|
|
21
|
+
description: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
handler(args: unknown): Promise<AuditResult>;
|
|
26
|
+
};
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Audit Tool
|
|
3
|
+
// Scan local entries for sensitive data before publishing
|
|
4
|
+
// ============================================================================
|
|
5
|
+
import * as fs from 'fs/promises';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import { glob } from 'glob';
|
|
8
|
+
import matter from 'gray-matter';
|
|
9
|
+
import { getLibraryPath, getLocalPath } from '../library/storage.js';
|
|
10
|
+
import { scanForSensitiveData } from '../library/sensitive-scanner.js';
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Tool Definition
|
|
13
|
+
// ============================================================================
|
|
14
|
+
export const auditTool = {
|
|
15
|
+
name: 'audit',
|
|
16
|
+
title: 'Audit Entries',
|
|
17
|
+
description: `Scan local entries for sensitive data (API keys, passwords, emails, tokens, credentials).
|
|
18
|
+
|
|
19
|
+
USE THIS TOOL WHEN:
|
|
20
|
+
- Before publishing a book — catches leaks before they go public
|
|
21
|
+
- User says "audit", "check for secrets", or "scan my entries"
|
|
22
|
+
- After recording entries that involved credentials or auth work
|
|
23
|
+
- Proactively before any library_publish() call
|
|
24
|
+
|
|
25
|
+
Returns a list of entries with sensitive data findings, or confirms all clear.
|
|
26
|
+
|
|
27
|
+
TRIGGER PATTERNS:
|
|
28
|
+
- Before publishing → audit()
|
|
29
|
+
- "Check my entries for secrets" → audit()
|
|
30
|
+
- Scan specific entries → audit({ entries: ["file1.md", "file2.md"] })`,
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
entries: {
|
|
35
|
+
type: 'array',
|
|
36
|
+
items: { type: 'string' },
|
|
37
|
+
description: 'Specific entry filenames to audit (omit to scan all local/)',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
async handler(args) {
|
|
42
|
+
const { entries: entryFilter } = (args || {});
|
|
43
|
+
const libraryPath = getLibraryPath();
|
|
44
|
+
const localPath = getLocalPath(libraryPath);
|
|
45
|
+
// Collect entries
|
|
46
|
+
const collectedEntries = [];
|
|
47
|
+
try {
|
|
48
|
+
const files = await glob(path.join(localPath, '**/*.md'), { nodir: true });
|
|
49
|
+
for (const filePath of files) {
|
|
50
|
+
const filename = path.basename(filePath);
|
|
51
|
+
if (entryFilter && entryFilter.length > 0) {
|
|
52
|
+
const matchesFilter = entryFilter.some(f => filename === f ||
|
|
53
|
+
filename === f + '.md' ||
|
|
54
|
+
filePath.endsWith(f) ||
|
|
55
|
+
filePath.endsWith(f + '.md'));
|
|
56
|
+
if (!matchesFilter)
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
61
|
+
const { data: frontmatter, content: body } = matter(content);
|
|
62
|
+
const trimmedBody = body.trim();
|
|
63
|
+
if (!trimmedBody)
|
|
64
|
+
continue;
|
|
65
|
+
let title = frontmatter.title;
|
|
66
|
+
if (!title) {
|
|
67
|
+
const headingMatch = trimmedBody.match(/^#\s+(.+)$/m);
|
|
68
|
+
title = headingMatch ? headingMatch[1].trim() : path.basename(filePath, '.md');
|
|
69
|
+
}
|
|
70
|
+
// Extract sections
|
|
71
|
+
const reasoningMatch = trimmedBody.match(/##\s*Reasoning\s*\n([\s\S]*?)(?=##|$)/i);
|
|
72
|
+
const exampleMatch = trimmedBody.match(/##\s*Example\s*\n([\s\S]*?)(?=##|$)/i);
|
|
73
|
+
collectedEntries.push({
|
|
74
|
+
title,
|
|
75
|
+
content: trimmedBody,
|
|
76
|
+
intent: frontmatter.intent || undefined,
|
|
77
|
+
context: frontmatter.context || undefined,
|
|
78
|
+
reasoning: reasoningMatch ? reasoningMatch[1].trim() : undefined,
|
|
79
|
+
example: exampleMatch ? exampleMatch[1].trim() : undefined,
|
|
80
|
+
originalPath: filePath,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Skip unparseable files
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
message: 'No .librarian/local/ directory found.',
|
|
92
|
+
total_scanned: 0,
|
|
93
|
+
findings: [],
|
|
94
|
+
clean: false,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (collectedEntries.length === 0) {
|
|
98
|
+
return {
|
|
99
|
+
success: true,
|
|
100
|
+
message: 'No entries found to audit.',
|
|
101
|
+
total_scanned: 0,
|
|
102
|
+
findings: [],
|
|
103
|
+
clean: true,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Run scan
|
|
107
|
+
const findings = scanForSensitiveData(collectedEntries);
|
|
108
|
+
if (findings.length === 0) {
|
|
109
|
+
return {
|
|
110
|
+
success: true,
|
|
111
|
+
message: `✅ All clear — scanned ${collectedEntries.length} entries, no sensitive data found.`,
|
|
112
|
+
total_scanned: collectedEntries.length,
|
|
113
|
+
findings: [],
|
|
114
|
+
clean: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const warnings = findings.map(f => ` ⚠ ${f.entry}: ${f.matches.join(', ')}`).join('\n');
|
|
118
|
+
return {
|
|
119
|
+
success: true,
|
|
120
|
+
message: `⚠ Found sensitive data in ${findings.length} of ${collectedEntries.length} entries:\n${warnings}\n\nClean these up with record() or delete() before publishing.`,
|
|
121
|
+
total_scanned: collectedEntries.length,
|
|
122
|
+
findings,
|
|
123
|
+
clean: false,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
interface PublishResult {
|
|
2
2
|
success: boolean;
|
|
3
3
|
message: string;
|
|
4
|
+
publish_token?: string;
|
|
4
5
|
book?: {
|
|
5
6
|
id?: string;
|
|
6
7
|
slug: string;
|
|
@@ -85,6 +86,10 @@ export declare const libraryPublishTool: {
|
|
|
85
86
|
type: string;
|
|
86
87
|
description: string;
|
|
87
88
|
};
|
|
89
|
+
publish_token: {
|
|
90
|
+
type: string;
|
|
91
|
+
description: string;
|
|
92
|
+
};
|
|
88
93
|
entries: {
|
|
89
94
|
type: string;
|
|
90
95
|
items: {
|
|
@@ -4,36 +4,41 @@
|
|
|
4
4
|
// ============================================================================
|
|
5
5
|
import * as fs from 'fs/promises';
|
|
6
6
|
import * as path from 'path';
|
|
7
|
+
import * as crypto from 'crypto';
|
|
7
8
|
import { glob } from 'glob';
|
|
8
9
|
import matter from 'gray-matter';
|
|
9
10
|
import { loadApiKey } from './auth.js';
|
|
10
11
|
import { getLibraryPath, getLocalPath } from '../library/storage.js';
|
|
12
|
+
import { scanForSensitiveData } from '../library/sensitive-scanner.js';
|
|
11
13
|
const TELVOK_API_URL = process.env.TELVOK_API_URL || 'https://telvok.com';
|
|
14
|
+
const TOKEN_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
|
15
|
+
let pendingPublish = null;
|
|
12
16
|
// ============================================================================
|
|
13
17
|
// Tool Definition
|
|
14
18
|
// ============================================================================
|
|
15
19
|
export const libraryPublishTool = {
|
|
16
20
|
name: 'library_publish',
|
|
17
21
|
title: 'Publish Book',
|
|
18
|
-
description: `Publish local entries as a book on Telvok
|
|
22
|
+
description: `Publish local entries as a book on Telvok library.
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
- User wants to share/sell their recorded knowledge
|
|
22
|
-
- User says "publish", "share", or "sell" their entries
|
|
23
|
-
- Creating a book from .librarian/local/ entries
|
|
24
|
+
⚠️ TWO-STEP PUBLISH FLOW (MANDATORY):
|
|
24
25
|
|
|
25
|
-
ALWAYS
|
|
26
|
+
Step 1: ALWAYS call with preview: true first. This shows what will be published
|
|
27
|
+
and returns a publish_token. Show the preview to the user and ASK FOR CONFIRMATION.
|
|
28
|
+
|
|
29
|
+
Step 2: ONLY after the user explicitly confirms, call again with the publish_token
|
|
30
|
+
from the preview response. Publishing WITHOUT a valid token will be rejected.
|
|
31
|
+
|
|
32
|
+
DO NOT skip the preview. DO NOT publish without user confirmation.
|
|
33
|
+
The tool will refuse to publish without a valid publish_token from a preview.
|
|
26
34
|
|
|
27
35
|
TRIGGER PATTERNS:
|
|
28
36
|
- "Publish my entries" → library_publish({ name: "...", pricing: { type: "open" }, preview: true })
|
|
29
|
-
- "
|
|
30
|
-
- After preview approval → add attestation and consumption, remove preview
|
|
31
|
-
|
|
32
|
-
Required for actual publish: name, pricing, consumption, attestation (all true).
|
|
37
|
+
- User says "yes, publish it" → library_publish({ ..., publish_token: "<token from preview>" })
|
|
33
38
|
|
|
34
39
|
Examples:
|
|
35
40
|
- Preview: library_publish({ name: "My Book", pricing: { type: "open" }, preview: true })
|
|
36
|
-
- Publish: library_publish({ name: "My Book", pricing: { type: "open" }, consumption: "download", attestation: { original_work: true, no_secrets: true, terms_accepted: true } })`,
|
|
41
|
+
- Publish: library_publish({ name: "My Book", pricing: { type: "open" }, consumption: "download", attestation: { original_work: true, no_secrets: true, terms_accepted: true }, publish_token: "abc123" })`,
|
|
37
42
|
inputSchema: {
|
|
38
43
|
type: 'object',
|
|
39
44
|
properties: {
|
|
@@ -78,7 +83,11 @@ Examples:
|
|
|
78
83
|
},
|
|
79
84
|
preview: {
|
|
80
85
|
type: 'boolean',
|
|
81
|
-
description: 'If true, show what would be published without publishing',
|
|
86
|
+
description: 'If true, show what would be published without publishing. Returns a publish_token.',
|
|
87
|
+
},
|
|
88
|
+
publish_token: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
description: 'Token from preview response. Required to actually publish. Single-use, expires in 5 minutes.',
|
|
82
91
|
},
|
|
83
92
|
entries: {
|
|
84
93
|
type: 'array',
|
|
@@ -99,7 +108,7 @@ Examples:
|
|
|
99
108
|
required: ['name', 'pricing'],
|
|
100
109
|
},
|
|
101
110
|
async handler(args) {
|
|
102
|
-
const { name, description, pricing, consumption, attestation, preview, entries: entryFilter, tags, license } = args;
|
|
111
|
+
const { name, description, pricing, consumption, attestation, preview, publish_token, entries: entryFilter, tags, license } = args;
|
|
103
112
|
// Validate name
|
|
104
113
|
if (!name || typeof name !== 'string' || name.trim().length < 3) {
|
|
105
114
|
throw new Error('Book name is required (minimum 3 characters)');
|
|
@@ -130,6 +139,24 @@ Examples:
|
|
|
130
139
|
}
|
|
131
140
|
// Collect entries from local/ (needed for preview and publish)
|
|
132
141
|
const collectedEntries = await collectLocalEntries(entryFilter);
|
|
142
|
+
// Scan for sensitive data before publishing
|
|
143
|
+
const sensitiveFindings = scanForSensitiveData(collectedEntries);
|
|
144
|
+
if (sensitiveFindings.length > 0) {
|
|
145
|
+
const warnings = sensitiveFindings.map(f => ` ⚠ ${f.entry}: ${f.matches.join(', ')}`).join('\n');
|
|
146
|
+
if (preview) {
|
|
147
|
+
// In preview mode, show warnings but continue
|
|
148
|
+
return {
|
|
149
|
+
success: true,
|
|
150
|
+
preview: true,
|
|
151
|
+
message: `⚠ SENSITIVE DATA DETECTED in ${sensitiveFindings.length} entry(s):\n${warnings}\n\nReview these entries before publishing. Remove credentials, API keys, passwords, and personal data.`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// In publish mode, block and require cleanup
|
|
155
|
+
return {
|
|
156
|
+
success: false,
|
|
157
|
+
message: `🚫 Publish blocked — sensitive data detected in ${sensitiveFindings.length} entry(s):\n${warnings}\n\nClean up these entries with record() or delete() before publishing. Use library_publish({ preview: true }) to re-check.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
133
160
|
if (collectedEntries.length === 0) {
|
|
134
161
|
return {
|
|
135
162
|
success: false,
|
|
@@ -140,12 +167,20 @@ Examples:
|
|
|
140
167
|
const pricingDisplay = pricing.type === 'open'
|
|
141
168
|
? 'Free'
|
|
142
169
|
: `$${((pricing.price_cents || 0) / 100).toFixed(2)}`;
|
|
143
|
-
// Handle preview mode - return summary
|
|
170
|
+
// Handle preview mode - return summary with publish token
|
|
144
171
|
if (preview) {
|
|
172
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
173
|
+
pendingPublish = {
|
|
174
|
+
token,
|
|
175
|
+
name: name.trim(),
|
|
176
|
+
entries_count: collectedEntries.length,
|
|
177
|
+
created: Date.now(),
|
|
178
|
+
};
|
|
145
179
|
return {
|
|
146
180
|
success: true,
|
|
147
181
|
preview: true,
|
|
148
|
-
message: `Preview of "${name.trim()}" - NOT published
|
|
182
|
+
message: `Preview of "${name.trim()}" - NOT published yet.\n\n⚠️ Show this to the user and ask for confirmation before publishing.`,
|
|
183
|
+
publish_token: token,
|
|
149
184
|
summary: {
|
|
150
185
|
name: name.trim(),
|
|
151
186
|
pricing: { type: pricing.type, display: pricingDisplay },
|
|
@@ -155,9 +190,34 @@ Examples:
|
|
|
155
190
|
file: path.basename(e.originalPath),
|
|
156
191
|
})),
|
|
157
192
|
},
|
|
158
|
-
next_steps: '
|
|
193
|
+
next_steps: 'Show preview to user. After they confirm, call library_publish() again with the publish_token to publish.',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// ========================================================================
|
|
197
|
+
// PUBLISH TOKEN VALIDATION
|
|
198
|
+
// Cannot publish without a valid token from preview
|
|
199
|
+
// ========================================================================
|
|
200
|
+
if (!publish_token) {
|
|
201
|
+
return {
|
|
202
|
+
success: false,
|
|
203
|
+
message: '🚫 Publishing requires a publish_token from a preview.\n\nYou must call library_publish({ preview: true, ... }) first, show the preview to the user, get their confirmation, then call again with the publish_token.\n\nThis is a safety measure to prevent accidental publishing.',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (!pendingPublish || pendingPublish.token !== publish_token) {
|
|
207
|
+
return {
|
|
208
|
+
success: false,
|
|
209
|
+
message: '🚫 Invalid or expired publish_token. Run a new preview first with library_publish({ preview: true, ... }).',
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (Date.now() - pendingPublish.created > TOKEN_EXPIRY_MS) {
|
|
213
|
+
pendingPublish = null;
|
|
214
|
+
return {
|
|
215
|
+
success: false,
|
|
216
|
+
message: '🚫 Publish token expired (5 minute limit). Run a new preview first.',
|
|
159
217
|
};
|
|
160
218
|
}
|
|
219
|
+
// Token is valid — consume it (single use)
|
|
220
|
+
pendingPublish = null;
|
|
161
221
|
// Validate consumption type (required for actual publish)
|
|
162
222
|
if (!consumption) {
|
|
163
223
|
return {
|