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:
78
apps/portal-shell/scripts/generate-pq-manifest.ts
Normal file
78
apps/portal-shell/scripts/generate-pq-manifest.ts
Normal 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:// URL(ESM 限制)
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user