Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e22ce3f34 | ||
|
|
63a64328ae | ||
|
|
eb17c7889a | ||
|
|
011c2f66c6 | ||
|
|
a7a272edae | ||
|
|
075b6df175 | ||
|
|
49cbe258e0 | ||
|
|
ff44ed5f4a |
@@ -0,0 +1,270 @@
|
||||
name: Module Package Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing protected version tag to publish
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Select and validate protected release tag
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||
esac
|
||||
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
git checkout --detach "$tag"
|
||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||
- name: Validate package versions
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
expected = tag.removeprefix("v")
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
if project.get("version") != expected:
|
||||
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||
webui = Path("webui/package.json")
|
||||
if webui.is_file():
|
||||
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||
if package.get("version") != expected:
|
||||
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||
release = Path("webui/package.release.json")
|
||||
if release.is_file():
|
||||
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||
if (
|
||||
release_package.get("name") != package.get("name")
|
||||
or release_package.get("version") != expected
|
||||
):
|
||||
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||
PY
|
||||
- name: Build immutable package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||
rm -rf dist .package-webui
|
||||
python -m build --wheel --outdir dist
|
||||
python -m twine check dist/*.whl
|
||||
if [[ -f webui/package.json ]]; then
|
||||
mkdir .package-webui
|
||||
cp -a webui/. .package-webui/
|
||||
rm -rf .package-webui/node_modules .package-webui/dist
|
||||
if [[ -f .package-webui/package.release.json ]]; then
|
||||
cp .package-webui/package.release.json .package-webui/package.json
|
||||
fi
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = ".package-webui/package.json";
|
||||
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||
for (const group of groups) {
|
||||
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||
if (!name.startsWith("@govoplan/")) continue;
|
||||
if (typeof specifier !== "string") {
|
||||
throw new Error(`${group}.${name} must use a string version`);
|
||||
}
|
||||
const packageSlug = name.slice("@govoplan/".length);
|
||||
if (!packageSlug.endsWith("-webui")) {
|
||||
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||
}
|
||||
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
packageJson[group][name] = gitTag[1];
|
||||
continue;
|
||||
}
|
||||
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||
throw new Error(
|
||||
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
delete packageJson.private;
|
||||
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
npm pkg delete private --prefix .package-webui
|
||||
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||
fi
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(Path("dist").iterdir()):
|
||||
if path.suffix not in {".whl", ".tgz"}:
|
||||
continue
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||
payload = {
|
||||
"schema_version": "1",
|
||||
"repository": os.environ["GITEA_REPOSITORY"],
|
||||
"tag": os.environ["RELEASE_TAG"],
|
||||
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
Path("dist/package-artifacts.json").write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
- name: Retain package hash evidence
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||
with:
|
||||
name: module-packages-${{ gitea.ref_name }}
|
||||
path: dist/package-artifacts.json
|
||||
- name: Check immutable registry state
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||
token = os.environ["PACKAGE_TOKEN"]
|
||||
|
||||
def should_publish(kind, name, version, path):
|
||||
package_url = "/".join(
|
||||
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||
)
|
||||
request = Request(
|
||||
package_url,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
files = json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(f"{kind} package {name}=={version} is not published yet")
|
||||
return True
|
||||
raise
|
||||
if not isinstance(files, list) or len(files) != 1:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||
)
|
||||
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if files[0].get("sha256") != expected_sha256:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||
)
|
||||
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||
return False
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
wheels = tuple(Path("dist").glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise SystemExit("release build must contain exactly one wheel")
|
||||
publish_pypi = should_publish(
|
||||
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||
)
|
||||
|
||||
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||
if len(tarballs) > 1:
|
||||
raise SystemExit("release build must contain at most one npm package")
|
||||
publish_npm = False
|
||||
if tarballs:
|
||||
webui = json.loads(
|
||||
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
publish_npm = should_publish(
|
||||
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||
)
|
||||
|
||||
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||
PY
|
||||
- name: Publish wheel and WebUI package
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
else
|
||||
echo "Exact wheel is already present; skipping immutable retry."
|
||||
fi
|
||||
shopt -s nullglob
|
||||
webui_packages=(dist/*.tgz)
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
printf '%s\n' \
|
||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||
> "$npmrc"
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||
--ignore-scripts --access public \
|
||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||
elif (( ${#webui_packages[@]} )); then
|
||||
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||
fi
|
||||
@@ -3,3 +3,34 @@
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (domain).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
The service-directory route, state, blocker, and accessibility mapping is
|
||||
recorded in
|
||||
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||
|
||||
Portal owns service discovery, presentation, and channel entry. Its
|
||||
`portal.service_directory` capability projects provider-owned, versioned
|
||||
service definitions into available, explainably unavailable, or undiscoverable
|
||||
entries. It does not persist the institutional service promise or import Cases,
|
||||
Forms, Workflow, Access, or Policy tables.
|
||||
|
||||
The capability works in a reduced composition without a Services provider by
|
||||
returning an empty directory. When a provider is present, provider-level access
|
||||
filtering remains authoritative and Portal adds only presentation-oriented
|
||||
availability checks for publication, effective time, audience, module, and
|
||||
capability requirements.
|
||||
|
||||
The tenant-scoped `/api/v1/portal/services` endpoint and `/portal` WebUI expose
|
||||
the projection. Audience visibility is derived from trusted principal and
|
||||
active function-assignment state on the server.
|
||||
|
||||
`POST /api/v1/portal/services/{service_id}/launch` re-fetches and re-evaluates
|
||||
the exact Service revision before any effect. URL entries return a validated
|
||||
redirect. Case, form, and workflow bindings delegate through the optional
|
||||
`cases.service_launcher`, `forms_runtime.service_launcher`, and
|
||||
`workflow_engine.service_launcher` contracts; each is tenant-bound and
|
||||
replay-safe. A missing owner launcher makes the entry explainably unavailable.
|
||||
Form launch resolves an exact published `<form-id>/<revision>` and returns the
|
||||
owner's Form-instance route rather than persisting values in Portal.
|
||||
|
||||
See [docs/SERVICE_DIRECTORY_CONCEPT.md](docs/SERVICE_DIRECTORY_CONCEPT.md).
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Portal Interface Pattern Migration
|
||||
|
||||
Portal is a public-service-style directory for authenticated institutional
|
||||
users. Services owns definitions, Policy/provider capabilities own availability,
|
||||
and Cases, Forms Runtime, or Workflow Engine owns the launch effect. Portal
|
||||
does not import those modules or infer their state.
|
||||
|
||||
| Surface | Task and archetype | Consequence and state contract |
|
||||
| --- | --- | --- |
|
||||
| `/portal` toolbar | Find and filter services | Search and the unavailable-service toggle affect only the directory result. Counts are announced as the result changes. |
|
||||
| Service directory | Public service/entry catalogue | Loading, empty, failed, available, unavailable, and capability-unknown states remain distinct. Only discoverable service metadata is rendered. |
|
||||
| Availability blocker | Explained disabled action | Open remains in its stable action position. The blocker states why launch is unavailable, what must happen, the responsible actor, and the destination. |
|
||||
| Open service | Consequential handoff | Portal re-evaluates the exact service revision with a replay-safe request, then hands off to the provider-owned route. It never reports success without a destination. |
|
||||
|
||||
The directory uses Core buttons, toggle, status, loading, alerts, scrolling,
|
||||
blocker explanation, guarded navigation, and documentation help. Availability
|
||||
is expressed in text as well as color. The responsive toolbar stacks before the
|
||||
service list and every action remains keyboard-operable. Optional owner modules
|
||||
remain capability-based and an unavailable provider does not become a broken
|
||||
link.
|
||||
|
||||
Verification:
|
||||
|
||||
- `npm run test:interface-pattern`
|
||||
- Portal service-directory and manifest tests
|
||||
- the Core TypeScript graph, structural localization audit, theme check, module
|
||||
permutations, and full-product bundle budget
|
||||
@@ -4,6 +4,12 @@ GovOPlaN Portal should expose a role-aware service directory. The directory is
|
||||
not a marketing catalogue; it is the user-facing map of what a person can do in
|
||||
the configured institution.
|
||||
|
||||
Portal owns presentation, discovery, entry, and channel-specific availability.
|
||||
It must not become the long-term owner of the institutional service definition.
|
||||
A shared Services contract should own the versioned promise independently so
|
||||
Cases, Forms, Workflow Engine, Reporting, external publication, and more than
|
||||
one portal surface can consume the same definition.
|
||||
|
||||
## Purpose
|
||||
|
||||
The service directory should help users find the right administrative action
|
||||
@@ -34,11 +40,22 @@ A service entry should describe:
|
||||
- policy and availability blockers
|
||||
- user-facing explanation and required documents
|
||||
- audit and evidence expectations
|
||||
- version and effective interval
|
||||
- legal or organizational basis, deadlines, fees, remedies, and service-level
|
||||
expectations
|
||||
- responsible organization/function, mandate/jurisdiction, result/decision,
|
||||
publication, and records bindings
|
||||
|
||||
The portal should request service contributions through core-mediated
|
||||
capability and UI contribution contracts. It must not import domain module
|
||||
internals.
|
||||
|
||||
The first implementation may consume module-contributed service definitions.
|
||||
Create a separate `govoplan-services` repository only when definitions require
|
||||
independent persistence/lifecycle and are consumed outside Portal by at least
|
||||
one other procedure or publication surface. Extraction must keep stable service
|
||||
keys and compatibility for existing Portal links.
|
||||
|
||||
Service visibility must consume access semantics through kernel capabilities:
|
||||
|
||||
- `access.semanticDirectory` resolves the actor's identity, account,
|
||||
@@ -56,3 +73,54 @@ The directory should explain unavailable services when the reason is useful:
|
||||
missing role, disabled module, tenant policy, missing connector, maintenance
|
||||
mode, or unavailable external provider. It should hide only services that are
|
||||
irrelevant or intentionally undiscoverable by policy.
|
||||
|
||||
## Implemented Slice
|
||||
|
||||
`portal.service_directory` consumes the optional `services.definitions`
|
||||
capability. It returns only tenant-scoped definitions and distinguishes
|
||||
`available`, `unavailable`, and `hidden` states. Draft, retired,
|
||||
outside-effective-time, and audience-inapplicable services are hidden. A
|
||||
suspended service or missing required module/capability remains discoverable
|
||||
with stable reason codes and its configured explanation reference.
|
||||
|
||||
The definition provider remains responsible for access and policy filtering.
|
||||
Portal cannot widen its result and returns an empty directory when no provider
|
||||
is installed. No service definition is persisted in Portal.
|
||||
|
||||
Tenant/package specializations are derived before presentation through Core's
|
||||
restrictive Service rule. They carry their parent version and may narrow
|
||||
audience, channels, effective time, and publication or add prerequisites,
|
||||
required evidence, legal bases, and bindings. They cannot silently loosen the
|
||||
system/package definition.
|
||||
|
||||
Policy, Mandate, connector, maintenance, and configuration requirements are
|
||||
evaluated through the optional `services.availability` capability. Module and
|
||||
capability requirements are checked against the active registry, and audience
|
||||
requirements against the supplied actor projection. Unknown requirements fail
|
||||
closed. Each requirement declares whether failure remains visible with an
|
||||
explanation or makes the entry undiscoverable.
|
||||
|
||||
`GET /api/v1/portal/services` and the `/portal` WebUI expose this projection.
|
||||
The API derives audience tokens from the authenticated principal instead of
|
||||
accepting caller-provided audience claims. Generic `public` and `authenticated`
|
||||
tokens are combined with stable account, identity, group, role, active function,
|
||||
and organization-unit tokens. Function slugs are included only after the
|
||||
optional Access semantic directory confirms an active, effective, same-tenant
|
||||
assignment. Missing or failing directory providers narrow the result.
|
||||
|
||||
The WebUI provides bounded search, availability filtering, explanations, and
|
||||
one launch action. `POST /api/v1/portal/services/{service_id}/launch` resolves
|
||||
the entry again at launch time so a stale browser projection cannot bypass a
|
||||
new audience, publication, effective-time, module, or capability restriction.
|
||||
Direct URL entries return only validated local or credential-free HTTP(S)
|
||||
redirects.
|
||||
|
||||
Case, form, and workflow bindings delegate to owner capabilities. Their launch result
|
||||
must retain the exact Service reference, tenant, binding kind, target reference,
|
||||
and replay-safe idempotency key. Portal never writes their tables. Missing
|
||||
launchers produce `service.launcher.missing:<kind>` and make the entry visibly
|
||||
unavailable. Forms Runtime now provides a definition-aware launcher when Forms
|
||||
and Forms Runtime are active. It resolves an exact published
|
||||
`<form-id>/<revision>`, validates launch values, and retains Service/binding
|
||||
provenance. Reduced installations still fail closed rather than simulating a
|
||||
submission in Portal.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-portal"
|
||||
version = "0.1.17"
|
||||
description = "GovOPlaN service discovery and public portal module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = ["govoplan-core>=0.1.17"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
govoplan_portal = ["py.typed"]
|
||||
|
||||
[project.entry-points."govoplan.modules"]
|
||||
"portal" = "govoplan_portal.backend.manifest:get_manifest"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""GovOPlaN Portal module."""
|
||||
|
||||
from govoplan_portal.backend.manifest import get_manifest
|
||||
|
||||
__all__ = ["get_manifest"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Portal backend contracts."""
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_SERVICE_AVAILABILITY,
|
||||
CAPABILITY_SERVICE_DEFINITIONS,
|
||||
service_launch_capability,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleInterfaceRequirement,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleArchitectureDocumentation,
|
||||
ModuleMaturityEvidence,
|
||||
)
|
||||
from govoplan_portal.backend.service_directory import (
|
||||
CAPABILITY_PORTAL_SERVICE_DIRECTORY,
|
||||
PortalServiceDirectory,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
|
||||
|
||||
MODULE_ID = "portal"
|
||||
MODULE_VERSION = "0.1.17"
|
||||
READ_SCOPE = "portal:service:read"
|
||||
SERVICE_LAUNCH_CAPABILITIES = tuple(
|
||||
service_launch_capability(kind) for kind in ("case", "form", "workflow")
|
||||
)
|
||||
|
||||
|
||||
def _service_directory(context: ModuleContext) -> PortalServiceDirectory:
|
||||
return PortalServiceDirectory(context.registry)
|
||||
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
from govoplan_portal.backend.router import configure_registry, router
|
||||
|
||||
configure_registry(context.registry)
|
||||
return router
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name="Portal",
|
||||
version=MODULE_VERSION,
|
||||
optional_dependencies=(
|
||||
"access",
|
||||
"services",
|
||||
"cases",
|
||||
"forms",
|
||||
"forms_runtime",
|
||||
"workflow_engine",
|
||||
),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_SERVICE_DEFINITIONS,
|
||||
CAPABILITY_SERVICE_AVAILABILITY,
|
||||
*SERVICE_LAUNCH_CAPABILITIES,
|
||||
),
|
||||
permissions=(
|
||||
PermissionDefinition(
|
||||
scope=READ_SCOPE,
|
||||
label="View service directory",
|
||||
description="Discover services available to the current account and function assignments.",
|
||||
category="Portal",
|
||||
level="tenant",
|
||||
module_id=MODULE_ID,
|
||||
resource="service",
|
||||
action="read",
|
||||
),
|
||||
),
|
||||
role_templates=(
|
||||
RoleTemplate(
|
||||
slug="portal_user",
|
||||
name="Portal user",
|
||||
description="Discover and enter available institutional services.",
|
||||
permissions=(READ_SCOPE,),
|
||||
default_authenticated=True,
|
||||
),
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="portal.service_directory", version="0.1.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="services.availability", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||
*(
|
||||
ModuleInterfaceRequirement(
|
||||
name=capability,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
)
|
||||
for capability in SERVICE_LAUNCH_CAPABILITIES
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_PORTAL_SERVICE_DIRECTORY: _service_directory,
|
||||
},
|
||||
route_factory=_router,
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/portal",
|
||||
label="Services",
|
||||
icon="landmark",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=25,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/portal-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/portal",
|
||||
component="PortalPage",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=25,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/portal",
|
||||
label="Services",
|
||||
icon="landmark",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=25,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="portal.navigation",
|
||||
module_id=MODULE_ID,
|
||||
kind="navigation",
|
||||
label="Services navigation",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="portal.directory",
|
||||
module_id=MODULE_ID,
|
||||
kind="route",
|
||||
label="Service directory",
|
||||
order=20,
|
||||
),
|
||||
),
|
||||
),
|
||||
capability_documentation={
|
||||
CAPABILITY_PORTAL_SERVICE_DIRECTORY: CapabilityDocumentation(
|
||||
label="Portal service directory",
|
||||
summary="Projects governed service definitions into role-aware availability entries.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="portal.service-directory",
|
||||
title="Service directory",
|
||||
summary="Find services available in the configured institution and understand relevant availability limits.",
|
||||
body=(
|
||||
"Portal presents provider-owned, versioned service definitions. "
|
||||
"Published services may be available, unavailable with a reason, "
|
||||
"or undiscoverable when they do not apply to the current audience. "
|
||||
"Opening a service re-evaluates that exact revision and delegates case, "
|
||||
"form, or workflow startup to an installed owner capability."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Service directory architecture",
|
||||
href="govoplan-portal/docs/SERVICE_DIRECTORY_CONCEPT.md",
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Portal interface pattern audit",
|
||||
href="govoplan-portal/docs/INTERFACE_PATTERN_MIGRATION.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
architecture=ModuleArchitectureDeclaration(
|
||||
layer="communication_participation",
|
||||
kind="domain",
|
||||
maturity="vertical_slice",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_service_directory.py",
|
||||
summary="Proves provider-neutral service discovery and explained availability.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/SERVICE_DIRECTORY_CONCEPT.md",
|
||||
summary="Defines Portal presentation and Services ownership boundaries.",
|
||||
),
|
||||
),
|
||||
known_limits=(
|
||||
"Portal does not persist service definitions; the Services provider remains authoritative.",
|
||||
"Case, Forms Runtime, and Workflow Engine own launch effects. Portal keeps entries unavailable whenever the selected owner capability is absent.",
|
||||
),
|
||||
owned_concepts=("service discovery", "service presentation", "channel entry"),
|
||||
non_owned_concepts=("institutional service definition", "case lifecycle"),
|
||||
reference_packages=("product.service-to-decision",),
|
||||
documentation=ModuleArchitectureDocumentation(
|
||||
security=("docs/SERVICE_DIRECTORY_CONCEPT.md",),
|
||||
operations=("docs/SERVICE_DIRECTORY_CONCEPT.md",),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.institutional import (
|
||||
InstitutionalContextError,
|
||||
InstitutionalReference,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_portal.backend.schemas import (
|
||||
PortalServiceLaunchRequest,
|
||||
PortalServiceLaunchResponse,
|
||||
PortalServiceListResponse,
|
||||
)
|
||||
from govoplan_portal.backend.service_directory import (
|
||||
PortalServiceDirectory,
|
||||
PortalServiceLaunchError,
|
||||
)
|
||||
|
||||
|
||||
READ_SCOPE = "portal:service:read"
|
||||
router = APIRouter(prefix="/portal", tags=["portal"])
|
||||
_registry: object | None = None
|
||||
|
||||
|
||||
def configure_registry(registry: object | None) -> None:
|
||||
global _registry
|
||||
_registry = registry
|
||||
|
||||
|
||||
@router.get("/services", response_model=PortalServiceListResponse)
|
||||
def api_list_portal_services(
|
||||
q: str = Query(default="", max_length=200),
|
||||
include_unavailable: bool = True,
|
||||
effective_at: datetime | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PortalServiceListResponse:
|
||||
if not has_scope(principal, READ_SCOPE):
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}")
|
||||
observed_at = effective_at or datetime.now(tz=UTC)
|
||||
if observed_at.tzinfo is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Portal service effective_at must include a timezone.",
|
||||
)
|
||||
try:
|
||||
entries = PortalServiceDirectory(_registry).list_entries(
|
||||
session,
|
||||
principal,
|
||||
tenant_id=principal.tenant_id,
|
||||
effective_at=observed_at,
|
||||
query=q.strip(),
|
||||
limit=limit,
|
||||
include_unavailable=include_unavailable,
|
||||
)
|
||||
except InstitutionalContextError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return PortalServiceListResponse(
|
||||
services=[entry.to_dict() for entry in entries]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/services/{service_id}/launch",
|
||||
response_model=PortalServiceLaunchResponse,
|
||||
)
|
||||
def api_launch_portal_service(
|
||||
service_id: str,
|
||||
payload: PortalServiceLaunchRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PortalServiceLaunchResponse:
|
||||
if not has_scope(principal, READ_SCOPE):
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}")
|
||||
if payload.requested_at.tzinfo is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Portal service launch requested_at must include a timezone.",
|
||||
)
|
||||
reference = InstitutionalReference(
|
||||
kind="service",
|
||||
owner_module="services",
|
||||
object_id=service_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
version=payload.service_version,
|
||||
valid_at=payload.requested_at,
|
||||
)
|
||||
try:
|
||||
result = PortalServiceDirectory(_registry).launch_service(
|
||||
session,
|
||||
principal,
|
||||
reference=reference,
|
||||
requested_at=payload.requested_at,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
parameters=payload.parameters,
|
||||
)
|
||||
session.commit()
|
||||
except LookupError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PortalServiceLaunchError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except (InstitutionalContextError, ValueError) as exc:
|
||||
session.rollback()
|
||||
status_code = 409 if "conflict" in str(exc).casefold() else 400
|
||||
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
|
||||
return PortalServiceLaunchResponse(**result.to_dict())
|
||||
|
||||
|
||||
__all__ = ["READ_SCOPE", "configure_registry", "router"]
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class PortalServiceEntryResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
definition: dict[str, Any]
|
||||
state: str
|
||||
reason_codes: list[str] = Field(default_factory=list)
|
||||
entry_binding: dict[str, Any] | None = None
|
||||
availability_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PortalServiceListResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
services: list[PortalServiceEntryResponse]
|
||||
|
||||
|
||||
class PortalServiceLaunchRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
service_version: str = Field(min_length=1, max_length=120)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
requested_at: datetime
|
||||
parameters: dict[str, Any] = Field(default_factory=dict, max_length=100)
|
||||
|
||||
|
||||
class PortalServiceLaunchResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
service_ref: dict[str, Any]
|
||||
binding: dict[str, Any]
|
||||
state: str
|
||||
target_ref: dict[str, Any] | None = None
|
||||
href: str | None = None
|
||||
replayed: bool = False
|
||||
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PortalServiceEntryResponse",
|
||||
"PortalServiceLaunchRequest",
|
||||
"PortalServiceLaunchResponse",
|
||||
"PortalServiceListResponse",
|
||||
]
|
||||
@@ -0,0 +1,501 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
AccessSemanticDirectory,
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
PrincipalRef,
|
||||
)
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_SERVICE_AVAILABILITY,
|
||||
CAPABILITY_SERVICE_DEFINITIONS,
|
||||
EvidenceReference,
|
||||
InstitutionalContextError,
|
||||
InstitutionalReference,
|
||||
ServiceBinding,
|
||||
ServiceAvailabilityAssessment,
|
||||
ServiceAvailabilityEvaluator,
|
||||
ServiceDefinition,
|
||||
ServiceDefinitionProvider,
|
||||
ServiceLaunchRequest,
|
||||
ServiceLaunchResult,
|
||||
ServiceLauncher,
|
||||
service_launch_capability,
|
||||
)
|
||||
|
||||
|
||||
CAPABILITY_PORTAL_SERVICE_DIRECTORY = "portal.service_directory"
|
||||
ServiceDiscoveryState = Literal["available", "unavailable", "hidden"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PortalServiceEntry:
|
||||
definition: ServiceDefinition
|
||||
state: ServiceDiscoveryState
|
||||
reason_codes: tuple[str, ...] = ()
|
||||
entry_binding: ServiceBinding | None = None
|
||||
availability_evidence: tuple[EvidenceReference, ...] = ()
|
||||
|
||||
@property
|
||||
def discoverable(self) -> bool:
|
||||
return self.state != "hidden"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.state == "available"
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"definition": self.definition.to_dict(include_inspection=False),
|
||||
"state": self.state,
|
||||
"reason_codes": list(self.reason_codes),
|
||||
"entry_binding": (
|
||||
self.entry_binding.to_dict() if self.entry_binding else None
|
||||
),
|
||||
"availability_evidence": [
|
||||
item.to_dict(include_inspection=False)
|
||||
for item in self.availability_evidence
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class PortalServiceDirectory:
|
||||
"""Role-aware projection over a provider-owned service definition catalogue."""
|
||||
|
||||
def __init__(self, registry: object | None = None) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def list_entries(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime,
|
||||
audiences: tuple[str, ...] | None = None,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
include_unavailable: bool = True,
|
||||
) -> tuple[PortalServiceEntry, ...]:
|
||||
if not tenant_id.strip():
|
||||
raise InstitutionalContextError("Portal service tenant id is required.")
|
||||
if not 1 <= limit <= 200:
|
||||
raise InstitutionalContextError(
|
||||
"Portal service directory limit must be between 1 and 200."
|
||||
)
|
||||
effective_audiences = (
|
||||
audiences
|
||||
if audiences is not None
|
||||
else principal_audiences(
|
||||
self._registry,
|
||||
session,
|
||||
principal,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at,
|
||||
)
|
||||
)
|
||||
provider = _capability(self._registry, CAPABILITY_SERVICE_DEFINITIONS)
|
||||
if not isinstance(provider, ServiceDefinitionProvider):
|
||||
return ()
|
||||
definitions = provider.list_service_definitions(
|
||||
session,
|
||||
principal,
|
||||
tenant_id=tenant_id,
|
||||
query=query,
|
||||
limit=limit,
|
||||
)
|
||||
evaluator = _capability(
|
||||
self._registry,
|
||||
CAPABILITY_SERVICE_AVAILABILITY,
|
||||
)
|
||||
entries: list[PortalServiceEntry] = []
|
||||
for definition in definitions[:limit]:
|
||||
assessment = None
|
||||
if isinstance(evaluator, ServiceAvailabilityEvaluator):
|
||||
try:
|
||||
assessment = evaluator.evaluate_service_availability(
|
||||
session,
|
||||
principal,
|
||||
definition=definition,
|
||||
effective_at=effective_at,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - isolate optional evaluators.
|
||||
assessment = ServiceAvailabilityAssessment(
|
||||
requirement_states={},
|
||||
reason_codes=("service.availability.evaluator_failed",),
|
||||
)
|
||||
entries.append(
|
||||
self.project(
|
||||
definition,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at,
|
||||
audiences=effective_audiences,
|
||||
assessment=assessment,
|
||||
)
|
||||
)
|
||||
return tuple(
|
||||
entry
|
||||
for entry in entries
|
||||
if entry.discoverable
|
||||
and (include_unavailable or entry.available)
|
||||
)
|
||||
|
||||
def get_entry(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
reference: InstitutionalReference,
|
||||
effective_at: datetime,
|
||||
) -> PortalServiceEntry | None:
|
||||
provider = _capability(self._registry, CAPABILITY_SERVICE_DEFINITIONS)
|
||||
if not isinstance(provider, ServiceDefinitionProvider):
|
||||
return None
|
||||
definition = provider.get_service_definition(
|
||||
session,
|
||||
principal,
|
||||
reference=reference,
|
||||
effective_at=effective_at,
|
||||
)
|
||||
if definition is None:
|
||||
return None
|
||||
if definition.reference != reference:
|
||||
raise InstitutionalContextError(
|
||||
"Service provider did not return the requested exact revision."
|
||||
)
|
||||
evaluator = _capability(
|
||||
self._registry,
|
||||
CAPABILITY_SERVICE_AVAILABILITY,
|
||||
)
|
||||
assessment = None
|
||||
if isinstance(evaluator, ServiceAvailabilityEvaluator):
|
||||
try:
|
||||
assessment = evaluator.evaluate_service_availability(
|
||||
session,
|
||||
principal,
|
||||
definition=definition,
|
||||
effective_at=effective_at,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - optional evaluator isolation.
|
||||
assessment = ServiceAvailabilityAssessment(
|
||||
requirement_states={},
|
||||
reason_codes=("service.availability.evaluator_failed",),
|
||||
)
|
||||
return self.project(
|
||||
definition,
|
||||
tenant_id=reference.tenant_id,
|
||||
effective_at=effective_at,
|
||||
audiences=principal_audiences(
|
||||
self._registry,
|
||||
session,
|
||||
principal,
|
||||
tenant_id=reference.tenant_id,
|
||||
effective_at=effective_at,
|
||||
),
|
||||
assessment=assessment,
|
||||
)
|
||||
|
||||
def launch_service(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
reference: InstitutionalReference,
|
||||
requested_at: datetime,
|
||||
idempotency_key: str,
|
||||
parameters: dict[str, object],
|
||||
) -> ServiceLaunchResult:
|
||||
entry = self.get_entry(
|
||||
session,
|
||||
principal,
|
||||
reference=reference,
|
||||
effective_at=requested_at,
|
||||
)
|
||||
if entry is None or not entry.discoverable:
|
||||
raise LookupError("Service not found.")
|
||||
if not entry.available:
|
||||
raise PortalServiceLaunchError(
|
||||
"Service is currently unavailable: "
|
||||
+ ", ".join(entry.reason_codes)
|
||||
)
|
||||
binding = entry.entry_binding
|
||||
if binding is None:
|
||||
raise PortalServiceLaunchError(
|
||||
"Service has no supported entry binding."
|
||||
)
|
||||
request = ServiceLaunchRequest(
|
||||
service_ref=entry.definition.reference,
|
||||
binding=binding,
|
||||
idempotency_key=idempotency_key,
|
||||
requested_at=requested_at,
|
||||
parameters=parameters,
|
||||
)
|
||||
if binding.kind in {"url", "external"}:
|
||||
return ServiceLaunchResult(
|
||||
service_ref=entry.definition.reference,
|
||||
binding=binding,
|
||||
state="redirect",
|
||||
href=binding.reference,
|
||||
metadata={},
|
||||
)
|
||||
capability_name = service_launch_capability(binding.kind)
|
||||
launcher = _capability(self._registry, capability_name)
|
||||
if not isinstance(launcher, ServiceLauncher):
|
||||
raise PortalServiceLaunchError(
|
||||
f"Service launcher is unavailable: {capability_name}."
|
||||
)
|
||||
result = launcher.launch_service(
|
||||
session,
|
||||
principal,
|
||||
definition=entry.definition,
|
||||
request=request,
|
||||
)
|
||||
if (
|
||||
result.service_ref != entry.definition.reference
|
||||
or result.binding != binding
|
||||
):
|
||||
raise InstitutionalContextError(
|
||||
"Service launcher returned a result for another definition or binding."
|
||||
)
|
||||
return result
|
||||
|
||||
def project(
|
||||
self,
|
||||
definition: ServiceDefinition,
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime,
|
||||
audiences: tuple[str, ...] = (),
|
||||
assessment: ServiceAvailabilityAssessment | None = None,
|
||||
) -> PortalServiceEntry:
|
||||
if definition.reference.tenant_id != tenant_id:
|
||||
raise InstitutionalContextError(
|
||||
"Portal cannot project a service from another tenant."
|
||||
)
|
||||
if assessment is not None and any(
|
||||
item.tenant_id != tenant_id for item in assessment.evidence
|
||||
):
|
||||
raise InstitutionalContextError(
|
||||
"Service availability evidence belongs to another tenant."
|
||||
)
|
||||
hidden_reasons: list[str] = []
|
||||
unavailable_reasons: list[str] = []
|
||||
if definition.publication_state in {"draft", "retired"}:
|
||||
hidden_reasons.append(
|
||||
f"service.publication.{definition.publication_state}"
|
||||
)
|
||||
elif definition.publication_state == "suspended":
|
||||
unavailable_reasons.append("service.publication.suspended")
|
||||
if not definition.temporal.effective_at(effective_at):
|
||||
hidden_reasons.append("service.outside_effective_interval")
|
||||
if not set(audiences).intersection(definition.audience):
|
||||
hidden_reasons.append("service.audience.not_applicable")
|
||||
|
||||
for binding in definition.bindings:
|
||||
if not binding.required:
|
||||
continue
|
||||
if binding.kind == "module" and not _has_module(
|
||||
self._registry, binding.reference
|
||||
):
|
||||
unavailable_reasons.append(
|
||||
f"service.required_module.missing:{binding.reference}"
|
||||
)
|
||||
elif binding.kind == "capability" and _capability(
|
||||
self._registry, binding.reference
|
||||
) is None:
|
||||
unavailable_reasons.append(
|
||||
f"service.required_capability.missing:{binding.reference}"
|
||||
)
|
||||
assessment_states = (
|
||||
assessment.requirement_states if assessment is not None else {}
|
||||
)
|
||||
for requirement in definition.availability_requirements:
|
||||
if requirement.kind == "module":
|
||||
resolved: bool | None = _has_module(
|
||||
self._registry,
|
||||
requirement.reference,
|
||||
)
|
||||
elif requirement.kind == "capability":
|
||||
resolved = (
|
||||
_capability(self._registry, requirement.reference) is not None
|
||||
)
|
||||
elif requirement.kind == "audience":
|
||||
resolved = requirement.reference in audiences
|
||||
else:
|
||||
resolved = assessment_states.get(requirement.key)
|
||||
if resolved is True:
|
||||
continue
|
||||
reason = (
|
||||
f"service.requirement.failed:{requirement.key}"
|
||||
if resolved is False
|
||||
else f"service.requirement.unknown:{requirement.key}"
|
||||
)
|
||||
reasons = (
|
||||
hidden_reasons
|
||||
if requirement.failure_state == "hidden"
|
||||
else unavailable_reasons
|
||||
)
|
||||
reasons.append(reason)
|
||||
if requirement.explanation_ref:
|
||||
reasons.append(
|
||||
f"service.explanation:{requirement.explanation_ref}"
|
||||
)
|
||||
if assessment is not None and (
|
||||
hidden_reasons or unavailable_reasons
|
||||
):
|
||||
unavailable_reasons.extend(assessment.reason_codes)
|
||||
if definition.availability_explanation_ref and unavailable_reasons:
|
||||
unavailable_reasons.append(
|
||||
f"service.explanation:{definition.availability_explanation_ref}"
|
||||
)
|
||||
entry_binding = next(
|
||||
(
|
||||
binding
|
||||
for binding in definition.bindings
|
||||
if binding.kind in {"form", "case", "workflow", "external", "url"}
|
||||
),
|
||||
None,
|
||||
)
|
||||
if entry_binding is not None and entry_binding.kind in {
|
||||
"form",
|
||||
"case",
|
||||
"workflow",
|
||||
}:
|
||||
capability_name = service_launch_capability(entry_binding.kind)
|
||||
if _capability(self._registry, capability_name) is None:
|
||||
unavailable_reasons.append(
|
||||
f"service.launcher.missing:{capability_name}"
|
||||
)
|
||||
state: ServiceDiscoveryState = (
|
||||
"hidden"
|
||||
if hidden_reasons
|
||||
else "unavailable"
|
||||
if unavailable_reasons
|
||||
else "available"
|
||||
)
|
||||
return PortalServiceEntry(
|
||||
definition=definition,
|
||||
state=state,
|
||||
reason_codes=tuple(
|
||||
dict.fromkeys((*hidden_reasons, *unavailable_reasons))
|
||||
),
|
||||
entry_binding=entry_binding,
|
||||
availability_evidence=(assessment.evidence if assessment else ()),
|
||||
)
|
||||
|
||||
|
||||
class PortalServiceLaunchError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def principal_audiences(
|
||||
registry: object | None,
|
||||
_session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime,
|
||||
) -> tuple[str, ...]:
|
||||
"""Build bounded audience tokens from trusted principal state.
|
||||
|
||||
Definitions can target generic authenticated users and stable account,
|
||||
identity, group, role, organization-unit, or function identifiers. Function
|
||||
slugs are included only after resolving an active tenant-bound assignment.
|
||||
Provider failures fail closed to the tokens already present on the signed-in
|
||||
principal.
|
||||
"""
|
||||
|
||||
ref = _principal_ref(principal)
|
||||
if ref is None or ref.tenant_id != tenant_id:
|
||||
return ("public",)
|
||||
tokens = {"public", "authenticated", f"account:{ref.account_id}"}
|
||||
if ref.identity_id:
|
||||
tokens.add(f"identity:{ref.identity_id}")
|
||||
tokens.update(f"group:{item}" for item in ref.group_ids)
|
||||
tokens.update(f"role:{item}" for item in ref.role_ids)
|
||||
tokens.update(
|
||||
f"function-assignment:{item}" for item in ref.function_assignment_ids
|
||||
)
|
||||
directory = _capability(registry, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY)
|
||||
if not isinstance(directory, AccessSemanticDirectory):
|
||||
return tuple(sorted(tokens))
|
||||
for assignment_id in sorted(ref.function_assignment_ids):
|
||||
try:
|
||||
assignment = directory.get_function_assignment(assignment_id)
|
||||
if (
|
||||
assignment is None
|
||||
or assignment.tenant_id != tenant_id
|
||||
or assignment.account_id != ref.account_id
|
||||
or assignment.status != "active"
|
||||
or (
|
||||
assignment.valid_from is not None
|
||||
and effective_at < assignment.valid_from
|
||||
)
|
||||
or (
|
||||
assignment.valid_until is not None
|
||||
and effective_at >= assignment.valid_until
|
||||
)
|
||||
):
|
||||
continue
|
||||
function = directory.get_function(assignment.function_id)
|
||||
if (
|
||||
function is None
|
||||
or function.tenant_id != tenant_id
|
||||
or function.status != "active"
|
||||
):
|
||||
continue
|
||||
except Exception: # noqa: BLE001 - optional directory failures fail closed.
|
||||
continue
|
||||
tokens.update(
|
||||
{
|
||||
f"function:{function.id}",
|
||||
f"function:{function.slug}",
|
||||
f"organization-unit:{assignment.organization_unit_id}",
|
||||
}
|
||||
)
|
||||
return tuple(sorted(tokens))
|
||||
|
||||
|
||||
def _principal_ref(principal: object) -> PrincipalRef | None:
|
||||
if isinstance(principal, PrincipalRef):
|
||||
return principal
|
||||
converter = getattr(principal, "to_platform_principal", None)
|
||||
if not callable(converter):
|
||||
return None
|
||||
try:
|
||||
value = converter()
|
||||
except Exception: # noqa: BLE001 - untrusted provider object.
|
||||
return None
|
||||
return value if isinstance(value, PrincipalRef) else None
|
||||
|
||||
|
||||
def _has_module(registry: object | None, module_id: str) -> bool:
|
||||
return bool(
|
||||
registry is not None
|
||||
and hasattr(registry, "has")
|
||||
and registry.has(module_id)
|
||||
)
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_PORTAL_SERVICE_DIRECTORY",
|
||||
"PortalServiceDirectory",
|
||||
"PortalServiceEntry",
|
||||
"PortalServiceLaunchError",
|
||||
"ServiceDiscoveryState",
|
||||
"principal_audiences",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_SERVICE_AVAILABILITY,
|
||||
CAPABILITY_SERVICE_DEFINITIONS,
|
||||
EvidenceReference,
|
||||
InstitutionalContextError,
|
||||
InstitutionalReference,
|
||||
ServiceBinding,
|
||||
ServiceAvailabilityAssessment,
|
||||
ServiceAvailabilityRequirement,
|
||||
ServiceDefinition,
|
||||
ServiceLaunchResult,
|
||||
TemporalRevision,
|
||||
)
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
FunctionAssignmentRef,
|
||||
FunctionRef,
|
||||
PrincipalRef,
|
||||
)
|
||||
from govoplan_portal.backend.manifest import get_manifest
|
||||
from govoplan_portal.backend.service_directory import (
|
||||
PortalServiceDirectory,
|
||||
principal_audiences,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def service(
|
||||
*,
|
||||
state: str = "published",
|
||||
requirements: tuple[ServiceAvailabilityRequirement, ...] = (),
|
||||
) -> ServiceDefinition:
|
||||
return ServiceDefinition(
|
||||
reference=InstitutionalReference(
|
||||
kind="service",
|
||||
owner_module="portal",
|
||||
object_id="permit",
|
||||
tenant_id="tenant-1",
|
||||
version="3",
|
||||
),
|
||||
key="permit.apply",
|
||||
temporal=TemporalRevision(
|
||||
revision="3",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
),
|
||||
title="Apply for a permit",
|
||||
audience=("resident",),
|
||||
bindings=(
|
||||
ServiceBinding("capability", "cases.service_intake"),
|
||||
ServiceBinding("case", "permit-application"),
|
||||
),
|
||||
availability_requirements=requirements,
|
||||
publication_state=state, # type: ignore[arg-type]
|
||||
availability_explanation_ref="docs:permit-requirements",
|
||||
)
|
||||
|
||||
|
||||
class Provider:
|
||||
def __init__(self, definition: ServiceDefinition | None = None) -> None:
|
||||
self.definition = definition or service()
|
||||
|
||||
def get_service_definition(self, session, principal, *, reference, effective_at=None):
|
||||
return self.definition
|
||||
|
||||
def list_service_definitions(self, session, principal, *, tenant_id, query="", limit=100):
|
||||
return (self.definition,)
|
||||
|
||||
|
||||
class Launcher:
|
||||
def launch_service(self, session, principal, *, definition, request):
|
||||
return ServiceLaunchResult(
|
||||
service_ref=definition.reference,
|
||||
binding=request.binding,
|
||||
state="started",
|
||||
target_ref=InstitutionalReference(
|
||||
kind="case",
|
||||
owner_module="cases",
|
||||
object_id="case-1",
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
),
|
||||
href="/cases/case-1",
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
class Registry:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
intake: bool = False,
|
||||
evaluator: object | None = None,
|
||||
definition: ServiceDefinition | None = None,
|
||||
) -> None:
|
||||
self.capabilities = {
|
||||
CAPABILITY_SERVICE_DEFINITIONS: Provider(definition)
|
||||
}
|
||||
if intake:
|
||||
self.capabilities["cases.service_intake"] = object()
|
||||
self.capabilities["cases.service_launcher"] = Launcher()
|
||||
if evaluator is not None:
|
||||
self.capabilities[CAPABILITY_SERVICE_AVAILABILITY] = evaluator
|
||||
|
||||
def has(self, module_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self.capabilities
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
return self.capabilities[name]
|
||||
|
||||
|
||||
class SemanticDirectory:
|
||||
def get_account(self, account_id):
|
||||
return None
|
||||
|
||||
def get_user(self, user_id):
|
||||
return None
|
||||
|
||||
def get_users(self, user_ids):
|
||||
return {}
|
||||
|
||||
def users_for_tenant(self, tenant_id):
|
||||
return ()
|
||||
|
||||
def get_group(self, group_id):
|
||||
return None
|
||||
|
||||
def get_groups(self, group_ids):
|
||||
return {}
|
||||
|
||||
def groups_for_tenant(self, tenant_id):
|
||||
return ()
|
||||
|
||||
def groups_for_user(self, user_id, *, tenant_id):
|
||||
return ()
|
||||
|
||||
def display_label(self, subject):
|
||||
return None
|
||||
|
||||
def get_identity(self, identity_id):
|
||||
return None
|
||||
|
||||
def accounts_for_identity(self, identity_id):
|
||||
return ()
|
||||
|
||||
def get_organization_unit(self, organization_unit_id):
|
||||
return None
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id):
|
||||
return ()
|
||||
|
||||
def get_function(self, function_id):
|
||||
return FunctionRef(
|
||||
id=function_id,
|
||||
tenant_id="tenant-1",
|
||||
organization_unit_id="unit-1",
|
||||
slug="permit-clerk",
|
||||
name="Permit clerk",
|
||||
)
|
||||
|
||||
def functions_for_organization_unit(
|
||||
self, organization_unit_id, *, include_subunits=False
|
||||
):
|
||||
return ()
|
||||
|
||||
def get_function_assignment(self, assignment_id):
|
||||
return FunctionAssignmentRef(
|
||||
id=assignment_id,
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
)
|
||||
|
||||
def function_assignments_for_account(self, account_id, *, tenant_id=None):
|
||||
return ()
|
||||
|
||||
|
||||
class PortalServiceDirectoryTests(unittest.TestCase):
|
||||
def test_provider_definition_is_available_when_requirements_exist(self) -> None:
|
||||
entries = PortalServiceDirectory(Registry(intake=True)).list_entries(
|
||||
None,
|
||||
None,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
audiences=("resident",),
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(entries))
|
||||
self.assertTrue(entries[0].available)
|
||||
self.assertEqual("case", entries[0].entry_binding.kind)
|
||||
|
||||
def test_missing_capability_is_explained_and_filterable(self) -> None:
|
||||
directory = PortalServiceDirectory(Registry())
|
||||
entry = directory.project(
|
||||
service(),
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
audiences=("resident",),
|
||||
)
|
||||
|
||||
self.assertEqual("unavailable", entry.state)
|
||||
self.assertIn(
|
||||
"service.required_capability.missing:cases.service_intake",
|
||||
entry.reason_codes,
|
||||
)
|
||||
self.assertEqual(
|
||||
(),
|
||||
directory.list_entries(
|
||||
None,
|
||||
None,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
audiences=("resident",),
|
||||
include_unavailable=False,
|
||||
),
|
||||
)
|
||||
|
||||
def test_exact_available_service_launch_uses_owner_launcher(self) -> None:
|
||||
definition = replace(service(), audience=("public",))
|
||||
result = PortalServiceDirectory(
|
||||
Registry(intake=True, definition=definition)
|
||||
).launch_service(
|
||||
None,
|
||||
None,
|
||||
reference=definition.reference,
|
||||
requested_at=NOW,
|
||||
idempotency_key="launch-1",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
self.assertEqual("started", result.state)
|
||||
self.assertEqual("case-1", result.target_ref.object_id)
|
||||
|
||||
def test_non_applicable_or_unpublished_service_is_hidden(self) -> None:
|
||||
directory = PortalServiceDirectory(Registry(intake=True))
|
||||
|
||||
self.assertEqual(
|
||||
"hidden",
|
||||
directory.project(
|
||||
service(state="draft"),
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
audiences=("resident",),
|
||||
).state,
|
||||
)
|
||||
self.assertEqual(
|
||||
"hidden",
|
||||
directory.project(
|
||||
service(),
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
audiences=("operator",),
|
||||
).state,
|
||||
)
|
||||
|
||||
def test_cross_tenant_projection_fails_closed(self) -> None:
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "another tenant"):
|
||||
PortalServiceDirectory().project(
|
||||
service(),
|
||||
tenant_id="tenant-2",
|
||||
effective_at=NOW,
|
||||
)
|
||||
|
||||
def test_governed_requirements_explain_or_conceal_failures(self) -> None:
|
||||
definition = service(
|
||||
requirements=(
|
||||
ServiceAvailabilityRequirement(
|
||||
"policy",
|
||||
"permit-access",
|
||||
explanation_ref="docs:permit-policy",
|
||||
),
|
||||
ServiceAvailabilityRequirement(
|
||||
"maintenance",
|
||||
"permit-backend",
|
||||
failure_state="hidden",
|
||||
),
|
||||
)
|
||||
)
|
||||
assessment = ServiceAvailabilityAssessment(
|
||||
requirement_states={"policy:permit-access": False},
|
||||
reason_codes=("policy.denied",),
|
||||
)
|
||||
|
||||
entry = PortalServiceDirectory(Registry(intake=True)).project(
|
||||
definition,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
audiences=("resident",),
|
||||
assessment=assessment,
|
||||
)
|
||||
|
||||
self.assertEqual("hidden", entry.state)
|
||||
self.assertIn(
|
||||
"service.requirement.failed:policy:permit-access",
|
||||
entry.reason_codes,
|
||||
)
|
||||
self.assertIn(
|
||||
"service.requirement.unknown:maintenance:permit-backend",
|
||||
entry.reason_codes,
|
||||
)
|
||||
self.assertIn("policy.denied", entry.reason_codes)
|
||||
|
||||
def test_availability_evidence_cannot_cross_tenants(self) -> None:
|
||||
assessment = ServiceAvailabilityAssessment(
|
||||
requirement_states={},
|
||||
evidence=(
|
||||
EvidenceReference(
|
||||
kind="record",
|
||||
owner_module="policy",
|
||||
evidence_id="decision-1",
|
||||
tenant_id="tenant-2",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "another tenant"):
|
||||
PortalServiceDirectory(Registry(intake=True)).project(
|
||||
service(),
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
audiences=("resident",),
|
||||
assessment=assessment,
|
||||
)
|
||||
|
||||
def test_principal_audiences_resolve_active_function_semantics(self) -> None:
|
||||
registry = Registry(intake=True)
|
||||
registry.capabilities[CAPABILITY_ACCESS_SEMANTIC_DIRECTORY] = (
|
||||
SemanticDirectory()
|
||||
)
|
||||
principal = PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-1",
|
||||
group_ids=frozenset({"group-1"}),
|
||||
role_ids=frozenset({"role-1"}),
|
||||
function_assignment_ids=frozenset({"assignment-1"}),
|
||||
)
|
||||
|
||||
audiences = principal_audiences(
|
||||
registry,
|
||||
None,
|
||||
principal,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
)
|
||||
|
||||
self.assertIn("authenticated", audiences)
|
||||
self.assertIn("function:permit-clerk", audiences)
|
||||
self.assertIn("organization-unit:unit-1", audiences)
|
||||
self.assertIn("role:role-1", audiences)
|
||||
|
||||
def test_empty_audience_projection_fails_closed(self) -> None:
|
||||
entry = PortalServiceDirectory(Registry(intake=True)).project(
|
||||
service(),
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
)
|
||||
|
||||
self.assertEqual("hidden", entry.state)
|
||||
self.assertIn("service.audience.not_applicable", entry.reason_codes)
|
||||
|
||||
def test_manifest_exposes_api_and_service_directory_webui(self) -> None:
|
||||
manifest = get_manifest()
|
||||
|
||||
self.assertEqual("portal", manifest.id)
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
self.assertIsNotNone(manifest.frontend)
|
||||
self.assertEqual("@govoplan/portal-webui", manifest.frontend.package_name)
|
||||
self.assertIn(
|
||||
"portal:service:read",
|
||||
{item.scope for item in manifest.permissions},
|
||||
)
|
||||
self.assertIn("portal.service_directory", manifest.capability_factories)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@govoplan/portal-webui",
|
||||
"version": "0.1.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/portal.css": "./src/styles/portal.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.17",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
|
||||
const page = fs.readFileSync("src/features/portal/PortalPage.tsx", "utf8");
|
||||
const styles = fs.readFileSync("src/styles/portal.css", "utf8");
|
||||
|
||||
assert.ok(page.includes("DocumentationHelpLink"), "Portal exposes configured-system help");
|
||||
assert.ok(page.includes("ActionBlockerHint"), "Unavailable launches expose the shared structured blocker");
|
||||
assert.ok(page.includes("disabledReason={entry.state !== \"available\" ? blocker.summary : undefined}"), "The launch action remains keyboard-explainable");
|
||||
assert.ok(page.includes("PageScrollViewport"), "Portal owns bounded directory scrolling");
|
||||
assert.ok(page.includes('aria-live="polite"'), "Changing result counts are announced");
|
||||
assert.ok(page.includes("useGuardedNavigate"), "Internal launch handoffs respect unsaved-work navigation");
|
||||
assert.ok(!page.includes("window.alert("), "Portal must not use browser alerts");
|
||||
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Portal uses semantic interactive elements");
|
||||
assert.ok(styles.includes("@media (max-width: 720px)"), "Portal retains a narrow-viewport toolbar layout");
|
||||
|
||||
console.log("Portal interface pattern contract passed.");
|
||||
@@ -0,0 +1,87 @@
|
||||
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
|
||||
export type PortalServiceBinding = {
|
||||
kind: string;
|
||||
reference: string;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
export type PortalServiceDefinition = {
|
||||
reference: {
|
||||
object_id: string;
|
||||
version?: string | null;
|
||||
};
|
||||
key: string;
|
||||
title: string;
|
||||
audience: string[];
|
||||
prerequisites: string[];
|
||||
required_evidence_types: string[];
|
||||
fee_refs: string[];
|
||||
deadline_refs: string[];
|
||||
channels: string[];
|
||||
publication_state: string;
|
||||
};
|
||||
|
||||
export type PortalServiceEntry = {
|
||||
definition: PortalServiceDefinition;
|
||||
state: "available" | "unavailable";
|
||||
reason_codes: string[];
|
||||
entry_binding?: PortalServiceBinding | null;
|
||||
availability_evidence: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type PortalServiceListResponse = {
|
||||
services: PortalServiceEntry[];
|
||||
};
|
||||
|
||||
export type PortalServiceLaunchResult = {
|
||||
service_ref: PortalServiceDefinition["reference"];
|
||||
binding: PortalServiceBinding;
|
||||
state: "started" | "redirect";
|
||||
target_ref?: Record<string, unknown> | null;
|
||||
href?: string | null;
|
||||
replayed: boolean;
|
||||
evidence: Array<Record<string, unknown>>;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function listPortalServices(
|
||||
settings: ApiSettings,
|
||||
options: {
|
||||
query?: string;
|
||||
includeUnavailable?: boolean;
|
||||
limit?: number;
|
||||
},
|
||||
signal?: AbortSignal
|
||||
): Promise<PortalServiceListResponse> {
|
||||
return apiFetch<PortalServiceListResponse>(
|
||||
settings,
|
||||
apiPath("/api/v1/portal/services", {
|
||||
q: options.query,
|
||||
include_unavailable: options.includeUnavailable,
|
||||
limit: options.limit
|
||||
}),
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
export function launchPortalService(
|
||||
settings: ApiSettings,
|
||||
serviceId: string,
|
||||
payload: {
|
||||
service_version: string;
|
||||
idempotency_key: string;
|
||||
requested_at: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<PortalServiceLaunchResult> {
|
||||
return apiFetch<PortalServiceLaunchResult>(
|
||||
settings,
|
||||
`/api/v1/portal/services/${encodeURIComponent(serviceId)}/launch`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { ArrowUpRight, Search } from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent
|
||||
} from "react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
DismissibleAlert,
|
||||
Button,
|
||||
DocumentationHelpLink,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
ToggleSwitch,
|
||||
useGuardedNavigate,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
launchPortalService,
|
||||
listPortalServices,
|
||||
type PortalServiceEntry
|
||||
} from "../../api/portal";
|
||||
|
||||
|
||||
export default function PortalPage({ settings }: PlatformRouteContext) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [submittedQuery, setSubmittedQuery] = useState("");
|
||||
const [includeUnavailable, setIncludeUnavailable] = useState(true);
|
||||
const [services, setServices] = useState<PortalServiceEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [launchingId, setLaunchingId] = useState("");
|
||||
const launchAttempts = useRef(new Map<string, {
|
||||
idempotencyKey: string;
|
||||
requestedAt: string;
|
||||
}>());
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
listPortalServices(
|
||||
settings,
|
||||
{
|
||||
query: submittedQuery,
|
||||
includeUnavailable,
|
||||
limit: 200
|
||||
},
|
||||
controller.signal
|
||||
).
|
||||
then((response) => setServices(response.services)).
|
||||
catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") {
|
||||
setError(reason instanceof Error ? reason.message : "Services could not be loaded.");
|
||||
}
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [includeUnavailable, settings, submittedQuery]);
|
||||
|
||||
const counts = useMemo(() => ({
|
||||
available: services.filter((entry) => entry.state === "available").length,
|
||||
unavailable: services.filter((entry) => entry.state === "unavailable").length
|
||||
}), [services]);
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmittedQuery(query.trim());
|
||||
}
|
||||
|
||||
async function launch(entry: PortalServiceEntry) {
|
||||
const serviceId = entry.definition.reference.object_id;
|
||||
const version = entry.definition.reference.version;
|
||||
if (!version) {
|
||||
setError("This service has no exact launchable revision.");
|
||||
return;
|
||||
}
|
||||
const attemptKey = `${serviceId}:${version}`;
|
||||
const attempt = launchAttempts.current.get(attemptKey) ?? {
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
requestedAt: new Date().toISOString()
|
||||
};
|
||||
launchAttempts.current.set(attemptKey, attempt);
|
||||
setLaunchingId(attemptKey);
|
||||
setError("");
|
||||
try {
|
||||
const result = await launchPortalService(settings, serviceId, {
|
||||
service_version: version,
|
||||
idempotency_key: attempt.idempotencyKey,
|
||||
requested_at: attempt.requestedAt
|
||||
});
|
||||
launchAttempts.current.delete(attemptKey);
|
||||
if (!result.href) {
|
||||
throw new Error("The service started without returning a destination.");
|
||||
}
|
||||
if (result.href.startsWith("/") && !result.href.startsWith("//")) {
|
||||
navigate(result.href);
|
||||
} else {
|
||||
window.location.assign(result.href);
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Service could not be started.");
|
||||
} finally {
|
||||
setLaunchingId("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="portal-page">
|
||||
<div className="portal-shell">
|
||||
<div className="portal-toolbar">
|
||||
<form className="portal-search" onSubmit={submit}>
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
aria-label="Search services"
|
||||
placeholder="Search services"
|
||||
/>
|
||||
<Button type="submit" variant="primary">Search</Button>
|
||||
</form>
|
||||
<DocumentationHelpLink
|
||||
reference={{ topicId: "portal.service-directory", documentationType: "user" }}
|
||||
label="Open service directory documentation"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="Show unavailable services"
|
||||
checked={includeUnavailable}
|
||||
onChange={setIncludeUnavailable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-result-summary" aria-live="polite">
|
||||
<strong>{counts.available}</strong> available
|
||||
{includeUnavailable && <><span aria-hidden="true">/</span><strong>{counts.unavailable}</strong> unavailable</>}
|
||||
</div>
|
||||
|
||||
<PageScrollViewport className="portal-results">
|
||||
{error &&
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{loading && <LoadingIndicator label="Loading services" />}
|
||||
{!loading && !error && services.length === 0 &&
|
||||
<div className="portal-empty">No matching services.</div>
|
||||
}
|
||||
{!loading && services.length > 0 &&
|
||||
<div className="portal-service-list">
|
||||
{services.map((entry) =>
|
||||
<ServiceEntry
|
||||
key={`${entry.definition.reference.object_id}:${entry.definition.reference.version ?? "current"}`}
|
||||
entry={entry}
|
||||
launching={launchingId === `${entry.definition.reference.object_id}:${entry.definition.reference.version ?? ""}`}
|
||||
onLaunch={() => void launch(entry)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</PageScrollViewport>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceEntry({
|
||||
entry,
|
||||
launching,
|
||||
onLaunch
|
||||
}: {
|
||||
entry: PortalServiceEntry;
|
||||
launching: boolean;
|
||||
onLaunch: () => void;
|
||||
}) {
|
||||
const reasons = userFacingReasons(entry.reason_codes);
|
||||
const blocker = serviceBlocker(entry.reason_codes, reasons);
|
||||
return (
|
||||
<article className={`portal-service-entry is-${entry.state}`}>
|
||||
<div className="portal-service-heading">
|
||||
<div>
|
||||
<h2>{entry.definition.title}</h2>
|
||||
<span className="portal-service-key">{entry.definition.key}</span>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={entry.state === "available" ? "active" : "warning"}
|
||||
label={entry.state === "available" ? "Available" : "Unavailable"}
|
||||
/>
|
||||
</div>
|
||||
<div className="portal-service-metadata">
|
||||
{entry.definition.channels.map((channel) =>
|
||||
<span key={channel}>{humanize(channel)}</span>
|
||||
)}
|
||||
{entry.definition.required_evidence_types.map((evidence) =>
|
||||
<span key={evidence}>{humanize(evidence)}</span>
|
||||
)}
|
||||
</div>
|
||||
{reasons.length > 0 &&
|
||||
<ul className="portal-service-reasons">
|
||||
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||
</ul>
|
||||
}
|
||||
{entry.entry_binding && entry.state !== "available" &&
|
||||
<ActionBlockerHint reason={blocker} tone="warning" />
|
||||
}
|
||||
<div className="portal-service-actions">
|
||||
{entry.entry_binding ?
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={launching || entry.state !== "available"}
|
||||
disabledReason={entry.state !== "available" ? blocker.summary : undefined}
|
||||
onClick={onLaunch}>
|
||||
{launching ? "Starting" : "Open"}
|
||||
<ArrowUpRight size={15} aria-hidden="true" />
|
||||
</Button> :
|
||||
<span className="portal-entry-kind">No launch destination</span>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function serviceBlocker(codes: string[], reasons: string[]) {
|
||||
const reasonCode = codes.find((code) => !code.startsWith("service.explanation:")) ?? "";
|
||||
if (reasonCode === "service.publication.suspended") {
|
||||
return {
|
||||
summary: reasons[0] ?? "This service is temporarily suspended.",
|
||||
requiredAction: "Resume the published service revision.",
|
||||
actor: "Service owner",
|
||||
target: "Service administration"
|
||||
};
|
||||
}
|
||||
if (reasonCode.includes("required_module.missing") || reasonCode.includes("required_capability.missing")) {
|
||||
return {
|
||||
summary: reasons[0] ?? "A required system component is unavailable.",
|
||||
requiredAction: "Install, enable, configure, or restore the required component.",
|
||||
actor: "System or module administrator",
|
||||
target: "Module administration"
|
||||
};
|
||||
}
|
||||
if (reasonCode.includes("evaluator_failed") || reasonCode.includes("requirement.unknown")) {
|
||||
return {
|
||||
summary: reasons[0] ?? "Availability could not be confirmed.",
|
||||
requiredAction: "Restore the availability evaluator and check the service again.",
|
||||
actor: "System operator",
|
||||
target: "Operations and service diagnostics"
|
||||
};
|
||||
}
|
||||
return {
|
||||
summary: reasons[0] ?? "This service is currently unavailable.",
|
||||
requiredAction: "Review and fulfil the service availability requirements.",
|
||||
actor: "Service owner or responsible authority",
|
||||
target: "Service details"
|
||||
};
|
||||
}
|
||||
|
||||
function userFacingReasons(codes: string[]): string[] {
|
||||
const values = codes.
|
||||
filter((code) => !code.startsWith("service.explanation:")).
|
||||
map((code) => {
|
||||
if (code === "service.publication.suspended") return "This service is temporarily suspended.";
|
||||
if (code.includes("required_module.missing") || code.includes("required_capability.missing")) {
|
||||
return "A required system component is unavailable.";
|
||||
}
|
||||
if (code.includes("evaluator_failed")) return "Availability could not be confirmed.";
|
||||
if (code.includes("requirement.failed")) return "An availability requirement is not met.";
|
||||
if (code.includes("requirement.unknown")) return "An availability requirement could not be confirmed.";
|
||||
return "This service is currently unavailable.";
|
||||
});
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, portalModule } from "./module";
|
||||
export * from "./api/portal";
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import "./styles/portal.css";
|
||||
|
||||
|
||||
const PortalPage = lazy(() => import("./features/portal/PortalPage"));
|
||||
|
||||
export const portalModule: PlatformWebModule = {
|
||||
id: "portal",
|
||||
label: "Services",
|
||||
version: "0.1.8",
|
||||
optionalDependencies: ["access", "services", "cases", "forms", "workflow_engine"],
|
||||
routes: [
|
||||
{
|
||||
path: "/portal",
|
||||
anyOf: ["portal:service:read"],
|
||||
order: 25,
|
||||
surfaceId: "portal.directory",
|
||||
render: (context) => createElement(PortalPage, context)
|
||||
}
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: "/portal",
|
||||
label: "Services",
|
||||
iconName: "landmark",
|
||||
anyOf: ["portal:service:read"],
|
||||
order: 25,
|
||||
surfaceId: "portal.navigation"
|
||||
}
|
||||
],
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "portal.navigation",
|
||||
moduleId: "portal",
|
||||
kind: "navigation",
|
||||
label: "Services navigation",
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: "portal.directory",
|
||||
moduleId: "portal",
|
||||
kind: "route",
|
||||
label: "Service directory",
|
||||
order: 20
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default portalModule;
|
||||
@@ -0,0 +1,150 @@
|
||||
.portal-page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portal-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.portal-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 58px;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.portal-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(620px, 100%);
|
||||
}
|
||||
|
||||
.portal-search input {
|
||||
min-width: 120px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.portal-result-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 18px;
|
||||
color: var(--text-soft);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.portal-results {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px 18px 24px;
|
||||
}
|
||||
|
||||
.portal-service-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.portal-service-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 190px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.portal-service-entry.is-unavailable {
|
||||
border-left-color: var(--warning);
|
||||
}
|
||||
|
||||
.portal-service-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.portal-service-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.portal-service-key,
|
||||
.portal-entry-kind {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.portal-service-metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.portal-service-metadata span {
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.portal-service-reasons {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 18px;
|
||||
color: var(--warning-text-strong);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.portal-service-entry .action-blocker-hint {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.portal-service-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 34px;
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.portal-service-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.portal-empty {
|
||||
padding: 36px 0;
|
||||
color: var(--text-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.portal-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.portal-search {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user