Skip to content

refactor: Zod-based config loader #998

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion .github/workflows/unused-dependencies.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
node-version: '20.x'
- name: 'Run depcheck'
run: |
npx depcheck --skip-missing --ignores="tsx,@babel/*,@commitlint/*,eslint,eslint-*,husky,mocha,ts-mocha,ts-node,concurrently,nyc,prettier,typescript,tsconfig-paths,vite-tsconfig-paths"
npx depcheck --skip-missing --ignores="tsx,@babel/*,@commitlint/*,eslint,eslint-*,husky,mocha,ts-mocha,ts-node,concurrently,nyc,prettier,typescript,tsconfig-paths,vite-tsconfig-paths,jsonschema"
echo $?
if [[ $? == 1 ]]; then
echo "Unused dependencies or devDependencies found"
Expand Down
34 changes: 21 additions & 13 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
/* eslint-disable max-len */
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import * as fs from 'fs';
import { configFile, setConfigFile, validate } from './src/config/file';
import { existsSync } from 'fs';
import { configFile, setConfigFile, loadConfig } from './src/config/file';
import proxy from './src/proxy';
import service from './src/service';

Expand All @@ -13,37 +13,45 @@ const argv = yargs(hideBin(process.argv))
validate: {
description:
'Check the proxy.config.json file in the current working directory for validation errors.',
required: false,
alias: 'v',
type: 'boolean',
},
config: {
description: 'Path to custom git-proxy configuration file.',
default: 'proxy.config.json',
required: false,
alias: 'c',
type: 'string',
default: 'proxy.config.json',
},
})
.strict()
.parseSync();

setConfigFile(argv.c as string || "");
setConfigFile(argv.config);

if (argv.v) {
if (!fs.existsSync(configFile)) {
if (argv.validate) {
if (!existsSync(configFile)) {
console.error(
`Config file ${configFile} doesn't exist, nothing to validate! Did you forget -c/--config?`,
`Config file ${configFile} doesn't exist, nothing to validate! Did you forget -c/--config?`,
);
process.exit(1);
}

validate();
console.log(`${configFile} is valid`);
process.exit(0);
try {
loadConfig();
console.log(`✔️ ${configFile} is valid`);
process.exit(0);
} catch (err: any) {
console.error('✖ Validation Error:', err.message);
process.exit(1);
}
}

validate();
try {
loadConfig();
} catch (err: any) {
console.error('✖ Validation Error:', err.message);
process.exit(1);
}

proxy.start();
service.start();
Expand Down
11 changes: 10 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@
"react-router-dom": "6.28.2",
"simple-git": "^3.25.0",
"uuid": "^11.0.0",
"yargs": "^17.7.2"
"yargs": "^17.7.2",
"zod": "^3.24.3"
},
"devDependencies": {
"@babel/core": "^7.23.2",
Expand Down
205 changes: 205 additions & 0 deletions proxy.config.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { z } from 'zod';

const TempPasswordSchema = z.object({
sendEmail: z.boolean().default(false),
emailConfig: z.record(z.unknown()).default({}),
});

const AuthorisedItemSchema = z.object({
project: z.string(),
name: z.string(),
url: z.string().regex(/^(?:https?:\/\/.+\.git|git@[^:]+:[^/]+\/.+\.git)$/i, {
message: 'Must be a Git HTTPS URL (https://... .git) or SSH URL (git@...:... .git)',
}),
});

const FsSinkSchema = z.object({
type: z.literal('fs'),
params: z.object({ filepath: z.string() }),
enabled: z.boolean().default(true),
});

const MongoSinkSchema = z.object({
type: z.literal('mongo'),
connectionString: z.string(),
options: z.object({
useNewUrlParser: z.boolean().default(true),
useUnifiedTopology: z.boolean().default(true),
tlsAllowInvalidCertificates: z.boolean().default(false),
ssl: z.boolean().default(false),
}),
enabled: z.boolean().default(false),
});

const SinkSchema = z.discriminatedUnion('type', [FsSinkSchema, MongoSinkSchema]);

const ActiveDirectoryConfigSchema = z.object({
url: z.string(),
baseDN: z.string(),
searchBase: z.string(),
});

const LocalAuthSchema = z.object({
type: z.literal('local'),
enabled: z.boolean().default(true),
});

const ADAuthSchema = z.object({
type: z.literal('ActiveDirectory'),
enabled: z.boolean().default(false),
adminGroup: z.string().default(''),
userGroup: z.string().default(''),
domain: z.string().default(''),
adConfig: ActiveDirectoryConfigSchema,
});

const AuthenticationSchema = z.discriminatedUnion('type', [LocalAuthSchema, ADAuthSchema]);

const GithubApiSchema = z.object({
baseUrl: z.string().url(),
});

const CommitEmailSchema = z.object({
local: z.object({ block: z.string().default('') }),
domain: z.object({ allow: z.string().default('.*') }),
});

const CommitBlockSchema = z.object({
literals: z.array(z.string()).default([]),
patterns: z.array(z.string()).default([]),
});

const CommitDiffSchema = z.object({
block: z.object({
literals: z.array(z.string()).default([]),
patterns: z.array(z.string()).default([]),
providers: z.record(z.unknown()).default({}),
}),
});

const AttestationQuestionSchema = z.object({
label: z.string(),
tooltip: z.object({
text: z.string(),
links: z.array(z.string()).default([]),
}),
});

export const RateLimitSchema = z
.object({
windowMs: z.number({ description: 'Sliding window in milliseconds' }),
limit: z.number({ description: 'Maximum number of requests' }),
statusCode: z.number().optional().default(429),
message: z.string().optional().default('Too many requests'),
})
.strict();

const FileConfigSourceSchema = z
.object({
type: z.literal('file'),
enabled: z.boolean().default(false),
path: z.string(),
})
.strict();

const HttpConfigSourceSchema = z
.object({
type: z.literal('http'),
enabled: z.boolean().default(false),
url: z.string().url(),
headers: z.record(z.string()).default({}),
auth: z
.object({
type: z.literal('bearer'),
token: z.string().default(''),
})
.strict()
.default({ type: 'bearer', token: '' }),
})
.strict();

const GitConfigSourceSchema = z
.object({
type: z.literal('git'),
enabled: z.boolean().default(false),
repository: z.string(),
branch: z.string().default('main'),
path: z.string(),
auth: z
.object({
type: z.literal('ssh'),
privateKeyPath: z.string(),
})
.strict(),
})
.strict();

const ConfigSourceSchema = z.discriminatedUnion('type', [
FileConfigSourceSchema,
HttpConfigSourceSchema,
GitConfigSourceSchema,
]);

export const ConfigurationSourcesSchema = z
.object({
enabled: z.boolean(),
reloadIntervalSeconds: z.number().optional().default(60),
merge: z.boolean().optional().default(false),
sources: z.array(ConfigSourceSchema).default([]),
})
.strict();

export const ConfigSchema = z
.object({
proxyUrl: z.string().url().default('https://github.com'),
cookieSecret: z.string().default(''),
sessionMaxAgeHours: z.number().int().positive().default(12),
rateLimit: RateLimitSchema.default({ windowMs: 600000, limit: 150 }),
configurationSources: ConfigurationSourcesSchema.default({
enabled: false,
reloadIntervalSeconds: 60,
merge: false,
sources: [],
}),
tempPassword: TempPasswordSchema.default({}),
authorisedList: z.array(AuthorisedItemSchema).default([]),
sink: z.array(SinkSchema).default([]),
authentication: z.array(AuthenticationSchema).default([{ type: 'local', enabled: true }]),
api: z
.object({
github: GithubApiSchema,
})
.default({ github: { baseUrl: 'https://api.github.com' } }),
commitConfig: z
.object({
author: z.object({ email: CommitEmailSchema }),
message: z.object({ block: CommitBlockSchema }),
diff: CommitDiffSchema,
})
.default({
author: { email: { local: { block: '' }, domain: { allow: '.*' } } },
message: { block: { literals: [], patterns: [] } },
diff: { block: { literals: [], patterns: [], providers: {} } },
}),
attestationConfig: z
.object({
questions: z.array(AttestationQuestionSchema).default([]),
})
.default({ questions: [] }),
domains: z.record(z.string(), z.string()).default({}),
privateOrganizations: z.array(z.string()).default([]),
urlShortener: z.string().default(''),
contactEmail: z.string().default(''),
csrfProtection: z.boolean().default(true),
plugins: z.array(z.unknown()).default([]),
tls: z
.object({
enabled: z.boolean().default(false),
key: z.string().default(''),
cert: z.string().default(''),
})
.default({}),
})
.strict();

export type Config = z.infer<typeof ConfigSchema>;
42 changes: 29 additions & 13 deletions src/config/file.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,43 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { validate as jsonSchemaValidate } from 'jsonschema';
import { ConfigSchema, type Config } from '../../proxy.config.schema';

export let configFile: string = join(process.cwd(), 'proxy.config.json');
export let configFile: string = join(process.cwd(), 'config.proxy.json');
export let config: Config;

/**
* Set the config file path.
* @param {string} file - The path to the config file.
* Sets the path to the configuration file.
*
* @param {string} file - The path to the configuration file.
* @return {void}
*/
export function setConfigFile(file: string) {
configFile = file;
}

/**
* Validate config file.
* @param {string} configFilePath - The path to the config file.
* @return {boolean} - Returns true if validation is successful.
* @throws Will throw an error if the validation fails.
* Loads and validates the configuration file using Zod.
* If validation succeeds, the parsed config is stored in the exported `config`.
*
* @return {Config} The validated and default-filled configuration object.
* @throws {ZodError} If validation fails.
*/
export function validate(configFilePath: string = configFile!): boolean {
const config = JSON.parse(readFileSync(configFilePath, 'utf-8'));
const schemaPath = join(process.cwd(), 'config.schema.json');
const schema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
jsonSchemaValidate(config, schema, { required: true, throwError: true });
export function loadConfig(): Config {
const raw = JSON.parse(readFileSync(configFile, 'utf-8'));
const parsed = ConfigSchema.parse(raw);
config = parsed;
return parsed;

Check warning on line 29 in src/config/file.ts

View check run for this annotation

Codecov / codecov/patch

src/config/file.ts#L26-L29

Added lines #L26 - L29 were not covered by tests
}

/**
* Validates a configuration file without mutating the exported `config`.
*
* @param {string} [filePath=configFile] - Path to the configuration file to validate.
* @return {boolean} Returns `true` if the file passes validation.
* @throws {ZodError} If validation fails.
*/
export function validate(filePath: string = configFile): boolean {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
ConfigSchema.parse(raw);
return true;
}
Loading