jamdesk 1.1.37 → 1.1.39
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/__tests__/integration/init.integration.test.js +44 -0
- package/dist/__tests__/integration/init.integration.test.js.map +1 -1
- package/dist/__tests__/unit/init.test.js +2 -1
- package/dist/__tests__/unit/init.test.js.map +1 -1
- package/package.json +1 -1
- package/templates/api-reference/openapi-example.mdx +55 -0
- package/templates/api-reference/request-response-examples.mdx +210 -0
- package/templates/components/callouts.mdx +56 -0
- package/templates/components/cards.mdx +80 -0
- package/templates/components/steps.mdx +39 -0
- package/templates/components/tabs-and-accordions.mdx +65 -0
- package/templates/docs.json +48 -0
- package/templates/introduction.mdx +40 -10
- package/templates/openapi/example-api.yaml +185 -0
- package/templates/quickstart.mdx +98 -9
- package/templates/writing/code-blocks.mdx +80 -0
- package/templates/writing/components.mdx +78 -0
- package/templates/writing/pages.mdx +59 -0
- package/vendored/app/[[...slug]]/page.tsx +26 -8
- package/vendored/app/api/chat/[project]/route.ts +53 -3
- package/vendored/app/api/docs-search/[project]/search/route.ts +48 -3
- package/vendored/app/layout.tsx +4 -4
- package/vendored/components/mdx/OpenApiEndpoint.tsx +2 -1
- package/vendored/components/navigation/Sidebar.tsx +9 -4
- package/vendored/components/search/SearchModal.tsx +13 -20
- package/vendored/hooks/useChat.ts +22 -4
- package/vendored/lib/chat-prompt.ts +1 -1
- package/vendored/lib/chat-tools.ts +3 -0
- package/vendored/lib/embedding-chunker.ts +18 -2
- package/vendored/lib/language-codes.json +27 -0
- package/vendored/lib/language-utils.ts +80 -5
- package/vendored/lib/link-rewriter.ts +67 -0
- package/vendored/lib/locale-helpers.ts +62 -0
- package/vendored/lib/openapi/code-examples.ts +5 -6
- package/vendored/lib/openapi/derive-auth.ts +46 -0
- package/vendored/lib/openapi/index.ts +7 -0
- package/vendored/lib/openapi/parser.ts +7 -2
- package/vendored/lib/openapi/resolve-server-url.ts +14 -0
- package/vendored/lib/openapi/types.ts +2 -0
- package/vendored/lib/path-safety.ts +96 -0
- package/vendored/lib/search-client.ts +117 -12
- package/vendored/lib/static-artifacts.ts +25 -1
- package/vendored/lib/static-file-route.ts +13 -0
- package/vendored/lib/vector-store.ts +70 -17
- package/vendored/scripts/build-search-index.cjs +91 -24
- package/vendored/themes/base.css +5 -0
- package/vendored/workspace-package-lock.json +6 -6
|
@@ -5,6 +5,21 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { LanguageCode, LanguageConfig } from './docs-types';
|
|
8
|
+
import LANGUAGE_CODES_JSON from './language-codes.json';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Canonical language code list, shared with the CJS build script via
|
|
12
|
+
* `lib/language-codes.json`. Updates to LANGUAGE_DISPLAY_NAMES below MUST
|
|
13
|
+
* also update language-codes.json. The sync test in
|
|
14
|
+
* __tests__/lib/language-codes-sync.test.ts catches drift.
|
|
15
|
+
*/
|
|
16
|
+
export const LANGUAGE_CODES = LANGUAGE_CODES_JSON as readonly LanguageCode[];
|
|
17
|
+
|
|
18
|
+
/** BCP-47 syntax check (2-3 letter primary tag + optional 2-4 letter
|
|
19
|
+
* region/script). Shared by the chat and docs-search REST endpoints to
|
|
20
|
+
* keep their request-validation contracts identical. Limitation:
|
|
21
|
+
* 3-segment tags like `zh-Hant-HK` are rejected. */
|
|
22
|
+
export const BCP47_LANGUAGE_RE = /^[a-zA-Z]{2,3}(?:[-_][a-zA-Z]{2,4})?$/;
|
|
8
23
|
|
|
9
24
|
/**
|
|
10
25
|
* Display names for each supported language code
|
|
@@ -275,21 +290,81 @@ export function isValidLanguageCode(code: string): code is LanguageCode {
|
|
|
275
290
|
return code in LANGUAGE_DISPLAY_NAMES;
|
|
276
291
|
}
|
|
277
292
|
|
|
293
|
+
/** Lowercase → canonical lookup, built once. Lets `extractLanguageFromPath`
|
|
294
|
+
* match URL segments regardless of case (`/pt-br/...` returns `'pt-BR'`).
|
|
295
|
+
* Vercel and some CDN configs serve mixed-case folder paths under
|
|
296
|
+
* lowercased URLs, so a strict case-sensitive match silently breaks
|
|
297
|
+
* every locale-aware feature on those URL forms.
|
|
298
|
+
* First insertion wins — iteration order MUST match the CJS map in
|
|
299
|
+
* `scripts/build-search-index.cjs`, so we iterate the shared JSON list
|
|
300
|
+
* (single source of truth) instead of `LANGUAGE_DISPLAY_NAMES` keys. */
|
|
301
|
+
const LANGUAGE_CODE_BY_LOWER: Map<string, LanguageCode> = (() => {
|
|
302
|
+
const m = new Map<string, LanguageCode>();
|
|
303
|
+
for (const k of LANGUAGE_CODES) {
|
|
304
|
+
const lower = k.toLowerCase();
|
|
305
|
+
if (!m.has(lower)) m.set(lower, k);
|
|
306
|
+
}
|
|
307
|
+
return m;
|
|
308
|
+
})();
|
|
309
|
+
|
|
278
310
|
/**
|
|
279
311
|
* Extract language code from a pathname
|
|
280
312
|
*
|
|
281
313
|
* @param pathname - The URL pathname (e.g., '/es/introduction' or '/docs/es/introduction')
|
|
282
|
-
* @returns The language code if found, undefined otherwise
|
|
314
|
+
* @returns The canonical-cased language code if found, undefined otherwise
|
|
283
315
|
*/
|
|
284
316
|
export function extractLanguageFromPath(pathname: string): LanguageCode | undefined {
|
|
285
317
|
// Remove /docs prefix if present (for backward compatibility)
|
|
286
318
|
const parts = pathname.replace(/^\/docs\/?/, '').replace(/^\//, '').split('/').filter(Boolean);
|
|
287
319
|
|
|
288
|
-
if (parts.length
|
|
289
|
-
|
|
290
|
-
|
|
320
|
+
if (parts.length === 0) return undefined;
|
|
321
|
+
return LANGUAGE_CODE_BY_LOWER.get(parts[0].toLowerCase());
|
|
322
|
+
}
|
|
291
323
|
|
|
292
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Resolve the active locale from a pathname, gated by the project's declared
|
|
326
|
+
* language whitelist.
|
|
327
|
+
*
|
|
328
|
+
* Returns:
|
|
329
|
+
* - `''` when the path has no language prefix, OR the prefix is not in the
|
|
330
|
+
* project's `navigation.languages[].language` whitelist (e.g. `dodo/de/foo`
|
|
331
|
+
* where dodo only declares `en`).
|
|
332
|
+
* - The canonical language code when the prefix matches the whitelist.
|
|
333
|
+
*
|
|
334
|
+
* Use this — not `extractLanguageFromPath` directly — for any feature that
|
|
335
|
+
* filters by language (search, language selector). The whitelist prevents
|
|
336
|
+
* false-positives on directories named after language codes but not actually
|
|
337
|
+
* translations.
|
|
338
|
+
*/
|
|
339
|
+
export function resolveLocaleFromPath(
|
|
340
|
+
pathname: string,
|
|
341
|
+
projectLanguages: readonly string[],
|
|
342
|
+
): string {
|
|
343
|
+
return resolveLocaleWithLoweredSet(pathname, buildLoweredLocaleSet(projectLanguages));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Build the lowercase whitelist Set used by `resolveLocaleWithLoweredSet`.
|
|
348
|
+
* Hoist this out of hot paths (build-time per-page loops, runtime per-keystroke)
|
|
349
|
+
* to avoid re-allocating on every call.
|
|
350
|
+
*/
|
|
351
|
+
export function buildLoweredLocaleSet(
|
|
352
|
+
projectLanguages: readonly string[],
|
|
353
|
+
): ReadonlySet<string> {
|
|
354
|
+
return new Set(projectLanguages.map((l) => l.toLowerCase()));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Hot-path variant of `resolveLocaleFromPath`: caller supplies a pre-built
|
|
359
|
+
* lowered Set. Same semantics; avoids per-call Set construction.
|
|
360
|
+
*/
|
|
361
|
+
export function resolveLocaleWithLoweredSet(
|
|
362
|
+
pathname: string,
|
|
363
|
+
loweredLanguageSet: ReadonlySet<string>,
|
|
364
|
+
): string {
|
|
365
|
+
const candidate = extractLanguageFromPath(pathname);
|
|
366
|
+
if (!candidate) return '';
|
|
367
|
+
return loweredLanguageSet.has(candidate.toLowerCase()) ? candidate : '';
|
|
293
368
|
}
|
|
294
369
|
|
|
295
370
|
/**
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown link rewriter for chat responses.
|
|
3
|
+
*
|
|
4
|
+
* Rewrites two forms to absolute URLs from a `pageSlug → absoluteUrl` map:
|
|
5
|
+
*
|
|
6
|
+
* 1. Bare-slug: `[Anchor](api-reference/foo)` → `[Anchor](https://x/docs/api-reference/foo)`
|
|
7
|
+
* 2. Absolute URL: `[Anchor](https://anything/.../api-reference/foo)` → canonical absolute URL
|
|
8
|
+
*
|
|
9
|
+
* Both forms are needed because Haiku at temp 0.3 produces a mix:
|
|
10
|
+
* - Sometimes copies the `URL:` line from chunk context verbatim (form 2, correct host)
|
|
11
|
+
* - Sometimes emits the pageSlug (form 1)
|
|
12
|
+
* - Sometimes hallucinates a host (form 2, wrong host) — the slug suffix still resolves
|
|
13
|
+
* - Sometimes lowercases the leading segment of mixed-case locale folders
|
|
14
|
+
* (`pt-BR/foo` → `pt-br/foo`) — hence the lowercase lookup map.
|
|
15
|
+
*
|
|
16
|
+
* Streaming note: this is NOT streaming-safe. Apply ONLY to a complete markdown
|
|
17
|
+
* string (the final tool input). The chat route streams the raw markdown live
|
|
18
|
+
* for typing-effect UX, then sends a `text_replace` SSE event with the
|
|
19
|
+
* rewritten string at end-of-stream.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
23
|
+
|
|
24
|
+
export function rewriteSlugLinks(
|
|
25
|
+
text: string,
|
|
26
|
+
slugToUrl: Record<string, string>,
|
|
27
|
+
): string {
|
|
28
|
+
const lookup: Record<string, string> = {};
|
|
29
|
+
for (const [key, value] of Object.entries(slugToUrl)) lookup[key.toLowerCase()] = value;
|
|
30
|
+
|
|
31
|
+
return text.replace(MARKDOWN_LINK_RE, (full, anchor: string, url: string, offset: number) => {
|
|
32
|
+
// Skip image syntax (). A leading "!" turns the same bracket
|
|
33
|
+
// pattern into an image — rewriting it would point the <img src> at a docs
|
|
34
|
+
// page if the URL happens to match a known slug.
|
|
35
|
+
if (offset > 0 && text[offset - 1] === '!') return full;
|
|
36
|
+
const resolved = resolveSlugFromUrl(url, lookup);
|
|
37
|
+
return resolved ? `[${anchor}](${resolved})` : full;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveSlugFromUrl(
|
|
42
|
+
url: string,
|
|
43
|
+
lookup: Record<string, string>,
|
|
44
|
+
): string | null {
|
|
45
|
+
let path = url;
|
|
46
|
+
let hash = '';
|
|
47
|
+
|
|
48
|
+
if (/^https?:\/\//i.test(url)) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = new URL(url);
|
|
51
|
+
path = parsed.pathname;
|
|
52
|
+
hash = parsed.hash;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
const hashIdx = path.indexOf('#');
|
|
58
|
+
if (hashIdx !== -1) {
|
|
59
|
+
hash = path.slice(hashIdx);
|
|
60
|
+
path = path.slice(0, hashIdx);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
path = path.replace(/^\.?\/+/, '').toLowerCase();
|
|
65
|
+
const hit = lookup[path] ?? lookup[path.replace(/^docs\//, '')];
|
|
66
|
+
return hit ? hit + hash : null;
|
|
67
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale helpers for AI chat embedding + retrieval.
|
|
3
|
+
*
|
|
4
|
+
* The chunker uses these at build time to tag each EmbeddingChunk with the
|
|
5
|
+
* locale derived from the file path (using the project's i18n config). The
|
|
6
|
+
* chat API uses the same shape at query time to filter Upstash Vector
|
|
7
|
+
* results to a single locale, preventing cross-language pollution.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface LanguageEntry {
|
|
11
|
+
code: string;
|
|
12
|
+
isDefault: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RawLanguageEntry {
|
|
16
|
+
language: string;
|
|
17
|
+
default?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalize the raw `navigation.languages` array into a stable, lowercased shape.
|
|
22
|
+
*
|
|
23
|
+
* - If no entry has `default: true`, the first entry is treated as default
|
|
24
|
+
* (matches resolveLanguageWithFallback() in language-utils.ts).
|
|
25
|
+
* - Codes are lowercased so `pt-BR` from docs.json matches `pt-br/...` paths.
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeLanguageList(
|
|
28
|
+
raw: RawLanguageEntry[] | undefined,
|
|
29
|
+
): LanguageEntry[] {
|
|
30
|
+
if (!raw || raw.length === 0) return [];
|
|
31
|
+
const hasExplicitDefault = raw.some((l) => l.default === true);
|
|
32
|
+
return raw.map((l, i) => ({
|
|
33
|
+
code: l.language.toLowerCase(),
|
|
34
|
+
isDefault: hasExplicitDefault ? l.default === true : i === 0,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Derive the locale for a chunk from its file path.
|
|
40
|
+
*
|
|
41
|
+
* Returns:
|
|
42
|
+
* - `null` when no i18n config is present (single-language project) — the
|
|
43
|
+
* filter must NOT be applied to chunks with no locale metadata, and a
|
|
44
|
+
* blanket "en" default would silently mislabel Spanish-only docs sites.
|
|
45
|
+
* - The matching language code when the leading path segment is in the
|
|
46
|
+
* configured allowlist.
|
|
47
|
+
* - The configured default language code otherwise.
|
|
48
|
+
*
|
|
49
|
+
* Critically, ONLY exact membership in the configured language list counts.
|
|
50
|
+
* Two-letter folders like `ai/`, `cn/`, `ui/`, `snippets/` are NOT treated as
|
|
51
|
+
* locales unless they are explicitly listed in `navigation.languages`.
|
|
52
|
+
*/
|
|
53
|
+
export function deriveChunkLocale(
|
|
54
|
+
filePath: string,
|
|
55
|
+
languages: LanguageEntry[],
|
|
56
|
+
): string | null {
|
|
57
|
+
if (languages.length === 0) return null;
|
|
58
|
+
const defaultCode = languages.find((l) => l.isDefault)?.code ?? languages[0].code;
|
|
59
|
+
const firstSegment = filePath.replace(/\\/g, '/').split('/')[0].toLowerCase();
|
|
60
|
+
const match = languages.find((l) => l.code === firstSegment);
|
|
61
|
+
return match ? match.code : defaultCode;
|
|
62
|
+
}
|
|
@@ -11,11 +11,10 @@ import type {
|
|
|
11
11
|
CodeExample,
|
|
12
12
|
JsonSchema,
|
|
13
13
|
} from './types';
|
|
14
|
+
import type { ApiAuthMethod } from '../docs-types';
|
|
15
|
+
import { resolveServerUrl } from './resolve-server-url';
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
* Supported auth methods from docs.json
|
|
17
|
-
*/
|
|
18
|
-
export type AuthMethod = 'bearer' | 'basic' | 'key' | 'cobo';
|
|
17
|
+
export type AuthMethod = ApiAuthMethod;
|
|
19
18
|
|
|
20
19
|
/**
|
|
21
20
|
* Configuration for code example generation
|
|
@@ -194,7 +193,7 @@ export function buildGeneratorContext(
|
|
|
194
193
|
config: CodeExampleConfig
|
|
195
194
|
): GeneratorContext {
|
|
196
195
|
const { method, path, parameters, requestBody } = endpoint;
|
|
197
|
-
const baseUrl = endpoint.servers[0]
|
|
196
|
+
const baseUrl = resolveServerUrl(endpoint.servers[0]) || DEFAULT_BASE_URL;
|
|
198
197
|
const formattedPath = formatPathWithParams(path, parameters);
|
|
199
198
|
const queryString = formatQueryParams(parameters);
|
|
200
199
|
const fullUrl = `${baseUrl}${formattedPath}${queryString}`;
|
|
@@ -257,7 +256,7 @@ export function generatePythonExample(
|
|
|
257
256
|
|
|
258
257
|
// Python handles query params separately (params=params in request call),
|
|
259
258
|
// so we build the URL without query string
|
|
260
|
-
const baseUrl = endpoint.servers[0]
|
|
259
|
+
const baseUrl = resolveServerUrl(endpoint.servers[0]) || DEFAULT_BASE_URL;
|
|
261
260
|
const formattedPath = formatPathWithParams(endpoint.path, parameters);
|
|
262
261
|
const pythonUrl = `${baseUrl}${formattedPath}`;
|
|
263
262
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AuthMethod } from './code-examples';
|
|
2
|
+
import type { SecurityRequirement } from './types';
|
|
3
|
+
|
|
4
|
+
export interface DerivedAuth {
|
|
5
|
+
method?: AuthMethod;
|
|
6
|
+
headerName?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Derive playground auth config from a parsed OpenAPI security list.
|
|
11
|
+
*
|
|
12
|
+
* Returns `{}` when no scheme is renderable in the playground (empty list,
|
|
13
|
+
* apiKey-in-query/cookie, etc). docs.json `api.mdx.auth` should still take
|
|
14
|
+
* precedence over this — callers in page.tsx merge with the docs.json value first.
|
|
15
|
+
*
|
|
16
|
+
* Falls through past schemes the playground can't render, so a spec offering
|
|
17
|
+
* "either apiKey-in-query or bearer" still works.
|
|
18
|
+
*/
|
|
19
|
+
export function deriveAuthFromSecurity(security: SecurityRequirement[]): DerivedAuth {
|
|
20
|
+
for (const req of security) {
|
|
21
|
+
const mapped = mapSingle(req);
|
|
22
|
+
if (mapped.method) return mapped;
|
|
23
|
+
}
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mapSingle(req: SecurityRequirement): DerivedAuth {
|
|
28
|
+
if (req.type === 'http') {
|
|
29
|
+
if (req.scheme === 'bearer') return { method: 'bearer' };
|
|
30
|
+
if (req.scheme === 'basic') return { method: 'basic' };
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (req.type === 'apiKey') {
|
|
35
|
+
if (req.in === 'header' && req.parameterName) {
|
|
36
|
+
return { method: 'key', headerName: req.parameterName };
|
|
37
|
+
}
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (req.type === 'oauth2' || req.type === 'openIdConnect') {
|
|
42
|
+
return { method: 'bearer' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
@@ -90,3 +90,10 @@ export {
|
|
|
90
90
|
generateNavigationGroups,
|
|
91
91
|
getSpecStats,
|
|
92
92
|
} from './generator';
|
|
93
|
+
|
|
94
|
+
// Auth derivation
|
|
95
|
+
export type { DerivedAuth } from './derive-auth';
|
|
96
|
+
export { deriveAuthFromSecurity } from './derive-auth';
|
|
97
|
+
|
|
98
|
+
// Server URL resolution
|
|
99
|
+
export { resolveServerUrl } from './resolve-server-url';
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Extracts parameters, request bodies, responses, and security info.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { OpenAPI, OpenAPIV3
|
|
8
|
+
import type { OpenAPI, OpenAPIV3 } from 'openapi-types';
|
|
9
9
|
import type {
|
|
10
10
|
HttpMethod,
|
|
11
11
|
ParsedOpenApiFrontmatter,
|
|
@@ -219,10 +219,14 @@ function parseSecuritySchemes(api: OpenAPI.Document): Map<string, SecurityRequir
|
|
|
219
219
|
scheme: 'scheme' in s ? s.scheme : undefined,
|
|
220
220
|
in: 'in' in s ? s.in as SecurityRequirement['in'] : undefined,
|
|
221
221
|
bearerFormat: 'bearerFormat' in s ? s.bearerFormat : undefined,
|
|
222
|
+
parameterName:
|
|
223
|
+
s.type === 'apiKey' && 'name' in s
|
|
224
|
+
? (s as OpenAPIV3.ApiKeySecurityScheme).name
|
|
225
|
+
: undefined,
|
|
222
226
|
});
|
|
223
227
|
}
|
|
224
228
|
}
|
|
225
|
-
|
|
229
|
+
|
|
226
230
|
// Swagger 2.0
|
|
227
231
|
if ('securityDefinitions' in api) {
|
|
228
232
|
const defs = (api as any).securityDefinitions;
|
|
@@ -233,6 +237,7 @@ function parseSecuritySchemes(api: OpenAPI.Document): Map<string, SecurityRequir
|
|
|
233
237
|
type: s.type,
|
|
234
238
|
scheme: s.scheme,
|
|
235
239
|
in: s.in,
|
|
240
|
+
parameterName: s.type === 'apiKey' ? s.name : undefined,
|
|
236
241
|
});
|
|
237
242
|
}
|
|
238
243
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ServerInfo } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Substitute `{varname}` placeholders in an OpenAPI server URL with the
|
|
5
|
+
* defaults declared in `server.variables`. Unknown placeholders are left
|
|
6
|
+
* intact so they remain visible to the user rather than producing a broken
|
|
7
|
+
* URL fragment.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveServerUrl(server: ServerInfo | undefined): string | undefined {
|
|
10
|
+
if (!server) return undefined;
|
|
11
|
+
return server.url.replace(/\{(\w+)\}/g, (_, key: string) => {
|
|
12
|
+
return server.variables?.[key]?.default ?? `{${key}}`;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -120,6 +120,8 @@ export interface SecurityRequirement {
|
|
|
120
120
|
scheme?: string; // For http type: 'bearer', 'basic'
|
|
121
121
|
in?: 'query' | 'header' | 'cookie'; // For apiKey type
|
|
122
122
|
bearerFormat?: string;
|
|
123
|
+
/** For apiKey schemes: the header / query / cookie parameter name (e.g. 'X-Api-Key'). */
|
|
124
|
+
parameterName?: string;
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
/**
|
|
@@ -97,6 +97,102 @@ export function validateDocsPath(
|
|
|
97
97
|
return { valid: true, resolvedPath: resolved };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Validates a git ref (branch name) for use in `git clone --branch <ref>`.
|
|
102
|
+
*
|
|
103
|
+
* Combines two rule sets:
|
|
104
|
+
* 1. git's `check-ref-format` constraints (forbids `..`, `@{`, trailing `.lock`,
|
|
105
|
+
* leading/trailing `/`, control chars, and a handful of ASCII punctuation).
|
|
106
|
+
* 2. A strict shell-safety allowlist — even though `cloneRepo` now uses
|
|
107
|
+
* `execFileSync` with an argv array, this validator is defense-in-depth so
|
|
108
|
+
* a future refactor that reintroduces a shell cannot be silently exploited.
|
|
109
|
+
*
|
|
110
|
+
* Allowed characters: A–Z, a–z, 0–9, and `._-/`.
|
|
111
|
+
*
|
|
112
|
+
* @param ref - Git ref received from a client (webhook payload / API request)
|
|
113
|
+
* @returns `{ valid: true }` or `{ valid: false, error }`
|
|
114
|
+
*/
|
|
115
|
+
export function validateGitRef(ref: unknown): SlugValidationResult {
|
|
116
|
+
if (typeof ref !== 'string') {
|
|
117
|
+
return { valid: false, error: 'Invalid git ref: must be a string' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Deliberately do NOT trim. The caller passes `ref` verbatim into argv, so
|
|
121
|
+
// the validator must judge the exact string the caller will use. If a
|
|
122
|
+
// consumer wants to allow trimming, they must trim before calling.
|
|
123
|
+
if (ref.length === 0) {
|
|
124
|
+
return { valid: false, error: 'Invalid git ref: cannot be empty' };
|
|
125
|
+
}
|
|
126
|
+
if (ref.length > 255) {
|
|
127
|
+
return { valid: false, error: 'Invalid git ref: exceeds 255 characters' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Allowlist: alphanumerics plus `._-/` only.
|
|
131
|
+
// Everything outside this set — including every shell metacharacter
|
|
132
|
+
// (`;`, backtick, `$`, `|`, `&`, `<`, `>`, `(`, `)`, `{`, `}`, `[`, `]`,
|
|
133
|
+
// single/double quotes, `\`, `#`, `!`, `*`, `?`, `~`, `^`, `:`, spaces,
|
|
134
|
+
// tabs, newlines, null bytes, and all other control chars) — is rejected.
|
|
135
|
+
if (!/^[A-Za-z0-9._\-\/]+$/.test(ref)) {
|
|
136
|
+
return { valid: false, error: 'Invalid git ref: contains disallowed characters' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The following enforce git check-ref-format rules that the allowlist
|
|
140
|
+
// cannot express positionally — characters like `.`, `/`, and `-` are
|
|
141
|
+
// permitted in general but disallowed in specific positions or sequences.
|
|
142
|
+
if (ref.includes('..')) {
|
|
143
|
+
return { valid: false, error: 'Invalid git ref: cannot contain ".." sequences' };
|
|
144
|
+
}
|
|
145
|
+
if (ref.startsWith('/') || ref.endsWith('/')) {
|
|
146
|
+
return { valid: false, error: 'Invalid git ref: cannot start or end with "/"' };
|
|
147
|
+
}
|
|
148
|
+
if (ref.startsWith('.') || ref.endsWith('.')) {
|
|
149
|
+
return { valid: false, error: 'Invalid git ref: cannot start or end with "."' };
|
|
150
|
+
}
|
|
151
|
+
if (ref.endsWith('.lock')) {
|
|
152
|
+
return { valid: false, error: 'Invalid git ref: cannot end with ".lock"' };
|
|
153
|
+
}
|
|
154
|
+
// Block leading `-` so git does not parse the ref as a CLI flag
|
|
155
|
+
// (e.g. `-oProxyCommand=...`, `--upload-pack=...`).
|
|
156
|
+
if (ref.startsWith('-')) {
|
|
157
|
+
return { valid: false, error: 'Invalid git ref: cannot start with "-"' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { valid: true };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Validates a GitHub `owner/repo` full-name string.
|
|
165
|
+
*
|
|
166
|
+
* Allowlist per segment: `[A-Za-z0-9][A-Za-z0-9_.-]*` with 1–100 chars each.
|
|
167
|
+
* Exactly one `/` separating owner and repo. Leading `-` is rejected so the
|
|
168
|
+
* value cannot be mistaken for a CLI flag even if it ever escapes argv to a
|
|
169
|
+
* shell (defense-in-depth).
|
|
170
|
+
*
|
|
171
|
+
* @param repoFullName - Full repo name from a webhook/API payload
|
|
172
|
+
* @returns `{ valid: true }` or `{ valid: false, error }`
|
|
173
|
+
*/
|
|
174
|
+
export function validateRepoFullName(repoFullName: unknown): SlugValidationResult {
|
|
175
|
+
if (typeof repoFullName !== 'string') {
|
|
176
|
+
return { valid: false, error: 'Invalid repoFullName: must be a string' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// No trim: validate the exact string the caller will pass into argv.
|
|
180
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(repoFullName)) {
|
|
181
|
+
return {
|
|
182
|
+
valid: false,
|
|
183
|
+
error: 'Invalid repoFullName: must match owner/repo with alphanumerics, "_", ".", or "-"',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Defense in depth against path traversal: `..` segments could resolve
|
|
188
|
+
// outside the intended repos dir if this value ever flows into a path-join.
|
|
189
|
+
if (repoFullName.includes('..')) {
|
|
190
|
+
return { valid: false, error: 'Invalid repoFullName: cannot contain ".." sequences' };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { valid: true };
|
|
194
|
+
}
|
|
195
|
+
|
|
100
196
|
/**
|
|
101
197
|
* Validates a project slug for use in file paths.
|
|
102
198
|
* Defense-in-depth: slugs come from Firestore but should still be validated
|