Skip to content

Commit c57c35d

Browse files
author
tuanductran
committed
feat(schemas): validate metadata boundaries with valibot
1 parent 5210f39 commit c57c35d

12 files changed

Lines changed: 238 additions & 31 deletions

File tree

packages/nextdns-scripts/src/__tests__/public-api.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest';
33
import {
44
AuditCheckSchema,
55
AuditReportSchema,
6+
PackageMetadataSchema,
67
parseAuditReport,
8+
parsePackageMetadata,
79
parseStatsReport,
810
SkillStatsSchema,
911
StatsReportSchema,
@@ -15,10 +17,12 @@ describe('maintenance package public schema API', () => {
1517
expect(AuditReportSchema).toBeDefined();
1618
expect(SkillStatsSchema).toBeDefined();
1719
expect(StatsReportSchema).toBeDefined();
20+
expect(PackageMetadataSchema).toBeDefined();
1821
});
1922

2023
it('exports callable report parsers from the package root', () => {
2124
expect(typeof parseAuditReport).toBe('function');
2225
expect(typeof parseStatsReport).toBe('function');
26+
expect(typeof parsePackageMetadata).toBe('function');
2327
});
2428
});

packages/nextdns-scripts/src/__tests__/schemas.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { parseAuditReport, parseStatsReport } from '../core/schemas.js';
3+
import { parseAuditReport, parsePackageMetadata, parseStatsReport } from '../core/schemas.js';
44

55
const validStatistics = {
66
generatedAt: '2026-08-18T00:00:00.000Z',
@@ -111,4 +111,14 @@ describe('schema parsing', () => {
111111
expect(report.skills[0]?.name).toBe('nextdns-api');
112112
expect(report.impactDistribution.HIGH).toBe(1);
113113
});
114+
115+
it('accepts package metadata with an optional version', () => {
116+
expect(parsePackageMetadata({ version: '0.4.0' })).toEqual({ version: '0.4.0' });
117+
expect(parsePackageMetadata({})).toEqual({});
118+
});
119+
120+
it('rejects malformed package metadata', () => {
121+
expect(() => parsePackageMetadata(null)).toThrow();
122+
expect(() => parsePackageMetadata({ version: 4 })).toThrow();
123+
});
114124
});

packages/nextdns-scripts/src/core/schemas.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import * as v from 'valibot';
22

33
const CountSchema = v.pipe(v.number(), v.integer(), v.minValue(0));
4+
const NonEmptyStringSchema = v.pipe(v.string(), v.minLength(1));
5+
6+
export const PackageMetadataSchema = v.object({
7+
version: v.optional(NonEmptyStringSchema),
8+
});
49

510
const AuditCheckNameSchema = v.picklist([
611
'referential-integrity',
@@ -53,11 +58,16 @@ export const AuditReportSchema = v.object({
5358
statistics: StatsReportSchema,
5459
});
5560

61+
export type PackageMetadata = v.InferOutput<typeof PackageMetadataSchema>;
5662
export type AuditCheck = v.InferOutput<typeof AuditCheckSchema>;
5763
export type AuditReport = v.InferOutput<typeof AuditReportSchema>;
5864
export type SkillStats = v.InferOutput<typeof SkillStatsSchema>;
5965
export type StatsReport = v.InferOutput<typeof StatsReportSchema>;
6066

67+
export function parsePackageMetadata(input: unknown): PackageMetadata {
68+
return v.parse(PackageMetadataSchema, input);
69+
}
70+
6171
export function parseAuditReport(input: unknown): AuditReport {
6272
return v.parse(AuditReportSchema, input);
6373
}
Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { existsSync, readFileSync } from 'node:fs';
22
import { fileURLToPath } from 'node:url';
33

4-
interface PackageMetadata {
5-
version?: unknown;
6-
}
4+
import { parsePackageMetadata } from './schemas.js';
75

86
export function getPackageVersion(): string {
97
const packagePaths = [
@@ -13,6 +11,10 @@ export function getPackageVersion(): string {
1311
const packagePath = packagePaths.find((candidate) => existsSync(candidate));
1412
if (!packagePath) return '0.0.0';
1513

16-
const metadata = JSON.parse(readFileSync(packagePath, 'utf8')) as PackageMetadata;
17-
return typeof metadata.version === 'string' ? metadata.version : '0.0.0';
14+
try {
15+
const metadata = parsePackageMetadata(JSON.parse(readFileSync(packagePath, 'utf8')));
16+
return metadata.version ?? '0.0.0';
17+
} catch {
18+
return '0.0.0';
19+
}
1820
}

packages/nextdns-scripts/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ export { formatAuditText, runAudit } from './commands/audit.js';
1010
export {
1111
AuditCheckSchema,
1212
AuditReportSchema,
13+
PackageMetadataSchema,
1314
parseAuditReport,
15+
parsePackageMetadata,
1416
parseStatsReport,
1517
SkillStatsSchema,
1618
StatsReportSchema,
1719
} from './core/schemas.js';
18-
export type { SkillStats, StatsReport } from './core/schemas.js';
20+
export type { PackageMetadata, SkillStats, StatsReport } from './core/schemas.js';
1921
export { getPackageVersion } from './core/version.js';
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, expect, it } from 'vite-plus/test';
2+
3+
import {
4+
BuildMetadataSchema,
5+
FrontmatterSchema,
6+
PackageMetadataSchema,
7+
parseBuildMetadata,
8+
parseFrontmatter,
9+
parsePackageMetadata,
10+
} from '../core/data-schemas.js';
11+
import { parseFrontmatter as parseMarkdownFrontmatter } from '../core/markdown.js';
12+
13+
const validBuildMetadata = {
14+
version: '1.2.3',
15+
organization: 'NextDNS Skills',
16+
date: '2026-08-18',
17+
abstract: 'Build metadata for the generated skills package.',
18+
references: [{ title: 'NextDNS API', url: 'https://api.nextdns.io' }],
19+
};
20+
21+
describe('data schemas', () => {
22+
describe('FrontmatterSchema', () => {
23+
it('accepts scalar and string-array frontmatter values', () => {
24+
const frontmatter = parseFrontmatter({
25+
title: 'Authentication',
26+
tags: ['api', 'security'],
27+
});
28+
29+
expect(frontmatter).toEqual({ title: 'Authentication', tags: ['api', 'security'] });
30+
});
31+
32+
it('rejects non-string frontmatter values', () => {
33+
expect(() => parseFrontmatter({ tags: ['api', 1] })).toThrow();
34+
expect(() => parseFrontmatter({ count: 2 })).toThrow();
35+
});
36+
37+
it('validates frontmatter produced by the markdown parser', () => {
38+
const frontmatter = parseMarkdownFrontmatter(`---
39+
title: 'Authentication'
40+
tags:
41+
- api
42+
- security
43+
---
44+
45+
# Authentication
46+
`);
47+
48+
expect(FrontmatterSchema).toBeDefined();
49+
expect(frontmatter).toEqual({ title: 'Authentication', tags: ['api', 'security'] });
50+
});
51+
});
52+
53+
describe('BuildMetadataSchema', () => {
54+
it('accepts complete build metadata with references', () => {
55+
const metadata = parseBuildMetadata(validBuildMetadata);
56+
57+
expect(metadata.version).toBe('1.2.3');
58+
expect(metadata.references?.[0]?.url).toBe('https://api.nextdns.io');
59+
});
60+
61+
it('accepts build metadata without optional references', () => {
62+
const { references, ...withoutReferences } = validBuildMetadata;
63+
64+
expect(references).toBeDefined();
65+
expect(parseBuildMetadata(withoutReferences).organization).toBe('NextDNS Skills');
66+
});
67+
68+
it('rejects missing required metadata fields', () => {
69+
const { abstract, ...withoutAbstract } = validBuildMetadata;
70+
71+
expect(abstract).toBeDefined();
72+
expect(() => parseBuildMetadata(withoutAbstract)).toThrow();
73+
});
74+
75+
it('rejects malformed metadata fields and references', () => {
76+
expect(() => parseBuildMetadata({ ...validBuildMetadata, version: 1 })).toThrow();
77+
expect(() =>
78+
parseBuildMetadata({
79+
...validBuildMetadata,
80+
references: [{ title: 'NextDNS API', url: '' }],
81+
})
82+
).toThrow();
83+
});
84+
});
85+
86+
describe('PackageMetadataSchema', () => {
87+
it('accepts package metadata with or without a version', () => {
88+
expect(parsePackageMetadata({ version: '0.4.0' }).version).toBe('0.4.0');
89+
expect(parsePackageMetadata({})).toEqual({});
90+
});
91+
92+
it('rejects malformed package metadata', () => {
93+
expect(() => parsePackageMetadata(null)).toThrow();
94+
expect(() => parsePackageMetadata({ version: 4 })).toThrow();
95+
});
96+
97+
it('exposes the schema for runtime composition', () => {
98+
expect(BuildMetadataSchema).toBeDefined();
99+
expect(PackageMetadataSchema).toBeDefined();
100+
});
101+
});
102+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from 'vite-plus/test';
2+
3+
import {
4+
BuildMetadataSchema,
5+
FrontmatterSchema,
6+
FrontmatterValueSchema,
7+
PackageMetadataSchema,
8+
parseBuildMetadata,
9+
parseFrontmatter,
10+
parsePackageMetadata,
11+
} from '../index.js';
12+
13+
describe('build package public schema API', () => {
14+
it('exports all data schema objects from the package root', () => {
15+
expect(BuildMetadataSchema).toBeDefined();
16+
expect(FrontmatterSchema).toBeDefined();
17+
expect(FrontmatterValueSchema).toBeDefined();
18+
expect(PackageMetadataSchema).toBeDefined();
19+
});
20+
21+
it('exports callable validated parsers from the package root', () => {
22+
expect(typeof parseBuildMetadata).toBe('function');
23+
expect(typeof parseFrontmatter).toBe('function');
24+
expect(typeof parsePackageMetadata).toBe('function');
25+
});
26+
});

packages/nextdns-skills-build/src/commands/build.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { Section } from '../core/types.js';
1111

1212
import { parseBuildCliOptions, type BuildCliOptions } from '../core/cli-validation.js';
1313
import { DEFAULT_SKILL, SKILLS, type SkillConfig } from '../core/config.js';
14+
import { parseBuildMetadata, type BuildMetadata } from '../core/data-schemas.js';
1415
import { parseRuleFile, type RuleFile } from '../core/parser.js';
1516
import { collectRuleFiles } from '../core/utils.js';
1617

@@ -29,13 +30,7 @@ function incrementVersion(version: string): string {
2930
*/
3031
function generateMarkdown(
3132
sections: Section[],
32-
metadata: {
33-
version: string;
34-
organization: string;
35-
date: string;
36-
abstract: string;
37-
references?: { title: string; url: string }[];
38-
},
33+
metadata: BuildMetadata,
3934
skillConfig: SkillConfig
4035
): string {
4136
let md = `# ${skillConfig.title}\n\n`;
@@ -192,16 +187,10 @@ async function buildSkill(skillConfig: SkillConfig, options: BuildCliOptions) {
192187
const sections = Array.from(sectionsMap.values()).sort((a, b) => a.number - b.number);
193188

194189
// Read metadata
195-
let metadata: {
196-
version: string;
197-
organization: string;
198-
date: string;
199-
abstract: string;
200-
references?: { title: string; url: string }[];
201-
};
190+
let metadata: BuildMetadata;
202191
try {
203192
const metadataContent = await readFile(skillConfig.metadataFile, 'utf-8');
204-
metadata = JSON.parse(metadataContent);
193+
metadata = parseBuildMetadata(JSON.parse(metadataContent));
205194
} catch {
206195
metadata = {
207196
version: '1.0.0',
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as v from 'valibot';
2+
3+
const NonEmptyStringSchema = v.pipe(v.string(), v.minLength(1));
4+
5+
export const FrontmatterValueSchema = v.union([v.string(), v.array(v.string())]);
6+
export const FrontmatterSchema = v.record(v.string(), FrontmatterValueSchema);
7+
8+
export const BuildMetadataSchema = v.object({
9+
version: NonEmptyStringSchema,
10+
organization: NonEmptyStringSchema,
11+
date: NonEmptyStringSchema,
12+
abstract: NonEmptyStringSchema,
13+
references: v.optional(
14+
v.array(
15+
v.object({
16+
title: NonEmptyStringSchema,
17+
url: NonEmptyStringSchema,
18+
})
19+
)
20+
),
21+
});
22+
23+
export const PackageMetadataSchema = v.object({
24+
version: v.optional(NonEmptyStringSchema),
25+
});
26+
27+
export type BuildMetadata = v.InferOutput<typeof BuildMetadataSchema>;
28+
export type FrontmatterValue = v.InferOutput<typeof FrontmatterValueSchema>;
29+
export type Frontmatter = v.InferOutput<typeof FrontmatterSchema>;
30+
export type PackageMetadata = v.InferOutput<typeof PackageMetadataSchema>;
31+
32+
export function parseBuildMetadata(input: unknown): BuildMetadata {
33+
return v.parse(BuildMetadataSchema, input);
34+
}
35+
36+
export function parseFrontmatter(input: unknown): Frontmatter {
37+
return v.parse(FrontmatterSchema, input);
38+
}
39+
40+
export function parsePackageMetadata(input: unknown): PackageMetadata {
41+
return v.parse(PackageMetadataSchema, input);
42+
}

packages/nextdns-skills-build/src/core/markdown.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
33

4-
export type FrontmatterValue = string | string[];
5-
export type Frontmatter = Record<string, FrontmatterValue>;
4+
import { parseFrontmatter as validateFrontmatter, type Frontmatter } from './data-schemas.js';
5+
6+
export type { Frontmatter, FrontmatterValue } from './data-schemas.js';
67

78
export function parseFrontmatter(content: string): Frontmatter {
89
if (!content.startsWith('---')) return {};
@@ -45,7 +46,7 @@ export function parseFrontmatter(content: string): Frontmatter {
4546
}
4647

4748
if (inArray && currentKey) result[currentKey] = arrayValues.slice();
48-
return result;
49+
return validateFrontmatter(result);
4950
}
5051

5152
export function collectMarkdownFiles(dir: string): string[] {

0 commit comments

Comments
 (0)