Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.web-plane.host-kit.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ VIEWER_PORT=5173
COORDINATOR_INTERNAL_API_BASE=
# Private value shared with bim-review-coordinator. Never commit a real token.
INTERNAL_API_AUTH_TOKEN=
# Private A4 coordinator/governance context token (minimum 16 characters).
# Both processes receive it from the real env file; never commit a real value.
A4_INTERNAL_CONTEXT_TOKEN=
# LAN demo default publishes viewer/coordinator/Kit WebRTC on the host IP.
# For local-only demo override PUBLIC_HOST=127.0.0.1 and VIEWER_BIND_HOST=127.0.0.1.
PUBLIC_HOST=192.168.10.105
Expand Down
8 changes: 8 additions & 0 deletions bim-review-coordinator/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ MINIO_WATCH_SELF_BASE_URL=
# ---- 2026-06-17 補齊:src/config.ts / app.ts / routes 已讀取但先前未記錄的 key(值留空或佔位,勿放實際機密)----
# Governance service (A1/A2/A3) base;coordinator proxy /api/governance/*
GOVERNANCE_API_BASE=http://127.0.0.1:49102
# A4 coordinator -> governance internal context token(至少 16 字元);由 secret injection 提供,勿提交實值
A4_INTERNAL_CONTEXT_TOKEN=
# 非 loopback governance exact origins(逗號分隔);host-kit compose 由 HOST_GOVERNANCE_API_BASE 明確注入
A4_TRUSTED_GOVERNANCE_ORIGINS=
# coordinator 可讀的 host-native conversion artifacts root;Docker 由 read-only mount 注入
A4_CONVERSION_ARTIFACTS_ROOT=
# governance host namespace 的同一 artifacts root;canonical deploy 注入 absolute path
A4_CONVERSION_ARTIFACTS_HOST_ROOT=
# Kit Manager API(operator UI 後端,:8010);coordinator forward-proxy /api/kit/*
KIT_MANAGER_API_BASE=http://127.0.0.1:8010
# Storage 路徑分層:STORAGE_ROOT=容器內路徑;STORAGE_HOST_ROOT=宿主映射;RUNTIME_STORAGE_ROOT=宿主絕對路徑(volume binding)
Expand Down
31 changes: 31 additions & 0 deletions bim-review-coordinator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Local review-session control plane for the AI-BIM governance workspace.
- Return session / stream config data to the viewer.
- Authenticate viewer lease claims and issue narrow runtime-command decisions.
- Maintain bounded stage-binding transactions and Kit-confirmed active/last-good evidence.
- Resolve session-scoped A4 search authority without accepting browser host paths.
- Broadcast basic session presence over Socket.IO namespace `/review`.
- Persist short-lived session events as JSONL files under `data/events`.

Expand Down Expand Up @@ -84,8 +85,38 @@ POST /api/review-sessions/{session_id}/stage-binding
POST /api/internal/review-sessions/{session_id}/runtime-command-authorizations
POST /api/internal/review-sessions/{session_id}/stage-binding-authorization-rollbacks
POST /api/internal/review-sessions/{session_id}/stage-binding-confirmations
POST /api/governance/search/model/for-session/{session_id}
POST /api/governance/search/model/for-session/{session_id}/partial-confirmation
POST /api/governance/search/model/for-ifc-ready/{job_id}
```

The canonical A4 search route authenticates the caller first, requires the
caller's active primary viewer lease, and resolves the active session's IFC,
mapping, model, artifact, and stage revision from coordinator-owned state. The
browser may send only `query`, bounded `limit`, `interpret_mode`, and optional
`retry_of_query_id`; host paths, trusted context, actor, and lease authority are
rejected. The generic `POST /api/governance/search/model` browser route is
disabled in every profile. `for-ifc-ready` remains an authenticated,
lab-only `ifc_ready_table_only` compatibility route until user auth carries
tenant/project authorization; it never forwards a mapping or session proof
context.

Trusted A4 forwarding requires a non-empty server-only
`A4_INTERNAL_CONTEXT_TOKEN` shared with governance-service and either an exact
loopback `GOVERNANCE_API_BASE` or an exact origin listed by
`A4_TRUSTED_GOVERNANCE_ORIGINS`. The host-kit deployment injects only its
configured `HOST_GOVERNANCE_API_BASE`, passes the shared token through env, and
mounts host conversion artifacts read-only at `A4_CONVERSION_ARTIFACTS_ROOT`.
Because governance remains host-native, canonical deploy also injects the same
tree's absolute host path as `A4_CONVERSION_ARTIFACTS_HOST_ROOT`; coordinator
validates the container-visible file and forwards only the identical
`<job>/element_mapping.json` suffix in the host namespace.
Redirects, oversized/non-JSON responses, and responses containing server paths
or credential-shaped fields fail closed.
The current `local-dev` identity/lease seam is labelled `lab` internally and
cannot mint proof, Issue, or 3D authority; production with pending SSO binding
is rejected.

The A4 handoff endpoints accept governance-signed row proofs, re-resolve the
authenticated primary-session binding, and store only a bounded, one-shot
opaque intent. `A4_HANDOFF_TTL_SECONDS` defaults to 60 seconds and is capped at
Expand Down
251 changes: 251 additions & 0 deletions bim-review-coordinator/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ import {
type RuleRunSessionResolution,
type RuleRunSourceMetadata,
} from "./routes/governanceProxy.js";
import {
registerA4SearchRoutes,
type A4SearchIfcReadyResolution,
type A4SearchPrincipal,
type A4SearchPrincipalResolution,
type A4SearchSessionResolution as A4SearchRouteSessionResolution,
} from "./routes/a4SearchRoutes.js";
import {
registerA4HandoffRoutes,
type A4SearchSessionResolution,
Expand Down Expand Up @@ -3969,6 +3976,250 @@ export function createCoordinatorApp(
};
}

function authenticateA4SearchPrincipal(
headers: Record<string, string | undefined>,
): A4SearchPrincipalResolution {
try {
const user = userAuthProvider.authenticate({ headers });
if (process.env.NODE_ENV === "production" && user.ssoBinding === "pending_oq5") {
return {
ok: false,
status: 503,
error_code: "a4_production_identity_unavailable",
detail: "Production A4 identity is unavailable.",
};
}
return {
ok: true,
principal: {
principal_ref: user.userId,
auth_scope: user.ssoBinding === "bound" ? "production" : "lab",
},
};
} catch (error) {
if (error instanceof AuthError) {
return {
ok: false,
status: error.statusCode === 403 ? 403 : 401,
error_code: "a4_authentication_required",
detail: "A4 authentication failed.",
};
}
return {
ok: false,
status: 503,
error_code: "a4_authentication_unavailable",
detail: "A4 authentication is unavailable.",
};
}
}

function isExactConversionArtifactUrl(
value: string | null | undefined,
conversionJobId: string,
filename: "model.usdc" | "element_mapping.json",
): boolean {
if (!value || !isSafeConversionJobId(conversionJobId)) return false;
try {
const parsed = new URL(value);
return (parsed.protocol === "http:" || parsed.protocol === "https:")
&& parsed.username.length === 0
&& parsed.password.length === 0
&& parsed.search.length === 0
&& parsed.hash.length === 0
&& parsed.pathname === `/artifacts/${conversionJobId}/${filename}`;
} catch {
return false;
}
}

function containedA4MappingPath(
binding: ArtifactBinding,
linkedConversionJobId: string | null | undefined,
): string | null {
const conversionJobId = binding.conversion_job_id;
if (
binding.conversion_authority !== "bim-streaming-server"
|| binding.conversion_status !== "ready"
|| !conversionJobId
|| conversionJobId !== linkedConversionJobId
|| !isExactConversionArtifactUrl(binding.url, conversionJobId, "model.usdc")
|| !isExactConversionArtifactUrl(binding.mapping_url, conversionJobId, "element_mapping.json")
) return null;

const artifactsRoot = path.resolve(config.a4ConversionArtifactsRoot);
const candidate = path.resolve(artifactsRoot, conversionJobId, "element_mapping.json");
const relative = path.relative(artifactsRoot, candidate);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return null;
try {
if (!fs.statSync(candidate).isFile()) return null;
const realRoot = fs.realpathSync(artifactsRoot);
const realCandidate = fs.realpathSync(candidate);
const realRelative = path.relative(realRoot, realCandidate);
if (!realRelative || realRelative.startsWith("..") || path.isAbsolute(realRelative)) return null;

const hostRootValue = config.a4ConversionArtifactsHostRoot;
const hostPath = /^[A-Za-z]:[\\/]|^\\\\/.test(hostRootValue) ? path.win32 : path.posix;
if (!hostPath.isAbsolute(hostRootValue)) return null;
const hostRoot = hostPath.resolve(hostRootValue);
const hostCandidate = hostPath.resolve(hostRoot, conversionJobId, "element_mapping.json");
const hostRelative = hostPath.relative(hostRoot, hostCandidate);
if (!hostRelative || hostRelative.startsWith("..") || hostPath.isAbsolute(hostRelative)) return null;
return hostCandidate;
} catch {
return null;
}
}

function resolveA4SearchSessionContext(
sessionId: string,
principal: A4SearchPrincipal,
): A4SearchRouteSessionResolution {
const session = store.get(sessionId);
if (!session) {
return {
ok: false,
status: 404,
error_code: "a4_session_not_found",
detail: "A4 review session was not found.",
};
}
if (session.status !== "active") {
return {
ok: false,
status: 409,
error_code: "a4_session_inactive",
detail: "A4 requires an active review session.",
};
}

const primaryLease = viewerLeaseStore.primary(sessionId);
if (!primaryLease || primaryLease.user_id !== principal.principal_ref) {
return {
ok: false,
status: 403,
error_code: "a4_primary_lease_required",
detail: "A4 requires the caller's active primary viewer lease.",
};
}

const modelVersionId = session.model_version_id?.trim();
const activeBinding = stageBindingAuthorityStore.activeBinding(sessionId, principal.principal_ref);
if (!activeBinding || activeBinding.lease_id !== primaryLease.lease_id) {
return {
ok: false,
status: 409,
error_code: "a4_session_stage_unavailable",
detail: "A4 session stage binding is not active.",
};
}
const activePrimary = activeBinding.stage_composition.primary;
const primaryBinding = session.artifact_bindings.find((binding) =>
binding.artifact_id === activePrimary.artifact_id,
);
if (
!modelVersionId
|| !primaryBinding
|| primaryBinding.model_version_id !== modelVersionId
|| !primaryBinding.artifact_id
|| primaryBinding.url !== activePrimary.usdc_url
) {
return {
ok: false,
status: 409,
error_code: "a4_session_model_unavailable",
detail: "A4 session model binding is incomplete.",
};
}

const linkedJob = latestIfcReadyJobForSession(sessionId);
if (!linkedJob || linkedJob.external_model_version_id !== modelVersionId) {
return {
ok: false,
status: 409,
error_code: "a4_session_model_unavailable",
detail: "A4 session model binding is incomplete.",
};
}
const source = resolveDownloadedJobForRuleRun(linkedJob, modelVersionId, session);
if (!source.ok) {
return {
ok: false,
status: 409,
error_code: "a4_session_source_unavailable",
detail: "A4 session source IFC is unavailable.",
};
}

const mappingPath = linkedJob.conversion_authority === "bim-streaming-server"
&& linkedJob.conversion_status === "ready"
? containedA4MappingPath(primaryBinding, linkedJob.conversion_job_id)
: null;
if (!mappingPath) {
return {
ok: false,
status: 409,
error_code: "a4_session_mapping_unavailable",
detail: "A4 session element mapping is unavailable.",
};
}

return {
ok: true,
context: {
ifc_source_path: source.context.ifc_source_path,
element_mapping_path: mappingPath,
model_version_id: modelVersionId,
review_session_id: sessionId,
primary_artifact_id: primaryBinding.artifact_id,
active_binding_revision: activeBinding.binding_revision_id,
mapping_provenance: "server_resolved",
// Current browser lease carriers are local runtime seams, not a
// production-verifiable shared capability. Governance therefore keeps
// this route table-only and cannot mint proof/Issue/3D authority.
primary_lease_capability: "lab_unverified",
},
};
}

function resolveA4SearchIfcReadyContext(jobId: string): A4SearchIfcReadyResolution {
const job = externalIfcReadyStore.get(jobId);
if (!job) {
return {
ok: false,
status: 404,
error_code: "a4_ifc_ready_not_found",
detail: "A4 IFC-ready job was not found.",
};
}
const source = resolveDownloadedJobForRuleRun(job, job.external_model_version_id ?? null);
if (!source.ok) {
return {
ok: false,
status: 409,
error_code: "a4_ifc_ready_source_unavailable",
detail: "A4 IFC-ready source is unavailable.",
};
}
return {
ok: true,
context: {
ifc_source_path: source.context.ifc_source_path,
model_version_id: source.context.model_version_id ?? null,
},
};
}

// Mount before the frozen generic governance proxy so Express's first-match
// routing enforces the A4 browser boundary without editing that shared file.
registerA4SearchRoutes(app, {
isSafeSessionId,
isSafeIfcReadyJobId,
authenticatePrincipal: authenticateA4SearchPrincipal,
resolveSessionContext: resolveA4SearchSessionContext,
resolveIfcReadyContext: resolveA4SearchIfcReadyContext,
});

registerGovernanceProxy(app, {
isSafeSessionId,
isSafeIfcReadyJobId,
Expand Down
16 changes: 16 additions & 0 deletions bim-review-coordinator/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ export interface CoordinatorConfig {
// edge artifact health:落地端 runtime data-plane metadata,不進雲端、不放 deploy checkout。
edgeSiteId: string;
edgeRuntimeDataRoot: string;
// Host-native conversion artifacts as visible to this process. Docker uses
// a dedicated read-only mount; host-local defaults to EDGE_RUNTIME_DATA_ROOT/artifacts.
a4ConversionArtifactsRoot: string;
// Same artifacts tree in the host-native governance process namespace.
// Docker deploy injects an absolute Windows/Linux host path separately.
a4ConversionArtifactsHostRoot: string;
artifactHealthLedgerStorePath: string;
// T7:使用者(local web view)auth provider,可替換;不做死 EZPLUS SSO,
// local web view ↔ 公司 SSO 真實銜接待 OQ5。
Expand Down Expand Up @@ -406,6 +412,10 @@ export function loadConfig(overrides: Partial<CoordinatorConfig> = {}): Coordina
path.join(cwd, "data", "conversion-ledger.json"),
edgeSiteId: process.env.EDGE_SITE_ID || "site_local_dev",
edgeRuntimeDataRoot,
a4ConversionArtifactsRoot:
process.env.A4_CONVERSION_ARTIFACTS_ROOT || path.join(edgeRuntimeDataRoot, "artifacts"),
a4ConversionArtifactsHostRoot:
process.env.A4_CONVERSION_ARTIFACTS_HOST_ROOT || path.join(edgeRuntimeDataRoot, "artifacts"),
artifactHealthLedgerStorePath:
process.env.ARTIFACT_HEALTH_LEDGER_STORE_PATH ||
artifactHealthLedgerDefaultPath,
Expand Down Expand Up @@ -473,5 +483,11 @@ export function loadConfig(overrides: Partial<CoordinatorConfig> = {}): Coordina
? path.join(cwd, "data", "artifact-health-ledger.json")
: path.join(finalEdgeRoot, "ledgers", "artifact-health-ledger.json");
}
if (!process.env.A4_CONVERSION_ARTIFACTS_ROOT && overrides.a4ConversionArtifactsRoot === undefined) {
merged.a4ConversionArtifactsRoot = path.join(merged.edgeRuntimeDataRoot, "artifacts");
}
if (!process.env.A4_CONVERSION_ARTIFACTS_HOST_ROOT && overrides.a4ConversionArtifactsHostRoot === undefined) {
merged.a4ConversionArtifactsHostRoot = merged.a4ConversionArtifactsRoot;
}
return merged;
}
Loading
Loading