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
79 lines
2.5 KiB
TypeScript
79 lines
2.5 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|