Files
Edu/apps/portal-shell/scripts/generate-pq-manifest.ts
SpecialX b30d43f983 feat(portal-shell): enable Apollo Client APQ + PQ manifest generator
Task 12-13 of portal-shell data abstraction plan (M3 security).

APQ (Automatic Persisted Queries):
- Add createPersistedQueryLink with sha256 to apollo-client.ts
- Production: client sends only query hash, not plaintext query
- Dev: NEXT_PUBLIC_APOLLO_APQ=false to disable for debugging
- Prevents attackers from crafting arbitrary queries via DevTools

PQ Manifest generator:
- New scripts/generate-pq-manifest.ts iterates operations barrel
- Outputs public/pq-manifest.json (sha256 -> query text whitelist)
- prebuild hook: codegen + generate-pq-manifest before next build
- 51 queries currently registered

- crypto-hash dependency added
- typecheck + lint (0 errors) + test (85/85) verified
2026-07-17 13:31:11 +08:00

79 lines
2.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Persisted Query Manifest 生成脚本v2.1 M3 安全加固)
*
* 构建时遍历 src/lib/api/operations/ 中所有 gql 文档,生成
* sha256(query) → query 文本 的白名单 manifest。
*
* 部署到 apollo-router 容器生产模式APOLLO_REQUIRE_PQ_MANIFEST=true
* 拒绝 manifest 之外的查询,防止攻击者构造任意 query 探测 schema。
*
* 产物apps/portal-shell/public/pq-manifest.json
* 关联portal-shell spec §4.2 PQ Manifest
*/
import { print } from "graphql";
import { sha256 } from "crypto-hash";
import * as fs from "node:fs";
import * as path from "node:path";
import * as url from "node:url";
// 使用 fileURLToPath 兼容 ESM 下 __dirname 缺失
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 动态 import operations barrel含 51 个 gql DocumentNode
// Windows 下动态 import 需 file:// URLESM 限制)
const operationsPath = path.resolve(
__dirname,
"../src/lib/api/operations/index.ts",
);
const operationsUrl = url.pathToFileURL(operationsPath).href;
async function generateManifest(): Promise<void> {
// tsx 运行时支持直接 import .ts
const operationsModule = (await import(operationsUrl)) as Record<
string,
unknown
>;
const manifest: Record<string, string> = {};
let skipped = 0;
for (const [, doc] of Object.entries(operationsModule)) {
// 仅处理 DocumentNode 对象(含 loc.source.body
if (
typeof doc === "object" &&
doc !== null &&
"loc" in doc &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(doc as any).loc !== null &&
typeof (doc as { loc: { source: { body: string } } }).loc.source?.body ===
"string"
) {
const query = print(doc as never);
const hash = await sha256(query);
manifest[hash] = query;
} else {
skipped++;
}
}
const outDir = path.resolve(__dirname, "../public");
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
const outPath = path.resolve(outDir, "pq-manifest.json");
fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2));
const count = Object.keys(manifest).length;
console.log(
`✓ PQ manifest generated: ${count} queries` +
(skipped > 0 ? ` (${skipped} non-document exports skipped)` : ""),
);
console.log(` Output: ${outPath}`);
}
generateManifest().catch((err) => {
console.error("✗ Failed to generate PQ manifest:", err);
process.exit(1);
});