File size: 4,648 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
#!/usr/bin/env node
/**
 * Cross-references openapi.yaml x-loopback-only / x-always-protected annotations
 * against the compile-time constants in src/server/authz/routeGuard.ts.
 *
 * Fails if any YAML annotation disagrees with the routeGuard.ts constants.
 */

import fs from "node:fs";
import path from "node:path";
import * as yaml from "js-yaml";

const ROOT = process.cwd();
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.ts");

function parseStringArray(match) {
  if (!match) return [];
  // Strip line comments before splitting β€” array entries in routeGuard.ts often
  // carry inline `// T-XX:` annotations that would otherwise pollute the parsed tokens.
  return match[1]
    .replace(/\/\/[^\n]*/g, "")
    .split(",")
    .map((s) => s.trim().replace(/^["']|["']$/g, ""))
    .filter(Boolean);
}

const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8");
const LOCAL_ONLY_PREFIXES = parseStringArray(
  guardSrc.match(/export const LOCAL_ONLY_API_PREFIXES.*?=\s*\[([^\]]+)\]/s)
);
const ALWAYS_PROTECTED_PATHS = parseStringArray(
  guardSrc.match(/export const ALWAYS_PROTECTED_API_PATHS.*?=\s*\[([^\]]+)\]/s)
);

if (LOCAL_ONLY_PREFIXES.length === 0 || ALWAYS_PROTECTED_PATHS.length === 0) {
  console.error("[openapi-security-tiers] FAIL β€” could not parse routeGuard.ts constants");
  process.exit(1);
}

const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const paths = raw.paths || {};

const errors = [];

for (const [pathStr, methods] of Object.entries(paths)) {
  if (!methods || typeof methods !== "object") continue;
  for (const [method, spec] of Object.entries(methods)) {
    if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;

    if (spec["x-loopback-only"] === true) {
      const matchesPrefix = LOCAL_ONLY_PREFIXES.some((prefix) => {
        const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
        return pathStr === norm || pathStr.startsWith(norm + "/");
      });
      if (!matchesPrefix) {
        errors.push(
          `${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` +
            `LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}]`
        );
      }
    }

    if (spec["x-always-protected"] === true) {
      const matchesPath = ALWAYS_PROTECTED_PATHS.some(
        (p) => pathStr === p || pathStr.startsWith(`${p}/`)
      );
      if (!matchesPath) {
        errors.push(
          `${method.toUpperCase()} ${pathStr}: has x-always-protected but is NOT in ` +
            `ALWAYS_PROTECTED_API_PATHS [${ALWAYS_PROTECTED_PATHS.join(", ")}]`
        );
      }
    }
  }
}

// Reverse pass: every YAML path that falls under a LOCAL_ONLY prefix should
// carry `x-loopback-only: true` on every method, otherwise external API
// consumers have no signal that the route is loopback-restricted. Closes the
// "new spawn-capable route added without annotation" regression class.
//
// Currently reported as warnings (non-fatal) because the v3.8.4 release ships
// with a known annotation gap on /api/services/* and /api/cli-tools/runtime/*
// that will be patched in a follow-up doc-only PR. Promote to errors once the
// backlog is cleared.
const reverseWarnings = [];
for (const [pathStr, methods] of Object.entries(paths)) {
  if (!methods || typeof methods !== "object") continue;
  const fallsUnderLocalOnly = LOCAL_ONLY_PREFIXES.some((prefix) => {
    const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
    return pathStr === norm || pathStr.startsWith(norm + "/");
  });
  if (!fallsUnderLocalOnly) continue;
  for (const [method, spec] of Object.entries(methods)) {
    if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;
    if (spec["x-loopback-only"] !== true) {
      reverseWarnings.push(
        `${method.toUpperCase()} ${pathStr}: falls under LOCAL_ONLY_API_PREFIXES ` +
          `but is missing x-loopback-only: true annotation`
      );
    }
  }
}

if (reverseWarnings.length > 0) {
  console.warn(
    `[openapi-security-tiers] WARN β€” ${reverseWarnings.length} LOCAL_ONLY paths missing x-loopback-only annotation (non-fatal, follow-up doc PR):`
  );
  reverseWarnings.forEach((w) => console.warn(`  - ${w}`));
}

if (errors.length === 0) {
  console.log("[openapi-security-tiers] PASS β€” all security tier annotations match routeGuard.ts");
  process.exit(0);
} else {
  console.error(`[openapi-security-tiers] FAIL β€” ${errors.length} annotation mismatches:`);
  errors.forEach((e) => console.error(`  - ${e}`));
  process.exit(1);
}