← Files WisprsARCHIVED FILE

scripts/validate-package.mjs

14.4 KB · Nov 4, 2026 · 00:11 UTC

↓ Download file

See the change to this file →

#!/usr/bin/env node

import { createHash } from 'node:crypto';
import { readFile, readdir } from 'node:fs/promises';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';

export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');

const ENDPOINT = 'https://wisprs.co/api/mcp';

const CLAUDE_ENDPOINT = 'https://wisprs.co/api/mcp/claude';

const CLAUDE_EXCLUDED_TOOLS = [
  'list_tts_voices',
  'synthesize_speech',
  'list_tts_syntheses',
  'get_tts_audio',
];

const CLAUDE_SUPPORTED_TOOLS = [
  'transcribe_url',
  'get_private_media_upload_handoff',
  'list_meeting_sessions',
  'schedule_meeting_capture',
  'cancel_meeting_capture',
  'list_support_tickets',
  'create_support_ticket',
  'list_webhook_endpoints',
  'create_webhook_endpoint',
  'update_webhook_endpoint',
  'delete_webhook_endpoint',
  'list_organization_members',
  'list_creator_packs',
  'list_creator_templates',
  'list_notifications',
  'mark_notifications_read',
  'list_operations',
  'get_job_status',
  'get_transcript',
  'get_transcript_artifact',
  'export_transcript',
  'search_library',
  'list_transcripts',
  'get_transcription_metadata',
  'list_folders',
  'get_usage_and_limits',
  'create_folder',
  'rename_folder',
  'move_transcript_to_folder',
  'delete_folder',
  'rename_transcript',
  'edit_transcript_text',
  'retry_transcription',
  'create_share_link',
  'revoke_share_link',
  'summarize_transcript',
  'generate_chapters',
  'repurpose_transcript',
  'translate_transcript',
];

const VERSION = /^(0|[1-9]\d*)$/;

const SEMVER =
  /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;

const SUPPORTED_TOOLS = [
  'transcribe_url',
  'get_private_media_upload_handoff',
  'list_tts_voices',
  'synthesize_speech',
  'list_tts_syntheses',
  'get_tts_audio',
  'list_meeting_sessions',
  'schedule_meeting_capture',
  'cancel_meeting_capture',
  'list_support_tickets',
  'create_support_ticket',
  'list_webhook_endpoints',
  'create_webhook_endpoint',
  'update_webhook_endpoint',
  'delete_webhook_endpoint',
  'list_organization_members',
  'list_creator_packs',
  'list_creator_templates',
  'list_notifications',
  'mark_notifications_read',
  'list_operations',
  'get_job_status',
  'get_transcript',
  'get_transcript_artifact',
  'export_transcript',
  'search_library',
  'list_transcripts',
  'get_transcription_metadata',
  'list_folders',
  'get_usage_and_limits',
  'create_folder',
  'rename_folder',
  'move_transcript_to_folder',
  'delete_folder',
  'rename_transcript',
  'edit_transcript_text',
  'retry_transcription',
  'create_share_link',
  'revoke_share_link',
  'summarize_transcript',
  'generate_chapters',
  'repurpose_transcript',
  'translate_transcript',
];

const REQUIRED_FILES = [
  '.claude-plugin/icon.png',
  '.claude-plugin/marketplace.json',
  '.claude-plugin/plugin.json',
  '.codex-plugin/plugin.json',
  '.github/workflows/validate.yml',
  '.mcp.json',
  '.mcp-openai.json',
  'CHANGELOG.md',
  'LICENSE',
  'PRIVACY.md',
  'README.md',
  'SECURITY.md',
  'assets/wisprs-icon.png',
  'assets/wisprs-logo.png',
  'docs/ANTHROPIC_SUBMISSION.md',
  'docs/HOST_COMPATIBILITY.md',
  'docs/OPENAI_SUBMISSION.md',
  'docs/REVIEWER_GUIDE.md',
  'docs/RELEASE_PROCESS.md',
  'docs/SUBMISSION_GATES.md',
  'package.json',
  'package-lock.json',
  'release.json',
  'skills/wisprs/SKILL.md',
];

function invariant(condition, message, errors) {
  if (!condition) errors.push(message);
}

async function readJson(path) {
  return JSON.parse(await readFile(join(ROOT, path), 'utf8'));
}

async function sha256(path) {
  return createHash('sha256')
    .update(await readFile(join(ROOT, path)))
    .digest('hex');
}

async function pngDimensions(path) {
  const bytes = await readFile(join(ROOT, path));

  if (bytes.length < 24 || bytes.subarray(1, 4).toString('ascii') !== 'PNG') {
    throw new Error(`${path} is not a PNG`);
  }

  return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}

async function walk(directory = ROOT) {
  const files = [];

  for (const entry of await readdir(directory, { withFileTypes: true })) {
    if (entry.name === '.git' || entry.name === 'dist' || entry.name === 'node_modules') continue;
    const path = join(directory, entry.name);

    if (entry.isDirectory()) files.push(...(await walk(path)));
    else if (entry.isFile()) files.push(path);
  }

  return files.sort();
}

export async function validatePackage() {
  const errors = [];
  const codex = await readJson('.codex-plugin/plugin.json');
  const claude = await readJson('.claude-plugin/plugin.json');
  const marketplace = await readJson('.claude-plugin/marketplace.json');
  const mcp = await readJson('.mcp.json');
  const openaiMcp = await readJson('.mcp-openai.json');
  const release = await readJson('release.json');
  const packageJson = await readJson('package.json');
  const skill = await readFile(join(ROOT, 'skills/wisprs/SKILL.md'), 'utf8');

  for (const file of REQUIRED_FILES) {
    try {
      await readFile(join(ROOT, file));
    } catch {
      errors.push(`missing required file: ${file}`);
    }
  }

  const versions = [
    codex.version,
    claude.version,
    marketplace.version,
    release.packageVersion,
    packageJson.version,
  ];

  invariant(
    versions.every((value) => value === versions[0]),
    `version mismatch: ${versions.join(', ')}`,
    errors
  );
  invariant(SEMVER.test(codex.version), 'package version must be strict semantic version', errors);
  invariant(
    VERSION.test(codex.version.split('.')[0]),
    'package major version is malformed',
    errors
  );
  invariant(codex.name === 'wisprs-ai-connectors', 'unexpected Codex plugin name', errors);
  invariant(claude.name === codex.name, 'Claude and Codex plugin names must match', errors);
  invariant(
    marketplace.plugins?.[0]?.name === codex.name,
    'marketplace plugin name must match manifests',
    errors
  );
  invariant(
    marketplace.plugins?.[0]?.source === './',
    'marketplace source must be package root',
    errors
  );
  invariant(
    codex.mcpServers === './.mcp-openai.json',
    'Codex manifest must reference .mcp-openai.json',
    errors
  );
  invariant(
    claude.mcpServers === './.mcp.json',
    'Claude manifest must reference .mcp.json',
    errors
  );

  const serverNames = Object.keys(mcp.mcpServers ?? {});
  invariant(
    serverNames.length === 1 && serverNames[0] === 'wisprs',
    'exactly one MCP server named wisprs is required',
    errors
  );
  const server = mcp.mcpServers?.wisprs;
  invariant(server?.type === 'http', 'Wisprs MCP transport must be http', errors);
  invariant(
    server?.url === CLAUDE_ENDPOINT,
    `Claude MCP endpoint must be ${CLAUDE_ENDPOINT}`,
    errors
  );
  invariant(
    Object.keys(server ?? {}).every((key) => ['type', 'url'].includes(key)),
    'MCP config must not contain headers, credentials, or unsupported fields',
    errors
  );
  invariant(
    release.server?.endpoint === ENDPOINT,
    'release universal endpoint must match OpenAI config',
    errors
  );
  // OpenAI reads sign-in settings from the plugin's MCP config (extensions["com.openai"].auth,
  // "Package your plugin" + "Submit your plugin" docs). Without them Codex treats the server as
  // unauthenticated and loops on 401s. Only DCR is declared: Clerk exposes a registration
  // endpoint through discovery, and the manifest must never carry client credentials.
  const openaiServer = openaiMcp.mcpServers?.wisprs;
  const openaiAuth = openaiServer?.extensions?.['com.openai']?.auth;
  invariant(
    openaiServer?.type === 'http' &&
      openaiServer?.url === ENDPOINT &&
      Object.keys(openaiMcp.mcpServers ?? {}).length === 1 &&
      Object.keys(openaiServer).every((key) => ['type', 'url', 'extensions'].includes(key)),
    'OpenAI config must contain only the canonical HTTPS endpoint and its auth extension',
    errors
  );
  invariant(
    openaiAuth?.type === 'oauth' &&
      openaiAuth?.client?.mode === 'dcr' &&
      Object.keys(openaiAuth.client).length === 1 &&
      Object.keys(openaiAuth).every((key) => ['type', 'client'].includes(key)) &&
      Object.keys(openaiServer.extensions).length === 1 &&
      Object.keys(openaiServer.extensions['com.openai']).length === 1,
    'OpenAI config must declare OAuth with DCR and no client credentials',
    errors
  );
  invariant(
    release.server?.claudeEndpoint === CLAUDE_ENDPOINT,
    'release Claude endpoint mismatch',
    errors
  );
  invariant(
    release.server?.transport === 'streamable-http',
    'release transport must be streamable-http',
    errors
  );
  invariant(
    release.server?.contractVersion === '1.17.0',
    'release contract version must be 1.17.0',
    errors
  );
  invariant(
    JSON.stringify(release.supportedTools) === JSON.stringify(SUPPORTED_TOOLS),
    'supported tool order/set changed without release review',
    errors
  );

  const claudeTools = CLAUDE_SUPPORTED_TOOLS;
  invariant(
    JSON.stringify(release.claudeSupportedTools) === JSON.stringify(CLAUDE_SUPPORTED_TOOLS),
    'Claude supported tool order/set changed without release review',
    errors
  );
  invariant(
    JSON.stringify(SUPPORTED_TOOLS.filter((tool) => !CLAUDE_SUPPORTED_TOOLS.includes(tool))) ===
      JSON.stringify(CLAUDE_EXCLUDED_TOOLS),
    'every universal tool omitted from Claude must be explicitly reviewed',
    errors
  );

  for (const tool of CLAUDE_EXCLUDED_TOOLS) {
    invariant(
      !skill.includes(`\`${tool}\``),
      `Claude skill must not instruct use of ${tool}`,
      errors
    );
  }

  for (const tool of claudeTools) {
    invariant(
      skill.includes(`\`${tool}\``) || skill.includes(tool),
      `skill does not describe supported tool ${tool}`,
      errors
    );
  }

  for (const tool of release.declaredFutureTools ?? []) {
    invariant(
      !skill.includes(`Call \`${tool}\``),
      `skill must not call future tool ${tool}`,
      errors
    );
  }

  invariant(
    skill.includes('untrusted data'),
    'skill must define prompt-injection boundary',
    errors
  );
  invariant(
    skill.includes('idempotencyKey'),
    'skill must define command idempotency behavior',
    errors
  );
  invariant(
    skill.includes('retryAfterSeconds'),
    'skill must define bounded polling behavior',
    errors
  );

  for (const [path, expected] of Object.entries(release.assets ?? {})) {
    invariant((await sha256(path)) === expected, `asset checksum mismatch: ${path}`, errors);
  }

  const icon = await pngDimensions('assets/wisprs-icon.png');
  const logo = await pngDimensions('assets/wisprs-logo.png');
  invariant(icon.width === 128 && icon.height === 128, 'composer icon must be 128x128', errors);
  invariant(logo.width === 512 && logo.height === 512, 'logo must be 512x512', errors);

  // Blocked terms are stored encoded so that this file, which ships in the
  // archive, never itself contains a fragment of anything it exists to keep
  // out. A previous version listed a hosting provider split across two string
  // literals; the first half still named the provider.
  const decode = (value) => Buffer.from(value, 'base64').toString('utf8');

  const forbiddenPaths = [
    decode('L1VzZXJzLw=='),
    decode('cHJvamVjdF9kb2NzLw=='),
    decode('c3JjL2xpYi8='),
    decode('Y29udGFiby1wcm9k'),
    decode('d2ludGVyLWV4b2R1cw=='),
    decode('L29wdC9hcHBzLw=='),
    decode('L2V0Yy93aXNwcnMv'),
  ];

  // Infrastructure and vendor names that identify how the service is hosted or
  // built. Reviewer evidence does not need them, and a public archive must not
  // advertise them. Matched case-insensitively.
  const forbiddenInfraTerms = [
    decode('Y29udGFibw=='),
    decode('YXR0ZW5kZWU='),
    decode('c3lzdGVtZA=='),
    decode('dGFpbHNjYWxl'),
    decode('aGV0em5lcg=='),
    decode('ZGlnaXRhbG9jZWFu'),
    decode('UlVOVElNRV9DT05UUk9MX0JMT0NLRUQ='),
  ];

  // Live third-party and tenant identifiers. Not secrets on their own, but they
  // name real production objects and belong in reviewer channels, not a public
  // archive.
  const forbiddenIdentifiers = [
    { label: 'auth-provider instance id', pattern: /\bins_[A-Za-z0-9]{20,}\b/ },
    { label: 'MCP OAuth client id', pattern: /\bclient(?:_i|I)d[`'"\s:=]+[A-Za-z0-9]{16}\b/ },
  ];

  const secretPatterns = [
    /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
    /(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/,
    /gh[opusr]_[A-Za-z0-9]{20,}/,
    /Bearer\s+[A-Za-z0-9._~-]{24,}/i,
  ];

  // Evidence that release.json does not publish never enters the archive, so it
  // is deliberately exempt from the content rules below: internal records are
  // allowed to name internal things. The publish list is the security boundary,
  // and build-archive.mjs reads the same list.
  const publishedEvidence = new Set(
    JSON.parse(await readFile(join(ROOT, 'release.json'), 'utf8')).publishedEvidence ?? []
  );

  const shipsInArchive = (path) =>
    !path.startsWith('docs/evidence/') || publishedEvidence.has(path.split('/').pop());

  for (const absolutePath of await walk()) {
    const path = relative(ROOT, absolutePath).split(sep).join('/');

    if (path.endsWith('.png') || path.endsWith('.zip')) continue;

    if (!shipsInArchive(path)) continue;
    const contents = await readFile(absolutePath, 'utf8');

    for (const forbidden of forbiddenPaths) {
      invariant(
        !contents.includes(forbidden),
        `${path} contains private implementation reference: ${forbidden}`,
        errors
      );
    }

    const lowered = contents.toLowerCase();

    for (const term of forbiddenInfraTerms) {
      invariant(
        !lowered.includes(term.toLowerCase()),
        `${path} names infrastructure or a vendor that must not ship: ${term}`,
        errors
      );
    }

    for (const { label, pattern } of forbiddenIdentifiers) {
      invariant(!pattern.test(contents), `${path} contains a live ${label}`, errors);
    }

    invariant(
      !contents.includes(['[', 'TODO', ':'].join('')),
      `${path} contains an unresolved placeholder`,
      errors
    );

    for (const pattern of secretPatterns) {
      invariant(!pattern.test(contents), `${path} appears to contain a secret`, errors);
    }
  }

  return errors;
}

if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
  const errors = await validatePackage();

  if (errors.length > 0) {
    console.error(`Package validation failed (${errors.length}):`);

    for (const error of errors) console.error(`- ${error}`);
    process.exitCode = 1;
  } else {
    console.log('Package validation passed.');
  }
}

SHA-256: 29a9bc088809eb7058b8900a370c133470940060a074dda1c234c5ff331064b6