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
This commit is contained in:
SpecialX
2026-07-17 13:31:11 +08:00
parent 1b5781bf42
commit b30d43f983
6 changed files with 178 additions and 5 deletions

View File

@@ -0,0 +1,78 @@
/**
* 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);
});