jamdesk 1.1.25 → 1.1.27

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.
Files changed (31) hide show
  1. package/dist/__tests__/integration/validate.integration.test.js +40 -1
  2. package/dist/__tests__/integration/validate.integration.test.js.map +1 -1
  3. package/dist/__tests__/unit/templates-consistency.test.d.ts +2 -0
  4. package/dist/__tests__/unit/templates-consistency.test.d.ts.map +1 -0
  5. package/dist/__tests__/unit/templates-consistency.test.js +19 -0
  6. package/dist/__tests__/unit/templates-consistency.test.js.map +1 -0
  7. package/dist/commands/validate.d.ts.map +1 -1
  8. package/dist/commands/validate.js +9 -6
  9. package/dist/commands/validate.js.map +1 -1
  10. package/dist/lib/navigation-validator.d.ts +39 -17
  11. package/dist/lib/navigation-validator.d.ts.map +1 -1
  12. package/dist/lib/navigation-validator.js +65 -36
  13. package/dist/lib/navigation-validator.js.map +1 -1
  14. package/dist/lib/openapi/types.d.ts +1 -0
  15. package/dist/lib/openapi/types.d.ts.map +1 -1
  16. package/package.json +1 -1
  17. package/vendored/app/[[...slug]]/page.tsx +66 -29
  18. package/vendored/app/api/docs-search/[project]/search/route.ts +168 -0
  19. package/vendored/components/openapi/OpenApiError.tsx +35 -0
  20. package/vendored/lib/docs-search-auth.ts +95 -0
  21. package/vendored/lib/docs-types.ts +14 -4
  22. package/vendored/lib/extract-highlights.ts +2 -2
  23. package/vendored/lib/mcp-search.ts +39 -0
  24. package/vendored/lib/middleware-helpers.ts +21 -1
  25. package/vendored/lib/openapi/parser.ts +7 -3
  26. package/vendored/lib/openapi/types.ts +1 -0
  27. package/vendored/lib/validate-config.ts +118 -13
  28. package/vendored/schema/docs-schema.json +0 -3
  29. package/vendored/shared/navigation-validator.ts +103 -53
  30. package/vendored/shared/status-reporter.ts +1 -1
  31. package/vendored/workspace-package-lock.json +6 -6
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Docs Search API — Semantic Search Endpoint
3
+ *
4
+ * REST API for external integrations (Intercom, Zendesk, custom chatbots)
5
+ * to search project documentation via vector similarity.
6
+ *
7
+ * Security:
8
+ * - Opaque API key auth (SHA-256 hash lookup in Upstash Redis; revocation
9
+ * is a Redis DEL, so there is no separate blocklist)
10
+ * - Per-key rate limiting (60 req/min)
11
+ * - CORS enabled (cross-origin access by design)
12
+ *
13
+ * Usage:
14
+ * POST https://acme.jamdesk.app/_api/search
15
+ * Authorization: Bearer jd_live_<32 hex>
16
+ * {"query": "How do I authenticate?", "limit": 5}
17
+ */
18
+ import { NextRequest, NextResponse } from 'next/server';
19
+ import { querySimilarChunks } from '@/lib/vector-store';
20
+ import { verifyApiKey } from '@/lib/docs-search-auth';
21
+ import { getBaseUrl, trackServerAnalytics } from '@/lib/route-helpers';
22
+ import { redis } from '@/lib/redis';
23
+
24
+ export const runtime = 'nodejs';
25
+ export const maxDuration = 30;
26
+
27
+ const CORS_HEADERS = {
28
+ 'Access-Control-Allow-Origin': '*',
29
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
30
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
31
+ };
32
+
33
+ const MAX_LIMIT = 20;
34
+ const DEFAULT_LIMIT = 5;
35
+ const MAX_QUERY_LENGTH = 500;
36
+ const RATE_LIMIT_PER_MIN = 60;
37
+
38
+ export async function OPTIONS(_request: NextRequest) {
39
+ return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
40
+ }
41
+
42
+ export async function POST(
43
+ request: NextRequest,
44
+ context: { params: Promise<{ project: string }> },
45
+ ): Promise<NextResponse> {
46
+ const { project } = await context.params;
47
+
48
+ // --- Auth: verify opaque key against Upstash ---
49
+ // A revoked key is a Redis DEL, so "not found" and "revoked" collapse
50
+ // into the same invalid_key reason — no separate blocklist check.
51
+ // RFC 7235: scheme is case-insensitive. Accept `Bearer`, `bearer`, etc.,
52
+ // and tolerate stray whitespace rather than 401-ing strict clients.
53
+ const token = (request.headers.get('Authorization') || '')
54
+ .replace(/^Bearer\s+/i, '')
55
+ .trim();
56
+ const verify = await verifyApiKey(token, project);
57
+
58
+ if (!verify.ok) {
59
+ const status =
60
+ verify.reason === 'wrong_project' ? 403 :
61
+ verify.reason === 'lookup_failed' ||
62
+ verify.reason === 'redis_unavailable' ? 503 :
63
+ 401;
64
+ return NextResponse.json(
65
+ { error: verify.reason },
66
+ { status, headers: CORS_HEADERS },
67
+ );
68
+ }
69
+
70
+ // --- Rate limiting: per key ID ---
71
+ // Always-call expire (not just on count === 1) — otherwise a transient
72
+ // failure between INCR and EXPIRE leaves the bucket immortal, slowly
73
+ // leaking Upstash keys. EXPIRE is idempotent.
74
+ if (redis) {
75
+ try {
76
+ const rlKey = `docs_search_rl:${verify.id}:${Math.floor(Date.now() / 60000)}`;
77
+ const count = await redis.incr(rlKey);
78
+ await redis.expire(rlKey, 120);
79
+
80
+ if (count > RATE_LIMIT_PER_MIN) {
81
+ return NextResponse.json(
82
+ { error: 'Rate limit exceeded' },
83
+ { status: 429, headers: { ...CORS_HEADERS, 'Retry-After': '60' } },
84
+ );
85
+ }
86
+ } catch {
87
+ // Redis down — allow through
88
+ }
89
+ }
90
+
91
+ // --- Parse & validate request body ---
92
+ let body: { query?: string; limit?: number };
93
+ try {
94
+ body = await request.json();
95
+ } catch {
96
+ return NextResponse.json(
97
+ { error: 'Invalid JSON body' },
98
+ { status: 400, headers: CORS_HEADERS },
99
+ );
100
+ }
101
+
102
+ const { query, limit: rawLimit } = body;
103
+
104
+ if (!query || typeof query !== 'string' || query.trim().length === 0) {
105
+ return NextResponse.json(
106
+ { error: 'Missing or empty "query" field' },
107
+ { status: 400, headers: CORS_HEADERS },
108
+ );
109
+ }
110
+
111
+ if (query.length > MAX_QUERY_LENGTH) {
112
+ return NextResponse.json(
113
+ { error: `Query exceeds ${MAX_QUERY_LENGTH} characters` },
114
+ { status: 400, headers: CORS_HEADERS },
115
+ );
116
+ }
117
+
118
+ // Use Number.isFinite so limit=0 clamps to 1 instead of falling to default
119
+ const parsedLimit = Number(rawLimit);
120
+ const effectiveLimit = Number.isFinite(parsedLimit)
121
+ ? parsedLimit
122
+ : DEFAULT_LIMIT;
123
+ const limit = Math.min(
124
+ Math.max(1, Math.floor(effectiveLimit)),
125
+ MAX_LIMIT,
126
+ );
127
+
128
+ // --- Semantic vector search ---
129
+ const startMs = Date.now();
130
+ let chunks;
131
+ try {
132
+ chunks = await querySimilarChunks(project, query.trim(), limit);
133
+ } catch (err) {
134
+ console.error('Vector search failed:', err);
135
+ return NextResponse.json(
136
+ { error: 'Search temporarily unavailable' },
137
+ { status: 502, headers: CORS_HEADERS },
138
+ );
139
+ }
140
+ const durationMs = Date.now() - startMs;
141
+
142
+ const resolvedHost = request.headers.get('x-jamdesk-forwarded-host')
143
+ || request.headers.get('x-original-host') || '';
144
+ const baseUrl = getBaseUrl(project, resolvedHost);
145
+
146
+ const results = chunks.map(chunk => ({
147
+ title: chunk.pageTitle,
148
+ section: chunk.sectionHeading || undefined,
149
+ slug: chunk.pageSlug,
150
+ content: chunk.content.slice(0, 500),
151
+ url: `${baseUrl}/${chunk.pageSlug}`,
152
+ score: Math.round(chunk.score * 1000) / 1000,
153
+ }));
154
+
155
+ // --- Analytics (fire-and-forget) ---
156
+ trackServerAnalytics({
157
+ projectSlug: project,
158
+ type: 'docs_search',
159
+ query: query.trim(),
160
+ resultsCount: results.length,
161
+ source: `key:${verify.id}`,
162
+ });
163
+
164
+ return NextResponse.json(
165
+ { results, query: query.trim(), total: results.length, durationMs },
166
+ { status: 200, headers: CORS_HEADERS },
167
+ );
168
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * OpenApiError — visible warning when OpenAPI spec resolution fails.
3
+ * Renders in all environments (dev, ISR, CLI) so users see the problem
4
+ * instead of a silently blank page.
5
+ */
6
+
7
+ interface OpenApiErrorProps {
8
+ message: string;
9
+ slug: string;
10
+ }
11
+
12
+ export function OpenApiError({ message, slug }: OpenApiErrorProps) {
13
+ return (
14
+ <div
15
+ role="alert"
16
+ style={{
17
+ margin: '1.5rem 0',
18
+ padding: '1rem 1.25rem',
19
+ borderRadius: '8px',
20
+ border: '1px solid #f59e0b',
21
+ backgroundColor: 'rgba(245, 158, 11, 0.08)',
22
+ fontSize: '0.875rem',
23
+ lineHeight: 1.5,
24
+ color: 'var(--color-text-primary, #1e293b)',
25
+ }}
26
+ >
27
+ <div style={{ fontWeight: 600, marginBottom: '0.375rem' }}>
28
+ OpenAPI Error
29
+ </div>
30
+ <div style={{ color: 'var(--color-text-muted, #64748b)' }}>
31
+ Failed to load the OpenAPI specification for <code>{slug}</code>: {message}
32
+ </div>
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Docs Search API key verification.
3
+ *
4
+ * Tokens are opaque: `jd_live_<32 hex chars>` (40 chars total, 128 bits
5
+ * of entropy from crypto.randomBytes). The `_live_` segment reserves
6
+ * namespace for a future `jd_test_` mode. The server hashes the token
7
+ * with SHA-256 and looks up `apikey:<hash>` in Upstash Redis — the
8
+ * dashboard function dual-writes this record on generate and deletes
9
+ * it on revoke. No JWT, no signing secret, no expiration.
10
+ */
11
+ import {createHash} from 'crypto';
12
+ import {redis} from './redis';
13
+ import {parseRedisConfig} from './domain-helpers';
14
+
15
+ const KEY_FORMAT = /^jd_live_[0-9a-f]{32}$/;
16
+
17
+ export type VerifyResult =
18
+ | {ok: true; id: string}
19
+ | {ok: false; reason: VerifyFailure};
20
+
21
+ export type VerifyFailure =
22
+ | 'invalid_key_format'
23
+ | 'invalid_key'
24
+ | 'wrong_project'
25
+ | 'lookup_failed'
26
+ | 'redis_unavailable';
27
+
28
+ interface StoredKey {
29
+ projectSlug: string;
30
+ id: string;
31
+ }
32
+
33
+ function hashApiKey(rawKey: string): string {
34
+ return createHash('sha256').update(rawKey).digest('hex');
35
+ }
36
+
37
+ function parseStoredKey(raw: unknown): StoredKey | null {
38
+ let parsed: Record<string, unknown> | null;
39
+ try {
40
+ parsed = parseRedisConfig(raw);
41
+ } catch {
42
+ return null;
43
+ }
44
+ if (
45
+ parsed &&
46
+ typeof parsed.projectSlug === 'string' &&
47
+ typeof parsed.id === 'string'
48
+ ) {
49
+ return parsed as unknown as StoredKey;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Verify a bearer token against the expected project slug.
56
+ *
57
+ * @param rawKey The bearer token exactly as received (no trimming — the
58
+ * caller is responsible for stripping the `Bearer ` prefix).
59
+ * @param projectSlug The slug from the URL path (`[project]` param).
60
+ * @returns `{ok: true, id}` on success, `{ok: false, reason}` otherwise.
61
+ * `id` is the short identifier suitable for audit logging;
62
+ * do NOT log the raw token or the hash.
63
+ */
64
+ export async function verifyApiKey(
65
+ rawKey: string,
66
+ projectSlug: string,
67
+ ): Promise<VerifyResult> {
68
+ if (typeof rawKey !== 'string' || !KEY_FORMAT.test(rawKey)) {
69
+ return {ok: false, reason: 'invalid_key_format'};
70
+ }
71
+
72
+ if (!redis) {
73
+ return {ok: false, reason: 'redis_unavailable'};
74
+ }
75
+
76
+ const hash = hashApiKey(rawKey);
77
+
78
+ let raw: unknown;
79
+ try {
80
+ raw = await redis.get(`apikey:${hash}`);
81
+ } catch {
82
+ return {ok: false, reason: 'lookup_failed'};
83
+ }
84
+
85
+ const stored = parseStoredKey(raw);
86
+ if (!stored) {
87
+ return {ok: false, reason: 'invalid_key'};
88
+ }
89
+
90
+ if (stored.projectSlug !== projectSlug) {
91
+ return {ok: false, reason: 'wrong_project'};
92
+ }
93
+
94
+ return {ok: true, id: stored.id};
95
+ }
@@ -214,9 +214,13 @@ export interface TabConfig {
214
214
  }
215
215
 
216
216
  /**
217
- * Anchor configuration (top-level navigation sections)
218
- * @deprecated Use TabConfig instead. Anchors in navigation are deprecated.
219
- * For external links, use the top-level `anchors` field with ExternalAnchorConfig.
217
+ * Legacy anchor configuration (top-level navigation sections).
218
+ *
219
+ * Retained so NavigationConfig / DropdownConfig / ProductConfig /
220
+ * VersionConfig / LanguageConfig can continue to type customer configs
221
+ * that still carry the legacy shape. New configs should use TabConfig
222
+ * for internal sections and top-level ExternalAnchorConfig for external
223
+ * links; `validateConfig` rejects `navigation.anchors` at build time.
220
224
  */
221
225
  export interface AnchorConfig {
222
226
  anchor: string;
@@ -792,9 +796,15 @@ export interface DocsConfig {
792
796
  // Required fields
793
797
  theme: ThemeName;
794
798
  name: string;
795
- colors: ColorsConfig;
796
799
  navigation: NavigationConfig;
797
800
 
801
+ // Boundary escape hatch — docs.json is user-authored and may carry fields
802
+ // not represented in this interface. Unknown keys read as `unknown`.
803
+ [key: string]: unknown;
804
+
805
+ // Optional — themes provide defaults at render time
806
+ colors?: ColorsConfig;
807
+
798
808
  // Optional fields
799
809
  description?: string;
800
810
  favicon?: Favicon;
@@ -12,7 +12,7 @@ import type { DocsConfig } from './docs-types.js';
12
12
  export interface ExtractedHighlights {
13
13
  siteName: string;
14
14
  theme: 'jam' | 'nebula' | 'pulsar';
15
- primaryColor: string;
15
+ primaryColor?: string;
16
16
  seoIndexable: boolean;
17
17
  analyticsIntegrations: string[];
18
18
  apiPlaygroundType: 'interactive' | 'simple' | 'hidden' | null;
@@ -118,7 +118,7 @@ export function extractConfigHighlights(config: DocsConfig, pageCount: number =
118
118
  return {
119
119
  siteName: config.name,
120
120
  theme: config.theme,
121
- primaryColor: config.colors.primary,
121
+ primaryColor: config.colors?.primary,
122
122
  seoIndexable,
123
123
  analyticsIntegrations,
124
124
  apiPlaygroundType,
@@ -7,6 +7,7 @@
7
7
  import { create, insertMultiple, search as oramaSearch, type Orama } from '@orama/orama';
8
8
  import { restore } from '@orama/plugin-data-persistence';
9
9
  import { getFileBufferFromR2 } from './r2';
10
+ import { querySimilarChunks } from './vector-store';
10
11
 
11
12
  // Types matching builder/build-service/lib/search-client.ts
12
13
  export interface SearchDocument {
@@ -28,6 +29,35 @@ export interface SearchResult {
28
29
  score: number;
29
30
  }
30
31
 
32
+ /**
33
+ * Vector search adapter for MCP. Wraps querySimilarChunks()
34
+ * and maps to SearchResult[] format. Returns null if vector
35
+ * store is not configured (Orama fallback kicks in).
36
+ */
37
+ export async function searchProjectWithVector(
38
+ project: string,
39
+ query: string,
40
+ limit: number,
41
+ ): Promise<SearchResult[] | null> {
42
+ if (!process.env.UPSTASH_VECTOR_REST_URL ||
43
+ !process.env.UPSTASH_VECTOR_REST_TOKEN) {
44
+ return null;
45
+ }
46
+
47
+ try {
48
+ const chunks = await querySimilarChunks(project, query, limit);
49
+ return chunks.map(chunk => ({
50
+ title: chunk.pageTitle,
51
+ url: chunk.pageSlug,
52
+ section: chunk.sectionHeading || undefined,
53
+ type: 'guide',
54
+ score: chunk.score,
55
+ }));
56
+ } catch {
57
+ return null; // Fall back to Orama on error
58
+ }
59
+ }
60
+
31
61
  // Orama schema type
32
62
  type SearchSchema = {
33
63
  id: 'string';
@@ -74,6 +104,15 @@ export async function searchProject(
74
104
  return [];
75
105
  }
76
106
 
107
+ // Try vector search first for unfiltered queries
108
+ if (type === 'all') {
109
+ const vectorResults = await searchProjectWithVector(project, query, limit);
110
+ if (vectorResults) {
111
+ return vectorResults.map(r => ({ ...r, url: `${docsPath}/${r.url}` }));
112
+ }
113
+ }
114
+
115
+ // Fall back to Orama text search
77
116
  const db = await getOrCreateIndex(project, docsPath);
78
117
 
79
118
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -7,7 +7,6 @@
7
7
 
8
8
  import { log } from './logger';
9
9
  import {
10
- resolveProject,
11
10
  resolveProjectFromHostname,
12
11
  resolveCustomDomain,
13
12
  getProjectConfig,
@@ -315,6 +314,7 @@ export const INTERNAL_API_ROUTES = [
315
314
  '/api/indexnow', // IndexNow key verification (app/api/indexnow/[key])
316
315
  '/api/isr-health', // Health check endpoint (app/api/isr-health)
317
316
  '/api/chat', // Chat endpoint (app/api/chat/[project])
317
+ '/api/docs-search', // Docs Search API (app/api/docs-search/[project]/search)
318
318
  '/api/mcp', // MCP endpoint (app/api/mcp/[project])
319
319
  '/api/og', // OG image generation (app/api/og)
320
320
  '/api/playground', // API playground (token, proxy, demo) — must skip hostAtDocs redirect
@@ -447,6 +447,26 @@ export function getChatApiPath(projectSlug: string): string {
447
447
  return `/api/chat/${projectSlug}`;
448
448
  }
449
449
 
450
+ /**
451
+ * Check if this is a docs search request that needs routing.
452
+ *
453
+ * @param pathname - Request pathname
454
+ * @returns true if this is a docs search request
455
+ */
456
+ export function isDocsSearchRequest(pathname: string): boolean {
457
+ return pathname === '/_api/search' || pathname === '/docs/_api/search';
458
+ }
459
+
460
+ /**
461
+ * Get the docs search API path for a project.
462
+ *
463
+ * @param projectSlug - Project identifier
464
+ * @returns Docs search API route path
465
+ */
466
+ export function getDocsSearchApiPath(projectSlug: string): string {
467
+ return `/api/docs-search/${projectSlug}/search`;
468
+ }
469
+
450
470
  const PLAYGROUND_PREFIX = '/_jd/playground/';
451
471
 
452
472
  /**
@@ -27,7 +27,7 @@ const VALID_METHODS: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'H
27
27
  * Full format: "/path/to/spec.yaml METHOD /endpoint/path"
28
28
  * Short format (with defaultSpecPath): "METHOD /endpoint/path"
29
29
  */
30
- export function parseOpenApiFrontmatter(value: string, defaultSpecPath?: string): ParsedOpenApiFrontmatter {
30
+ export function parseOpenApiFrontmatter(value: string, defaultSpecPath?: string | string[]): ParsedOpenApiFrontmatter {
31
31
  const trimmed = value.trim();
32
32
  const parts = trimmed.split(/\s+/);
33
33
 
@@ -38,7 +38,7 @@ export function parseOpenApiFrontmatter(value: string, defaultSpecPath?: string)
38
38
 
39
39
  if (isMethodFirst) {
40
40
  // Short format: "METHOD /endpoint/path"
41
- if (!defaultSpecPath) {
41
+ if (!defaultSpecPath || (Array.isArray(defaultSpecPath) && defaultSpecPath.length === 0)) {
42
42
  throw createFrontmatterError(
43
43
  value,
44
44
  'Short format (e.g., "GET /users") requires api.openapi to be configured in docs.json.\n' +
@@ -46,6 +46,8 @@ export function parseOpenApiFrontmatter(value: string, defaultSpecPath?: string)
46
46
  );
47
47
  }
48
48
 
49
+ const resolvedDefault = Array.isArray(defaultSpecPath) ? defaultSpecPath[0] : defaultSpecPath;
50
+
49
51
  const [method, ...pathParts] = parts;
50
52
  const endpointPath = pathParts.join(' ');
51
53
 
@@ -58,9 +60,10 @@ export function parseOpenApiFrontmatter(value: string, defaultSpecPath?: string)
58
60
  }
59
61
 
60
62
  return {
61
- specPath: defaultSpecPath.startsWith('/') ? defaultSpecPath : `/${defaultSpecPath}`,
63
+ specPath: resolvedDefault.startsWith('/') ? resolvedDefault : `/${resolvedDefault}`,
62
64
  method: method.toUpperCase() as HttpMethod,
63
65
  path: endpointPath,
66
+ isShortFormat: true,
64
67
  };
65
68
  }
66
69
  }
@@ -106,6 +109,7 @@ export function parseOpenApiFrontmatter(value: string, defaultSpecPath?: string)
106
109
  specPath,
107
110
  method: upperMethod,
108
111
  path: endpointPath,
112
+ isShortFormat: false,
109
113
  };
110
114
  }
111
115
 
@@ -164,6 +164,7 @@ export interface ParsedOpenApiFrontmatter {
164
164
  specPath: string;
165
165
  method: HttpMethod;
166
166
  path: string;
167
+ isShortFormat: boolean;
167
168
  }
168
169
 
169
170
  /**