feat(identity): redirect legacy Zhuyuan ids to ICE-P
This commit is contained in:
parent
263142cc6b
commit
aa6652a646
28 changed files with 491 additions and 106 deletions
|
|
@ -3,7 +3,7 @@
|
|||
国内主节点在回环端口 `3922` 提供只读 API,广州备案前门通过专用 SSH
|
||||
隧道发布为 `https://guanghulab.com/api/ai/`。
|
||||
|
||||
权威数据只来自 `routing/repository-route-map.json`。AI 应先读取
|
||||
仓库权威数据来自 `routing/repository-route-map.json`。AI 应先读取
|
||||
`/api/ai/v1/repositories`,再按 `REPO-xxx` 解析国内主路径;新加坡地址只作
|
||||
历史备用,不参与默认路由。
|
||||
|
||||
|
|
@ -11,3 +11,16 @@
|
|||
`/api/ai/v1/nodes` 后,可以通过 `/api/ai/v1/resolve?id=JD-FD-PRIMARY` 或
|
||||
`/api/ai/v1/resolve?id=ZY-OPS-LOOP-001` 定位服务器导航地图与铸渊本轮恢复链。
|
||||
公开地图不包含地址、密码、令牌或密钥。
|
||||
|
||||
主体类型来自 `identity/fifth-domain-subject-registry.json`,旧号兼容关系来自
|
||||
`identity/subject-id-alias-map.json`。AI 可读取 `/api/ai/v1/subjects`,或直接
|
||||
解析编号:
|
||||
|
||||
```text
|
||||
/api/ai/v1/resolve?id=ICE-P-ZY001
|
||||
/api/ai/v1/resolve?id=ICE-GL-ZY001
|
||||
```
|
||||
|
||||
第二个请求会返回 `canonical_id=ICE-P-ZY001`、`redirected=true` 和同一当前路径。
|
||||
只有别名表中的精确旧号允许自动重定向;冲突号返回 409,未知号返回 404。重定向不授予
|
||||
仓库写入或服务器执行权限。
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ test("code map resolves REPO-012 as current and keeps REPO-001 as domestic histo
|
|||
assert.match(codeMap, /^FD-REPO-MAP-001=routing\/repository-route-map\.json$/m);
|
||||
assert.match(codeMap, /^FD-WORLD-TREE-001=routing\/fifth-domain-world-tree\.json$/m);
|
||||
assert.match(codeMap, /^FD-NODE-MAP-001=routing\/server-node-map\.json$/m);
|
||||
assert.match(codeMap, /^FD-SUBJECT-ID-ALIAS-MAP-001=identity\/subject-id-alias-map\.json$/m);
|
||||
assert.match(codeMap, /^ICE-P-ZY001=.*zhuyuan-persona-system\/INDEX\.hdlp/m);
|
||||
assert.match(codeMap, /^ZY-OPS-LOOP-001=.*zhuyuan-persona-system\//m);
|
||||
});
|
||||
|
||||
|
|
@ -46,7 +48,8 @@ test("Zhuyuan current chain resolves to the domestic node without secrets", () =
|
|||
assert.equal(nodeMap.map_id, "FD-NODE-MAP-001");
|
||||
const loop = nodeMap.persona_routes.find(item => item.route_id === "ZY-OPS-LOOP-001");
|
||||
assert.equal(loop.primary_node, "JD-FD-PRIMARY");
|
||||
assert.match(read(loop.path), /ICE-GL-ZY001/);
|
||||
assert.match(read(loop.path), /ICE-GL-ZY001|ICE-P-ZY001/);
|
||||
assert.equal(loop.persona_system, "ICE-P-ZY001");
|
||||
const serialized = JSON.stringify(nodeMap);
|
||||
assert.doesNotMatch(serialized, /ssh-(?:rsa|ed25519)\s+[A-Za-z0-9+/]/i);
|
||||
assert.doesNotMatch(serialized, /"(?:password|token|private_key|ip)"\s*:/i);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ const path = require("node:path");
|
|||
|
||||
const DEFAULT_MAP = path.resolve(__dirname, "../../routing/repository-route-map.json");
|
||||
const DEFAULT_NODE_MAP = path.resolve(__dirname, "../../routing/server-node-map.json");
|
||||
const DEFAULT_SUBJECT_REGISTRY = path.resolve(__dirname, "../../identity/fifth-domain-subject-registry.json");
|
||||
const DEFAULT_SUBJECT_ALIAS_MAP = path.resolve(__dirname, "../../identity/subject-id-alias-map.json");
|
||||
|
||||
function loadMap(filename = process.env.GUANGHU_REPOSITORY_MAP || DEFAULT_MAP) {
|
||||
return JSON.parse(fs.readFileSync(filename, "utf8"));
|
||||
|
|
@ -15,6 +17,45 @@ function loadNodeMap(filename = process.env.GUANGHU_NODE_MAP || DEFAULT_NODE_MAP
|
|||
return JSON.parse(fs.readFileSync(filename, "utf8"));
|
||||
}
|
||||
|
||||
function loadSubjectRegistry(filename = process.env.GUANGHU_SUBJECT_REGISTRY || DEFAULT_SUBJECT_REGISTRY) {
|
||||
return JSON.parse(fs.readFileSync(filename, "utf8"));
|
||||
}
|
||||
|
||||
function loadSubjectAliasMap(filename = process.env.GUANGHU_SUBJECT_ALIAS_MAP || DEFAULT_SUBJECT_ALIAS_MAP) {
|
||||
return JSON.parse(fs.readFileSync(filename, "utf8"));
|
||||
}
|
||||
|
||||
function resolveSubjectId(aliasMap, requestedId) {
|
||||
const requested = String(requestedId || "").trim();
|
||||
const key = requested.toUpperCase();
|
||||
const conflict = (aliasMap.conflicts || []).find(item => String(item.id).toUpperCase() === key);
|
||||
if (conflict) {
|
||||
return {
|
||||
status: "CONFLICT_REJECTED",
|
||||
requested_id: requested,
|
||||
canonical_id: null,
|
||||
redirected: false,
|
||||
reason: conflict.state || "CONFLICT",
|
||||
};
|
||||
}
|
||||
for (const mapping of aliasMap.mappings || []) {
|
||||
const canonical = String(mapping.canonical_id);
|
||||
const identifiers = [canonical, ...(mapping.aliases || [])];
|
||||
if (identifiers.some(id => String(id).toUpperCase() === key)) {
|
||||
return {
|
||||
status: "RESOLVED",
|
||||
requested_id: requested,
|
||||
canonical_id: canonical,
|
||||
redirected: key !== canonical.toUpperCase(),
|
||||
subject_kind: mapping.subject_kind,
|
||||
route_id: mapping.route_id,
|
||||
current_path: mapping.current_path,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { status: "NOT_FOUND_NO_GUESS", requested_id: requested, canonical_id: null, redirected: false };
|
||||
}
|
||||
|
||||
function normalize(value) {
|
||||
return String(value || "").toLowerCase().replace(/[\s·._/-]+/g, " ").trim();
|
||||
}
|
||||
|
|
@ -37,13 +78,19 @@ function search(map, query) {
|
|||
.map(item => item.repository);
|
||||
}
|
||||
|
||||
function searchAll(repositoryMap, nodeMap, query) {
|
||||
function searchAll(repositoryMap, nodeMap, query, subjectRegistry = null) {
|
||||
const terms = normalize(query).split(" ").filter(Boolean);
|
||||
if (!terms.length) return search(repositoryMap, query);
|
||||
const candidates = [
|
||||
...repositoryMap.repositories.map(item => ({ kind: "repository", item, key: item.code, text: [item.code, item.slug, item.name_zh, item.role, item.state, ...(item.keywords || [])] })),
|
||||
...nodeMap.nodes.map(item => ({ kind: "server_node", item, key: item.node_id, text: [item.node_id, item.name_zh, item.role, item.state, ...(item.keywords || [])] })),
|
||||
...nodeMap.persona_routes.map(item => ({ kind: "persona_route", item, key: item.route_id, text: [item.route_id, item.name_zh, item.role, item.persona_system, ...(item.keywords || [])] })),
|
||||
...((subjectRegistry && subjectRegistry.subjects) || []).map(item => ({
|
||||
kind: "subject",
|
||||
item,
|
||||
key: item.id,
|
||||
text: [item.id, item.name, item.subject_kind, ...(item.legacy_ids || []), ...(item.roles || [])],
|
||||
})),
|
||||
];
|
||||
return candidates
|
||||
.map(candidate => {
|
||||
|
|
@ -59,6 +106,8 @@ function searchAll(repositoryMap, nodeMap, query) {
|
|||
function createServer(options = {}) {
|
||||
const mapFile = options.mapFile || process.env.GUANGHU_REPOSITORY_MAP || DEFAULT_MAP;
|
||||
const nodeMapFile = options.nodeMapFile || process.env.GUANGHU_NODE_MAP || DEFAULT_NODE_MAP;
|
||||
const subjectRegistryFile = options.subjectRegistryFile || process.env.GUANGHU_SUBJECT_REGISTRY || DEFAULT_SUBJECT_REGISTRY;
|
||||
const subjectAliasMapFile = options.subjectAliasMapFile || process.env.GUANGHU_SUBJECT_ALIAS_MAP || DEFAULT_SUBJECT_ALIAS_MAP;
|
||||
return http.createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (req.method !== "GET") return json(res, 405, { error: "method_not_allowed" });
|
||||
|
|
@ -73,12 +122,19 @@ function createServer(options = {}) {
|
|||
try { return json(res, 200, loadNodeMap(nodeMapFile), 300); }
|
||||
catch { return json(res, 503, { error: "node_map_unavailable" }); }
|
||||
}
|
||||
if (url.pathname === "/v1/subjects") {
|
||||
try { return json(res, 200, loadSubjectRegistry(subjectRegistryFile), 300); }
|
||||
catch { return json(res, 503, { error: "subject_registry_unavailable" }); }
|
||||
}
|
||||
if (url.pathname === "/v1/search") {
|
||||
const query = String(url.searchParams.get("q") || "").slice(0, 200);
|
||||
let nodeMap;
|
||||
let subjectRegistry;
|
||||
try { nodeMap = loadNodeMap(nodeMapFile); }
|
||||
catch { return json(res, 503, { error: "node_map_unavailable" }); }
|
||||
const results = searchAll(map, nodeMap, query);
|
||||
try { subjectRegistry = loadSubjectRegistry(subjectRegistryFile); }
|
||||
catch { return json(res, 503, { error: "subject_registry_unavailable" }); }
|
||||
const results = searchAll(map, nodeMap, query, subjectRegistry);
|
||||
return json(res, 200, {
|
||||
schema: "guanghu.ai-search-response/v1",
|
||||
query,
|
||||
|
|
@ -98,7 +154,47 @@ function createServer(options = {}) {
|
|||
const node = nodeMap.nodes.find(item => item.node_id.toUpperCase() === id);
|
||||
if (node) return json(res, 200, node, 300);
|
||||
const personaRoute = nodeMap.persona_routes.find(item => item.route_id.toUpperCase() === id);
|
||||
return personaRoute ? json(res, 200, personaRoute, 300) : json(res, 404, { error: "route_not_found", id });
|
||||
if (personaRoute) return json(res, 200, personaRoute, 300);
|
||||
|
||||
let aliasMap;
|
||||
let subjectRegistry;
|
||||
try {
|
||||
aliasMap = loadSubjectAliasMap(subjectAliasMapFile);
|
||||
subjectRegistry = loadSubjectRegistry(subjectRegistryFile);
|
||||
} catch {
|
||||
return json(res, 503, { error: "subject_identity_map_unavailable" });
|
||||
}
|
||||
const exactSubject = subjectRegistry.subjects.find(item => item.id.toUpperCase() === id);
|
||||
if (exactSubject) {
|
||||
return json(res, 200, {
|
||||
schema: "guanghu.subject-resolution/v1",
|
||||
status: "RESOLVED",
|
||||
requested_id: id,
|
||||
canonical_id: exactSubject.id,
|
||||
redirected: false,
|
||||
subject_kind: exactSubject.subject_kind,
|
||||
subject: exactSubject,
|
||||
}, 300);
|
||||
}
|
||||
const identity = resolveSubjectId(aliasMap, id);
|
||||
if (identity.status === "CONFLICT_REJECTED") {
|
||||
return json(res, 409, { error: "subject_id_conflict", ...identity });
|
||||
}
|
||||
if (identity.canonical_id) {
|
||||
const subject = subjectRegistry.subjects.find(item => item.id.toUpperCase() === identity.canonical_id.toUpperCase());
|
||||
if (!subject) return json(res, 503, { error: "canonical_subject_missing", ...identity });
|
||||
return json(res, 200, {
|
||||
schema: "guanghu.subject-resolution/v1",
|
||||
...identity,
|
||||
subject,
|
||||
navigation: {
|
||||
route_id: identity.route_id,
|
||||
path: identity.current_path,
|
||||
rule: "Canonicalize the subject id before navigation; legacy ids grant no authority.",
|
||||
},
|
||||
}, 300);
|
||||
}
|
||||
return json(res, 404, { error: "route_not_found", id });
|
||||
}
|
||||
if (url.pathname === "/openapi.json") return json(res, 200, openApi(), 3600);
|
||||
if (url.pathname === "/well-known") return json(res, 200, {
|
||||
|
|
@ -107,6 +203,8 @@ function createServer(options = {}) {
|
|||
canonical_repository: map.repositories[0].primary.url,
|
||||
repository_map: map.canonical_api,
|
||||
server_node_map: "https://guanghulab.com/api/ai/v1/nodes",
|
||||
subject_registry: "https://guanghulab.com/api/ai/v1/subjects",
|
||||
subject_alias_map: "https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/branch/main/identity/subject-id-alias-map.json",
|
||||
search_api: "https://guanghulab.com/api/ai/v1/search?q={query}",
|
||||
resolve_api: "https://guanghulab.com/api/ai/v1/resolve?id={NUMBER}",
|
||||
openapi: "https://guanghulab.com/api/ai/openapi.json",
|
||||
|
|
@ -157,6 +255,7 @@ function openApi() {
|
|||
paths: {
|
||||
"/v1/repositories": { get: { summary: "读取最新仓库编号路径映射", responses: { "200": { description: "Repository route map" } } } },
|
||||
"/v1/nodes": { get: { summary: "读取最新服务器节点与人格路径编号映射", responses: { "200": { description: "Server node map" } } } },
|
||||
"/v1/subjects": { get: { summary: "读取人类、人格体与公共系统的分型身份注册表", responses: { "200": { description: "Subject registry" } } } },
|
||||
"/v1/search": { get: { summary: "按中文、编号或项目名检索", parameters: [{ name: "q", in: "query", schema: { type: "string" } }], responses: { "200": { description: "Search results" } } } },
|
||||
"/v1/resolve": { get: { summary: "解析仓库、服务器节点或人格路径编号", parameters: [{ name: "id", in: "query", required: true, schema: { type: "string", example: "ZY-OPS-LOOP-001" } }], responses: { "200": { description: "Resolved numbered route" }, "404": { description: "Unknown route" } } } }
|
||||
}
|
||||
|
|
@ -169,4 +268,7 @@ if (require.main === module) {
|
|||
createServer().listen(port, host, () => process.stdout.write(`guanghu-ai-discovery listening on ${host}:${port}\n`));
|
||||
}
|
||||
|
||||
module.exports = { createServer, loadMap, loadNodeMap, search, searchAll };
|
||||
module.exports = {
|
||||
createServer, loadMap, loadNodeMap, loadSubjectRegistry, loadSubjectAliasMap,
|
||||
resolveSubjectId, search, searchAll,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { createServer, loadMap, loadNodeMap, search, searchAll } = require("./server");
|
||||
const {
|
||||
createServer, loadMap, loadNodeMap, loadSubjectRegistry, loadSubjectAliasMap,
|
||||
resolveSubjectId, search, searchAll,
|
||||
} = require("./server");
|
||||
|
||||
test("repository map has unique sequential codes and domestic primary routes", () => {
|
||||
const map = loadMap();
|
||||
|
|
@ -33,7 +36,7 @@ test("server node map binds Zhuyuan routes to the domestic primary", () => {
|
|||
assert.equal(map.map_id, "FD-NODE-MAP-001");
|
||||
assert.equal(map.default_node, "JD-FD-PRIMARY");
|
||||
const node = map.nodes.find(item => item.node_id === "JD-FD-PRIMARY");
|
||||
assert.ok(node.persona_systems.includes("ICE-GL-ZY001"));
|
||||
assert.ok(node.persona_systems.includes("ICE-P-ZY001"));
|
||||
const loop = map.persona_routes.find(item => item.route_id === "ZY-OPS-LOOP-001");
|
||||
assert.equal(loop.primary_node, "JD-FD-PRIMARY");
|
||||
assert.match(loop.path, /zhuyuan-persona-system/);
|
||||
|
|
@ -49,6 +52,30 @@ test("Zhuyuan Chinese query returns the numbered operation loop", () => {
|
|||
assert.equal(results[0].route_id, "ZY-OPS-LOOP-001");
|
||||
});
|
||||
|
||||
test("legacy Zhuyuan ids canonicalize before navigation", () => {
|
||||
const aliases = loadSubjectAliasMap();
|
||||
for (const legacyId of ["ICE-GL-ZY001", "ICE-PZY-001"]) {
|
||||
const resolved = resolveSubjectId(aliases, legacyId);
|
||||
assert.equal(resolved.canonical_id, "ICE-P-ZY001");
|
||||
assert.equal(resolved.redirected, true);
|
||||
assert.equal(resolved.route_id, "FD-PERSONA-LOGIN-001");
|
||||
}
|
||||
assert.equal(resolveSubjectId(aliases, "ICE-P-ZY001").redirected, false);
|
||||
});
|
||||
|
||||
test("human ids remain human and conflicted persona ids fail closed", () => {
|
||||
const aliases = loadSubjectAliasMap();
|
||||
assert.equal(resolveSubjectId(aliases, "ICE-GL∞").status, "NOT_FOUND_NO_GUESS");
|
||||
assert.equal(resolveSubjectId(aliases, "ICE-PCA-001").status, "CONFLICT_REJECTED");
|
||||
});
|
||||
|
||||
test("searching the old id returns the canonical subject", () => {
|
||||
const results = searchAll(loadMap(), loadNodeMap(), "ICE-GL-ZY001", loadSubjectRegistry());
|
||||
const subject = results.find(item => item.kind === "subject");
|
||||
assert.equal(subject.id, "ICE-P-ZY001");
|
||||
assert.ok(subject.legacy_ids.includes("ICE-GL-ZY001"));
|
||||
});
|
||||
|
||||
test("public endpoints are read-only and expose CORS", async () => {
|
||||
const server = createServer();
|
||||
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
|
|
@ -63,7 +90,21 @@ test("public endpoints are read-only and expose CORS", async () => {
|
|||
assert.equal((await nodeResponse.json()).node_id, "JD-FD-PRIMARY");
|
||||
const loopResponse = await fetch(`${base}/v1/resolve?id=ZY-OPS-LOOP-001`);
|
||||
assert.equal(loopResponse.status, 200);
|
||||
assert.equal((await loopResponse.json()).persona_system, "ICE-GL-ZY001");
|
||||
assert.equal((await loopResponse.json()).persona_system, "ICE-P-ZY001");
|
||||
const legacyResponse = await fetch(`${base}/v1/resolve?id=ICE-GL-ZY001`);
|
||||
assert.equal(legacyResponse.status, 200);
|
||||
const legacyResolution = await legacyResponse.json();
|
||||
assert.equal(legacyResolution.canonical_id, "ICE-P-ZY001");
|
||||
assert.equal(legacyResolution.redirected, true);
|
||||
assert.equal(legacyResolution.subject.subject_kind, "persona_system");
|
||||
const canonicalResponse = await fetch(`${base}/v1/resolve?id=ICE-P-ZY001`);
|
||||
assert.equal((await canonicalResponse.json()).redirected, false);
|
||||
const humanResponse = await fetch(`${base}/v1/resolve?id=${encodeURIComponent("ICE-GL∞")}`);
|
||||
const humanResolution = await humanResponse.json();
|
||||
assert.equal(humanResponse.status, 200);
|
||||
assert.equal(humanResolution.subject_kind, "human");
|
||||
assert.equal(humanResolution.canonical_id, "ICE-GL∞");
|
||||
assert.equal((await fetch(`${base}/v1/resolve?id=ICE-PCA-001`)).status, 409);
|
||||
const manifestResponse = await fetch(`${base}/well-known`);
|
||||
const manifest = await manifestResponse.json();
|
||||
assert.equal(manifest.write_authorization.request_credential_required, false);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
- Legacy Fifth Domain history: https://guanghulab.com/fifth-domain/bingshuo/fifth-domain
|
||||
- Repository number map: https://guanghulab.com/api/ai/v1/repositories
|
||||
- Server node and persona route map: https://guanghulab.com/api/ai/v1/nodes
|
||||
- Human, persona and system subject registry: https://guanghulab.com/api/ai/v1/subjects
|
||||
- Search: https://guanghulab.com/api/ai/v1/search?q=光湖语言世界%20第五域
|
||||
- Resolve a code: https://guanghulab.com/api/ai/v1/resolve?id=REPO-001
|
||||
- Resolve Zhuyuan current loop: https://guanghulab.com/api/ai/v1/resolve?id=ZY-OPS-LOOP-001
|
||||
|
|
@ -30,8 +31,10 @@ historical backup routes only.
|
|||
|
||||
FD-NODE-MAP-001 publishes public-safe server node ids and persona route ids. Resolve
|
||||
the node id, read its navigation map, and obtain separate Lake Lamp authorization
|
||||
before any write operation. ICE-GL-ZY001 currently maps through ZY-OPS-LOOP-001 to
|
||||
JD-FD-PRIMARY.
|
||||
before any write operation. ICE-GL-ZY001 and ICE-PZY-001 first redirect through
|
||||
FD-SUBJECT-ID-ALIAS-MAP-001 to the canonical Zhuyuan persona id ICE-P-ZY001, which
|
||||
then maps through ZY-OPS-LOOP-001 to JD-FD-PRIMARY. The redirect changes navigation,
|
||||
not authority.
|
||||
|
||||
For any write operation from a phone or a non-local AI instance, create a public
|
||||
no-authority workorder. Give the returned request_url to Ice Shuo. The URL cannot
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ test("front door keeps the legally required ICP link", () => {
|
|||
test("front door makes the new code channel the primary route", () => {
|
||||
assert.match(html, /href="\/code\/"/);
|
||||
assert.match(html, /光湖代码频道/);
|
||||
assert.match(llms, /https:\/\/guanghulab\.com\/code\/bingshuo\/fifth-domain/);
|
||||
assert.match(llms, /https:\/\/guanghulab\.com\/code\/bingshuo\/guanghu-ice-heart/);
|
||||
assert.match(llms, /ICE-GL-ZY001[\s\S]*ICE-P-ZY001/);
|
||||
assert.match(robots, /Allow: \/code\//);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue