forked from wangziqi/gongxue-base
test: add production launch evidence gate
This commit is contained in:
119
scripts/production-launch-gate-test.js
Normal file
119
scripts/production-launch-gate-test.js
Normal file
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { gateChecks, requiredAttestations } from './production-launch-gate.js';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const scriptPath = path.join(repoRoot, 'scripts', 'production-launch-gate.js');
|
||||
|
||||
function isoNow() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createEvidence(tempDir, overrides = {}) {
|
||||
const artifactDir = path.join(tempDir, 'launch-artifacts');
|
||||
fs.mkdirSync(artifactDir, { recursive: true });
|
||||
|
||||
const checks = gateChecks.map(spec => {
|
||||
const artifact = `launch-artifacts/${spec.id}.log`;
|
||||
fs.writeFileSync(path.join(tempDir, artifact), `[PASS] ${spec.id}\n`, 'utf8');
|
||||
return {
|
||||
id: spec.id,
|
||||
status: 'pass',
|
||||
command: `npm run ${spec.commandIncludes} -- recorded-for-launch-gate`,
|
||||
completedAt: isoNow(),
|
||||
artifact,
|
||||
summary: { ...spec.summary },
|
||||
};
|
||||
});
|
||||
|
||||
const attestations = requiredAttestations.map(spec => ({
|
||||
id: spec.id,
|
||||
status: 'approved',
|
||||
approver: 'test-owner',
|
||||
approvedAt: isoNow(),
|
||||
notes: spec.label,
|
||||
}));
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
environment: 'production',
|
||||
commit: '52cef9fabcd1234567890abcdef1234567890abc',
|
||||
target: {
|
||||
apiBaseUrl: 'https://api.gongxue100.com',
|
||||
studentH5Url: 'https://www.gongxue100.com',
|
||||
tenantAdminH5Url: 'https://admin.gongxue100.com',
|
||||
platformAdminH5Url: 'https://console.gongxue100.com',
|
||||
},
|
||||
checks,
|
||||
attestations,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runGate(evidence, options = {}) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-'));
|
||||
const evidencePath = path.join(tempDir, 'evidence.json');
|
||||
const finalEvidence = typeof evidence === 'function' ? evidence(tempDir) : evidence;
|
||||
fs.writeFileSync(evidencePath, JSON.stringify(finalEvidence, null, 2), 'utf8');
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--evidence', evidencePath, '--json', ...(options.args || [])], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
PATH: process.env.PATH || '',
|
||||
Path: process.env.Path || '',
|
||||
SystemRoot: process.env.SystemRoot || '',
|
||||
ComSpec: process.env.ComSpec || '',
|
||||
TEMP: process.env.TEMP || os.tmpdir(),
|
||||
TMP: process.env.TMP || os.tmpdir(),
|
||||
},
|
||||
});
|
||||
|
||||
const payload = JSON.parse(result.stdout || '{}');
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
return { ...result, payload };
|
||||
}
|
||||
|
||||
const safe = runGate(tempDir => createEvidence(tempDir));
|
||||
assert.equal(safe.status, 0, `complete launch evidence should pass: ${safe.stdout} ${safe.stderr}`);
|
||||
assert.equal(safe.payload.summary?.blocker, 0, 'complete launch evidence should have no blockers');
|
||||
|
||||
const missingArtifact = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'auth.remote-smoke');
|
||||
fs.rmSync(path.join(tempDir, item.artifact), { force: true });
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(missingArtifact.status, 0, 'missing artifact should fail launch gate');
|
||||
assert.ok(
|
||||
missingArtifact.payload.checks?.some(item => item.id === 'check.auth.remote-smoke.artifact' && item.status === 'blocker'),
|
||||
'missing artifact should be reported as a blocker',
|
||||
);
|
||||
|
||||
const wrongMigrationProfile = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'migration.pb-production-dry-run');
|
||||
item.summary.migrationProfile = 'development';
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(wrongMigrationProfile.status, 0, 'development dry-run evidence should fail launch gate');
|
||||
assert.ok(
|
||||
wrongMigrationProfile.payload.checks?.some(item => item.id === 'check.migration.pb-production-dry-run.summary' && item.status === 'blocker'),
|
||||
'migration profile mismatch should be reported as a blocker',
|
||||
);
|
||||
|
||||
const missingAttestation = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
evidence.attestations = evidence.attestations.filter(item => item.id !== 'backup.snapshot');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(missingAttestation.status, 0, 'missing manual attestation should fail launch gate');
|
||||
assert.ok(
|
||||
missingAttestation.payload.checks?.some(item => item.id === 'attestation.backup.snapshot' && item.status === 'blocker'),
|
||||
'missing attestation should be reported as a blocker',
|
||||
);
|
||||
|
||||
console.log('[PASS] production launch gate');
|
||||
422
scripts/production-launch-gate.js
Normal file
422
scripts/production-launch-gate.js
Normal file
@@ -0,0 +1,422 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const defaultEvidencePath = path.resolve(process.cwd(), 'docs/refactor/production-launch-evidence.json');
|
||||
const defaultMaxAgeDays = 14;
|
||||
|
||||
const gateChecks = [
|
||||
{
|
||||
id: 'readiness.production.env',
|
||||
label: 'Production environment readiness',
|
||||
commandIncludes: 'readiness:production',
|
||||
summary: { blocker: 0 },
|
||||
},
|
||||
{
|
||||
id: 'readiness.production.db',
|
||||
label: 'Production database readiness',
|
||||
commandIncludes: 'readiness:production:db',
|
||||
summary: { blocker: 0 },
|
||||
},
|
||||
{
|
||||
id: 'auth.remote-smoke',
|
||||
label: 'Remote Supabase Auth/JWKS smoke',
|
||||
commandIncludes: 'smoke:auth:remote',
|
||||
summary: { failed: 0, requireAdminTokens: true },
|
||||
},
|
||||
{
|
||||
id: 'rls.tenant-isolation',
|
||||
label: 'Runtime tenant RLS isolation',
|
||||
commandIncludes: 'test:rls',
|
||||
summary: { failed: 0 },
|
||||
},
|
||||
{
|
||||
id: 'migration.pb-production-dry-run',
|
||||
label: 'PocketBase production dry-run',
|
||||
commandIncludes: 'pb:import:dry-run',
|
||||
summary: {
|
||||
blocker: 0,
|
||||
warning: 0,
|
||||
migrationProfile: 'production',
|
||||
requiredCollectionsMissing: 0,
|
||||
criticalFieldCoverageWarnings: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'migration.pb-import-validate',
|
||||
label: 'PocketBase import validation',
|
||||
commandIncludes: 'pb:import:validate',
|
||||
summary: { fail: 0 },
|
||||
},
|
||||
{
|
||||
id: 'api.integration',
|
||||
label: 'API integration regression',
|
||||
commandIncludes: 'test:api',
|
||||
summary: { failed: 0 },
|
||||
},
|
||||
{
|
||||
id: 'worker.assets',
|
||||
label: 'Asset worker security regression',
|
||||
commandIncludes: 'test:worker:assets',
|
||||
summary: { failed: 0 },
|
||||
},
|
||||
{
|
||||
id: 'worker.commerce',
|
||||
label: 'Commerce/payment worker regression',
|
||||
commandIncludes: 'test:worker:commerce',
|
||||
summary: { failed: 0 },
|
||||
},
|
||||
{
|
||||
id: 'worker.imports',
|
||||
label: 'Async import worker regression',
|
||||
commandIncludes: 'test:worker:imports',
|
||||
summary: { failed: 0 },
|
||||
},
|
||||
{
|
||||
id: 'worker.public-banks',
|
||||
label: 'Public question bank worker regression',
|
||||
commandIncludes: 'test:worker:public-banks',
|
||||
summary: { failed: 0 },
|
||||
},
|
||||
{
|
||||
id: 'taro.check',
|
||||
label: 'Taro type/security guardrails',
|
||||
commandIncludes: 'check:taro',
|
||||
summary: { failed: 0 },
|
||||
},
|
||||
{
|
||||
id: 'taro.build.student',
|
||||
label: 'Student H5 build',
|
||||
commandIncludes: 'build:taro:h5:student',
|
||||
summary: { failed: 0 },
|
||||
},
|
||||
{
|
||||
id: 'taro.build.tenant',
|
||||
label: 'Tenant admin H5 build',
|
||||
commandIncludes: 'build:taro:h5:tenant',
|
||||
summary: { failed: 0 },
|
||||
},
|
||||
{
|
||||
id: 'taro.build.platform',
|
||||
label: 'Platform admin H5 build',
|
||||
commandIncludes: 'build:taro:h5:platform',
|
||||
summary: { failed: 0 },
|
||||
},
|
||||
{
|
||||
id: 'audit.runtime',
|
||||
label: 'Runtime dependency audit',
|
||||
commandIncludes: 'audit:runtime',
|
||||
summary: { critical: 0, high: 0 },
|
||||
},
|
||||
{
|
||||
id: 'security.codex-scan',
|
||||
label: 'Codex Security scan',
|
||||
commandIncludes: 'codex-security',
|
||||
summary: { critical: 0, high: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
const requiredAttestations = [
|
||||
{
|
||||
id: 'backup.snapshot',
|
||||
label: 'Production database backup/snapshot is prepared',
|
||||
},
|
||||
{
|
||||
id: 'rollback.plan',
|
||||
label: 'Rollback plan and old PocketBase read-only snapshot are prepared',
|
||||
},
|
||||
{
|
||||
id: 'migration.sampling',
|
||||
label: 'Real migrated users/questions/orders/assets were sampled',
|
||||
},
|
||||
{
|
||||
id: 'provider.production-accounts',
|
||||
label: 'SMS/OAuth/payment production accounts and callback domains were verified',
|
||||
},
|
||||
{
|
||||
id: 'object-storage.production-controls',
|
||||
label: 'Object storage AV/content scan, CDN boundary, watermark and lifecycle controls were verified',
|
||||
},
|
||||
{
|
||||
id: 'payment.reconciliation-sampling',
|
||||
label: 'Real payment/refund bill samples were reconciled',
|
||||
},
|
||||
{
|
||||
id: 'frontend.runtime-config-review',
|
||||
label: 'Three H5 runtime-config.json files were reviewed for public-only values',
|
||||
},
|
||||
];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
evidencePath: defaultEvidencePath,
|
||||
json: false,
|
||||
maxAgeDays: defaultMaxAgeDays,
|
||||
allowStale: false,
|
||||
};
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--json') options.json = true;
|
||||
else if (arg === '--allow-stale') options.allowStale = true;
|
||||
else if (arg === '--evidence') {
|
||||
options.evidencePath = path.resolve(process.cwd(), argv[index + 1] || '');
|
||||
index += 1;
|
||||
} else if (arg.startsWith('--evidence=')) {
|
||||
options.evidencePath = path.resolve(process.cwd(), arg.slice('--evidence='.length));
|
||||
} else if (arg === '--max-age-days') {
|
||||
options.maxAgeDays = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
} else if (arg.startsWith('--max-age-days=')) {
|
||||
options.maxAgeDays = Number(arg.slice('--max-age-days='.length));
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(options.maxAgeDays) || options.maxAgeDays <= 0) options.maxAgeDays = defaultMaxAgeDays;
|
||||
return options;
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isApproved(value) {
|
||||
return ['approved', 'pass', 'passed', 'ok', 'complete', 'completed'].includes(normalizeStatus(value));
|
||||
}
|
||||
|
||||
function findById(items, id) {
|
||||
return (Array.isArray(items) ? items : []).find(item => item && item.id === id);
|
||||
}
|
||||
|
||||
function resolveArtifact(evidencePath, artifact) {
|
||||
if (!artifact) return '';
|
||||
if (path.isAbsolute(artifact)) return artifact;
|
||||
return path.resolve(path.dirname(evidencePath), artifact);
|
||||
}
|
||||
|
||||
function daysSince(value, now = Date.now()) {
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return Number.POSITIVE_INFINITY;
|
||||
return (now - timestamp) / (24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function hasPath(object, keyPath) {
|
||||
const parts = keyPath.split('.');
|
||||
let cursor = object;
|
||||
for (const part of parts) {
|
||||
if (!cursor || typeof cursor !== 'object' || !(part in cursor)) return false;
|
||||
cursor = cursor[part];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function valueAt(object, keyPath) {
|
||||
const parts = keyPath.split('.');
|
||||
let cursor = object;
|
||||
for (const part of parts) cursor = cursor?.[part];
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function compareSummary(actualSummary, expectedSummary) {
|
||||
const failures = [];
|
||||
for (const [key, expectedValue] of Object.entries(expectedSummary || {})) {
|
||||
if (!hasPath(actualSummary || {}, key)) {
|
||||
failures.push(`${key} is missing`);
|
||||
continue;
|
||||
}
|
||||
const actualValue = valueAt(actualSummary, key);
|
||||
if (actualValue !== expectedValue) {
|
||||
failures.push(`${key} expected ${JSON.stringify(expectedValue)} but got ${JSON.stringify(actualValue)}`);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
function resultCollector() {
|
||||
const checks = [];
|
||||
function pass(id, message, details = {}) {
|
||||
checks.push({ status: 'pass', id, message, details });
|
||||
}
|
||||
function warn(id, message, details = {}) {
|
||||
checks.push({ status: 'warn', id, message, details });
|
||||
}
|
||||
function block(id, message, details = {}) {
|
||||
checks.push({ status: 'blocker', id, message, details });
|
||||
}
|
||||
return { checks, pass, warn, block };
|
||||
}
|
||||
|
||||
function validateTopLevel(evidence, collector) {
|
||||
if (evidence.schemaVersion !== 1) {
|
||||
collector.block('evidence.schema_version', 'Evidence schemaVersion must be 1', { actual: evidence.schemaVersion });
|
||||
} else {
|
||||
collector.pass('evidence.schema_version', 'Evidence schema version is supported');
|
||||
}
|
||||
|
||||
if (evidence.environment !== 'production') {
|
||||
collector.block('evidence.environment', 'Evidence environment must be production', { actual: evidence.environment });
|
||||
} else {
|
||||
collector.pass('evidence.environment', 'Evidence environment is production');
|
||||
}
|
||||
|
||||
if (!evidence.commit || !/^[0-9a-f]{7,40}$/i.test(String(evidence.commit))) {
|
||||
collector.block('evidence.commit', 'Evidence must include the reviewed deployment commit hash');
|
||||
} else {
|
||||
collector.pass('evidence.commit', 'Deployment commit is recorded', { commit: evidence.commit });
|
||||
}
|
||||
|
||||
const target = evidence.target || {};
|
||||
const requiredTargets = ['apiBaseUrl', 'studentH5Url', 'tenantAdminH5Url', 'platformAdminH5Url'];
|
||||
for (const key of requiredTargets) {
|
||||
const value = String(target[key] || '');
|
||||
if (!value.startsWith('https://')) {
|
||||
collector.block(`target.${key}`, `${key} must be an HTTPS production URL`, { value });
|
||||
} else {
|
||||
collector.pass(`target.${key}`, `${key} is HTTPS`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateGateCheck(spec, evidence, options, collector) {
|
||||
const item = findById(evidence.checks, spec.id);
|
||||
if (!item) {
|
||||
collector.block(`check.${spec.id}`, `${spec.label} evidence is missing`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizeStatus(item.status) !== 'pass') {
|
||||
collector.block(`check.${spec.id}.status`, `${spec.label} must have status=pass`, { status: item.status });
|
||||
} else {
|
||||
collector.pass(`check.${spec.id}.status`, `${spec.label} passed`);
|
||||
}
|
||||
|
||||
if (spec.commandIncludes && !String(item.command || '').includes(spec.commandIncludes)) {
|
||||
collector.block(`check.${spec.id}.command`, `${spec.label} command must include ${spec.commandIncludes}`, {
|
||||
command: item.command || '',
|
||||
});
|
||||
} else {
|
||||
collector.pass(`check.${spec.id}.command`, `${spec.label} command is recorded`);
|
||||
}
|
||||
|
||||
const age = daysSince(item.completedAt);
|
||||
if (!options.allowStale && age > options.maxAgeDays) {
|
||||
collector.block(`check.${spec.id}.freshness`, `${spec.label} evidence is stale or missing completedAt`, {
|
||||
completedAt: item.completedAt || '',
|
||||
maxAgeDays: options.maxAgeDays,
|
||||
});
|
||||
} else {
|
||||
collector.pass(`check.${spec.id}.freshness`, `${spec.label} evidence is fresh enough`, {
|
||||
completedAt: item.completedAt || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (!item.artifact) {
|
||||
collector.block(`check.${spec.id}.artifact`, `${spec.label} must include a saved artifact/log path`);
|
||||
} else {
|
||||
const artifactPath = resolveArtifact(options.evidencePath, item.artifact);
|
||||
if (!fs.existsSync(artifactPath)) {
|
||||
collector.block(`check.${spec.id}.artifact`, `${spec.label} artifact file is missing`, { artifact: item.artifact });
|
||||
} else {
|
||||
collector.pass(`check.${spec.id}.artifact`, `${spec.label} artifact exists`, { artifact: item.artifact });
|
||||
}
|
||||
}
|
||||
|
||||
const summaryFailures = compareSummary(item.summary || {}, spec.summary);
|
||||
if (summaryFailures.length > 0) {
|
||||
collector.block(`check.${spec.id}.summary`, `${spec.label} summary does not satisfy gate`, { failures: summaryFailures });
|
||||
} else {
|
||||
collector.pass(`check.${spec.id}.summary`, `${spec.label} summary satisfies gate`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateAttestation(spec, evidence, options, collector) {
|
||||
const item = findById(evidence.attestations, spec.id);
|
||||
if (!item) {
|
||||
collector.block(`attestation.${spec.id}`, `${spec.label} attestation is missing`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isApproved(item.status)) {
|
||||
collector.block(`attestation.${spec.id}.status`, `${spec.label} must be approved`, { status: item.status });
|
||||
} else {
|
||||
collector.pass(`attestation.${spec.id}.status`, `${spec.label} is approved`);
|
||||
}
|
||||
|
||||
if (!String(item.approver || '').trim()) {
|
||||
collector.block(`attestation.${spec.id}.approver`, `${spec.label} must include an approver`);
|
||||
} else {
|
||||
collector.pass(`attestation.${spec.id}.approver`, `${spec.label} has an approver`);
|
||||
}
|
||||
|
||||
const age = daysSince(item.approvedAt);
|
||||
if (!options.allowStale && age > options.maxAgeDays) {
|
||||
collector.block(`attestation.${spec.id}.freshness`, `${spec.label} approval is stale or missing approvedAt`, {
|
||||
approvedAt: item.approvedAt || '',
|
||||
maxAgeDays: options.maxAgeDays,
|
||||
});
|
||||
} else {
|
||||
collector.pass(`attestation.${spec.id}.freshness`, `${spec.label} approval is fresh enough`, {
|
||||
approvedAt: item.approvedAt || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateEvidence(evidence, options) {
|
||||
const collector = resultCollector();
|
||||
validateTopLevel(evidence, collector);
|
||||
for (const spec of gateChecks) validateGateCheck(spec, evidence, options, collector);
|
||||
for (const spec of requiredAttestations) validateAttestation(spec, evidence, options, collector);
|
||||
return collector.checks;
|
||||
}
|
||||
|
||||
function summarize(checks) {
|
||||
return checks.reduce(
|
||||
(summary, item) => {
|
||||
summary[item.status] += 1;
|
||||
return summary;
|
||||
},
|
||||
{ blocker: 0, warn: 0, pass: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
function printHuman(options, evidence, checks) {
|
||||
const summary = summarize(checks);
|
||||
console.log('Production launch gate');
|
||||
console.log(`Evidence: ${options.evidencePath}`);
|
||||
console.log(`Host: ${os.hostname()}`);
|
||||
console.log(`Target API: ${evidence.target?.apiBaseUrl || '(missing)'}`);
|
||||
console.log(`Summary: ${summary.blocker} blocker(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`);
|
||||
for (const item of checks) {
|
||||
const marker = item.status === 'blocker' ? 'BLOCK' : item.status === 'warn' ? 'WARN ' : 'PASS ';
|
||||
console.log(`[${marker}] ${item.id}: ${item.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv);
|
||||
if (!fs.existsSync(options.evidencePath)) {
|
||||
const message = `Evidence file not found: ${options.evidencePath}`;
|
||||
if (options.json) console.log(JSON.stringify({ summary: { blocker: 1, warn: 0, pass: 0 }, checks: [{ status: 'blocker', id: 'evidence.file', message }] }, null, 2));
|
||||
else console.error(`${message}\nCreate it from docs/refactor/production-launch-evidence.template.json after running real production checks.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const evidence = readJson(options.evidencePath);
|
||||
const checks = validateEvidence(evidence, options);
|
||||
const summary = summarize(checks);
|
||||
if (options.json) console.log(JSON.stringify({ summary, checks }, null, 2));
|
||||
else printHuman(options, evidence, checks);
|
||||
if (summary.blocker > 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) {
|
||||
main();
|
||||
}
|
||||
|
||||
export { gateChecks, requiredAttestations, validateEvidence };
|
||||
Reference in New Issue
Block a user