Compare commits
4
Commits
ee19343697
...
v0.1.15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98a73622ba | ||
|
|
befe8aef82 | ||
|
|
8cfd6bfd48 | ||
|
|
0168c5ecd5 |
@@ -14,6 +14,8 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
publish-packages:
|
publish-packages:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
with:
|
with:
|
||||||
@@ -29,7 +31,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||||
@@ -43,24 +44,6 @@ jobs:
|
|||||||
echo "Release tag is not contained in main" >&2
|
echo "Release tag is not contained in main" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
python - "$tag" <<'PY'
|
|
||||||
import fnmatch
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
tag = sys.argv[1]
|
|
||||||
repository = os.environ["GITEA_REPOSITORY"]
|
|
||||||
request = urllib.request.Request(
|
|
||||||
f"{os.environ['GITEA_API_URL']}/repos/{repository}/tag_protections",
|
|
||||||
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(request, timeout=30) as response:
|
|
||||||
protections = json.load(response)
|
|
||||||
if not any(fnmatch.fnmatchcase(tag, item.get("name_pattern", "")) for item in protections):
|
|
||||||
raise SystemExit(f"Release tag {tag!r} is not covered by repository tag protection")
|
|
||||||
PY
|
|
||||||
git checkout --detach "$tag"
|
git checkout --detach "$tag"
|
||||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||||
@@ -130,7 +113,7 @@ jobs:
|
|||||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
const gitTag = specifier.match(
|
const gitTag = specifier.match(
|
||||||
new RegExp(
|
new RegExp(
|
||||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/GovOPlaN/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (gitTag) {
|
if (gitTag) {
|
||||||
@@ -180,6 +163,78 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: module-packages-${{ gitea.ref_name }}
|
name: module-packages-${{ gitea.ref_name }}
|
||||||
path: dist/package-artifacts.json
|
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
|
- name: Publish wheel and WebUI package
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -189,13 +244,17 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
test -n "$PACKAGE_USERNAME"
|
test -n "$PACKAGE_USERNAME"
|
||||||
test -n "$PACKAGE_TOKEN"
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||||
python -m twine upload --non-interactive \
|
python -m twine upload --non-interactive \
|
||||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||||
dist/*.whl
|
dist/*.whl
|
||||||
|
else
|
||||||
|
echo "Exact wheel is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
webui_packages=(dist/*.tgz)
|
webui_packages=(dist/*.tgz)
|
||||||
if (( ${#webui_packages[@]} )); then
|
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||||
npmrc="$(mktemp)"
|
npmrc="$(mktemp)"
|
||||||
trap 'rm -f "$npmrc"' EXIT
|
trap 'rm -f "$npmrc"' EXIT
|
||||||
chmod 600 "$npmrc"
|
chmod 600 "$npmrc"
|
||||||
@@ -203,7 +262,9 @@ jobs:
|
|||||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||||
> "$npmrc"
|
> "$npmrc"
|
||||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "${webui_packages[0]}" \
|
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||||
--ignore-scripts --access public \
|
--ignore-scripts --access public \
|
||||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
--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
|
fi
|
||||||
|
|||||||
@@ -20,3 +20,9 @@ preference and availability collection remains in `govoplan-poll`.
|
|||||||
|
|
||||||
See [the domain and assurance boundary](docs/VOTING_DOMAIN.md) for operations,
|
See [the domain and assurance boundary](docs/VOTING_DOMAIN.md) for operations,
|
||||||
security, recovery, and integration details.
|
security, recovery, and integration details.
|
||||||
|
|
||||||
|
POLYAS is the first planned external provider; its bounded operator-assisted
|
||||||
|
profile and integration prerequisites are documented in
|
||||||
|
[the POLYAS provider profile](docs/POLYAS_PROVIDER_PROFILE.md). Development of
|
||||||
|
a native certifiable provider follows the staged, independently evaluated
|
||||||
|
[certifiable Voting program](docs/CERTIFIABLE_VOTING_PROGRAM.md).
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Native certifiable Voting program
|
||||||
|
|
||||||
|
## Objective and non-claim
|
||||||
|
|
||||||
|
GovOPlaN may develop a native end-to-end verifiable Voting provider, but the
|
||||||
|
current platform and bundled `local_confidential` provider are not certified
|
||||||
|
voting products. Certification cannot be obtained by adding a label, tests, or
|
||||||
|
general platform security controls. It applies to a precisely bounded Target
|
||||||
|
of Evaluation (TOE), version, evaluated configuration, lifecycle, and evidence
|
||||||
|
set assessed by an independent laboratory and certification authority.
|
||||||
|
|
||||||
|
The native provider must therefore be an isolated assurance component behind
|
||||||
|
`voting.provider.<id>`, not an implicit claim over all of GovOPlaN. Voting owns
|
||||||
|
the governed ballot lifecycle and evidence projection; the TOE owns ballot
|
||||||
|
secrecy, cryptographic casting, verification, tallying, and the evaluated
|
||||||
|
ceremony. Policy, Access, Identity Trust, Encryption, Forms Runtime, Workflow
|
||||||
|
Engine, Committee, Decisions, Audit, Records, and Reporting may support the
|
||||||
|
journey without being silently pulled into the TOE.
|
||||||
|
|
||||||
|
## Program stages
|
||||||
|
|
||||||
|
### 1. Protection profile and legal target
|
||||||
|
|
||||||
|
- identify election classes, jurisdictions, attack potential, voting
|
||||||
|
principles, accessibility duties, and retention obligations;
|
||||||
|
- select the applicable BSI Protection Profile/TR and Common Criteria target;
|
||||||
|
- engage a recognized evaluation facility before fixing the architecture;
|
||||||
|
- write the Security Target, assumptions, threats, organizational policies,
|
||||||
|
security objectives, and evaluated configuration.
|
||||||
|
|
||||||
|
### 2. TOE and trust boundaries
|
||||||
|
|
||||||
|
- specify client, election server, bulletin board, verifier, tally component,
|
||||||
|
key ceremony, build/release chain, time source, and operator boundaries;
|
||||||
|
- define electorate preparation and archival as explicit supporting processes
|
||||||
|
when they are outside the TOE;
|
||||||
|
- prohibit node-local authoritative state and undeclared side channels;
|
||||||
|
- define compromise, suspension, challenge, annulment, recovery, and evidence
|
||||||
|
export before implementation.
|
||||||
|
|
||||||
|
### 3. Protocol and independent review
|
||||||
|
|
||||||
|
- select a published, independently reviewed end-to-end verifiable protocol;
|
||||||
|
- use reviewed cryptographic libraries and parameter suites rather than
|
||||||
|
designing new cryptography;
|
||||||
|
- provide individual and universal verification without exposing vote choice;
|
||||||
|
- define coercion-resistance claims truthfully, including what is not solved;
|
||||||
|
- commission independent cryptographic and privacy review before production.
|
||||||
|
|
||||||
|
### 4. Conformance implementation
|
||||||
|
|
||||||
|
- implement canonical ballot/electorate/result/evidence encodings;
|
||||||
|
- bind every cast and tally artifact to the frozen definition and electorate;
|
||||||
|
- provide deterministic conformance fixtures, malformed-input suites,
|
||||||
|
property tests, fault injection, and cross-implementation verification;
|
||||||
|
- preserve receipt privacy and prevent credentials, raw votes, or private keys
|
||||||
|
from entering GovOPlaN evidence projections;
|
||||||
|
- expose certification state through `VotingProviderAssuranceDeclaration`.
|
||||||
|
|
||||||
|
### 5. Controlled lifecycle
|
||||||
|
|
||||||
|
- reproducible, signed builds and reviewed dependencies;
|
||||||
|
- role-separated source, release, election, key-custody, and audit authority;
|
||||||
|
- vulnerability handling, maintenance impact analysis, SBOM, provenance, and
|
||||||
|
controlled update path for in-progress elections;
|
||||||
|
- production ceremonies, backup/restore, disaster recovery, secure deletion,
|
||||||
|
monitoring, incident response, and independently witnessed evidence.
|
||||||
|
|
||||||
|
### 6. Evaluation and operation
|
||||||
|
|
||||||
|
- laboratory pre-evaluation and gap remediation;
|
||||||
|
- formal Common Criteria evaluation/certification of an exact TOE version;
|
||||||
|
- target-specific deployment acceptance against the evaluated configuration;
|
||||||
|
- certificate and maintenance-report monitoring;
|
||||||
|
- fail-closed retirement or profile downgrade when validity expires or the
|
||||||
|
evaluated configuration changes.
|
||||||
|
|
||||||
|
## Work-product gates
|
||||||
|
|
||||||
|
Native implementation can proceed through fixtures and research profiles, but
|
||||||
|
the `external_certified` runtime profile remains unavailable until all of these
|
||||||
|
are independently evidenced:
|
||||||
|
|
||||||
|
- approved Security Target and TOE boundary;
|
||||||
|
- independent protocol/cryptographic review;
|
||||||
|
- conformance and adverse-condition evidence;
|
||||||
|
- controlled build and release provenance;
|
||||||
|
- operational ceremony and recovery evidence;
|
||||||
|
- valid product/version/configuration-specific certificate.
|
||||||
|
|
||||||
|
Research, evaluation, and certified states are separate. A provider in
|
||||||
|
evaluation may support a bounded test profile, but cannot become certified by
|
||||||
|
configuration or administrator override.
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# POLYAS provider profile
|
||||||
|
|
||||||
|
## Current integration position
|
||||||
|
|
||||||
|
POLYAS is the first external provider selected for high-assurance GovOPlaN
|
||||||
|
Voting. This is an integration decision, not a certification claim. Until a
|
||||||
|
contracted machine interface, sandbox, exact product/version binding, and
|
||||||
|
current certificate evidence are available, the integration remains
|
||||||
|
operator-assisted and must not advertise the `external_certified` assurance
|
||||||
|
profile.
|
||||||
|
|
||||||
|
The public POLYAS material documents the Online Voting Manager, spreadsheet
|
||||||
|
electoral-roll import, PDF/Excel result export, an election control portal,
|
||||||
|
verification tools, SecureLink, and an electoral-board interface. It does not
|
||||||
|
document a stable public API that is sufficient for an unattended GovOPlaN
|
||||||
|
provider. The initial integration therefore uses the existing external
|
||||||
|
provider contract as its target and keeps manual handoffs explicit:
|
||||||
|
|
||||||
|
1. GovOPlaN freezes the ballot definition and electorate hashes.
|
||||||
|
2. An authorized election officer creates and seals the corresponding POLYAS
|
||||||
|
election using a reviewed export.
|
||||||
|
3. GovOPlaN records the POLYAS project reference, exact product/profile, and
|
||||||
|
handoff evidence without storing voter credentials.
|
||||||
|
4. Voters enter the provider through its controlled launch or invitation path.
|
||||||
|
5. An authorized officer imports signed result and protocol artifacts.
|
||||||
|
6. GovOPlaN verifies the frozen binding, records aggregate results and evidence,
|
||||||
|
and retains certification, challenge, and annulment as separate actions.
|
||||||
|
|
||||||
|
Operator-assisted imports must be labelled as such. Browser automation or
|
||||||
|
screen scraping is not an acceptable production API.
|
||||||
|
|
||||||
|
## Provider information required
|
||||||
|
|
||||||
|
Before implementing unattended preparation, launch, status, or result
|
||||||
|
acquisition, obtain from POLYAS:
|
||||||
|
|
||||||
|
- the contracted API/protocol specification and versioning policy;
|
||||||
|
- sandbox credentials and representative test-election fixtures;
|
||||||
|
- supported ballot methods, weighting, voter groups, replacement, quorum, and
|
||||||
|
threshold semantics;
|
||||||
|
- idempotency, revision, sealing, cancellation, outcome-unknown, and retry
|
||||||
|
behavior;
|
||||||
|
- invitation and voter-authentication boundaries;
|
||||||
|
- signed result, archive, audit, and verification artifact formats;
|
||||||
|
- retention, deletion, subprocessor, location, incident, and DPA terms;
|
||||||
|
- product/version-specific Security Target, certificate, maintenance reports,
|
||||||
|
validity period, and evaluated configuration;
|
||||||
|
- recovery and continuity evidence for an election in progress.
|
||||||
|
|
||||||
|
Credentials belong in governed credential envelopes. Raw selections, voter
|
||||||
|
credentials, recovery codes, and private provider keys must never cross the
|
||||||
|
Voting provider boundary.
|
||||||
|
|
||||||
|
## Certification gate
|
||||||
|
|
||||||
|
The BSI certificate `BSI-DSZ-CC-0862-V2-2021` for POLYAS CORE 2.5.0, including
|
||||||
|
maintained versions described by its maintenance reports, was valid through
|
||||||
|
2026-06-24. As of 2026-08-04, that validity date has passed. A new election must
|
||||||
|
not be labelled `external_certified` from this historical certificate alone.
|
||||||
|
|
||||||
|
The adapter must expose a `VotingProviderAssuranceDeclaration`. The runtime
|
||||||
|
accepts `external_certified` only when the declaration pins:
|
||||||
|
|
||||||
|
- the exact provider and implementation contract;
|
||||||
|
- supported assurance profile and protocol version;
|
||||||
|
- certification authority and reference;
|
||||||
|
- an independently retrievable evidence reference;
|
||||||
|
- a current validity window.
|
||||||
|
|
||||||
|
The declaration is frozen with the ballot and revalidated before cast and
|
||||||
|
finalization. Expiry, revocation, provider replacement, protocol change, or
|
||||||
|
certificate substitution fails closed and requires explicit reconciliation.
|
||||||
|
|
||||||
|
Authoritative references:
|
||||||
|
|
||||||
|
- [BSI certificate record](https://www.bsi.bund.de/SharedDocs/Zertifikate_CC/CC/Sonstiges/0862_0862V2.html)
|
||||||
|
- [BSI TR-03169](https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03169/BSI-TR-03169.pdf)
|
||||||
|
- [POLYAS security overview](https://support.polyas.com/en/faqs/security/ensure-secure-voting/)
|
||||||
|
- [POLYAS election control portal](https://support.polyas.com/en/online-voting-manager/features/authentication/election-control-portal/)
|
||||||
|
|
||||||
@@ -32,6 +32,12 @@ credentials inside its own assurance boundary and returns aggregate counts,
|
|||||||
weighted counts, a result hash, and evidence. GovOPlaN does not claim that a
|
weighted counts, a result hash, and evidence. GovOPlaN does not claim that a
|
||||||
provider or deployment satisfies legal or certification requirements merely
|
provider or deployment satisfies legal or certification requirements merely
|
||||||
because the adapter contract is implemented.
|
because the adapter contract is implemented.
|
||||||
|
Each provider declares supported assurance profiles, protocol and
|
||||||
|
implementation identity, and certification state. Voting pins that declaration
|
||||||
|
when opening and revalidates it before provider casting and finalization.
|
||||||
|
`external_certified` requires a current authority, certificate reference,
|
||||||
|
evidence reference, and validity window; a changed, expired, or revoked claim
|
||||||
|
fails closed.
|
||||||
Core bounds provider evidence to JSON, 64 items and 64 KiB and rejects fields
|
Core bounds provider evidence to JSON, 64 items and 64 KiB and rejects fields
|
||||||
that can carry credentials, private key material, plaintext, or raw
|
that can carry credentials, private key material, plaintext, or raw
|
||||||
selections before Voting or Committee can persist the projection.
|
selections before Voting or Committee can persist the projection.
|
||||||
@@ -57,6 +63,14 @@ external certification. It therefore cannot be selected for `secret` or
|
|||||||
the ballot opens; externally hosted providers may continue to require a
|
the ballot opens; externally hosted providers may continue to require a
|
||||||
pre-existing reference.
|
pre-existing reference.
|
||||||
|
|
||||||
|
POLYAS is the selected first external provider, initially through an explicit
|
||||||
|
operator-assisted handoff until a contracted API and sandbox are available.
|
||||||
|
The historical POLYAS CORE 2.5 Common Criteria certificate expired on
|
||||||
|
2026-06-24, so its reference alone cannot enable `external_certified`. See
|
||||||
|
[the POLYAS provider profile](POLYAS_PROVIDER_PROFILE.md). Native certifiable
|
||||||
|
development is governed by the separate
|
||||||
|
[certifiable Voting program](CERTIFIABLE_VOTING_PROGRAM.md).
|
||||||
|
|
||||||
## Lifecycle and concurrency
|
## Lifecycle and concurrency
|
||||||
|
|
||||||
Ballots move through `draft -> open -> closed -> certified`. A closed or
|
Ballots move through `draft -> open -> closed -> certified`. A closed or
|
||||||
@@ -109,4 +123,7 @@ node-local filesystem.
|
|||||||
- raw selections are never returned by list, detail, result, or history APIs
|
- raw selections are never returned by list, detail, result, or history APIs
|
||||||
- provider result keys must exactly match frozen options
|
- provider result keys must exactly match frozen options
|
||||||
- external results require evidence and cannot exceed the frozen electorate
|
- external results require evidence and cannot exceed the frozen electorate
|
||||||
|
- external providers must match the assurance declaration frozen at opening
|
||||||
|
- externally certified providers must remain currently certified through
|
||||||
|
provider casting and finalization
|
||||||
- certification and annulment use separate permissions
|
- certification and annulment use separate permissions
|
||||||
|
|||||||
+2
-2
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-voting"
|
name = "govoplan-voting"
|
||||||
version = "0.1.14"
|
version = "0.1.15"
|
||||||
description = "Governed voting, ballot assurance, tally, and certification for GovOPlaN."
|
description = "Governed voting, ballot assurance, tally, and certification for GovOPlaN."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = ["govoplan-core>=0.1.14", "govoplan-access>=0.1.8"]
|
dependencies = ["govoplan-core>=0.1.15", "govoplan-access>=0.1.15"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN Voting module."""
|
"""GovOPlaN Voting module."""
|
||||||
|
|
||||||
__version__ = "0.1.14"
|
__version__ = "0.1.15"
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ from govoplan_core.core.voting import (
|
|||||||
ExternalVotingCastRequest,
|
ExternalVotingCastRequest,
|
||||||
ExternalVotingFinalizationRequest,
|
ExternalVotingFinalizationRequest,
|
||||||
ExternalVotingPreparationRequest,
|
ExternalVotingPreparationRequest,
|
||||||
|
VOTING_CERTIFICATION_NOT_CERTIFIED,
|
||||||
VotingReceipt,
|
VotingReceipt,
|
||||||
VotingResult,
|
VotingResult,
|
||||||
|
VotingProviderAssuranceDeclaration,
|
||||||
)
|
)
|
||||||
from govoplan_voting.backend.db.models import (
|
from govoplan_voting.backend.db.models import (
|
||||||
VotingConfidentialBallot,
|
VotingConfidentialBallot,
|
||||||
@@ -49,6 +51,19 @@ class LocalConfidentialVotingProvider:
|
|||||||
def __init__(self, registry: object | None) -> None:
|
def __init__(self, registry: object | None) -> None:
|
||||||
self._registry = registry
|
self._registry = registry
|
||||||
|
|
||||||
|
def assurance_declaration(self) -> VotingProviderAssuranceDeclaration:
|
||||||
|
return VotingProviderAssuranceDeclaration(
|
||||||
|
provider_id=LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
||||||
|
implementation_ref="govoplan-voting/local-confidential@1",
|
||||||
|
supported_assurance_profiles=("confidential",),
|
||||||
|
certification_state=VOTING_CERTIFICATION_NOT_CERTIFIED,
|
||||||
|
protocol_ref="govoplan:voting:local-confidential",
|
||||||
|
protocol_version="1.0",
|
||||||
|
notes=(
|
||||||
|
"Server-readable reference provider; no anonymity, secrecy, coercion-resistance, or certification claim.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def prepare_ballot(
|
def prepare_ballot(
|
||||||
self,
|
self,
|
||||||
session: object,
|
session: object,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ from govoplan_voting.backend.local_confidential_provider import (
|
|||||||
|
|
||||||
MODULE_ID = "voting"
|
MODULE_ID = "voting"
|
||||||
MODULE_NAME = "Voting"
|
MODULE_NAME = "Voting"
|
||||||
MODULE_VERSION = "0.1.14"
|
MODULE_VERSION = "0.1.15"
|
||||||
READ_SCOPE = "voting:ballot:read"
|
READ_SCOPE = "voting:ballot:read"
|
||||||
MANAGE_SCOPE = "voting:ballot:manage"
|
MANAGE_SCOPE = "voting:ballot:manage"
|
||||||
CAST_SCOPE = "voting:ballot:cast"
|
CAST_SCOPE = "voting:ballot:cast"
|
||||||
@@ -296,6 +296,7 @@ manifest = ModuleManifest(
|
|||||||
body=(
|
body=(
|
||||||
"Opening a ballot freezes its definition and electorate hashes. Native recorded ballots retain active vote records for reconstruction; they are not secret. "
|
"Opening a ballot freezes its definition and electorate hashes. Native recorded ballots retain active vote records for reconstruction; they are not secret. "
|
||||||
"Confidential, secret, and externally certified profiles require an installed provider and retain only aggregate results, receipts, hashes, and evidence. "
|
"Confidential, secret, and externally certified profiles require an installed provider and retain only aggregate results, receipts, hashes, and evidence. "
|
||||||
|
"Provider assurance, protocol identity, certificate evidence, and validity are pinned when the ballot opens and revalidated before provider effects. "
|
||||||
"The bundled local confidential provider encrypts raw selections through Encryption and supports interactive casting, but remains server-readable and uncertified. "
|
"The bundled local confidential provider encrypts raw selections through Encryption and supports interactive casting, but remains server-readable and uncertified. "
|
||||||
"Closure, certification, challenge, and annulment remain separate auditable transitions."
|
"Closure, certification, challenge, and annulment remain separate auditable transitions."
|
||||||
),
|
),
|
||||||
@@ -308,6 +309,16 @@ manifest = ModuleManifest(
|
|||||||
href="govoplan-voting/docs/VOTING_DOMAIN.md",
|
href="govoplan-voting/docs/VOTING_DOMAIN.md",
|
||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="POLYAS provider profile",
|
||||||
|
href="govoplan-voting/docs/POLYAS_PROVIDER_PROFILE.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Native certifiable Voting program",
|
||||||
|
href="govoplan-voting/docs/CERTIFIABLE_VOTING_PROGRAM.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
@@ -382,6 +393,7 @@ manifest = ModuleManifest(
|
|||||||
"The native profile is recorded and reconstructable, not cryptographically secret.",
|
"The native profile is recorded and reconstructable, not cryptographically secret.",
|
||||||
"Confidential, secret, and externally certified profiles require an installed provider capability and fail closed otherwise.",
|
"Confidential, secret, and externally certified profiles require an installed provider capability and fail closed otherwise.",
|
||||||
"The bundled local confidential provider is server-decryptable and is neither anonymous, coercion-resistant, secret, nor externally certified.",
|
"The bundled local confidential provider is server-decryptable and is neither anonymous, coercion-resistant, secret, nor externally certified.",
|
||||||
|
"POLYAS remains an operator-assisted integration target until a contracted API, sandbox, current certification evidence, and conformance fixtures are available.",
|
||||||
"Formal public-election certification remains a deployment-specific legal, organizational, and provider assurance decision.",
|
"Formal public-election certification remains a deployment-specific legal, organizational, and provider assurance decision.",
|
||||||
),
|
),
|
||||||
supported_authority_modes=("native_authoritative", "external_authoritative"),
|
supported_authority_modes=("native_authoritative", "external_authoritative"),
|
||||||
@@ -402,7 +414,11 @@ manifest = ModuleManifest(
|
|||||||
reference_packages=("product.service-to-decision",),
|
reference_packages=("product.service-to-decision",),
|
||||||
migration_docs=("docs/VOTING_DOMAIN.md",),
|
migration_docs=("docs/VOTING_DOMAIN.md",),
|
||||||
recovery_docs=("docs/VOTING_DOMAIN.md",),
|
recovery_docs=("docs/VOTING_DOMAIN.md",),
|
||||||
security_docs=("docs/VOTING_DOMAIN.md",),
|
security_docs=(
|
||||||
|
"docs/VOTING_DOMAIN.md",
|
||||||
|
"docs/POLYAS_PROVIDER_PROFILE.md",
|
||||||
|
"docs/CERTIFIABLE_VOTING_PROGRAM.md",
|
||||||
|
),
|
||||||
operations_docs=("docs/VOTING_DOMAIN.md",),
|
operations_docs=("docs/VOTING_DOMAIN.md",),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ from govoplan_core.core.voting import (
|
|||||||
VotingBallotCreateCommand,
|
VotingBallotCreateCommand,
|
||||||
VotingBallotRef,
|
VotingBallotRef,
|
||||||
VotingCastCommand,
|
VotingCastCommand,
|
||||||
|
VotingCapabilityError,
|
||||||
VotingReceipt,
|
VotingReceipt,
|
||||||
VotingResult,
|
VotingResult,
|
||||||
|
require_voting_provider_assurance,
|
||||||
voting_provider_capability,
|
voting_provider_capability,
|
||||||
)
|
)
|
||||||
from govoplan_voting.backend.db.models import (
|
from govoplan_voting.backend.db.models import (
|
||||||
@@ -265,6 +267,16 @@ class SqlVotingBallots:
|
|||||||
raise VotingStoreError(
|
raise VotingStoreError(
|
||||||
"The selected Voting assurance profile requires an available external provider."
|
"The selected Voting assurance profile requires an available external provider."
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
declaration = require_voting_provider_assurance(
|
||||||
|
provider,
|
||||||
|
provider_id=provider_id,
|
||||||
|
assurance_profile=profile,
|
||||||
|
at=_now(),
|
||||||
|
)
|
||||||
|
except VotingCapabilityError as exc:
|
||||||
|
raise VotingStoreError(str(exc)) from exc
|
||||||
|
payload["provider_assurance"] = declaration.to_dict()
|
||||||
definition_hash, electorate_hash = _frozen_hashes(payload)
|
definition_hash, electorate_hash = _frozen_hashes(payload)
|
||||||
payload["definition_sha256"] = definition_hash
|
payload["definition_sha256"] = definition_hash
|
||||||
payload["electorate_sha256"] = electorate_hash
|
payload["electorate_sha256"] = electorate_hash
|
||||||
@@ -343,6 +355,7 @@ class SqlVotingBallots:
|
|||||||
"electorate_sha256": electorate_hash,
|
"electorate_sha256": electorate_hash,
|
||||||
"provider_id": payload.get("provider_id"),
|
"provider_id": payload.get("provider_id"),
|
||||||
"provider_ballot_ref": payload.get("provider_ballot_ref"),
|
"provider_ballot_ref": payload.get("provider_ballot_ref"),
|
||||||
|
"provider_assurance": payload.get("provider_assurance"),
|
||||||
"provider_evidence": [dict(item) for item in provider_evidence],
|
"provider_evidence": [dict(item) for item in provider_evidence],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -403,6 +416,7 @@ class SqlVotingBallots:
|
|||||||
raise VotingStoreError(
|
raise VotingStoreError(
|
||||||
"This Voting provider does not expose an interactive cast capability."
|
"This Voting provider does not expose an interactive cast capability."
|
||||||
)
|
)
|
||||||
|
_require_pinned_provider_assurance(current.payload, provider)
|
||||||
try:
|
try:
|
||||||
receipt = provider.cast_ballot(
|
receipt = provider.cast_ballot(
|
||||||
typed_session,
|
typed_session,
|
||||||
@@ -863,6 +877,7 @@ class SqlVotingBallots:
|
|||||||
provider = _capability(self._registry, voting_provider_capability(provider_id))
|
provider = _capability(self._registry, voting_provider_capability(provider_id))
|
||||||
if not isinstance(provider, ExternalVotingProvider):
|
if not isinstance(provider, ExternalVotingProvider):
|
||||||
raise VotingStoreError(f"Voting provider is unavailable: {provider_id}.")
|
raise VotingStoreError(f"Voting provider is unavailable: {provider_id}.")
|
||||||
|
_require_pinned_provider_assurance(current.payload, provider)
|
||||||
electorate = list(current.payload["electorate"])
|
electorate = list(current.payload["electorate"])
|
||||||
try:
|
try:
|
||||||
result = provider.finalize_ballot(
|
result = provider.finalize_ballot(
|
||||||
@@ -944,6 +959,7 @@ def _payload_from_command(command: VotingBallotCreateCommand) -> dict[str, Any]:
|
|||||||
"closes_at": _datetime_text(command.closes_at),
|
"closes_at": _datetime_text(command.closes_at),
|
||||||
"provider_id": _optional_text(command.provider_id),
|
"provider_id": _optional_text(command.provider_id),
|
||||||
"provider_ballot_ref": _optional_text(command.provider_ballot_ref),
|
"provider_ballot_ref": _optional_text(command.provider_ballot_ref),
|
||||||
|
"provider_assurance": None,
|
||||||
"metadata": dict(command.metadata),
|
"metadata": dict(command.metadata),
|
||||||
"definition_sha256": None,
|
"definition_sha256": None,
|
||||||
"electorate_sha256": None,
|
"electorate_sha256": None,
|
||||||
@@ -1029,6 +1045,28 @@ def _validate_window(payload: Mapping[str, Any]) -> None:
|
|||||||
raise VotingStoreError("Voting ballot has already reached its close time.")
|
raise VotingStoreError("Voting ballot has already reached its close time.")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_pinned_provider_assurance(
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
provider: object,
|
||||||
|
) -> None:
|
||||||
|
provider_id = str(payload.get("provider_id") or "").strip()
|
||||||
|
assurance_profile = str(payload.get("assurance_profile") or "").strip()
|
||||||
|
try:
|
||||||
|
current = require_voting_provider_assurance(
|
||||||
|
provider,
|
||||||
|
provider_id=provider_id,
|
||||||
|
assurance_profile=assurance_profile,
|
||||||
|
at=_now(),
|
||||||
|
)
|
||||||
|
except VotingCapabilityError as exc:
|
||||||
|
raise VotingStoreError(str(exc)) from exc
|
||||||
|
pinned = payload.get("provider_assurance")
|
||||||
|
if not isinstance(pinned, Mapping) or dict(pinned) != current.to_dict():
|
||||||
|
raise VotingStoreError(
|
||||||
|
"Voting provider assurance changed after the ballot was frozen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _frozen_hashes(payload: Mapping[str, Any]) -> tuple[str, str]:
|
def _frozen_hashes(payload: Mapping[str, Any]) -> tuple[str, str]:
|
||||||
electorate = list(payload.get("electorate") or [])
|
electorate = list(payload.get("electorate") or [])
|
||||||
definition = {
|
definition = {
|
||||||
|
|||||||
+104
-1
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
import hashlib
|
import hashlib
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
import unittest
|
import unittest
|
||||||
@@ -10,10 +10,13 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from govoplan_core.core.voting import (
|
from govoplan_core.core.voting import (
|
||||||
|
VOTING_CERTIFICATION_CERTIFIED,
|
||||||
|
VOTING_CERTIFICATION_IN_EVALUATION,
|
||||||
VotingBallotCreateCommand,
|
VotingBallotCreateCommand,
|
||||||
VotingCastCommand,
|
VotingCastCommand,
|
||||||
VotingElector,
|
VotingElector,
|
||||||
VotingOption,
|
VotingOption,
|
||||||
|
VotingProviderAssuranceDeclaration,
|
||||||
voting_provider_capability,
|
voting_provider_capability,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.encryption import (
|
from govoplan_core.core.encryption import (
|
||||||
@@ -51,6 +54,17 @@ class FakeRegistry:
|
|||||||
return self.capabilities.get(name)
|
return self.capabilities.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeExternalVotingProvider:
|
||||||
|
def __init__(self, declaration: VotingProviderAssuranceDeclaration) -> None:
|
||||||
|
self.declaration = declaration
|
||||||
|
|
||||||
|
def assurance_declaration(self) -> VotingProviderAssuranceDeclaration:
|
||||||
|
return self.declaration
|
||||||
|
|
||||||
|
def finalize_ballot(self, session, principal, *, request):
|
||||||
|
raise AssertionError("finalization should not run in assurance gate tests")
|
||||||
|
|
||||||
|
|
||||||
class FakeKeyVault:
|
class FakeKeyVault:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.vaults: dict[tuple[str, str], object] = {}
|
self.vaults: dict[tuple[str, str], object] = {}
|
||||||
@@ -290,6 +304,95 @@ class VotingTests(unittest.TestCase):
|
|||||||
idempotency_key="open-secret",
|
idempotency_key="open-secret",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_external_certified_provider_claim_is_current_and_frozen(self) -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
candidate = FakeExternalVotingProvider(
|
||||||
|
VotingProviderAssuranceDeclaration(
|
||||||
|
provider_id="certified",
|
||||||
|
implementation_ref="vendor/adapter@1",
|
||||||
|
supported_assurance_profiles=("external_certified",),
|
||||||
|
certification_state=VOTING_CERTIFICATION_IN_EVALUATION,
|
||||||
|
protocol_ref="vendor:ballot",
|
||||||
|
protocol_version="3.0",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry = FakeRegistry()
|
||||||
|
registry.capabilities[voting_provider_capability("certified")] = candidate
|
||||||
|
service = SqlVotingBallots(registry)
|
||||||
|
|
||||||
|
with self.Session() as session:
|
||||||
|
created = service.create_ballot(
|
||||||
|
session,
|
||||||
|
self.manager,
|
||||||
|
command=command(
|
||||||
|
assurance="external_certified",
|
||||||
|
provider_id="certified",
|
||||||
|
),
|
||||||
|
idempotency_key="create-certified-candidate",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(VotingStoreError, "currently valid"):
|
||||||
|
service.open_ballot(
|
||||||
|
session,
|
||||||
|
self.manager,
|
||||||
|
ballot_id=created.id,
|
||||||
|
expected_revision=created.revision,
|
||||||
|
idempotency_key="open-certified-candidate",
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate.declaration = VotingProviderAssuranceDeclaration(
|
||||||
|
provider_id="certified",
|
||||||
|
implementation_ref="vendor/adapter@1",
|
||||||
|
supported_assurance_profiles=("external_certified",),
|
||||||
|
certification_state=VOTING_CERTIFICATION_CERTIFIED,
|
||||||
|
protocol_ref="vendor:ballot",
|
||||||
|
protocol_version="3.0",
|
||||||
|
certification_authority="Independent authority",
|
||||||
|
certification_reference="certificate-2026-1",
|
||||||
|
certification_evidence_ref="evidence://certificate-2026-1",
|
||||||
|
certification_valid_from=now - timedelta(days=1),
|
||||||
|
certification_valid_until=now + timedelta(days=1),
|
||||||
|
)
|
||||||
|
with self.Session() as session:
|
||||||
|
created = service.create_ballot(
|
||||||
|
session,
|
||||||
|
self.manager,
|
||||||
|
command=command(
|
||||||
|
assurance="external_certified",
|
||||||
|
provider_id="certified",
|
||||||
|
),
|
||||||
|
idempotency_key="create-certified",
|
||||||
|
)
|
||||||
|
opened = service.open_ballot(
|
||||||
|
session,
|
||||||
|
self.manager,
|
||||||
|
ballot_id=created.id,
|
||||||
|
expected_revision=created.revision,
|
||||||
|
idempotency_key="open-certified",
|
||||||
|
)
|
||||||
|
detail = service.get_ballot(
|
||||||
|
session,
|
||||||
|
self.manager,
|
||||||
|
ballot_id=created.id,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"certificate-2026-1",
|
||||||
|
detail["provider_assurance"]["certification_reference"],
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate.declaration = replace(
|
||||||
|
candidate.declaration,
|
||||||
|
certification_reference="certificate-2026-2",
|
||||||
|
certification_evidence_ref="evidence://certificate-2026-2",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(VotingStoreError, "changed after"):
|
||||||
|
service.close_ballot(
|
||||||
|
session,
|
||||||
|
self.manager,
|
||||||
|
ballot_id=opened.id,
|
||||||
|
expected_revision=opened.revision,
|
||||||
|
idempotency_key="close-certified",
|
||||||
|
)
|
||||||
|
|
||||||
def test_local_confidential_provider_encrypts_casts_and_returns_aggregates(
|
def test_local_confidential_provider_encrypts_casts_and_returns_aggregates(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/voting-webui",
|
"name": "@govoplan/voting-webui",
|
||||||
"version": "0.1.14",
|
"version": "0.1.15",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"./styles/voting.css": "./src/styles/voting.css"
|
"./styles/voting.css": "./src/styles/voting.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.14",
|
"@govoplan/core-webui": "^0.1.15",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20"
|
"react-dom": ">=19.2.7 <20"
|
||||||
|
|||||||
Reference in New Issue
Block a user