15 Commits
1.6 ... main

Author SHA1 Message Date
33e971bab7 Branding aktualisiert
All checks were successful
release-tag / release-image (push) Successful in 1m57s
2026-07-26 20:19:01 +02:00
3abd0d2ebc CSS angepasst
All checks were successful
release-tag / release-image (push) Successful in 2m14s
2026-07-24 10:46:33 +02:00
7dbc1c8552 Go Version Bugfix
All checks were successful
release-tag / release-image (push) Successful in 2m5s
2026-07-24 10:26:52 +02:00
981615f677 Dockerfile Bugfix
Some checks failed
release-tag / release-image (push) Has been cancelled
2026-07-24 10:24:39 +02:00
30ba365b39 Go Version Bugfix
Some checks failed
release-tag / release-image (push) Failing after 1m39s
2026-07-24 10:18:12 +02:00
6e152a5121 2.0.2 Update und Anpassungen
Some checks failed
release-tag / release-image (push) Failing after 1m38s
2026-07-24 10:08:19 +02:00
0a3700cd8b LS-Bugfix
All checks were successful
release-tag / release-image (push) Successful in 2m6s
2026-07-24 08:14:46 +02:00
b8d3a6092a OV
All checks were successful
release-tag / release-image (push) Successful in 2m0s
2026-07-24 07:31:19 +02:00
881d93ddd9 R
Some checks failed
release-tag / release-image (push) Failing after 1m10s
2026-07-24 07:26:32 +02:00
4e2c648ede 2.0.0 Production-RC
Some checks failed
release-tag / release-image (push) Has been cancelled
2026-07-24 07:23:59 +02:00
ec58fc30f8 1.8.2 - Anpassungen am Bulk-System
All checks were successful
release-tag / release-image (push) Successful in 1m40s
2026-07-23 08:49:28 +02:00
4439684e41 Update 1.8.1 Anpassung der Barrierefreiheit
All checks were successful
release-tag / release-image (push) Successful in 1m35s
2026-07-23 06:18:10 +02:00
db817eabe4 Workflow-Update für Bulk Image
All checks were successful
release-tag / release-image (push) Successful in 1m40s
2026-07-22 22:35:48 +02:00
13efcfb986 Update auf 1.8.0 mit Bulk-Option
All checks were successful
release-tag / release-image (push) Successful in 1m50s
2026-07-22 22:27:42 +02:00
95c47ee4a7 Update auf 1.7.0
All checks were successful
release-tag / release-image (push) Successful in 1m49s
2026-07-22 20:32:05 +02:00
86 changed files with 4491 additions and 4940 deletions

View File

@@ -1,65 +1,128 @@
# Public origin only: scheme + host, no path/query/fragment.
BASE_URL=https://ai.trustednet.eu
PUBLIC_NAME=AI Usage Disclosure
CONTACT_URL=https://ai.trustednet.eu
SALES_URL=https://ai.trustednet.eu/product
# =============================================================================
# AI Usage Disclosure Standard - complete runtime configuration reference
# =============================================================================
# Copy to .env and adjust for your deployment. Values ending in *_FILE are
# recommended for Docker/Kubernetes secrets. If a *_FILE value is configured,
# the file is authoritative and the corresponding plain environment value is
# ignored.
# -----------------------------------------------------------------------------
# HTTP / public identity
# -----------------------------------------------------------------------------
LISTEN_ADDRESS=:8080
BASE_URL=http://localhost:8080
OUTPUT_BASE_URL=http://localhost:8080
PUBLIC_NAME=UCNG AI Usage Disclosure
CONTACT_URL=https://b1tsblog.org/page/ai
DEFAULT_LANGUAGE=de
# Proxy and logging. Enable proxy headers only with explicit trusted CIDRs.
TRUST_PROXY=true
TRUSTED_PROXY_CIDRS=
LOG_CLIENT_IP=false
ENABLE_HSTS=true
# Used only by the binary's "--healthcheck" command inside containers.
HEALTHCHECK_URL=http://127.0.0.1:8080/healthz
# Prometheus is disabled by default. Enabling it requires a long bearer token;
# METRICS_TOKEN_FILE is also supported. Keep the endpoint on an internal network.
# Runtime mode: full (UI + API), api (machine APIs only), bulk (Bulk UI + API).
SERVICE_MODE=full
# CORS for /v1, /schema and /context endpoints. Empty disables CORS headers.
API_ALLOWED_ORIGIN=*
# -----------------------------------------------------------------------------
# Reverse proxy, security and request logging
# -----------------------------------------------------------------------------
# Proxy headers are trusted only when the direct peer matches one of these
# CIDRs. TRUST_PROXY=true without a valid CIDR list is rejected at startup.
TRUST_PROXY=false
TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# Client IP addresses are omitted from structured request logs by default.
LOG_CLIENT_IP=false
# Enable only when the public service is served exclusively over HTTPS.
ENABLE_HSTS=false
# A commercial deployment can fail closed when no valid runtime licence exists.
REQUIRE_LICENSE=false
WHITE_LABEL=false
# -----------------------------------------------------------------------------
# Prometheus
# -----------------------------------------------------------------------------
# /metrics returns 404 while disabled. When enabled, a bearer token of at least
# 32 characters is mandatory. Prefer METRICS_TOKEN_FILE in containers.
METRICS_ENABLED=false
METRICS_TOKEN=
METRICS_TOKEN_FILE=
# ---------------------------------------------------------------------------
# Legal operator data. Fill with accurate information before public operation.
# LEGAL_STRICT=true prevents startup while core legal/privacy fields are empty
# or contain REPLACE_ME/CHANGEME/TODO.
# ---------------------------------------------------------------------------
LEGAL_STRICT=true
LEGAL_NAME=REPLACE_ME
LEGAL_ADDRESS=REPLACE_ME
# -----------------------------------------------------------------------------
# Operator / legal / privacy information
# -----------------------------------------------------------------------------
# The application exposes /legal, /privacy and /accessibility in full and bulk
# mode. These pages are configurable operator information, not legal advice.
#
# LEGAL_STRICT=true prevents startup while the core operator/privacy fields are
# empty or contain REPLACE_ME, CHANGEME or TODO. For local development leave it
# false; set it to true after completing the production information.
LEGAL_STRICT=false
LEGAL_NAME=
LEGAL_ADDRESS=
LEGAL_REPRESENTATIVE=
LEGAL_EMAIL=REPLACE_ME
LEGAL_EMAIL=
LEGAL_PHONE=
LEGAL_REGISTER=
LEGAL_REGISTER_NUMBER=
LEGAL_VAT_ID=
# Only set these if the service contains journalistic-editorial content and
# § 18(2) MStV applies.
# Only set these after assessing whether a journalistically/editorially designed
# service requires a specifically named responsible person in your jurisdiction.
EDITORIAL_RESPONSIBLE_NAME=
EDITORIAL_RESPONSIBLE_ADDRESS=
DATA_PROTECTION_CONTACT=
HOSTING_PROVIDER=REPLACE_ME
HOSTING_PROVIDER=
HOSTING_ADDRESS=
LOG_RETENTION=REPLACE_ME
LOG_RETENTION=7 days
DATA_RECIPIENTS=
THIRD_COUNTRY_TRANSFERS=
SUPERVISORY_AUTHORITY_NAME=
SUPERVISORY_AUTHORITY_URL=
# One of: not_applicable, not_participating, participating.
# Assess applicability under the VSBG; do not copy a status blindly.
CONSUMER_DISPUTE_STATUS=REPLACE_ME
# If "participating" is selected and LEGAL_STRICT=true, body and URL are required.
CONSUMER_DISPUTE_STATUS=not_applicable
CONSUMER_DISPUTE_BODY=
CONSUMER_DISPUTE_URL=
ACCESSIBILITY_CONTACT=
ACCESSIBILITY_STATUS=
# Runtime license issued by the standalone Universal License Platform.
# No private keys or configurable public keys belong in this application.
# -----------------------------------------------------------------------------
# Runtime licence - issued by the standalone Universal License Platform
# -----------------------------------------------------------------------------
# No private signing key and no configurable issuer public key belong here.
# Prefer LICENSE_TOKEN_FILE for Docker/Kubernetes secrets.
LICENSE_TOKEN=
LICENSE_TOKEN_FILE=
LICENSE_INSTANCE_ID=
LICENSE_MODE=offline
LICENSE_SERVER_URL=https://license.trustednet.eu
LICENSE_SERVER_URL=
LICENSE_CACHE_FILE=/data/license-lease.json
LICENSE_REFRESH_INTERVAL=15m
LICENSE_REQUEST_TIMEOUT=5s
# -----------------------------------------------------------------------------
# Bulk workspace / API
# -----------------------------------------------------------------------------
# The dedicated bulk image forces the intended bulk service mode and can require
# the licensed "bulk_api" capability. BULK_MAX_ITEMS may be further reduced by
# the licence limit "bulk_items".
BULK_MAX_ITEMS=500
BULK_MAX_BODY_BYTES=4194304
BULK_REQUIRE_API_KEY=false
BULK_API_KEY=
BULK_API_KEY_FILE=
# -----------------------------------------------------------------------------
# Removed / intentionally unsupported legacy marketing variables
# -----------------------------------------------------------------------------
# SALES_URL and PRICE_* are intentionally not runtime settings anymore. Pricing
# and Pro advertising were removed from the public application UI.

View File

@@ -0,0 +1,51 @@
name: release-tag
on:
push:
branches:
- 'main'
jobs:
release-image:
runs-on: ubuntu-latest
env:
DOCKER_ORG: sendnrw
DOCKER_LATEST: latest
RUNNER_TOOL_CACHE: /toolcache
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v2
with: # replace it with your local IP
config-inline: |
[registry."git.send.nrw"]
http = true
insecure = true
- name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: git.send.nrw # replace it with your local IP
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.bulk
platforms: |
linux/amd64
push: true
tags: | # replace it with your local IP and tags
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:bulk-${{ steps.meta.outputs.REPO_VERSION }}
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:bulk-${{ env.DOCKER_LATEST }}

3
.gitignore vendored
View File

@@ -6,3 +6,6 @@
*.key
*.private
.DS_Store
secrets/*.txt
!secrets/README.md

View File

@@ -1,135 +1,88 @@
# Changelog
## 1.6.3 - 2026-07-21
## 2.0.2
- Hardened EU AI Act guidance against the Commission Article 50 guidelines published on 20 July 2026.
- Declaration schema 1.2 adds optional Article-50 `legalContext` categories.
- Generator distinguishes voluntary transparency from potentially legally relevant disclosures.
- Deepfake, public-interest text and artistic/creative/satirical/fictional context can be recorded explicitly.
- Article badges use clearer first-exposure wording when the supplied context indicates a likely disclosure case.
- Emoji embeds add adjacent plain text when a likely legal disclosure is indicated.
- Public-interest assessment considers substantive review together with expressly named editorial responsibility.
- Editorial responsibility can be classified as publisher responsibility or another responsible party and is stored explicitly.
- Declaration pages show a cautious, non-binding Article-50 assessment and no longer imply editorial responsibility unless it is expressly stated.
- Presets no longer infer human/editorial review.
- Added first-exposure, machine-readable provider-marking and emoji-only disclosure guardrails.
- Added configurable Impressum, privacy and accessibility pages plus security hardening for logs, proxy trust, CSP, HSTS and metrics.
- `.env.example` ist nun die vollständige Runtime-Konfigurationsreferenz statt nur eines Minimalbeispiels.
- Vertrauenswürdige Reverse-Proxy-Netze über `TRUSTED_PROXY_CIDRS`; weitergereichte Client-IP-Header werden nur von explizit vertrauenswürdigen Peers akzeptiert.
- Datenschutzfreundliches Request-Logging mit `LOG_CLIENT_IP=false` als Standard und optionales `ENABLE_HSTS`.
- Prometheus ist standardmäßig deaktiviert und kann nur mit Bearer-Token (`METRICS_TOKEN` / `METRICS_TOKEN_FILE`) aktiviert werden.
- Mehrsprachige Betreiberseiten `/legal`, `/imprint`, `/privacy` und `/accessibility` mit konfigurierbaren Betreiber-, Datenschutz- und Barrierefreiheitsangaben.
- `LEGAL_STRICT` als Fail-Fast-Prüfung für zentrale Betreiber-/Datenschutzfelder und die Konfiguration der Verbraucherstreitbeilegung.
- Betreiberseiten und ihre statischen Assets bleiben auch bei einer Runtime-Lizenzstörung erreichbar.
- Konfigurations-Regressionstests sowie neue Dokumentation in `docs/CONFIGURATION.md` und `docs/OPERATOR-PAGES.md`.
- `SALES_URL` und `PRICE_*` bleiben bewusst entfernt, da Preisübersicht und Pro-Werbung nicht mehr Teil der öffentlichen Anwendung sind.
## Unreleased
## 2.0.1
- No unreleased changes.
- **Bulk Workspace wiederhergestellt:** Der dedizierte Bulk-Container liefert wieder eine vollständige Weboberfläche unter `/` und `/bulk`.
- Visueller Workflow zum Anwenden eines gemeinsamen Artikelprofils auf viele URLs/IDs.
- Erweiterter JSON-Modus für heterogene Bulk-Requests bleibt direkt mit `POST /v1/bulk/declarations` kompatibel.
- Ergebnisansicht mit Erfolgs-/Fehlerstatus, Artikel-50-Einordnung sowie direkten Links zu Erklärung, Manifest und Badge.
- JSON- und CSV-Export der Bulk-Ergebnisse.
- API-Key-Eingabe wird ausschließlich im `sessionStorage` des aktuellen Browser-Tabs gehalten; der Schlüssel wird nicht in Ergebnisse geschrieben.
- Bulk-Workspace respektiert `bulk_api`, `bulk_items`, `BULK_MAX_ITEMS` und `BULK_REQUIRE_API_KEY`.
- Dedizierter Bulk-Container behält Fail-Closed-Lizenzierung, API-Key-Schutz, Read-only-Filesystem und alle 2.0-Härtungen bei.
- `LICENSE_TOKEN_FILE` und `BULK_API_KEY_FILE` haben nun Vorrang vor gleichnamigen Environment-Werten; veraltete `.env`-Secrets können Docker-/Kubernetes-Secrets nicht mehr überschreiben.
- Bulk-Oberfläche für alle acht unterstützten Sprachen vorbereitet; Deutsch und Englisch vollständig redaktionell ausgearbeitet, weitere Sprachen mit lokalisierten Kerntexten und englischem Fallback für seltene Verwaltungsbegriffe.
## 2.0.0
### Article 50 decision support
- regulatorischer Kontext als strukturierter Bestandteil des Schemas 1.2;
- Deepfake-, Public-Interest-Text-, künstlerischer Kontext-, Human-Review-, Editorial-Responsibility-, First-Exposure- und Accessibility-Felder;
- nicht bindender `/v1/article50-assessment.json`-Endpunkt;
- explizitere Badge-Darstellung für markierte Artikel-50-Szenarien;
- zusätzliche Warnung zur angemessenen Offenlegungsform bei künstlerischen/kreativen/satirischen/fiktionalen Deepfake-Kontexten;
- `no-ai`-Preset behauptet keine menschliche KI-Prüfung mehr.
### Professional runtime
- `SERVICE_MODE=full|api|bulk`;
- `REQUIRE_LICENSE` kann offizielle lizenzierte Deployments fail-closed betreiben;
- konfigurierbares API-CORS über `API_ALLOWED_ORIGIN`;
- White-Label-Capability;
- neutralisierte UI-Begriffe für lizenzierte Anpassungen statt Pro-Werbung;
- einheitlicher API-Fehler `licensed_feature_required`.
### Bulk & export
- lizenzierte Capability `bulk_api`;
- lizenzierte Capability `export_bundle`;
- dedizierter `Dockerfile.bulk`;
- API-Key-Schutz für Bulk über Bearer oder `X-API-Key`;
- Secret-Dateien via `BULK_API_KEY_FILE` und `LICENSE_TOKEN_FILE`;
- Item-/Body-Limits und optionales Lizenzlimit `bulk_items`;
- Bulk-Metriken;
- Export-Bundle mit SHA-256-Digest der Deklaration.
### Deployment & supply chain
- gehärtete Kubernetes-Vorlage für den internen Bulk-Service;
- separates Swarm-Bulk-Stack-Beispiel;
- kommerzielles Compose-Beispiel mit Datei-Secrets;
- Kubernetes ohne automatisch gemountete ServiceAccount-Tokens;
- CI baut Full- und Bulk-Images für amd64/arm64, erzeugt SBOM/Provenance und signiert veröffentlichte Digests keyless mit Sigstore/Cosign;
- OCI-Metadaten in beiden Dockerfiles.
### Documentation
- neue Dokumentation für Article-50-Decision-Support, Bulk API, kommerzielles Deployment und Lizenzstrategie;
- README vollständig auf die 2.0-Produktarchitektur aktualisiert;
- OpenAPI auf Version 2.0.0 erweitert.
## 1.6.2
- Added a dedicated, long-form background page at `/background`.
- Added localized legal and editorial explanations for German, English, French, Spanish, Italian, Dutch, Portuguese and Polish.
- Explained the separate obligations in Article 50(1)(5), with a specific section for articles, websites, images, audio and video.
- Added a clear distinction between legal labelling duties and broader voluntary disclosure.
- Added official links to the EU AI Act, the Commission guidelines of 20 July 2026, the Article 50 Q&A, the Code of Practice and the EU icons.
- Changed all “Background” navigation links to the new internal page.
- Added language switching, canonical URLs and `hreflang` metadata to the background page.
- Added regression tests for all eight languages and navigation.
- eigene mehrsprachige Hintergrundseite zu Artikel 50 des EU AI Act;
- interne Navigation auf `/background` umgestellt.
## 1.6.1
- Added a generator control for the declaration evidence basis: `selfDeclared`, `technicallyRecorded`, `signed`, and `verified`.
- The selected evidence basis is now included in declaration URLs, HTML pages, SVG links, and JSON-LD manifests.
- Reworked declaration summaries with more professional, contextual wording and a clear explanation of the selected evidence basis.
- Added a scope note for digitally signed declarations: signatures prove origin and integrity, not substantive correctness.
- Improved German and English preset descriptions and article-level headings.
- Added a full cell grid, fixed column proportions, consistent alignment, and responsive overflow to component tables.
- Restyled declaration metadata as a consistent two-column grid.
- Documented the assurance parameter in OpenAPI and added regression tests for all supported values.
- professionellere Erklärungstexte;
- gerasterte Tabellenansicht;
- auswählbare Nachweisgrundlage.
## 1.6.0
- removed `cmd/licensectl`, `cmd/licenseweb`, `cmd/licenseserver` and all embedded license-authority/server code;
- removed private-key handling, key generation, token signing, license registry storage and lease signing from the product project;
- replaced the previous internal licensing implementation with a verification-only client snapshot compatible with Universal License Platform v1.0.0;
- switched online verification to `POST /api/v1/licenses/validate` with legacy `/v1/introspect` fallback;
- added support for the platform-signed `verification.serverUrl` claim and well-known discovery;
- retained only the build-time public trust store, runtime token verification, feature/limit gates and signed hybrid lease cache;
- removed the runtime insecure-Pro environment override so official builds always require a platform-issued token;
- removed license-server Docker, Compose, Kubernetes and OpenAPI artifacts from this project;
- documented the standalone platform as the sole license issuance and administration authority.
## 1.5.0
- replaced customer-configurable public keys with a build-time embedded trust store;
- added universal `pkg/licensekit` and `pkg/licenseclient` packages;
- added product-bound licences with arbitrary features, limits, domains and instance IDs;
- added offline, hybrid and online verification modes;
- added a central introspection and revocation server with signed short-lived leases;
- expanded the local licence web interface for multiple products and optional central registration;
- added separate issuer and lease keys with key IDs and rotation support;
- removed `LICENSE_PUBLIC_KEY` from application, Docker, Kubernetes and Swarm configuration;
- added reference deployment and architecture documentation.
## 1.4.1
- `*` is now accepted as a global licensed-domain wildcard.
- The license web interface documents the difference between `*` and `*.example.org`.
- Added regression coverage for arbitrary public domains and localhost.
## 1.3.3
- Changed the default right-hand colour of article-level “Artikeltransparenz” badges from an extent-derived warning colour to a calmer violet (`#7c3aed`).
- Individual declaration colours remain unchanged.
- Explicit Pro badge colours continue to override the article default.
## 1.3.2
- Sprachumschalter direkt auf Deklarationsseiten hinzugefügt.
- Beim Sprachwechsel bleiben sämtliche Artikel-, Review-, Assurance- und Pro-Parameter erhalten.
- Serverseitig gerenderte Sprachlinks als Fallback ohne JavaScript.
- `hreflang`-Links für alle acht Sprachen und `x-default` ergänzt.
- Responsive Darstellung des Sprachumschalters innerhalb der Ergebnis-Karte.
## 1.3.1
- Artikelkomponenten besitzen nun eine eigene Auswahl für die menschliche Prüfung.
- Der Generator überträgt die gewählte Prüfart statt immer `editorial` zu setzen.
- Komponenten ohne KI erhalten standardmäßig `humanReview: none`.
- Zusammenfassungstexte behaupten bei Komponenten ohne KI keine redaktionelle KI-Prüfung mehr.
## 1.3.0
- Added article-level declarations that combine text, images, research, translation, audio, video and code under one link.
- Added automatically generated 510 sentence narrative summaries alongside structured component tables.
- Added a generator switch between single-use and article/site declarations.
- Improved declaration-page typography, responsive headline containment and table layout.
- Article declarations remain available through HTML, SVG badge and JSON-LD endpoints.
## 1.2.0
- Added a responsive marketing page at `/product`.
- Added `/pricing` and `/install` section redirects.
- Added localized product, feature, edition, pricing, installation and FAQ copy for all eight supported languages.
- Added Community/Pro comparison and configurable pricing labels.
- Added Docker Compose, Docker CLI, Kubernetes, Docker Swarm and native Go installation instructions with copy controls.
- Added environment-variable reference and deployment FAQ.
- Added `SALES_URL`, `PRICE_COMMUNITY`, `PRICE_PRO`, `PRICE_PUBLISHER` and `PRICE_AGENCY`.
- Added a product/pricing link to the generator navigation.
- Added route and language-negotiation tests for the marketing page.
## 1.1.0
- Added German, English, French, Spanish, Italian, Dutch, Portuguese and Polish language catalogs.
- Added `Accept-Language` negotiation and `DEFAULT_LANGUAGE`.
- Localized presets, disclosure values, generator UI and declaration pages.
- Added optional `presentation` data to schema 1.1 while retaining validator compatibility for legacy 1.0 declarations.
- Added Pro capabilities `custom_text`, `custom_badge` and reserved `white_label`.
- Added custom declaration titles and descriptions.
- Added custom badge labels and six-digit hex colours.
- Added offline Ed25519 license verification, expiration and domain restrictions.
- Added `cmd/licensectl` for key generation and license signing.
- Added `/v1/capabilities`.
- Added Kubernetes optional Secret integration and deployment variables for Pro.
- Added server-side `403 pro_feature_required` enforcement.
## 1.0.0
- Initial stateless badge, declaration, JSON-LD and validation service.
- Lizenzprüfung an die eigenständige Universal License Platform angepasst;
- sämtliche Lizenz-Erstellungs- und Admin-Funktionen aus dem Projekt entfernt.

View File

@@ -1,19 +1,33 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26-alpine AS build
ARG GO_IMAGE=golang:1.26-alpine
FROM ${GO_IMAGE} AS build
WORKDIR /src
RUN apk add --no-cache ca-certificates tzdata
COPY go.mod ./
COPY .env.example ./.env.example
COPY third_party ./third_party
COPY cmd ./cmd
COPY internal ./internal
COPY web ./web
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ai-disclosure ./cmd/server
RUN CGO_ENABLED=0 GOOS=linux go test ./... && \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ai-disclosure ./cmd/server
FROM scratch
ARG VERSION=2.0.3
ARG REVISION=unknown
ARG CREATED=unknown
LABEL org.opencontainers.image.title="AI Disclosure Standard" \
org.opencontainers.image.description="Open, multilingual AI usage disclosure service with optional licensed operational capabilities" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.created="${CREATED}" \
org.opencontainers.image.licenses="MIT"
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /out/ai-disclosure /ai-disclosure
USER 65532:65532
EXPOSE 8080
ENV LISTEN_ADDRESS=:8080 LICENSE_CACHE_FILE=/data/license-lease.json
ENV LISTEN_ADDRESS=:8080 \
SERVICE_MODE=full \
LICENSE_CACHE_FILE=/data/license-lease.json
ENTRYPOINT ["/ai-disclosure"]

36
Dockerfile.bulk Normal file
View File

@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1.7
ARG GO_IMAGE=golang:1.26-alpine
FROM ${GO_IMAGE} AS build
WORKDIR /src
RUN apk add --no-cache ca-certificates tzdata
COPY go.mod ./
COPY .env.example ./.env.example
COPY third_party ./third_party
COPY cmd ./cmd
COPY internal ./internal
COPY web ./web
RUN CGO_ENABLED=0 GOOS=linux go test ./... && \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ai-disclosure ./cmd/server
FROM scratch
ARG VERSION=2.0.3
ARG REVISION=unknown
ARG CREATED=unknown
LABEL org.opencontainers.image.title="AI Disclosure Standard Bulk" \
org.opencontainers.image.description="Licensed bulk workspace and API for professional AI usage declaration processing" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.created="${CREATED}" \
org.opencontainers.image.licenses="MIT"
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /out/ai-disclosure /ai-disclosure
USER 65532:65532
EXPOSE 8080
ENV LISTEN_ADDRESS=:8080 \
SERVICE_MODE=bulk \
REQUIRE_LICENSE=true \
BULK_REQUIRE_API_KEY=true \
API_ALLOWED_ORIGIN= \
LICENSE_CACHE_FILE=/data/license-lease.json
ENTRYPOINT ["/ai-disclosure"]

View File

@@ -1,4 +1,6 @@
.PHONY: run test test-license-client check build docker-build
.PHONY: run test test-license-client check build docker-build docker-build-bulk docker-build-all
VERSION ?= 2.0.2
run:
go run ./cmd/server
@@ -21,4 +23,9 @@ build:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/ai-disclosure ./cmd/server
docker-build:
docker build -t ai-disclosure-standard:1.6.3-local .
docker build --build-arg VERSION=$(VERSION) -t ai-disclosure-standard:$(VERSION)-local .
docker-build-bulk:
docker build -f Dockerfile.bulk --build-arg VERSION=$(VERSION) -t ai-disclosure-standard-bulk:$(VERSION)-local .
docker-build-all: docker-build docker-build-bulk

473
README.md
View File

@@ -1,25 +1,73 @@
# AI Disclosure Standard 1.6.3
# AI Disclosure Standard 2.0.2
Ein zustandsloser Go-Dienst für sichtbare und maschinenlesbare Erklärungen zur KI-Nutzung in Artikeln, Webseiten und einzelnen Inhaltsbestandteilen.
Ein mehrsprachiger, selbst hostbarer Go-Dienst für sichtbare und maschinenlesbare Erklärungen zur KI-Nutzung in Artikeln, Webseiten und einzelnen Inhaltsbestandteilen.
## Funktionen
Die Community-Basis bleibt offen und ohne Plattformzwang nutzbar. Für professionelle Betreiber stehen zusätzlich lizenzierbare Laufzeitfunktionen wie eigene Darstellung, Bulk-Verarbeitung, Export-Bundles und White-Label-Betrieb zur Verfügung. Lizenzen werden ausschließlich durch die separat betriebene **Universal License Platform** ausgestellt.
- einzelne Deklarationen und zusammengefasste Artikel-/Webseiten-Deklarationen;
- SVG-Badges, HTML-Erklärung und JSON-LD unter einem gemeinsamen Link;
## Funktionsumfang
### Offener Kern
- Einzeldeklarationen und zusammengefasste Artikel-/Webseiten-Deklarationen;
- Text, Titelbild, weitere Bilder, Recherche, Übersetzung, Audio, Video und Code getrennt erfassbar;
- automatisch erzeugte, professionell formulierte Zusammenfassung als Fließtext und strukturierte Tabelle;
- frei wählbare Nachweisgrundlage: Selbsterklärung, technisch protokolliert, signiert oder verifiziert;
- SVG-Badges, HTML-Erklärung und JSON-LD aus derselben Deklaration;
- automatisch erzeugte Fließtext-Zusammenfassung und sauber gerasterte Tabellenansicht;
- Nachweisgrundlagen `selfDeclared`, `technicallyRecorded`, `signed` und `verified`;
- Deutsch, Englisch, Französisch, Spanisch, Italienisch, Niederländisch, Portugiesisch und Polnisch;
- Sprachumschalter direkt auf der Ergebnisseite;
- mehrsprachige Hintergrundseite zu Artikel 50 des EU AI Act, Kennzeichnungspflichten und freiwilliger Transparenz;
- optionale lizenzierte Funktionen für eigene Texte und Badge-Darstellung;
- Offline-, Hybrid- und Online-Prüfung über die eigenständige Universal License Platform;
- konfigurierbare Seiten für Impressum, Datenschutz und Barrierefreiheit;
- CSP mit Request-Nonce, minimierte Logs, vertrauensgebundene Proxy-Header und gehärtete Container-Defaults;
- Health-, Readiness- und optional geschützter Prometheus-Endpunkt;
- Docker-, Kubernetes- und Docker-Swarm-Deployment.
- Sprachumschalter direkt auf der Ergebnis-Seite;
- mehrsprachige Hintergrundseite zu Artikel 50 des EU AI Act;
- technisches, **nicht bindendes** Article-50-Decision-Support-Modell;
- strukturierte Erfassung von Deepfake-Kontext, Texten zu Angelegenheiten von öffentlichem Interesse, substanzieller menschlicher Prüfung, redaktioneller Verantwortung, Erstexposition und Barrierefreiheit;
- Validator, JSON-Schema und OpenAPI 3.1;
- Health-, Readiness- und Prometheus-Endpunkte;
- Docker, Docker Compose, Docker Swarm und Kubernetes;
- SBOM, Build-Provenance und keyless signierte offizielle Container-Images in der mitgelieferten CI-Pipeline.
## Start unter Windows
### Lizenzierbare Betriebsfunktionen
Die offizielle Distribution kennt folgende Capability-IDs:
```text
custom_text
custom_badge
white_label
bulk_api
export_bundle
```
Zusätzlich kann die Lizenzplattform numerische Limits liefern, beispielsweise:
```text
bulk_items=500
```
Die offene Standardfunktionalität hängt nicht von einer Lizenz ab. Ein Betreiber kann für offizielle kommerzielle Deployments mit `REQUIRE_LICENSE=true` festlegen, dass die Instanz ohne gültige Runtime-Lizenz nicht betriebsbereit ist und keine fachlichen Endpunkte bedient.
## Artikel-50-Unterstützung
Der Generator kann ergänzend zur eigentlichen KI-Nutzung Tatsachen erfassen, die für Artikel 50 relevant sein können:
- Deepfake bzw. realitätsähnlich erzeugter/manipulierter Inhalt;
- Text zur Information der Öffentlichkeit über eine Angelegenheit von öffentlichem Interesse;
- offensichtlich künstlerischer, kreativer, satirischer oder fiktionaler Kontext;
- substanzielle menschliche Prüfung bzw. redaktionelle Kontrolle;
- bestätigte redaktionelle Verantwortung;
- Offenlegung spätestens bei der ersten Exposition;
- Berücksichtigung anwendbarer Barrierefreiheitsanforderungen.
Daraus erzeugt die Anwendung eine konservative technische Einordnung unter:
```text
GET /v1/article50-assessment.json
```
Diese Einordnung ist ausdrücklich **keine Rechtsberatung und keine automatische Konformitätsentscheidung**. Die Rohdaten bleiben Teil der Deklaration und können unabhängig von der Bewertung weiterverarbeitet werden.
Weitere Details: [`docs/ARTICLE-50-DECISION-SUPPORT.md`](docs/ARTICLE-50-DECISION-SUPPORT.md)
## Schnellstart
Unter Windows:
```powershell
go run .\cmd\server
@@ -28,61 +76,174 @@ go run .\cmd\server
Danach:
```text
Generator: http://localhost:8080/
Produktseite: http://localhost:8080/product
Hintergrund: http://localhost:8080/background
Impressum: http://localhost:8080/impressum
Datenschutz: http://localhost:8080/datenschutz
Barrierefrei: http://localhost:8080/barrierefreiheit
Healthcheck: http://localhost:8080/healthz
Funktionen: http://localhost:8080/v1/capabilities
Generator: http://localhost:8080/
Produktseite: http://localhost:8080/product
Hintergrund: http://localhost:8080/background
Capabilities: http://localhost:8080/v1/capabilities
Health: http://localhost:8080/healthz
Readiness: http://localhost:8080/readyz
```
Go lädt `.env` nicht selbst. Unter PowerShell kann die Datei vor dem Start in die Prozessumgebung übernommen werden oder Docker Compose mit `--env-file .env` verwendet werden.
## Docker Compose
Go lädt `.env` beim direkten Start nicht automatisch. Docker Compose kann sie explizit verwenden:
```powershell
Copy-Item .env.example .env
docker compose --env-file .env up -d --build
```
## Lizenzprüfung
## Betriebsmodi
Dieses Projekt stellt **keine Lizenzen aus** und enthält keine Schlüsselgenerierung, privaten Schlüssel, Lizenzverwaltung, Admin-Oberfläche oder eigenen Lizenzserver. Diese Aufgaben gehören ausschließlich in die separat betriebene **Universal License Platform**.
`SERVICE_MODE` trennt öffentliche Oberfläche und Server-to-Server-Betrieb:
Der Produktserver enthält nur den Laufzeit-Client und akzeptiert:
| Modus | Zweck |
|---|---|
| `full` | Generator, Produkt-/Hintergrundseiten, Deklarationen und APIs |
| `api` | Deklarations-, Badge- und Maschinen-APIs ohne öffentliche Generator-/Produktseite |
| `bulk` | eigenständiger Bulk Workspace plus Bulk-/Validierungs-/Betriebs-APIs |
```env
LICENSE_TOKEN=...
LICENSE_MODE=offline
LICENSE_SERVER_URL=
LICENSE_INSTANCE_ID=
Der dedizierte Bulk-Container setzt bereits:
```text
SERVICE_MODE=bulk
REQUIRE_LICENSE=true
BULK_REQUIRE_API_KEY=true
API_ALLOWED_ORIGIN=
```
Der Produktname ist fest verdrahtet:
## Professioneller Bulk-Betrieb
Der Bulk-Container enthält wieder die vollständige browserbasierte **Bulk Workspace**-Oberfläche. Sie ist unter `/` und `/bulk` erreichbar und ergänzt die API, statt sie zu ersetzen. API-Keys werden im Browser nur im `sessionStorage` des aktuellen Tabs gehalten.
Build:
```bash
docker build -f Dockerfile.bulk -t ai-disclosure-standard-bulk:2.0.2 .
```
Die Oberfläche ist nach dem Start erreichbar unter:
```text
http://localhost:8080/
http://localhost:8080/bulk
```
Der programmatische Bulk-Endpunkt lautet:
```text
POST /v1/bulk/declarations
```
Er benötigt in der offiziellen Bulk-Distribution:
1. eine gültige Lizenz mit Capability `bulk_api`;
2. optional ein Lizenzlimit `bulk_items`;
3. einen serverseitigen API-Schlüssel.
Authentifizierung:
```http
Authorization: Bearer <bulk-api-key>
```
oder:
```http
X-API-Key: <bulk-api-key>
```
Beispiel:
```json
{
"items": [
{
"id": "article-42",
"parameters": {
"mode": "article",
"lang": "de",
"textExtent": "partial",
"textReview": "expert",
"publicInterestText": "true",
"substantialHumanReview": "true",
"editorialResponsibilityConfirmed": "true"
}
}
]
}
```
Grenzen:
```env
BULK_MAX_ITEMS=500
BULK_MAX_BODY_BYTES=4194304
BULK_REQUIRE_API_KEY=true
BULK_API_KEY_FILE=/run/secrets/bulk/key
```
Ist `LICENSE_TOKEN_FILE` bzw. `BULK_API_KEY_FILE` gesetzt, ist die angegebene Datei **autoritativ** und hat Vorrang vor einem eventuell gleichzeitig vorhandenen `LICENSE_TOKEN` bzw. `BULK_API_KEY` aus `.env`. Dadurch können veraltete Environment-Werte Docker-/Kubernetes-Secrets nicht unbemerkt überschreiben.
Siehe [`docs/BULK-API.md`](docs/BULK-API.md) und [`docs/BULK-WORKSPACE.md`](docs/BULK-WORKSPACE.md).
## Supply-Chain-Sicherheit
Die CI-Pipeline erzeugt SBOM/Provenance und signiert veröffentlichte Full- und Bulk-Container keyless mit Sigstore/Cosign über GitHub OIDC. Für produktive Deployments sollte nach Digest ausgerollt und die Signatur verifiziert werden. Details: [`docs/SUPPLY-CHAIN.md`](docs/SUPPLY-CHAIN.md).
## Export-Bundle
Mit der Capability `export_bundle` steht zur Verfügung:
```text
GET /v1/declaration.bundle.json
```
Das Bundle enthält:
- Deklaration;
- Article-50-Assessment;
- kanonische URLs;
- Erzeugungszeitpunkt des Bundles;
- einen SHA-256-Digest der serialisierten Deklaration.
Der Digest ist ein Integritätsmerkmal, **keine digitale Signatur**.
## Lizenzprüfung
Dieses Repository stellt keine Lizenzen aus und enthält keine privaten Lizenzschlüssel, Keygen-Funktionen, Lizenzverwaltung oder Admin-Oberfläche. Diese Aufgaben liegen ausschließlich in der eigenständigen Universal License Platform.
Produkt-ID:
```text
ai-disclosure-standard
```
Für die vorhandenen Funktionen verwendet die Plattform diese Feature-IDs:
Der Produktserver enthält nur den Runtime-Client. Typische Konfiguration:
```text
custom_text
custom_badge
white_label
```env
LICENSE_TOKEN=...
LICENSE_MODE=hybrid
LICENSE_SERVER_URL=https://licenses.example.org
LICENSE_INSTANCE_ID=publisher-01
LICENSE_CACHE_FILE=/data/license-lease.json
```
### Vertrauensschlüssel einbetten
Alternativ können Secrets über Dateien eingebunden werden:
Die öffentlichen Issuer- und Lease-Schlüssel werden von der Universal License Platform bereitgestellt. Lade dort den Trust Store herunter und ersetze vor dem Build:
```env
LICENSE_TOKEN_FILE=/run/secrets/license/token
```
### Vertrauensschlüssel
Die öffentlichen Issuer- und Lease-Schlüssel werden vor dem Build in
```text
internal/app/trusted_keys.json
```
PowerShell-Beispiel:
eingebettet. Es gibt absichtlich keinen zur Laufzeit austauschbaren `LICENSE_PUBLIC_KEY`.
Beispiel:
```powershell
Invoke-WebRequest `
@@ -92,185 +253,147 @@ Invoke-WebRequest `
go build .\cmd\server
```
Der Trust Store wird mit `go:embed` fest in das Binary eingebaut. Es gibt absichtlich kein `LICENSE_PUBLIC_KEY` und keinen zur Laufzeit austauschbaren Trust Store.
Die Laufzeitprüfung unterstützt Offline-, Hybrid- und Online-Modus. Hybrid nutzt einen signierten Lease-Cache und eignet sich für hochverfügbare Installationen, die kurze Ausfälle des Lizenzdienstes tolerieren sollen.
### Prüfmodi
Siehe [`docs/LICENSE-INTEGRATION.md`](docs/LICENSE-INTEGRATION.md).
**Offline** prüft den signierten Lizenz-Token ausschließlich lokal.
## Produktionsdeployment mit Docker Compose
```env
LICENSE_MODE=offline
LICENSE_TOKEN=...
Für den offenen Einzelserver:
```bash
docker compose up -d --build
```
**Hybrid** fragt die Universal License Platform ab und speichert ein kurzlebiges, signiertes Lease. Bei temporärer Nichterreichbarkeit kann das letzte gültige Lease innerhalb der in der Lizenz festgelegten Grace-Periode verwendet werden.
Für ein Beispiel mit Runtime-Secrets und separatem Bulk-Dienst:
```env
LICENSE_MODE=hybrid
LICENSE_TOKEN=...
LICENSE_CACHE_FILE=/data/license-lease.json
```bash
mkdir -p secrets
printf '%s' '<LICENSE_TOKEN>' > secrets/license_token.txt
printf '%s' '<LONG_RANDOM_API_KEY>' > secrets/bulk_api_key.txt
BASE_URL=https://ai.example.org \
LICENSE_SERVER_URL=https://licenses.example.org \
docker compose -f compose.commercial.yaml up -d --build
```
**Online** benötigt eine erfolgreiche aktuelle Prüfung durch die Plattform.
Der Bulk-Dienst nutzt `OUTPUT_BASE_URL`, damit seine Resultate auf die öffentliche Full-/API-Instanz verweisen können. Die Bulk-Instanz liefert selbst den Bulk Workspace, aber bewusst keine öffentlichen Deklarationsseiten aus. Der Bulk-Port ist in diesem Beispiel nur an `127.0.0.1:8081` gebunden. In einer echten Produktionsumgebung sollte der Bulk-Service intern bleiben oder hinter einem authentifizierenden API-Gateway betrieben werden.
```env
LICENSE_MODE=online
LICENSE_TOKEN=...
## Kubernetes
Öffentliche Anwendung:
```bash
kubectl apply -f deploy/kubernetes.yaml
```
Bei Hybrid- und Online-Lizenzen übernimmt der Client bevorzugt die von der Plattform signiert in der Lizenz gespeicherte Server-URL. `LICENSE_SERVER_URL` ist nur ein expliziter Override beziehungsweise Fallback.
Optionales Lizenz-Secret:
Der Client verwendet die Plattform-API:
```text
POST /api/v1/licenses/validate
```bash
kubectl create secret generic ai-disclosure-license \
--from-literal=token='<LICENSE_TOKEN>'
```
und unterstützt für bestehende Installationen weiterhin:
Lizenzierter interner Bulk-Dienst:
```text
POST /v1/introspect
```bash
kubectl create secret generic ai-disclosure-commercial \
--from-literal=license-token='<LICENSE_TOKEN>' \
--from-literal=bulk-api-key='<LONG_RANDOM_API_KEY>'
kubectl apply -f deploy/kubernetes-bulk.yaml
```
Weitere Einzelheiten stehen in [`docs/LICENSE-INTEGRATION.md`](docs/LICENSE-INTEGRATION.md). Für bestehende 1.5-Installationen siehe [`docs/MIGRATION-1.5-TO-1.6.md`](docs/MIGRATION-1.5-TO-1.6.md).
Das Bulk-Manifest enthält absichtlich **keinen Ingress**.
Siehe [`docs/COMMERCIAL-DEPLOYMENT.md`](docs/COMMERCIAL-DEPLOYMENT.md).
## Konfiguration
### Betrieb und Sicherheit
Die Datei [`.env.example`](.env.example) ist ab Version 2.0.2 die **vollständige Referenz** aller vom offiziellen Server unterstützten Runtime-Variablen. Ein Regressionstest stellt sicher, dass die dokumentierten Kernvariablen nicht versehentlich aus der Beispieldatei verschwinden.
| Variable | Standard | Bedeutung |
|---|---|---|
| `LISTEN_ADDRESS` | `:8080` | HTTP-Adresse |
| `BASE_URL` | `http://localhost:8080` | öffentliche Origin ohne Pfad, Query oder Fragment |
| `PUBLIC_NAME` | `AI Usage Disclosure` | sichtbarer Produktname |
| `CONTACT_URL` | Projektseite | Kontakt-/Informationsseite |
| `DEFAULT_LANGUAGE` | `de` | Standardsprache |
| `TRUST_PROXY` | `false` | Proxy-Header nur berücksichtigen, wenn zusätzlich vertrauenswürdige Netze gesetzt sind |
| `TRUSTED_PROXY_CIDRS` | leer | kommagetrennte CIDRs der tatsächlich kontrollierten Reverse Proxies |
| `LOG_CLIENT_IP` | `false` | Client-IP in Anwendungslogs aufnehmen; aus Datenschutzgründen standardmäßig deaktiviert |
| `ENABLE_HSTS` | `true` | HSTS bei einer `https://`-Basis-URL senden |
| `METRICS_ENABLED` | `false` | `/metrics` aktivieren |
| `METRICS_TOKEN` | leer | bei aktiviertem `/metrics` verpflichtender Bearer-Token; auch als `METRICS_TOKEN_FILE` |
Neu bzw. explizit dokumentiert sind insbesondere:
### Betreiber- und Datenschutzangaben
- vertrauenswürdige Proxy-Netze über `TRUSTED_PROXY_CIDRS`;
- datensparsame Request-Logs über `LOG_CLIENT_IP=false`;
- optionales HSTS über `ENABLE_HSTS`;
- standardmäßig deaktivierte und per Bearer-Token geschützte Prometheus-Metriken;
- `_FILE`-Varianten für Lizenz-, Bulk- und Metrics-Secrets;
- vollständige Betreiber-, Datenschutz- und Barrierefreiheitsdaten für `/legal`, `/privacy` und `/accessibility`;
- `LEGAL_STRICT` als Fail-Fast-Prüfung gegen leere Produktions-Platzhalter.
Die Seiten `/impressum`, `/datenschutz` und `/barrierefreiheit` werden aus Umgebungsvariablen erzeugt. Mindestens `LEGAL_NAME`, `LEGAL_ADDRESS`, `LEGAL_EMAIL`, `HOSTING_PROVIDER`, `LOG_RETENTION` und `CONSUMER_DISPUTE_STATUS` müssen vor öffentlichem Betrieb geprüft werden. Mit `LEGAL_STRICT=true` startet der Server nicht, solange Pflichtwerte fehlen oder Platzhalter wie `REPLACE_ME` enthalten.
`SALES_URL` und die früheren `PRICE_*`-Variablen sind bewusst **nicht** mehr unterstützt: Preisübersicht und Pro-Werbung wurden aus der öffentlichen Anwendung entfernt. Tote Konfigurationsparameter werden nicht weitergeführt.
Weitere Variablen stehen vollständig in [`.env.example`](.env.example). Dazu gehören Vertretungsberechtigte, Register- und Umsatzsteuerangaben, redaktionell Verantwortliche, Datenschutzkontakt, Empfänger, Drittlandübermittlungen, Aufsichtsbehörde, Verbraucherstreitbeilegung und Barrierefreiheitskontakt.
Eine kommentierte Beschreibung aller Variablen steht zusätzlich in [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md).
### Lizenzprüfung
| Variable | Standard | Bedeutung |
|---|---|---|
| `LICENSE_TOKEN` | leer | von der Universal License Platform ausgestellter Token |
| `LICENSE_MODE` | `offline` | Mindestmodus `offline`, `hybrid` oder `online` |
| `LICENSE_SERVER_URL` | leer | optionaler Prüfserver-Override |
| `LICENSE_INSTANCE_ID` | leer | optionale Instanzbindung |
| `LICENSE_CACHE_FILE` | `./data/license-lease.json` | signierter Hybrid-Lease-Cache |
| `LICENSE_REFRESH_INTERVAL` | `15m` | Hintergrundaktualisierung |
| `LICENSE_REQUEST_TIMEOUT` | `5s` | Timeout der Onlineprüfung |
Die mitgelieferten Rechtstexte sind eine technisch abgestimmte Vorlage, keine individuelle Rechtsberatung. Die konkrete Einordnung hängt unter anderem von Betreiber, Hosting, Vertragsmodell, Zielgruppe, Zusatzdiensten und redaktionellen Inhalten ab. Siehe [`docs/LEGAL-AND-SECURITY.md`](docs/LEGAL-AND-SECURITY.md) und den [`Reviewbericht vom 20. Juli 2026`](docs/REVIEW-2026-07-20.md).
Für die Abgrenzung zum EU AI Act und insbesondere zu Artikel 4 und Artikel 50 siehe außerdem [`docs/EU-AI-ACT-COMPLIANCE.md`](docs/EU-AI-ACT-COMPLIANCE.md). Das JSON-LD dieses Projekts ist ergänzende Dokumentation und kein automatischer Ersatz für eine Provider-Markierung nach Artikel 50 Absatz 2.
## API
## Wichtige API-Endpunkte
```text
GET /badge/{preset}.svg
GET /v1/badge.svg
GET /background
GET /impressum
GET /datenschutz
GET /barrierefreiheit
GET /declaration
GET /v1/declaration.json
GET /v1/article50-assessment.json
GET /v1/declaration.bundle.json # licensed: export_bundle
POST /v1/bulk/declarations # licensed: bulk_api
POST /v1/validate
GET /v1/capabilities
GET /healthz
GET /readyz
GET /metrics optional, standardmäßig deaktiviert
GET /metrics # optional, Bearer-geschützt
GET /legal # UI modes
GET /privacy # UI modes
GET /accessibility # UI modes
```
Beispiel für eine Artikelerklärung:
## Sicherheit
```text
/declaration?mode=article&textExtent=none&textReview=none&imageExtent=full&imageReview=editorial&researchExtent=assisted&researchReview=expert&assurance=technicallyRecorded&lang=de
```
- minimales Scratch-Runtime-Image;
- non-root UID/GID `65532`;
- read-only root filesystem in den mitgelieferten Deployments;
- alle Linux-Capabilities entfernt;
- `no-new-privileges`;
- Kubernetes `RuntimeDefault` seccomp;
- keine ServiceAccount-Tokens in Kubernetes-Pods;
- Lizenz- und Bulk-Secrets können als Dateien gemountet werden;
- CSP, `X-Content-Type-Options`, `X-Frame-Options`, Referrer- und Permissions-Policy;
- konfigurierbares CORS;
- Proxy-Header werden nur von explizit vertrauenswürdigen CIDR-Netzen akzeptiert;
- Client-IP-Logging ist standardmäßig deaktiviert;
- optionales HSTS für HTTPS-only Deployments;
- Prometheus ist standardmäßig deaktiviert und bei Aktivierung Bearer-geschützt;
- Request-IDs und strukturierte JSON-Logs;
- Body- und Itemlimits für die Bulk-API;
- optionale API-Key-Authentifizierung des Bulk-Endpunkts;
- Lizenzstatus wird bei `REQUIRE_LICENSE=true` nicht nur in Readiness, sondern auch im Request-Pfad durchgesetzt.
### SVG-Darstellungen
## Open Source und kommerzieller Betrieb
Der Query-Parameter `theme` unterstützt drei Darstellungen:
Der aktuelle Quellstand trägt die mitgelieferte MIT-Lizenz. Damit eignet er sich als offene Referenzimplementierung und ist frei integrierbar. Die Runtime-Capabilities sind eine technische Produktgrenze der offiziellen Distribution und der Universal License Platform.
| Wert | Ausgabe |
|---|---|
| `color` | klassisches zweifarbiges Text-Badge |
| `mono` | monochromes Text-Badge |
| `emoji` | quadratisches, rein grafisches SVG-Symbol mit zugänglichem Titel |
Für ein Geschäftsmodell, das **zusätzlich** urheberrechtlich zwischen Community- und kommerzieller Distribution unterscheiden soll, sollte vor Veröffentlichung bewusst eine Dual-Licensing- bzw. Open-Core-Strategie festgelegt werden. Die vorhandene MIT-Lizenz selbst räumt weitgehende Nutzungs-, Änderungs- und Weitergaberechte ein. Siehe [`docs/LICENSING-STRATEGY.md`](docs/LICENSING-STRATEGY.md).
Die Emoji-Variante verwendet je nach Preset ein Mensch-, Recherche-, Zusammenfassungs- oder Blitzsymbol. Beispiel:
```text
/badge/research.svg?theme=emoji&lang=de&link=auto
```
Bei `theme=emoji` werden `style=flat` und `style=flat-square` ignoriert, da die Ausgabe immer quadratisch ist. Mit der Pro-Funktion `custom_badge` steuert `leftColor` die Symbolfarbe und `rightColor` die Hintergrundfarbe.
### Nachweisgrundlage
Der Generator bietet vier interoperable Werte. Sie werden über den Query-Parameter `assurance` an HTML- und JSON-LD-Ausgaben übertragen:
| Wert | Bedeutung |
|---|---|
| `selfDeclared` | Die veröffentlichende Person oder Organisation stellt die Angaben selbst bereit. |
| `technicallyRecorded` | Die Angaben wurden im Erstellungs- oder Veröffentlichungsprozess technisch protokolliert. |
| `signed` | Die Erklärung wurde digital signiert; Herkunft und Unverändertheit können geprüft werden. |
| `verified` | Die Angaben wurden nach einem dokumentierten Verfahren zusätzlich verifiziert. |
Eine digitale Signatur bestätigt die Herkunft und Integrität der Erklärung, nicht automatisch die inhaltliche Richtigkeit ihrer Angaben. Nicht zutreffende Nachweisstufen sollten nicht ausgewählt werden.
## Entwicklung und Prüfung
```powershell
go test .\...
go vet .\...
```
Der eingebundene, reine Laufzeit-Client wird separat geprüft:
```powershell
Set-Location .\third_party\license-platform-client
go test .\...
```
Gesamtprüfung über Make:
## Entwicklung und Qualitätssicherung
```bash
make check
```
## Projektgrenze
entspricht im Wesentlichen:
Im Hauptprojekt verbleiben ausschließlich:
```bash
gofmt
go vet ./...
go test -race ./...
cd third_party/license-platform-client
go vet ./...
go test -race ./...
```
- ein eingebetteter öffentlicher Trust Store;
- ein verifikationsfähiger Client;
- Feature- und Limit-Abfragen;
- optionaler signierter Lease-Cache.
Container:
Nicht enthalten sind:
```bash
make docker-build-all
```
- private Schlüssel;
- Keygen oder Lizenzsignierung;
- Lizenzportal oder Admin-API;
- Lizenzdatenbank;
- Widerrufsverwaltung oder Lease-Signierung.
Diese Funktionen werden nur in der eigenständigen Universal License Platform betrieben.
### Art.-50-Kontext (Schema 1.2)
Optional kann `legalContext` den rechtlich relevanten Veröffentlichungskontext als Selbsteinordnung dokumentieren: `deepfake`, `publicInterestText`, `artisticCreativeSatiricalFictional` oder `otherVoluntary`. Der Generator nutzt diese Angaben für eine vorsichtige, nicht bindende Einschätzung und für eine eindeutigere Erstkennzeichnung. Ein Deepfake ist dabei nicht jedes KI-generierte Bild. Die Einordnung muss durch den Publisher anhand des konkreten Inhalts und Veröffentlichungskontexts erfolgen.
Bei `publicInterestText` berücksichtigt die Einschätzung, ob eine substanzielle inhaltliche Prüfung (`editorial` oder `expert`) und eine ausdrücklich benannte `editorialResponsibility` dokumentiert sind. Bei einer als wahrscheinlich kennzeichnungspflichtig eingeordneten Nutzung erzeugt der Badge eine unmittelbar verständliche Beschriftung statt nur „Artikeltransparenz“. Im `emoji`-Modus ergänzt der generierte Einbettungscode bei solchen Fällen zusätzlich sichtbaren Text.
Die CI baut und veröffentlicht bei Pushes auf `main` sowohl das normale als auch das dedizierte Bulk-Image für `linux/amd64` und `linux/arm64` und fordert SBOM/Provenance-Ausgabe von BuildKit an.

View File

@@ -1,30 +1,33 @@
# Security policy
# Security
Version 1.6.x is the supported line in this project archive.
## Supported version
Security fixes are intended for the current 2.x line.
## Runtime hardening
The supplied container/deployment examples run non-root with a read-only root filesystem, dropped Linux capabilities and no privilege escalation. Kubernetes examples use `RuntimeDefault` seccomp and disable automatic service-account token mounting.
Runtime secrets can be read from files through `LICENSE_TOKEN_FILE` and `BULK_API_KEY_FILE`. Do not commit real licence tokens or API keys.
The dedicated bulk image is fail-closed by default: it requires a valid runtime licence, the `bulk_api` capability and an API key configuration before becoming ready.
## Reporting
Report suspected vulnerabilities privately to the project operator before public disclosure. Configure the operator's security contact outside this source archive and publish it through the project website or a `security.txt` file at the deployment edge.
Do not open public issues containing active licence tokens, API keys, private infrastructure details or exploitable security findings. Use the project contact channel configured by the maintainer.
## Secure defaults
## Trust store
- HTML responses use a restrictive Content Security Policy with a fresh nonce.
- Request IDs are length- and character-validated before they enter logs.
- Query strings and client IP addresses are not written to application logs by default.
- Forwarded client addresses are trusted only from explicitly configured proxy CIDRs.
- `/metrics` is disabled by default and requires a Bearer token when enabled.
- JSON validation requests are size-limited, strictly decoded and concurrency-limited.
- The reference container runs without root, Linux capabilities or a writable root filesystem.
Only public issuer/lease keys from the operator's Universal License Platform belong in `internal/app/trusted_keys.json`. Never place private signing keys in this repository or image.
See [`docs/LEGAL-AND-SECURITY.md`](docs/LEGAL-AND-SECURITY.md) for deployment controls that remain the operator's responsibility, including TLS, edge rate limits, patching, secret management, network segmentation and log deletion.
## Proxy trust and client addresses
## License integration
`TRUST_PROXY=true` requires `TRUSTED_PROXY_CIDRS`. Forwarded address headers are used only when the directly connected peer belongs to one of those networks. Client IPs are omitted from request logs unless `LOG_CLIENT_IP=true` is explicitly enabled.
- Customer installations configure only `LICENSE_TOKEN` and optional client settings.
- `LICENSE_PUBLIC_KEY`, private keys and signing keys are not supported.
- Public issuer and lease keys are embedded from `internal/app/trusted_keys.json` at build time.
- Key generation, license issuance, token registries, revocation and lease signing exist only in the standalone Universal License Platform.
- Use HTTPS for hybrid and online verification.
- Protect the hybrid cache directory from other local users; it contains signed lease tokens, not private keys.
## Prometheus
Online mode fails closed if the platform is unavailable. Hybrid mode may continue only while a previously signed lease remains valid within the grace period encoded in the license.
`/metrics` is disabled by default. `METRICS_ENABLED=true` requires a bearer secret of at least 32 characters via `METRICS_TOKEN` or preferably `METRICS_TOKEN_FILE`. Do not expose the endpoint publicly even when authentication is enabled.
## Operator information
`LEGAL_STRICT=true` is a deployment guard against empty or placeholder operator/privacy configuration. It does not certify legal compliance and should be combined with a review of the actual deployment and applicable jurisdiction.

97
compose.commercial.yaml Normal file
View File

@@ -0,0 +1,97 @@
# Production-oriented example. Create the files described in secrets/README.md first.
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
VERSION: "2.0.2"
image: ai-disclosure-standard:2.0.2
environment:
BASE_URL: "${BASE_URL:?set BASE_URL}"
OUTPUT_BASE_URL: "${BASE_URL:?set BASE_URL}"
PUBLIC_NAME: "${PUBLIC_NAME:-AI Usage Disclosure}"
CONTACT_URL: "${CONTACT_URL:-https://b1tsblog.org/page/ai}"
DEFAULT_LANGUAGE: "${DEFAULT_LANGUAGE:-de}"
TRUST_PROXY: "${TRUST_PROXY:-true}"
SERVICE_MODE: full
API_ALLOWED_ORIGIN: "${API_ALLOWED_ORIGIN:-*}"
LICENSE_TOKEN_FILE: /run/secrets/license_token
LICENSE_MODE: "${LICENSE_MODE:-hybrid}"
LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-}"
LICENSE_INSTANCE_ID: "${LICENSE_INSTANCE_ID:-commercial-app}"
LICENSE_CACHE_FILE: /data/license-lease.json
REQUIRE_LICENSE: "${REQUIRE_LICENSE:-false}"
WHITE_LABEL: "${WHITE_LABEL:-false}"
secrets:
- license_token
volumes:
- app-license-cache:/data
ports:
- "8080:8080"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
security_opt: ["no-new-privileges:true"]
cap_drop: ["ALL"]
restart: unless-stopped
healthcheck:
test: ["CMD", "/ai-disclosure", "--healthcheck"]
interval: 15s
timeout: 3s
retries: 3
start_period: 5s
bulk:
build:
context: .
dockerfile: Dockerfile.bulk
args:
VERSION: "2.0.2"
image: ai-disclosure-standard-bulk:2.0.2
environment:
BASE_URL: "${BULK_BASE_URL:-http://bulk:8080}"
OUTPUT_BASE_URL: "${BASE_URL:?set BASE_URL}"
DEFAULT_LANGUAGE: "${DEFAULT_LANGUAGE:-de}"
SERVICE_MODE: bulk
API_ALLOWED_ORIGIN: "${BULK_ALLOWED_ORIGIN:-}"
LICENSE_TOKEN_FILE: /run/secrets/license_token
LICENSE_MODE: "${LICENSE_MODE:-hybrid}"
LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-}"
LICENSE_INSTANCE_ID: "${BULK_INSTANCE_ID:-commercial-bulk}"
LICENSE_CACHE_FILE: /data/license-lease.json
REQUIRE_LICENSE: "true"
BULK_REQUIRE_API_KEY: "true"
BULK_API_KEY_FILE: /run/secrets/bulk_api_key
BULK_MAX_ITEMS: "${BULK_MAX_ITEMS:-500}"
BULK_MAX_BODY_BYTES: "${BULK_MAX_BODY_BYTES:-4194304}"
secrets:
- license_token
- bulk_api_key
volumes:
- bulk-license-cache:/data
# Keep this private in production; the loopback mapping is for local operators.
ports:
- "127.0.0.1:8081:8080"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
security_opt: ["no-new-privileges:true"]
cap_drop: ["ALL"]
restart: unless-stopped
healthcheck:
test: ["CMD", "/ai-disclosure", "--healthcheck"]
interval: 15s
timeout: 3s
retries: 3
start_period: 5s
secrets:
license_token:
file: ./secrets/license_token.txt
bulk_api_key:
file: ./secrets/bulk_api_key.txt
volumes:
app-license-cache:
bulk-license-cache:

View File

@@ -1,8 +1,32 @@
services:
app:
image: git.send.nrw/sendnrw/ai-disclosure-standard:latest
env_file:
- .env
build:
context: .
dockerfile: Dockerfile
args:
VERSION: "2.0.2"
image: ai-disclosure-standard:2.0.2-local
environment:
BASE_URL: "${BASE_URL:-http://localhost:8080}"
PUBLIC_NAME: "${PUBLIC_NAME:-AI Usage Disclosure}"
CONTACT_URL: "${CONTACT_URL:-https://b1tsblog.org/page/ai}"
DEFAULT_LANGUAGE: "${DEFAULT_LANGUAGE:-de}"
TRUST_PROXY: "${TRUST_PROXY:-false}"
SERVICE_MODE: "${SERVICE_MODE:-full}"
API_ALLOWED_ORIGIN: "${API_ALLOWED_ORIGIN:-*}"
LICENSE_TOKEN: "${LICENSE_TOKEN:-}"
LICENSE_MODE: "${LICENSE_MODE:-offline}"
LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-}"
LICENSE_INSTANCE_ID: "${LICENSE_INSTANCE_ID:-}"
LICENSE_CACHE_FILE: /data/license-lease.json
LICENSE_REFRESH_INTERVAL: "${LICENSE_REFRESH_INTERVAL:-15m}"
LICENSE_REQUEST_TIMEOUT: "${LICENSE_REQUEST_TIMEOUT:-5s}"
REQUIRE_LICENSE: "${REQUIRE_LICENSE:-false}"
WHITE_LABEL: "${WHITE_LABEL:-false}"
BULK_MAX_ITEMS: "${BULK_MAX_ITEMS:-500}"
BULK_MAX_BODY_BYTES: "${BULK_MAX_BODY_BYTES:-4194304}"
BULK_REQUIRE_API_KEY: "${BULK_REQUIRE_API_KEY:-false}"
BULK_API_KEY: "${BULK_API_KEY:-}"
ports:
- "8080:8080"
volumes:

178
deploy/kubernetes-bulk.yaml Normal file
View File

@@ -0,0 +1,178 @@
# Dedicated licensed bulk service. Keep the Service internal unless an authenticated
# API gateway is placed in front of it.
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-disclosure-bulk
labels:
app.kubernetes.io/name: ai-disclosure-bulk
app.kubernetes.io/version: "2.0.2"
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: ai-disclosure-bulk
template:
metadata:
labels:
app.kubernetes.io/name: ai-disclosure-bulk
app.kubernetes.io/version: "2.0.2"
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: /metrics
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: bulk
image: ghcr.io/REPLACE_ME/ai-disclosure-standard-bulk:2.0.2
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
env:
- name: BASE_URL
value: http://ai-disclosure-bulk
- name: OUTPUT_BASE_URL
value: https://ai.example.org
- name: DEFAULT_LANGUAGE
value: de
- name: SERVICE_MODE
value: bulk
- name: REQUIRE_LICENSE
value: "true"
- name: API_ALLOWED_ORIGIN
value: ""
- name: LICENSE_TOKEN_FILE
value: /run/secrets/license/token
- name: LICENSE_MODE
value: hybrid
- name: LICENSE_SERVER_URL
value: https://licenses.example.org
- name: LICENSE_INSTANCE_ID
valueFrom:
fieldRef:
fieldPath: metadata.uid
- name: LICENSE_CACHE_FILE
value: /data/license-lease.json
- name: BULK_REQUIRE_API_KEY
value: "true"
- name: BULK_API_KEY_FILE
value: /run/secrets/bulk/key
- name: BULK_MAX_ITEMS
value: "500"
- name: BULK_MAX_BODY_BYTES
value: "4194304"
volumeMounts:
- name: license-cache
mountPath: /data
- name: license-token
mountPath: /run/secrets/license
readOnly: true
- name: bulk-api-key
mountPath: /run/secrets/bulk
readOnly: true
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: "1"
memory: 192Mi
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: license-cache
emptyDir: {}
- name: license-token
secret:
secretName: ai-disclosure-commercial
items:
- key: license-token
path: token
- name: bulk-api-key
secret:
secretName: ai-disclosure-commercial
items:
- key: bulk-api-key
path: key
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: ai-disclosure-bulk
---
apiVersion: v1
kind: Service
metadata:
name: ai-disclosure-bulk
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: ai-disclosure-bulk
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: ai-disclosure-bulk
spec:
minAvailable: 2
selector:
matchLabels:
app.kubernetes.io/name: ai-disclosure-bulk
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: ai-disclosure-bulk
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: ai-disclosure-bulk
minReplicas: 3
maxReplicas: 20
behavior:
scaleDown:
stabilizationWindowSeconds: 300
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 65

View File

@@ -4,6 +4,7 @@ metadata:
name: ai-disclosure
labels:
app.kubernetes.io/name: ai-disclosure
app.kubernetes.io/version: "2.0.2"
spec:
replicas: 3
strategy:
@@ -18,14 +19,20 @@ spec:
metadata:
labels:
app.kubernetes.io/name: ai-disclosure
app.kubernetes.io/version: "2.0.2"
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: /metrics
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.6.3
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:2.0.2
imagePullPolicy: IfNotPresent
ports:
- name: http
@@ -37,42 +44,18 @@ spec:
value: AI Usage Disclosure
- name: CONTACT_URL
value: https://b1tsblog.org/page/ai
- name: SALES_URL
value: https://b1tsblog.org/page/ai
- name: DEFAULT_LANGUAGE
value: de
- name: TRUST_PROXY
value: "false"
- name: LOG_CLIENT_IP
value: "false"
- name: ENABLE_HSTS
value: "true"
- name: METRICS_ENABLED
value: "false"
# Replace the following values before deployment. LEGAL_STRICT
# deliberately prevents accidental public startup with placeholders.
- name: LEGAL_STRICT
value: "true"
- name: LEGAL_NAME
value: REPLACE_ME
- name: LEGAL_ADDRESS
value: REPLACE_ME
- name: LEGAL_EMAIL
value: REPLACE_ME
- name: HOSTING_PROVIDER
value: REPLACE_ME
- name: LOG_RETENTION
value: REPLACE_ME
- name: CONSUMER_DISPUTE_STATUS
value: REPLACE_ME
- name: LICENSE_TOKEN
valueFrom:
secretKeyRef:
name: ai-disclosure-license
key: token
optional: true
- name: SERVICE_MODE
value: full
- name: API_ALLOWED_ORIGIN
value: "*"
- name: LICENSE_TOKEN_FILE
value: /run/secrets/license/token
- name: LICENSE_MODE
value: offline
value: hybrid
- name: LICENSE_SERVER_URL
value: ""
- name: LICENSE_INSTANCE_ID
@@ -81,13 +64,18 @@ spec:
fieldPath: metadata.uid
- name: LICENSE_CACHE_FILE
value: /data/license-lease.json
- name: WHITE_LABEL
value: "false"
volumeMounts:
- name: license-cache
mountPath: /data
- name: license-token
mountPath: /run/secrets/license
readOnly: true
resources:
requests:
cpu: 25m
memory: 24Mi
memory: 32Mi
limits:
cpu: 500m
memory: 128Mi
@@ -115,6 +103,13 @@ spec:
volumes:
- name: license-cache
emptyDir: {}
- name: license-token
secret:
secretName: ai-disclosure-license
optional: true
items:
- key: token
path: token
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname

View File

@@ -1,81 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: universal-license-server
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: universal-license-server
template:
metadata:
labels:
app.kubernetes.io/name: universal-license-server
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: server
image: ghcr.io/REPLACE_ME/universal-license-server:1.5.0
ports:
- name: http
containerPort: 8091
env:
- name: LICENSE_SERVER_ADDRESS
value: :8091
- name: LICENSE_SERVER_DATA
value: /data/licenses.json
- name: LICENSE_TRUST_STORE_FILE
value: /config/trusted-keys.json
- name: LEASE_SIGNING_KEY_ID
value: lease-2026
- name: LEASE_SIGNING_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: universal-license-secrets
key: lease-private-key
- name: LICENSE_SERVER_ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: universal-license-secrets
key: admin-token
volumeMounts:
- name: data
mountPath: /data
- name: trust-store
mountPath: /config
readOnly: true
readinessProbe:
httpGet:
path: /healthz
port: http
livenessProbe:
httpGet:
path: /healthz
port: http
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: data
persistentVolumeClaim:
claimName: universal-license-data
- name: trust-store
configMap:
name: universal-license-trust-store
---
apiVersion: v1
kind: Service
metadata:
name: universal-license-server
spec:
selector:
app.kubernetes.io/name: universal-license-server
ports:
- name: http
port: 80
targetPort: http

View File

@@ -0,0 +1,61 @@
version: "3.9"
services:
bulk:
image: ghcr.io/REPLACE_ME/ai-disclosure-standard-bulk:2.0.2
environment:
BASE_URL: http://bulk:8080
OUTPUT_BASE_URL: https://ai.example.org
DEFAULT_LANGUAGE: de
SERVICE_MODE: bulk
API_ALLOWED_ORIGIN: ""
LICENSE_TOKEN_FILE: /run/secrets/license_token
LICENSE_MODE: "${LICENSE_MODE:-hybrid}"
LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-https://licenses.example.org}"
LICENSE_INSTANCE_ID: "${LICENSE_INSTANCE_ID:-swarm-bulk}"
LICENSE_CACHE_FILE: /data/license-lease.json
REQUIRE_LICENSE: "true"
BULK_REQUIRE_API_KEY: "true"
BULK_API_KEY_FILE: /run/secrets/bulk_api_key
BULK_MAX_ITEMS: "${BULK_MAX_ITEMS:-500}"
BULK_MAX_BODY_BYTES: "${BULK_MAX_BODY_BYTES:-4194304}"
secrets:
- license_token
- bulk_api_key
networks: [internal]
volumes:
- bulk-license-cache:/data
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
cap_drop: [ALL]
healthcheck:
test: ["CMD", "/ai-disclosure", "--healthcheck"]
interval: 15s
timeout: 3s
retries: 3
start_period: 5s
deploy:
replicas: 3
update_config:
order: start-first
parallelism: 1
restart_policy:
condition: on-failure
resources:
reservations:
cpus: "0.05"
memory: 32M
limits:
cpus: "1.0"
memory: 192M
secrets:
license_token:
external: true
bulk_api_key:
external: true
networks:
internal:
driver: overlay
attachable: false
volumes:
bulk-license-cache:

View File

@@ -1,43 +1,33 @@
version: "3.9"
services:
app:
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.6.3
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:2.0.2
environment:
BASE_URL: https://ai.example.org
PUBLIC_NAME: AI Usage Disclosure
CONTACT_URL: https://b1tsblog.org/page/ai
SALES_URL: "${SALES_URL:-https://b1tsblog.org/page/ai}"
DEFAULT_LANGUAGE: de
TRUST_PROXY: "false"
LOG_CLIENT_IP: "false"
ENABLE_HSTS: "true"
METRICS_ENABLED: "false"
LEGAL_STRICT: "${LEGAL_STRICT:-true}"
LEGAL_NAME: "${LEGAL_NAME:-REPLACE_ME}"
LEGAL_ADDRESS: "${LEGAL_ADDRESS:-REPLACE_ME}"
LEGAL_EMAIL: "${LEGAL_EMAIL:-REPLACE_ME}"
HOSTING_PROVIDER: "${HOSTING_PROVIDER:-REPLACE_ME}"
LOG_RETENTION: "${LOG_RETENTION:-REPLACE_ME}"
CONSUMER_DISPUTE_STATUS: "${CONSUMER_DISPUTE_STATUS:-REPLACE_ME}"
TRUST_PROXY: "true"
SERVICE_MODE: full
API_ALLOWED_ORIGIN: "*"
LICENSE_TOKEN: "${LICENSE_TOKEN:-}"
LICENSE_MODE: "${LICENSE_MODE:-offline}"
LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-}"
LICENSE_INSTANCE_ID: "${LICENSE_INSTANCE_ID:-swarm}"
LICENSE_CACHE_FILE: /data/license-lease.json
WHITE_LABEL: "${WHITE_LABEL:-false}"
ports:
- target: 8080
published: 8080
protocol: tcp
mode: ingress
networks:
- public
networks: [public]
volumes:
- license-cache:/data
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
cap_drop:
- ALL
cap_drop: [ALL]
healthcheck:
test: ["CMD", "/ai-disclosure", "--healthcheck"]
interval: 15s
@@ -67,7 +57,7 @@ services:
resources:
reservations:
cpus: "0.05"
memory: 24M
memory: 32M
limits:
cpus: "0.50"
memory: 128M
@@ -75,6 +65,5 @@ networks:
public:
driver: overlay
attachable: true
volumes:
license-cache:

View File

@@ -0,0 +1,52 @@
# Article 50 Decision Support
Die Anwendung bildet Artikel 50 des EU AI Act nicht als binäre „compliant / non-compliant“-Entscheidung ab. Sie erfasst stattdessen Tatsachen und erzeugt daraus eine konservative, nicht bindende technische Einordnung.
## Erfasste Tatsachen
`regulatoryContext` kann enthalten:
```json
{
"framework": "EU-AI-Act-Article-50",
"publicInterestText": true,
"deepfake": false,
"artisticCreativeSatiricalFictional": false,
"substantialHumanReview": true,
"editorialResponsibilityConfirmed": true,
"firstExposureDisclosure": true,
"accessibilityConsidered": true
}
```
Zusätzlich kann `editorialResponsibility` Name und URL der verantwortlichen Person oder Organisation dokumentieren.
## Ergebnis-Codes
- `not_assessed` — kein regulatorischer Kontext angegeben;
- `voluntary_transparency` — kein typischer Deepfake-/Public-Interest-Textfall markiert;
- `deepfake_disclosure_relevant` — Deepfake-/realitätsähnlicher Manipulationskontext angegeben;
- `public_interest_text_disclosure_relevant` — Text zu öffentlichem Interesse, Ausnahmevoraussetzungen nicht vollständig bestätigt;
- `public_interest_text_possible_exemption` — substanzielle menschliche Prüfung/redaktionelle Kontrolle **und** redaktionelle Verantwortung bestätigt;
- `multiple_article50_contexts` — mehrere regulatorische Kontexte (z. B. Deepfake-Bild und Public-Interest-Text) liegen gleichzeitig vor. Die einzelnen Befunde bleiben zusätzlich in `findings` erhalten.
Zusätzliche Warnungen betreffen insbesondere:
- nicht bestätigte Offenlegung bei erster Exposition;
- nicht bestätigte Barrierefreiheitsberücksichtigung;
- fehlende redaktionelle Verantwortung trotz angegebener substantieller Prüfung;
- angemessene Offenlegungsform bei offensichtlich künstlerischen, kreativen, satirischen oder fiktionalen Werken.
## Sichtbares Badge
Bei einem markierten Deepfake- oder relevanten Public-Interest-Textfall verwendet die Standarddarstellung ein deutlicheres amberfarbenes Badge und eine explizitere Beschriftung. Die ausführliche Seite bleibt die zweite Informationsebene.
## Rechtliche Grenze
Die Funktion ist eine technische Entscheidungshilfe. Sie ersetzt weder die Prüfung des konkreten Anwendungsfalls noch andere Pflichten des EU AI Act oder sonstiges Unions-/nationales Recht.
Offizielle Ausgangspunkte:
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
- https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems
- https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content

View File

@@ -1,6 +1,6 @@
# Mehrsprachige Hintergrundseite
Version 1.6.3 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit.
Version 2.0.2 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit.
## Routen

114
docs/BULK-API.md Normal file
View File

@@ -0,0 +1,114 @@
# Bulk API
Der Bulk-Dienst ist für Publisher, CMS-Integrationen, Agenturen und andere professionelle Workflows gedacht. Er stellt sowohl eine browserbasierte Bulk Workspace als auch die serverseitige API bereit.
## Capability
Die Runtime-Lizenz benötigt:
```text
bulk_api
```
Optional kann die Lizenzplattform ein Limit liefern:
```text
bulk_items=250
```
Der effektive Grenzwert ist der kleinere Wert aus Lizenzlimit und `BULK_MAX_ITEMS`.
## Dedizierter Container
`Dockerfile.bulk` verwendet dasselbe getestete Go-Binary, setzt aber sichere Betriebsdefaults:
```text
SERVICE_MODE=bulk
REQUIRE_LICENSE=true
BULK_REQUIRE_API_KEY=true
API_ALLOWED_ORIGIN=
```
Der dedizierte Modus enthält bewusst keine öffentliche Generator-, Produkt- oder Hintergrundseite, stellt aber die spezialisierte Bulk Workspace unter `/` und `/bulk` bereit. Die eigentliche Verarbeitung erfolgt weiterhin über denselben authentifizierten API-Endpunkt.
## Bulk Workspace
Die browserbasierte Oberfläche ist erreichbar unter:
```text
/
/bulk
```
Sie bietet einen visuellen Mehrfach-Workflow, einen erweiterten JSON-Modus, Ergebnislinks sowie JSON-/CSV-Export. Details stehen in [`BULK-WORKSPACE.md`](BULK-WORKSPACE.md).
## Authentifizierung
Bei aktivierter API-Key-Pflicht:
```http
Authorization: Bearer <key>
```
oder:
```http
X-API-Key: <key>
```
Der Schlüssel kann über `BULK_API_KEY_FILE` aus einem Container-/Kubernetes-Secret gelesen werden.
## Anfrage
```http
POST /v1/bulk/declarations
Content-Type: application/json
Authorization: Bearer ...
```
```json
{
"items": [
{
"id": "post-1001",
"parameters": {
"mode": "article",
"lang": "de",
"textExtent": "none",
"textReview": "none",
"coverImageExtent": "full",
"coverImageReview": "editorial",
"researchExtent": "assisted",
"researchReview": "expert",
"assurance": "technicallyRecorded"
}
}
]
}
```
Jedes Element wird unabhängig verarbeitet. Ein fachlich fehlerhaftes Element führt zu einem Item-Fehler, ohne die übrigen Elemente zu verwerfen.
## Betriebsgrenzen
- `BULK_MAX_ITEMS` begrenzt Items pro Request;
- `BULK_MAX_BODY_BYTES` begrenzt die Requestgröße;
- ein Lizenzlimit `bulk_items` kann die Itemzahl weiter reduzieren;
- der Dedicated Container wird ohne gültige `bulk_api`-Capability nicht ready;
- bei `REQUIRE_LICENSE=true` werden fachliche Requests ohne gültige Runtime-Lizenz mit 503 blockiert;
- Prometheus exportiert Bulk-Request-, Item- und Failure-Counter.
## Netzwerk
Der Bulk-Service sollte standardmäßig intern betrieben werden. Die Kubernetes-Vorlage liefert nur einen `ClusterIP`-Service und bewusst keinen Ingress. Bei externer Veröffentlichung empfiehlt sich zusätzlich ein API-Gateway mit TLS, Rate-Limiting und organisationsspezifischer Authentifizierung.
## Getrennte Runtime- und Ausgabe-URL
Bei einer separaten Bulk-Instanz kann `BASE_URL` die interne bzw. lizenzgebundene Adresse des Bulk-Dienstes sein, während `OUTPUT_BASE_URL` auf die öffentliche Full-/API-Instanz zeigt:
```env
BASE_URL=http://ai-disclosure-bulk
OUTPUT_BASE_URL=https://ai.example.org
```
Dadurch enthalten Bulk-Ergebnisse nutzbare öffentliche Deklarations-, Manifest- und Badge-URLs.

69
docs/BULK-WORKSPACE.md Normal file
View File

@@ -0,0 +1,69 @@
# Bulk Workspace
Der dedizierte Bulk-Modus stellt neben `POST /v1/bulk/declarations` wieder eine browserbasierte Arbeitsoberfläche bereit.
## Routen
```text
GET /
GET /bulk
POST /v1/bulk/declarations
```
Die Oberfläche ist für interne Publisher-, Agentur- und Enterprise-Workflows gedacht. Sie ersetzt die API nicht, sondern verwendet denselben Endpunkt.
## Visueller Workflow
Im Standardworkflow werden Inhalte zeilenweise angegeben:
```text
article-1001 | https://example.org/articles/1001
article-1002 | https://example.org/articles/1002
```
Alternativ genügt eine URL pro Zeile. IDs werden dann automatisch erzeugt.
Ein gemeinsames Artikelprofil kann u. a. festlegen:
- Text, Titelbild, weitere Bilder, Recherche, Übersetzung und Code;
- KI-Anteil und menschliche Prüfung pro Bereich;
- Nachweisgrundlage;
- relevante Artikel-50-Angaben;
- redaktionelle Verantwortung.
## Erweiterter JSON-Modus
Heterogene Daten können als regulärer Bulk-Request eingefügt werden. Dadurch können einzelne Artikel vollständig unterschiedliche Parameter erhalten.
## API-Key
Ist `BULK_REQUIRE_API_KEY=true`, fragt die Oberfläche den Schlüssel lokal ab. Er wird nur im `sessionStorage` des aktuellen Browser-Tabs gehalten und als `Authorization: Bearer ...` an den Bulk-Endpunkt gesendet.
Der Schlüssel wird nicht serverseitig in HTML eingebettet und nicht in Exportdateien übernommen.
## Ergebnisse
Die Oberfläche zeigt pro Datensatz:
- ID;
- Validierungsstatus;
- technische Artikel-50-Einordnung;
- Link zur Erklärung;
- Link zum JSON-LD-Manifest;
- Badge-Link;
- Validierungsfehler.
Das Gesamtergebnis kann als JSON oder CSV exportiert werden.
## Sicherheitsmodell
Der dedizierte Container behält die 2.0-Sicherheitsdefaults:
```text
SERVICE_MODE=bulk
REQUIRE_LICENSE=true
BULK_REQUIRE_API_KEY=true
API_ALLOWED_ORIGIN=
```
Die Oberfläche ist deshalb kein Ersatz für Netzwerksegmentierung. Für externe Veröffentlichung sollte weiterhin ein TLS-Reverse-Proxy bzw. API-Gateway mit zusätzlicher Authentifizierung eingesetzt werden.

View File

@@ -0,0 +1,88 @@
# Commercial Deployment
Diese Datei beschreibt die technische Härtung der offiziellen, lizenzierten Distribution. Sie ist keine Vertrags- oder Lizenzvorlage.
## Runtime-Secrets
Bevorzugt werden Datei-Secrets:
```env
LICENSE_TOKEN_FILE=/run/secrets/license/token
BULK_API_KEY_FILE=/run/secrets/bulk/key
```
statt Secrets direkt in Prozesslisten, Compose-Dateien oder Git-Repositories zu hinterlegen.
## Lizenzmodi
- `offline`: vollständig lokale Signaturprüfung;
- `hybrid`: zentrale Prüfung mit signiertem Lease-Cache und Offline-Grace-Periode;
- `online`: aktuelle zentrale Prüfung erforderlich.
Für hochverfügbare Publisher-Installationen ist `hybrid` meist der geeignetste technische Kompromiss. Für einen streng kontrollierten, zentral widerrufbaren Dienst kann `online` sinnvoll sein.
## Full-/API-Container
Ein lizenziertes Full-Deployment kann beispielsweise setzen:
```env
REQUIRE_LICENSE=true
LICENSE_MODE=hybrid
WHITE_LABEL=true
```
`WHITE_LABEL=true` wirkt nur mit der Capability `white_label`.
## Bulk
Der Bulk-Dienst sollte separat skaliert werden. Er benötigt:
```text
bulk_api
```
und sollte mit `BULK_REQUIRE_API_KEY=true` betrieben werden. Das mitgelieferte `Dockerfile.bulk` erzwingt diese Defaults bereits.
## Kubernetes
`deploy/kubernetes-bulk.yaml` enthält:
- 3 Replikate;
- Rolling Update ohne geplante Unterbrechung;
- PodDisruptionBudget;
- HPA;
- Topology Spread;
- non-root;
- read-only root filesystem;
- seccomp `RuntimeDefault`;
- keine Linux-Capabilities;
- deaktiviertes ServiceAccount-Token;
- Lizenz- und API-Key-Secrets als Dateien;
- internen ClusterIP-Service;
- keinen öffentlichen Ingress.
## CORS
`API_ALLOWED_ORIGIN=*` ist für offen konsumierbare Badge-/Manifest-APIs bequem. Für interne kommerzielle APIs kann CORS vollständig deaktiviert werden:
```env
API_ALLOWED_ORIGIN=
```
oder auf einen kontrollierten Origin beschränkt werden.
## Observability
- `X-Request-ID` wird übernommen oder erzeugt;
- Requests werden strukturiert als JSON protokolliert;
- `/metrics` liefert Prometheus-Metriken;
- `/healthz` ist reine Liveness;
- `/readyz` berücksichtigt bei Bulk die Lizenz-Capability und die notwendige API-Key-Konfiguration.
## Supply Chain
Die GitHub-Actions-Vorlage baut Full- und Bulk-Images für amd64/arm64, erzeugt BuildKit-Provenance und SBOMs und signiert den veröffentlichten Multi-Arch-Digest keyless mit Sigstore/Cosign über GitHub OIDC. Produktive Deployments sollten freigegebene Digests statt ausschließlich `latest` verwenden und die Signatur vor dem Rollout prüfen. Siehe [`SUPPLY-CHAIN.md`](SUPPLY-CHAIN.md).
## Getrennte Bulk-Topologie
In einer professionellen Topologie sollte der interne Bulk-Dienst `BASE_URL` für seine eigene Instanz-/Domainbindung verwenden und `OUTPUT_BASE_URL` auf die öffentlich erreichbare Deklarationsinstanz setzen. So bleiben erzeugte Links gültig, obwohl `SERVICE_MODE=bulk` selbst keine HTML-Deklarationen oder Badge-Endpunkte bereitstellt.

94
docs/CONFIGURATION.md Normal file
View File

@@ -0,0 +1,94 @@
# Runtime configuration
`.env.example` is the canonical and complete reference for environment-based runtime configuration in version 2.0.2.
The application deliberately does **not** support the former `SALES_URL` or `PRICE_*` variables. Pricing and Pro advertising were removed from the public UI; dead configuration keys are not kept for compatibility.
## HTTP and identity
- `LISTEN_ADDRESS`
- `BASE_URL`
- `OUTPUT_BASE_URL`
- `PUBLIC_NAME`
- `CONTACT_URL`
- `DEFAULT_LANGUAGE`
- `HEALTHCHECK_URL`
- `SERVICE_MODE`
- `API_ALLOWED_ORIGIN`
## Proxy, logging and transport security
- `TRUST_PROXY`
- `TRUSTED_PROXY_CIDRS`
- `LOG_CLIENT_IP`
- `ENABLE_HSTS`
- `REQUIRE_LICENSE`
- `WHITE_LABEL`
`TRUST_PROXY=true` is rejected unless at least one valid CIDR is configured. Forwarded client-IP headers are only considered when the direct peer address is inside one of those networks.
`LOG_CLIENT_IP=false` keeps client addresses out of structured request logs.
`ENABLE_HSTS=true` emits `Strict-Transport-Security: max-age=31536000; includeSubDomains`; enable it only on deployments that are actually HTTPS-only.
## Prometheus
- `METRICS_ENABLED`
- `METRICS_TOKEN`
- `METRICS_TOKEN_FILE`
Metrics are disabled by default. When enabled, a bearer token of at least 32 characters is mandatory. A configured `METRICS_TOKEN_FILE` has priority over `METRICS_TOKEN`.
## Operator, privacy and accessibility pages
The following settings feed `/legal`, `/privacy` and `/accessibility` in `full` and `bulk` mode:
- `LEGAL_STRICT`
- `LEGAL_NAME`
- `LEGAL_ADDRESS`
- `LEGAL_REPRESENTATIVE`
- `LEGAL_EMAIL`
- `LEGAL_PHONE`
- `LEGAL_REGISTER`
- `LEGAL_REGISTER_NUMBER`
- `LEGAL_VAT_ID`
- `EDITORIAL_RESPONSIBLE_NAME`
- `EDITORIAL_RESPONSIBLE_ADDRESS`
- `DATA_PROTECTION_CONTACT`
- `HOSTING_PROVIDER`
- `HOSTING_ADDRESS`
- `LOG_RETENTION`
- `DATA_RECIPIENTS`
- `THIRD_COUNTRY_TRANSFERS`
- `SUPERVISORY_AUTHORITY_NAME`
- `SUPERVISORY_AUTHORITY_URL`
- `CONSUMER_DISPUTE_STATUS`
- `CONSUMER_DISPUTE_BODY`
- `CONSUMER_DISPUTE_URL`
- `ACCESSIBILITY_CONTACT`
- `ACCESSIBILITY_STATUS`
`LEGAL_STRICT=true` is a deployment guard, not a legal-compliance determination. It prevents startup if the core operator/privacy data is empty or still contains common placeholder markers. The pages themselves explicitly state that they do not replace legal review.
## Runtime licensing
- `LICENSE_TOKEN`
- `LICENSE_TOKEN_FILE`
- `LICENSE_INSTANCE_ID`
- `LICENSE_MODE`
- `LICENSE_SERVER_URL`
- `LICENSE_CACHE_FILE`
- `LICENSE_REFRESH_INTERVAL`
- `LICENSE_REQUEST_TIMEOUT`
The application contains no licence issuer, private signing key or configurable issuer public key. Licence creation remains external in the Universal License Platform.
## Bulk
- `BULK_MAX_ITEMS`
- `BULK_MAX_BODY_BYTES`
- `BULK_REQUIRE_API_KEY`
- `BULK_API_KEY`
- `BULK_API_KEY_FILE`
The `bulk_items` licence limit can further reduce the effective item count. A configured `BULK_API_KEY_FILE` has priority over `BULK_API_KEY`.

View File

@@ -1,157 +0,0 @@
# EU AI Act: Compliance-Grenzen und Betreiber-Checkliste
**Rechtsstand: 21. Juli 2026**
Diese Datei beschreibt, welche Teile des EU AI Act (Verordnung (EU) 2024/1689) dieses Projekt technisch unterstützen kann und welche Pflichten außerhalb seines Funktionsumfangs liegen. Sie ersetzt keine Prüfung des konkreten Einsatzes.
## 1. Was dieses Projekt ist
Die mitgelieferte Anwendung erzeugt deterministisch SVG-Hinweise, Erklärungsseiten und JSON-LD aus den vom Nutzer eingegebenen Angaben. Sie enthält selbst kein KI-Modell und führt keine Modellinferenz aus.
Der AI Act erfasst nur Systeme, die unter die Definition eines „AI system“ in Artikel 3 Absatz 1 fallen. Ob eine konkrete, veränderte oder erweiterte Installation diese Definition erfüllt, ist anhand der tatsächlichen technischen Funktion zu prüfen.
Offizielle Quelle:
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-3
## 2. Dieses Projekt ist keine vollständige AI-Act-Compliance-Lösung
Der Generator unterstützt vor allem die **sichtbare und dokumentierte Offenlegung von KI-Nutzung**. Er kann insbesondere bei freiwilliger Transparenz und bei bestimmten Betreiberpflichten aus Artikel 50 Absatz 4 helfen.
Er erfüllt oder prüft **nicht automatisch**:
- die Anbieterpflicht zur technischen, maschinenlesbaren Markierung von generierten oder manipulierten Ausgaben nach Artikel 50 Absatz 2;
- die Transparenzpflicht bei direkter Mensch-KI-Interaktion nach Artikel 50 Absatz 1;
- Informationspflichten bei Emotionserkennung oder biometrischer Kategorisierung nach Artikel 50 Absatz 3;
- die Pflicht zur AI Literacy nach Artikel 4;
- Verbote nach Artikel 5;
- die Einstufung und Pflichten für Hochrisiko-KI nach Kapitel III;
- Pflichten für Anbieter von General-Purpose-AI-Modellen nach Kapitel V;
- sonstige Pflichten aus Datenschutz-, Urheber-, Verbraucher-, Medien- oder Produktsicherheitsrecht.
## 3. Artikel 50: sichtbare Offenlegung durch Betreiber
Artikel 50 gilt ab **2. August 2026**. Betreiber bestimmter generativer KI-Systeme müssen insbesondere Deepfakes und bestimmte KI-generierte oder manipulierte Texte zu Angelegenheiten von öffentlichem Interesse offenlegen.
Für solche sichtbaren Offenlegungen gilt nach Artikel 50 Absatz 5 und den Leitlinien der Kommission insbesondere:
1. Der Hinweis muss **klar und unterscheidbar** sein.
2. Er muss **spätestens bei der ersten Exposition** gegenüber der betroffenen natürlichen Person erscheinen.
3. Er muss die geltenden Anforderungen an **Barrierefreiheit** berücksichtigen.
4. Bei Deepfakes genügt eine nur maschinenlesbare Markierung des Anbieters nicht als sichtbare Betreiber-Offenlegung.
Daher gilt für die Einbettung dieses Projekts:
- Badge oder Klartext unmittelbar am betroffenen Inhalt platzieren;
- nicht ausschließlich im Footer, Impressum oder auf einer erst später erreichbaren Unterseite;
- aussagekräftigen Alternativtext verwenden;
- bei der quadratischen Emoji-Variante zusätzlich einen unmittelbar verständlichen Text oder eine gleichwertig eindeutige sichtbare Kennzeichnung verwenden.
Offizielle Quellen:
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
- https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act
- https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content
## 4. Menschliche Prüfung und redaktionelle Kontrolle
Für bestimmte veröffentlichte KI-generierte oder manipulierte Texte zu Angelegenheiten von öffentlichem Interesse kann die Kennzeichnungspflicht nach Artikel 50 Absatz 4 entfallen, wenn die gesetzlichen Voraussetzungen tatsächlich erfüllt sind.
Die Kommission stellt klar:
- „human review“ erfordert eine bewusste inhaltliche Prüfung durch Personen mit einschlägigem Wissen und professionellem Urteil;
- „editorial control“ setzt tatsächliche inhaltliche Entscheidungsbefugnis voraus, also insbesondere die Möglichkeit, Inhalte aus sachlichen Gründen zu genehmigen, zu ändern oder abzulehnen;
- rein oberflächliche, formale oder prozedurale Prüfungen wie Rechtschreib- oder Grammatikprüfung genügen nicht;
- zusätzlich muss eine natürliche oder juristische Person die redaktionelle Verantwortung für die Veröffentlichung tragen.
Aus diesem Grund setzt der Generator seit dieser Härtung **keine menschliche oder redaktionelle Prüfung mehr automatisch voraus**. Der Nutzer muss den tatsächlich durchgeführten Prozess ausdrücklich auswählen.
Die Felder `editorialResponsibility.name` und `editorialResponsibility.url` dienen nur der Dokumentation. Ihre Angabe beweist nicht automatisch, dass die gesetzlichen Voraussetzungen vorliegen.
## 5. Artikel 50 Absatz 2: JSON-LD ist kein Ersatz für Provider-Marking
Anbieter generativer KI-Systeme müssen in den erfassten Fällen dafür sorgen, dass generierte oder manipulierte Inhalte maschinenlesbar markiert und als künstlich generiert oder manipuliert erkennbar sind. Nach Artikel 50 Absatz 2 müssen die technischen Lösungen wirksam, interoperabel, robust und zuverlässig sein, soweit dies technisch machbar ist.
Das von diesem Projekt erzeugte **JSON-LD ist ergänzende Dokumentation**. Es ist nicht als Ersatz für die technische Markierung gedacht, die der Anbieter des generativen KI-Systems in oder an der Ausgabe implementieren muss.
Wer selbst Anbieter eines generativen KI-Systems im Sinne des AI Act ist, muss deshalb zusätzlich eine geeignete Provider-Marking-Lösung umsetzen und dokumentieren. Der von der Kommission positiv bewertete Code of Practice kann hierfür als freiwilliger Compliance-Rahmen genutzt werden.
Offizielle Quellen:
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
- https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content
- https://digital-strategy.ec.europa.eu/en/library/commission-opinion-assessment-code-practice-transparency-ai-generated-content
## 6. Artikel 4: AI Literacy
Artikel 4 gilt bereits seit **2. Februar 2025**. Anbieter und Betreiber von KI-Systemen müssen nach besten Kräften Maßnahmen treffen, um ein ausreichendes Maß an AI Literacy bei Mitarbeitenden und sonstigen Personen sicherzustellen, die in ihrem Auftrag mit Betrieb oder Nutzung von KI-Systemen befasst sind.
Ein Badge oder eine Veröffentlichungserklärung erfüllt diese Organisationspflicht nicht. Betreiber sollten mindestens dokumentieren:
- welche KI-Systeme eingesetzt werden;
- welche Rollen sie bedienen;
- welche Kenntnisse und Schulungen erforderlich sind;
- welche internen Regeln für Prüfung, Freigabe und Eskalation gelten;
- wann Schulungen oder Richtlinien zuletzt aktualisiert wurden.
Offizielle Quelle:
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-4
## 7. Risiko- und Rollenprüfung vor Produktivnutzung
Vor dem Einsatz sollte die Organisation dokumentieren:
1. Ist die eingesetzte Software überhaupt ein KI-System im Sinne von Artikel 3 Absatz 1?
2. Welche Rolle liegt vor: Anbieter, Betreiber, Importeur, Distributor oder Produkthersteller?
3. Fällt das konkrete KI-System unter ein Verbot des Artikels 5?
4. Ist es ein Hochrisiko-KI-System nach Artikel 6 bzw. Anhang III?
5. Handelt es sich um ein General-Purpose-AI-Modell oder ein darauf basierendes System mit zusätzlichen Pflichten?
6. Greift eine Transparenzpflicht aus Artikel 50?
7. Welche anderen Rechtsgebiete sind zusätzlich betroffen, insbesondere DSGVO, Urheberrecht, Verbraucher- und Medienrecht?
Offizielle Einstiegsquellen:
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-2
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-5
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-6
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/annex-3
## 8. Zeitlicher Stand
Nach Artikel 113 gilt der AI Act grundsätzlich ab 2. August 2026; einzelne Teile gelten bereits früher. Insbesondere gelten Kapitel I und II, einschließlich Artikel 4 und der Verbote des Artikels 5, seit 2. Februar 2025.
Für Artikel 50 beginnen die Transparenzpflichten am 2. August 2026. Die Kommission weist für bestimmte bereits vor diesem Datum in Verkehr gebrachte generative KI-Systeme auf eine begrenzte Übergangsregel für die Anbieter-Markierung nach Artikel 50 Absatz 2 bis 2. Dezember 2026 hin.
Offizielle Quellen:
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-113
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/timeline/timeline-implementation-eu-ai-act
- https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act
## 9. Praktische Mindestregel für dieses Projekt
Wenn ein Inhalt **möglicherweise** unter Artikel 50 Absatz 4 fällt, ist die rechtlich defensivere Konfiguration:
- KI-Nutzung wahrheitsgemäß als `partial`, `mostly` oder `full` angeben;
- die konkrete Tätigkeit angeben;
- `humanReview` nur dann auf `editorial` oder `expert` setzen, wenn eine tatsächlich substanzielle inhaltliche Prüfung stattgefunden hat;
- die redaktionell verantwortliche Person oder Organisation dokumentieren, wenn dies zutrifft;
- einen sichtbaren textlichen Hinweis oder ein verständliches Standard-Badge spätestens bei der ersten Exposition platzieren;
- das Emoji nicht als alleinige gesetzliche Kennzeichnung verwenden;
- das JSON-LD nur als zusätzliche Dokumentation behandeln.
Bei Zweifeln über die Ausnahme wegen menschlicher Prüfung ist eine **zusätzliche Offenlegung regelmäßig die risikoärmere technische Entscheidung**, solange dadurch keine anderen rechtlichen Pflichten verletzt werden.
## Strukturierter rechtlicher Kontext in Schema 1.2
Die Deklaration kann optional `legalContext.categories` enthalten. Unterstützt werden `deepfake`, `publicInterestText`, `artisticCreativeSatiricalFictional` und `otherVoluntary`. Diese Angaben sind ausdrücklich als Selbsteinordnung konzipiert und ersetzen keine rechtliche Subsumtion.
Der Generator trennt damit zwei Ebenen:
1. **Erstkennzeichnung am Inhalt:** Bei einer als voraussichtlich offenlegungspflichtig eingeordneten Nutzung wird eine verständliche Beschriftung wie „KI-generierter / manipulierter Inhalt“ oder „KI-generierte / bearbeitete Inhalte“ erzeugt.
2. **Zweite Ebene:** Die `/declaration`-Seite dokumentiert KI-Anteil, Tätigkeiten, menschliche Prüfung, Nachweisgrundlage, rechtlichen Kontext und gegebenenfalls redaktionelle Verantwortung.
Für `publicInterestText` wird eine mögliche Ausnahme nur als Möglichkeit angezeigt, wenn sowohl eine substanzielle Prüfung (`editorial` oder `expert`) als auch eine ausdrücklich benannte redaktionelle Verantwortung dokumentiert sind. Formale Prüfung (`basic`) reicht dafür nicht. Bei `deepfake` bleibt die Einschätzung auf Offenlegung gerichtet; die zusätzliche Kategorie `artisticCreativeSatiricalFictional` weist auf die begrenzte, angemessene Offenlegungsform für evident kreative Werke hin.
Das System formuliert bewusst „spricht vieles dafür“ beziehungsweise „kann in Betracht kommen“ und gibt keine verbindliche Rechtsentscheidung aus.

View File

@@ -1,101 +0,0 @@
# EU AI Act Review Ergänzung vom 21.07.2026
## Ergebnis
Die zusätzlichen Anforderungen wurden in Version 1.6.3 / Declaration Schema 1.2 aufgenommen. Das Projekt trennt nun ausdrücklich zwischen freiwilliger KI-Transparenz und einem vom Nutzer selbst angegebenen rechtlichen Kontext für Artikel 50.
Eine Nutzung des Standards allein begründet weiterhin keine vollständige EU-AI-Act-Konformität. Die konkrete Rolle (Provider/Deployer), das eingesetzte KI-System, der Inhalt, der Veröffentlichungskontext sowie weitere Pflichten des AI Act müssen separat geprüft werden.
## Neu aufgenommen
### 1. Strukturierter rechtlicher Kontext
`legalContext.categories` unterstützt:
- `deepfake`
- `publicInterestText`
- `artisticCreativeSatiricalFictional`
- `otherVoluntary`
Die Angaben sind als Selbsteinordnung konzipiert und nicht als automatische rechtliche Subsumtion.
### 2. Deepfake-Abgrenzung
Der Generator erklärt, dass nicht jedes KI-generierte Bild ein Deepfake ist. Die Kategorie ist für KI-generierte oder manipulierte Bild-, Audio- oder Videoinhalte vorgesehen, die bestehenden oder plausibel existierenden Personen, Objekten, Orten, Entitäten oder Ereignissen ähneln und fälschlich authentisch oder wahr erscheinen können.
### 3. Texte zu Angelegenheiten von öffentlichem Interesse
Für `publicInterestText` berücksichtigt die vorsichtige Einschätzung:
- ob eine substanzielle menschliche Prüfung oder redaktionelle Kontrolle dokumentiert wurde (`editorial` oder `expert`), und
- ob eine redaktionell verantwortliche natürliche oder juristische Person ausdrücklich benannt wurde.
Formale Prüfung (`basic`) wird nicht als ausreichende inhaltliche Prüfung behandelt.
### 4. Redaktionelle Verantwortung
Die Verantwortung wird nun expliziter gespeichert:
```json
{
"editorialResponsibility": {
"assumed": true,
"role": "publisher",
"name": "Beispielredaktion",
"url": "https://example.org/impressum"
}
}
```
Unterstützte Rollen:
- `publisher`
- `other`
Eine bloße URL ohne benannte verantwortliche Stelle wird nicht akzeptiert.
### 5. Zwei Ebenen der Kennzeichnung
Bei freiwilliger Transparenz kann weiterhin z. B. „Artikeltransparenz“ verwendet werden.
Wenn die Selbsteinordnung auf einen voraussichtlich kennzeichnungspflichtigen Artikel-50-Fall hindeutet, wird die Erstkennzeichnung klarer formuliert, z. B.:
- „KI-generierter / manipulierter Inhalt“
- „KI-generierte / bearbeitete Inhalte“
Die ausführliche zweite Ebene bleibt die `/declaration`-Seite.
### 6. Emoji-Modus
Der quadratische Emoji-Modus bleibt verfügbar. Bei einem als wahrscheinlich offenlegungspflichtig eingeordneten Fall ergänzt der generierte HTML-/Markdown-Einbettungscode neben dem Emoji zusätzlich sichtbaren Klartext. Das Emoji wird weiterhin nicht als offizielles EU-Icon oder als alleiniger Konformitätsnachweis dargestellt.
### 7. Custom-Badge-Schutz
In einem vom Nutzer als wahrscheinlich gesetzlich relevanten Artikel-50-Fall kann ein Pro-Custom-Text die eindeutige Erstkennzeichnung nicht mehr durch eine abstrakte Badge-Botschaft ersetzen. Farben und linke Beschriftung bleiben anpassbar; die entscheidende Offenlegungsbotschaft bleibt verständlich.
### 8. Vorsichtige Einschätzung auf der Declaration-Seite
Die Seite zeigt nun eine gesonderte „Vorsichtige Art.-50-Einschätzung“.
Beispiele:
- Deepfake: „spricht vieles dafür“, dass eine klare Offenlegung spätestens bei erster Exposition erforderlich ist.
- Public-Interest-Text ohne dokumentierte Ausnahmevoraussetzungen: „spricht vieles dafür“, dass eine Offenlegung erfolgen sollte.
- Public-Interest-Text mit substantieller Prüfung und ausdrücklich benannter redaktioneller Verantwortung: die Ausnahme „kann in Betracht kommen“.
Es werden bewusst keine verbindlichen Formulierungen wie „rechtlich zwingend“ oder „AI-Act-konform“ ausgegeben.
## Aktuelle offizielle Grundlagen
- Artikel 50 AI Act: https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
- Kommissionsleitlinien vom 20.07.2026: https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems
- FAQ zu Artikel 50: https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act
- EU-Icons: https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content
- Code of Practice: https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content
- Kommissionsbewertung des Code of Practice: https://digital-strategy.ec.europa.eu/en/library/commission-opinion-assessment-code-practice-transparency-ai-generated-content
## Verbleibende rechtliche Grenze
Der Standard kann Transparenz- und Dokumentationsprozesse unterstützen. Er kann nicht automatisch prüfen oder erfüllen, ob beispielsweise Artikel 4 (AI Literacy), Artikel 5 (verbotene Praktiken), Hochrisiko-Pflichten, GPAI-Pflichten, Datenschutz-, Urheber-, Medien- oder Verbraucherrecht im konkreten Einsatzfall greifen.
Daher sollte öffentlich weiterhin nicht pauschal mit „EU AI Act compliant“ oder „erfüllt alle Anforderungen des EU AI Act“ geworben werden.

View File

@@ -1,148 +0,0 @@
# Rechtlicher und sicherer Produktivbetrieb
Stand: 20. Juli 2026
Diese Datei beschreibt die mitgelieferten technischen Schutzmaßnahmen und die Punkte, die ein Betreiber vor einer öffentlichen Bereitstellung selbst prüfen und vervollständigen muss. Sie ersetzt keine Rechtsberatung für den konkreten Einzelfall.
## 1. Vor dem ersten öffentlichen Start
1. `.env.example` nach `.env` kopieren und alle `REPLACE_ME`-Werte durch zutreffende Angaben ersetzen.
2. `LEGAL_STRICT=true` aktiv lassen. Der Dienst verweigert dann den Start, wenn Kernangaben fehlen.
3. Impressum, Datenschutzerklärung und Barrierefreiheitserklärung im fertig deployten System prüfen:
- `/impressum`
- `/datenschutz`
- `/barrierefreiheit`
4. Tatsächliche Hosting-, Proxy-, CDN-, DNS-, Logging-, Backup-, Monitoring- und Lizenzdienste mit der Datenschutzerklärung abgleichen.
5. Bei Verbraucherverträgen prüfen, welche Angaben nach dem Verbraucherstreitbeilegungsgesetz erforderlich sind. Die frühere EU-OS-Plattform wurde eingestellt; ein alter OS-Link sollte nicht übernommen werden.
6. Bei journalistisch-redaktionellen Angeboten prüfen, ob ein Verantwortlicher nach § 18 Abs. 2 MStV benannt werden muss.
7. Bei Angeboten an Verbraucher prüfen, ob das BFSG und die BFSGV anwendbar sind. Eine bloße Selbsterklärung ersetzt keinen Accessibility-Audit.
## 2. Anbieterkennzeichnung
Die Vorlage deckt typische Felder für § 5 DDG und § 18 MStV ab. Je nach Betreiber können weitere Angaben erforderlich sein, etwa:
- Rechtsform und Vertretungsberechtigte;
- Register, Registernummer und Registergericht;
- Umsatzsteuer-Identifikationsnummer;
- Aufsichtsbehörde und berufsrechtliche Angaben;
- redaktionell Verantwortliche mit Name und Anschrift;
- Erklärung zur Verbraucherstreitbeilegung.
Offizielle Grundlagen:
- § 5 DDG: https://www.gesetze-im-internet.de/ddg/__5.html
- § 18 MStV: https://www.gesetze-bayern.de/Content/Document/MStV-18
- § 36 VSBG: https://www.gesetze-im-internet.de/vsbg/__36.html
## 3. Datenschutz
Die ausgelieferte Weboberfläche verwendet keine Cookies, kein Tracking und keine Browser-Speicher wie Local Storage. Das allein macht einen Betrieb nicht automatisch datenschutzkonform. Relevant sind insbesondere die tatsächlichen Infrastruktur-Logs und zusätzlich eingebundene Dienste.
Technischer Standardzustand:
- kein Datenbank- oder Session-Speicher für Generator-Eingaben;
- keine Query-Strings in den Anwendungslogs;
- keine Client-IP in den Anwendungslogs, solange `LOG_CLIENT_IP=false` bleibt;
- URL-Parameter können dennoch in Browser-Verläufen, Reverse-Proxy-, CDN- oder Hosting-Logs erscheinen;
- Hybrid- und Online-Lizenzmodi kommunizieren mit dem konfigurierten Lizenzserver;
- öffentliche Erklärungs-URLs dürfen keine vertraulichen oder unnötigen personenbezogenen Daten enthalten.
Der Betreiber muss insbesondere festlegen und umsetzen:
- Rechtsgrundlagen und Zwecke;
- Empfänger und Auftragsverarbeiter;
- Lösch- und Aufbewahrungsfristen;
- Drittlandübermittlungen;
- technisch-organisatorische Maßnahmen;
- Prozesse für Betroffenenrechte und Datenschutzvorfälle.
Offizielle Grundlagen:
- Art. 13 DSGVO: https://eur-lex.europa.eu/eli/reg/2016/679/oj
- § 25 TDDDG: https://www.gesetze-im-internet.de/ttdsg/__25.html
## 4. Barrierefreiheit
Die Anwendung nutzt semantische Formulare und Tabellen, sichtbare Fokuszustände, responsive Layouts und Textalternativen. Sie erhebt ohne gesonderten Audit keinen Anspruch auf vollständige WCAG- oder EN-301-549-Konformität.
Für ein erfasstes Verbraucherangebot sind unter anderem zu prüfen:
- Tastaturbedienung und Fokusreihenfolge;
- Kontraste, Zoom und Reflow;
- verständliche Fehlermeldungen;
- Screenreader-Ausgabe;
- Alternativtexte eingebetteter Badges;
- Barrierefreiheit des vollständigen Bestell- oder Vertragspfads;
- gesetzlich verlangte Informationen zur Barrierefreiheit.
Offizielle Informationen: https://www.bundesfachstelle-barrierefreiheit.de/DE/Barrierefreiheitsstaerkungsgesetz
## 5. Sicherheitsstandard
Mitgeliefert werden unter anderem:
- restriktive Content Security Policy mit zufälliger Nonce je HTML-Antwort;
- `frame-ancestors 'none'`, `X-Frame-Options: DENY`, `nosniff`, Referrer- und Permissions-Policy;
- HSTS bei HTTPS-Basis-URL;
- Größenlimit und striktes JSON-Decoding für den Validator;
- Begrenzung paralleler Validierungsanfragen;
- validierte und begrenzte Request-IDs;
- Proxy-Header nur aus ausdrücklich konfigurierten Proxy-Netzen;
- standardmäßig deaktivierte Client-IP-Logs und Prometheus-Metriken;
- verpflichtender Bearer-Schutz bei aktiviertem `/metrics`;
- Non-Root-Container, schreibgeschütztes Dateisystem, entfernte Linux-Capabilities und `no-new-privileges`.
Zusätzlich in der Betriebsumgebung erforderlich:
- TLS-Terminierung und sichere Zertifikatsverwaltung;
- Rate Limits am Edge/Ingress;
- regelmäßige Image- und Abhängigkeitsupdates;
- Secret-Management statt Klartext-Umgebungsvariablen, soweit möglich;
- Netzwerksegmentierung für Metriken und Lizenzserver;
- zentrale Log-Löschung entsprechend `LOG_RETENTION`;
- Backup-, Restore- und Incident-Response-Verfahren;
- Überwachung ohne unnötige personenbezogene Telemetrie.
## 6. Reverse Proxy
`TRUST_PROXY=true` darf nur zusammen mit `TRUSTED_PROXY_CIDRS` verwendet werden. Trage ausschließlich Netze ein, aus denen dein kontrollierter Reverse Proxy die Anwendung tatsächlich erreicht. Andernfalls könnten Clients weitergeleitete IP-Header vortäuschen.
Beispiel für einen ausschließlich lokalen Proxy:
```env
TRUST_PROXY=true
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
```
Docker-, Kubernetes- oder Cloud-Netze unterscheiden sich je Umgebung und dürfen nicht pauschal kopiert werden.
## 7. Metriken
`/metrics` ist standardmäßig nicht registriert. Für eine Aktivierung:
```env
METRICS_ENABLED=true
METRICS_TOKEN_FILE=/run/secrets/metrics-token
```
Der Bearer-Token schützt den Endpunkt auf Anwendungsebene. Zusätzlich sollte der Endpunkt nicht über den öffentlichen Ingress erreichbar sein.
## 8. KI-Transparenz und EU AI Act
Der Standard unterstützt freiwillige Dokumentation und bestimmte Transparenz-Workflows, insbesondere sichtbare Offenlegungen. Er entscheidet nicht automatisch, ob Artikel 50 des AI Act auf einen konkreten Inhalt anwendbar ist. Die offiziellen Transparenzleitlinien der Europäischen Kommission wurden am 20. Juli 2026 veröffentlicht; Artikel 50 gilt ab dem 2. August 2026.
Wichtige Grenzen:
- Presets unterstellen keine menschliche oder redaktionelle Prüfung mehr. Ein solcher Prozess muss ausdrücklich angegeben werden.
- Eine bloß formale Prüfung wie Rechtschreibung oder Grammatik ist nach den Kommissionsleitlinien keine substantielle menschliche Prüfung oder redaktionelle Kontrolle im Sinne der Ausnahme für bestimmte Texte nach Artikel 50 Absatz 4.
- Ein sichtbarer Hinweis muss bei einer einschlägigen Pflicht klar und unterscheidbar spätestens bei der ersten Exposition erscheinen.
- Die Emoji-Variante ist nur ein kompakter Zusatz und sollte bei einer gesetzlichen Kennzeichnung nicht ohne verständlichen Text eingesetzt werden.
- Das erzeugte JSON-LD ist ergänzende Dokumentation und kein automatischer Ersatz für die Provider-Markierung nach Artikel 50 Absatz 2.
- Der AI Act enthält weitere Pflichten außerhalb dieses Projekts, unter anderem AI Literacy nach Artikel 4 sowie gegebenenfalls Verbote, Hochrisiko- oder GPAI-Pflichten.
Siehe die ausführliche Checkliste in [`EU-AI-ACT-COMPLIANCE.md`](EU-AI-ACT-COMPLIANCE.md).
- AI Act: https://eur-lex.europa.eu/eli/reg/2024/1689/oj
- Leitlinien: https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems
- FAQ: https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act
- Code of Practice: https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content

View File

@@ -16,7 +16,7 @@ Initialization is performed in `internal/app/server.go`. The product ID and embe
```go
licenses := licenseclient.New(ctx, licenseclient.Config{
Product: "ai-disclosure-standard",
ClientVersion: "1.6.3",
ClientVersion: "2.0.2",
Token: cfg.LicenseToken,
TrustStore: trustStore,
BaseURL: cfg.BaseURL,

View File

@@ -119,7 +119,7 @@ Anfrage:
"baseUrl": "https://ai.example.org",
"host": "ai.example.org",
"instanceId": "production-eu-1",
"clientVersion": "1.6.3"
"clientVersion": "2.0.2"
}
```
@@ -170,3 +170,23 @@ Unter `third_party/license-platform-client` liegt eine client-only, protokollkom
Private-Key-Handling, Signierfunktionen, Admin-Server und Persistenz wurden bewusst nicht übernommen.
Sobald das eigenständige Modul über einen stabilen Go-Modul-Tag erreichbar ist, kann in `go.mod` der lokale `replace`-Eintrag entfernt und direkt die veröffentlichte SDK-Version verwendet werden.
## Capabilities used by 2.0
```text
custom_text
custom_badge
white_label
bulk_api
export_bundle
```
The optional numeric limit `bulk_items` caps the number of items accepted by a single Bulk API request. The application always applies the stricter value of the runtime configuration and the signed licence limit.
For an official deployment that must fail closed when the licence is unavailable, set:
```env
REQUIRE_LICENSE=true
```
This affects both readiness and ordinary application requests. Liveness, readiness, metrics and capability inspection remain available for operations and diagnosis.

View File

@@ -0,0 +1,40 @@
# Licensing Strategy
## Aktueller Stand
Der Quellstand enthält derzeit die MIT-Lizenz. Sie erlaubt ausdrücklich Nutzung, Änderung, Weitergabe, Unterlizenzierung und Verkauf von Kopien unter Einhaltung der Lizenzbedingungen.
Die Runtime-Lizenz der Universal License Platform ist deshalb primär eine **technische und vertragliche Grenze der offiziellen Distribution**, nicht ein Ersatz für eine bewusst gewählte Software-Lizenzstrategie.
## Mögliche Produktstrategien
### Permissive Open Source + kommerzielle Dienste
MIT/Apache-2.0 bleibt bestehen. Monetarisiert werden beispielsweise:
- offizielle Managed-/Hosted-Dienste;
- Support und SLA;
- Verifikations- und Signaturdienste;
- Marken-/Trust-Programme;
- offizielle Images und Updatekanäle.
Das maximiert Adoption, schützt aber einzelne lokal vorhandene Funktionen nicht exklusiv.
### Dual Licensing
Eine häufige Alternative ist eine Copyleft-Open-Source-Lizenz für die Community-Ausgabe und eine separate kommerzielle Lizenz für Kunden, die die Copyleft-Bedingungen nicht übernehmen möchten. Eine solche Umstellung sollte nur vorgenommen werden, wenn die erforderlichen Rechte an allen Beiträgen vorliegen.
### Open Core
Ein vollständig offener Kern und separat lizenzierte, nicht unter der Open-Source-Lizenz veröffentlichte Enterprise-Komponenten sind ebenfalls möglich. Dabei muss die Lizenzgrenze im Repository und in Build-Artefakten eindeutig dokumentiert werden.
## Empfehlung vor öffentlichem Launch
Vor einer kommerziellen Veröffentlichung sollten insbesondere entschieden und juristisch geprüft werden:
1. welche Teile tatsächlich Open Source sein sollen;
2. ob MIT beibehalten, Dual Licensing verwendet oder ein Open-Core-Modell gewählt wird;
3. welche Marken/Logos ausschließlich für offizielle bzw. verifizierte Implementierungen verwendet werden dürfen;
4. welche Vertragsbedingungen für Runtime-Lizenzen, Support, SLA und Verifikation gelten.
Diese Datei beschreibt Produktarchitektur und ersetzt keine Rechtsberatung.

View File

@@ -1,35 +0,0 @@
# Marketing page
The product page is served by the same Go binary as the badge and declaration API.
## Routes
- `/product` — complete product, feature, pricing and installation page
- `/pricing` — redirects to `/product#pricing`
- `/install` — redirects to `/product#install`
All routes support `?lang=de|en|fr|es|it|nl|pt|pl` and `Accept-Language` negotiation.
## Runtime configuration
```text
SALES_URL=https://example.org/contact
PRICE_COMMUNITY=0 €
PRICE_PRO=19 €
PRICE_PUBLISHER=79 €
PRICE_AGENCY=199 €
```
`SALES_URL` is used by paid-plan calls to action. When it is empty, the service falls back to `CONTACT_URL`.
The page deliberately markets only capabilities that exist in the current build. Domain counts, support levels and commercial terms are offer definitions; adjust them before publication.
## Editing content
- Page structure: `web/templates/marketing.html`
- Styling: `web/static/style.css`
- Copy controls and language switch: `web/static/marketing.js`
- Localised product copy: `internal/marketing/content.go`
- Generator navigation label: `internal/i18n/marketing.go`
No external fonts, scripts, images, analytics or cookies are required.

13
docs/OPERATOR-PAGES.md Normal file
View File

@@ -0,0 +1,13 @@
# Operator, privacy and accessibility pages
Version 2.0.2 provides multilingual operator pages in `full` and `bulk` mode:
- `/legal` (`/imprint` is an alias)
- `/privacy`
- `/accessibility`
All eight application languages are supported through `?lang=de`, `?lang=en`, `?lang=fr`, `?lang=es`, `?lang=it`, `?lang=nl`, `?lang=pt` and `?lang=pl`. Each page emits language-alternate metadata.
These routes remain reachable when `REQUIRE_LICENSE=true` and the runtime licence is unavailable, so legally relevant operator information does not disappear because of a licensing incident. Their static assets are exempt for the same reason.
The content is generated from environment configuration. Empty optional fields are not rendered. `LEGAL_STRICT` can be used as a production deployment guard, but it does not assert that every field required by every jurisdiction is present.

View File

@@ -1,85 +0,0 @@
# Rechts- und Sicherheitsreview
Stand: 20. Juli 2026
## Einordnung
Dieses Review verbessert den technischen Standardzustand und stellt konfigurierbare Vorlagen für zentrale Betreiberinformationen bereit. Es ist keine individuelle Rechtsberatung und keine Garantie vollständiger Rechtskonformität. Die abschließende Prüfung muss anhand des tatsächlichen Betreibers, Sitzes, Geschäftsmodells, Hostings, Vertragsangebots und aller eingebundenen Dienste erfolgen.
## Ausgangslage
Im geprüften Archiv fehlten eigene Seiten für Anbieterkennzeichnung, Datenschutz und Barrierefreiheit. Zudem wurde die Client-IP bei jedem Request protokolliert, Proxy-Header konnten ohne explizite Netzbindung vertraut werden, der Metrik-Endpunkt war grundsätzlich registriert und der öffentliche Capability-Endpunkt konnte interne Lizenzmetadaten ausgeben.
## Umgesetzte Änderungen
### Rechtliche Informationen
- neue Routen `/impressum`, `/datenschutz` und `/barrierefreiheit`;
- zentrale Footer-Verlinkung auf allen HTML-Seiten;
- konfigurierbare Betreiber-, Register-, Steuer-, Datenschutz-, Hosting-, Streitbeilegungs- und Barrierefreiheitsangaben;
- sichtbare Warnung bei fehlenden Angaben;
- `LEGAL_STRICT=true` verhindert einen Produktivstart mit fehlenden Kernangaben oder typischen Platzhaltern;
- keine erfundenen Betreiberangaben und keine pauschale Behauptung vollständiger Barrierefreiheit;
- Hinweise auf die tatsächliche Verarbeitung von URL-Parametern, Infrastruktur-Logs und optionaler Online-Lizenzprüfung;
- kein veralteter Link zur eingestellten EU-OS-Plattform.
### Anwendungssicherheit und Datenschutz durch Voreinstellungen
- Client-IP-Logging standardmäßig deaktiviert;
- Query-Strings werden nicht in Anwendungslogs übernommen;
- Proxy-Header werden nur von explizit konfigurierten CIDR-Netzen ausgewertet;
- die weitergeleitete IP wird von rechts nach links über die vertrauenswürdige Proxy-Kette bestimmt, damit vorgeschobene Header-Werte nicht als Client-IP gelten;
- validierte und auf 64 Zeichen begrenzte Request-IDs;
- restriktive Content Security Policy mit zufälliger Nonce je HTML-Antwort;
- HSTS nur bei HTTPS-Konfiguration und ohne pauschales `includeSubDomains`;
- `frame-ancestors 'none'`, `X-Frame-Options: DENY`, `nosniff`, Referrer- und Permissions-Policy;
- Validator mit 1-MiB-Limit, strikt erlaubten JSON-Feldern, Content-Type-Prüfung und Parallelitätsgrenze;
- `/metrics` standardmäßig nicht registriert; bei Aktivierung ist ein Bearer-Token zwingend;
- öffentliche Capability-Antwort enthält keine Lizenz-ID, Kundendaten, internen Serveradressen oder Diagnosedetails;
- externe Konfigurations-URLs werden auf absolute HTTP(S)-URLs ohne Zugangsdaten begrenzt;
- rechtliche Kontaktfelder werden als reine E-Mail-Adressen validiert;
- fehlerhafte boolesche Umgebungsvariablen und ungültige Proxy-CIDRs führen zum Startfehler statt zu einem stillen Fallback.
### Deployment
- Compose-, Kubernetes- und Swarm-Beispiele um die Rechts- und Sicherheitskonfiguration erweitert;
- sichere Containeroptionen beibehalten: Non-Root, read-only Root-Dateisystem, keine Linux-Capabilities, `no-new-privileges`;
- öffentliche Prometheus-Autodiscovery aus dem Kubernetes-Beispiel entfernt;
- Dokumentation für TLS, Rate Limits, Secrets, Netzsegmentierung, Löschfristen, Backups, Updates und Incident Response ergänzt.
## Noch durch den Betreiber zu erledigen
Vor einem öffentlichen Start sind mindestens folgende Punkte mit echten Angaben und der tatsächlichen Infrastruktur abzugleichen:
1. Firma/Name, ladungsfähige Anschrift, Vertretungsberechtigte, Register, Umsatzsteuer-ID und gegebenenfalls berufsrechtliche Angaben.
2. Redaktionell Verantwortliche, falls journalistisch-redaktionelle Inhalte unter § 18 Abs. 2 MStV angeboten werden.
3. Hosting-Anbieter, eingesetzte Reverse Proxies/CDNs, tatsächliche Logfelder, Löschfristen, Auftragsverarbeitungsverträge und mögliche Drittlandübermittlungen.
4. Anwendbarkeit und zutreffender Status nach dem Verbraucherstreitbeilegungsgesetz.
5. Anwendbarkeit von BFSG/BFSGV sowie ein tatsächlicher Accessibility-Test des vollständigen Angebots.
6. Datenschutzrechtliche Einordnung des Lizenzservers bei Hybrid- oder Online-Betrieb.
7. TLS, Edge-Rate-Limits, Secret-Management, Patch-Prozess, Schwachstellenscans, Monitoring, Backups und Incident-Response in der Zielumgebung.
8. Juristische Schlussprüfung der veröffentlichten Texte, insbesondere bei Verbraucherverträgen, reglementierten Berufen oder internationalen Angeboten.
## Technische Prüfung
Ausgeführt wurden:
```text
go test ./...
go vet ./...
cd third_party/license-platform-client && go test ./... && go vet ./...
```
Alle Prüfungen waren erfolgreich. Da die isolierte Prüfumgebung die in `go.mod` verlangte Go-1.26-Toolchain nicht herunterladen konnte, wurden die Tests mit der lokal vorhandenen Go-Version 1.23.2 und einer nur temporär angepassten Modfile ausgeführt. Die Projektdatei `go.mod` bleibt unverändert bei Go 1.26.
Zusätzlich wurden alle YAML-Dateien geparst, die Go-Dateien mit `gofmt` geprüft und Templates/JavaScript auf Inline-Eventhandler, Cookies sowie Browser-Speicherzugriffe durchsucht.
## Offizielle Ausgangspunkte
- § 5 DDG: https://www.gesetze-im-internet.de/ddg/__5.html
- § 18 MStV: https://www.gesetze-bayern.de/Content/Document/MStV-18
- Art. 13 DSGVO: https://eur-lex.europa.eu/eli/reg/2016/679/oj
- § 25 TDDDG: https://www.gesetze-im-internet.de/ttdsg/__25.html
- § 36 VSBG: https://www.gesetze-im-internet.de/vsbg/__36.html
- Bundesfachstelle Barrierefreiheit zum BFSG: https://www.bundesfachstelle-barrierefreiheit.de/DE/Barrierefreiheitsstaerkungsgesetz
- EU AI Act: https://eur-lex.europa.eu/eli/reg/2024/1689/oj

33
docs/SUPPLY-CHAIN.md Normal file
View File

@@ -0,0 +1,33 @@
# Supply-chain security
Die offizielle GitHub-Actions-Pipeline erzeugt für den normalen und den Bulk-Container Multi-Arch-Images für `linux/amd64` und `linux/arm64`.
Für veröffentlichte Images werden zusätzlich erzeugt:
- BuildKit-Provenance (`mode=max`);
- ein SBOM-Attest;
- eine keyless Sigstore/Cosign-Signatur des Multi-Arch-Image-Digests über GitHub OIDC.
Die Signatur schützt die Herkunft eines offiziellen Image-Digests. Sie ersetzt weder die Runtime-Lizenzprüfung noch eine fachliche Verifikation einer KI-Nutzungserklärung.
## Verifikation eines offiziellen Images
Das konkrete Repository und der GitHub-Workflow müssen bei der Verifikation als erwartete Identität verwendet werden. Beispiel:
```bash
cosign verify \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com \
--certificate-identity-regexp='https://github.com/OWNER/REPOSITORY/.github/workflows/ci.yml@refs/heads/main' \
ghcr.io/OWNER/REPOSITORY@sha256:...
```
Für besonders kontrollierte Produktionsumgebungen sollte ein Deployment nur freigegebene Digests verwenden und die Signatur vor dem Rollout durch eine Admission Policy oder die CI/CD-Plattform prüfen.
## Release-Prinzipien
- Deployment nach Digest statt ausschließlich nach `latest`;
- geschützte Release-Tags und Branch-Protection für `main`;
- getrennte Secrets für Runtime-Lizenz und Bulk-API-Key;
- keine privaten Lizenzschlüssel in diesem Repository;
- regelmäßige Aktualisierung der Build-Images und GitHub Actions;
- reproduzierbare Versionierung von Schema, Anwendung und Container.

185
internal/app/bulk.go Normal file
View File

@@ -0,0 +1,185 @@
package app
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"github.com/b1tsblog/ai-disclosure-standard/internal/declaration"
"github.com/b1tsblog/ai-disclosure-standard/internal/i18n"
)
type bulkRequest struct {
Items []bulkItem `json:"items"`
}
type bulkItem struct {
ID string `json:"id,omitempty"`
Parameters map[string]string `json:"parameters"`
}
type bulkResult struct {
ID string `json:"id,omitempty"`
Valid bool `json:"valid"`
Declaration *declaration.Declaration `json:"declaration,omitempty"`
Assessment *declaration.Article50Assessment `json:"article50Assessment,omitempty"`
DeclarationURL string `json:"declarationUrl,omitempty"`
ManifestURL string `json:"manifestUrl,omitempty"`
BadgeURL string `json:"badgeUrl,omitempty"`
Error *bulkError `json:"error,omitempty"`
}
type bulkError struct {
Code string `json:"code"`
Detail string `json:"detail"`
}
type declarationBundle struct {
Product string `json:"product"`
ProductVersion string `json:"productVersion"`
GeneratedAt string `json:"generatedAt"`
Declaration declaration.Declaration `json:"declaration"`
DeclarationDigest string `json:"declarationDigest"`
Assessment declaration.Article50Assessment `json:"article50Assessment"`
DeclarationURL string `json:"declarationUrl"`
ManifestURL string `json:"manifestUrl"`
BadgeURL string `json:"badgeUrl"`
}
func (s *Server) handleBulkDeclarations(w http.ResponseWriter, r *http.Request) {
s.metrics.bulkRequests.Add(1)
if !s.bulkAuthorized(r) {
s.metrics.bulkFailures.Add(1)
w.Header().Set("WWW-Authenticate", `Bearer realm="bulk-api"`)
s.problem(w, http.StatusUnauthorized, "bulk_authentication_required", "A valid bulk API key is required.")
return
}
if !s.licenses.Has(FeatureBulkAPI) {
s.metrics.bulkFailures.Add(1)
s.problem(w, http.StatusForbidden, "licensed_feature_required", "The bulk API requires the licensed capability bulk_api.")
return
}
limit := s.cfg.BulkMaxItems
if licensedLimit, ok := s.licenses.Limit("bulk_items"); ok && licensedLimit > 0 && int64(limit) > licensedLimit {
limit = int(licensedLimit)
}
body := http.MaxBytesReader(w, r.Body, s.cfg.BulkMaxBodyBytes)
defer body.Close()
dec := json.NewDecoder(body)
dec.DisallowUnknownFields()
var request bulkRequest
if err := dec.Decode(&request); err != nil {
s.metrics.bulkFailures.Add(1)
s.problem(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if err := ensureEOF(dec); err != nil {
s.metrics.bulkFailures.Add(1)
s.problem(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if len(request.Items) == 0 {
s.metrics.bulkFailures.Add(1)
s.problem(w, http.StatusBadRequest, "empty_bulk_request", "At least one item is required.")
return
}
if len(request.Items) > limit {
s.metrics.bulkFailures.Add(1)
s.problem(w, http.StatusRequestEntityTooLarge, "bulk_limit_exceeded", "The request exceeds the configured or licensed bulk item limit.")
return
}
s.metrics.bulkItems.Add(uint64(len(request.Items)))
results := make([]bulkResult, 0, len(request.Items))
for _, item := range request.Items {
q := url.Values{}
for key, value := range item.Parameters {
q.Set(key, value)
}
if q.Get("lang") == "" {
q.Set("lang", s.cfg.DefaultLanguage)
}
d, err := s.declarationFromQuery(q)
if err != nil {
results = append(results, bulkResult{ID: strings.TrimSpace(item.ID), Valid: false, Error: &bulkError{Code: "invalid_declaration", Detail: err.Error()}})
continue
}
assessment := declaration.AssessArticle50(d)
declarationURL, manifestURL, badgeURL := s.generatedURLs(q)
results = append(results, bulkResult{
ID: strings.TrimSpace(item.ID), Valid: true, Declaration: &d, Assessment: &assessment,
DeclarationURL: declarationURL, ManifestURL: manifestURL, BadgeURL: badgeURL,
})
}
s.writeJSON(w, http.StatusOK, map[string]any{
"product": ProductID, "productVersion": ProductVersion, "count": len(results), "items": results,
})
}
func (s *Server) handleArticle50Assessment(w http.ResponseWriter, r *http.Request) {
q := cloneValues(r.URL.Query())
q.Set("lang", s.languageFromValues(r, q))
d, err := s.declarationFromQuery(q)
if err != nil {
s.declarationError(w, err)
return
}
locale := i18n.Get(d.Language)
assessment := declaration.AssessArticle50(d)
view := article50AssessmentView(assessment, locale)
s.writeJSON(w, http.StatusOK, map[string]any{
"assessment": assessment,
"title": view.Title,
"summary": view.Summary,
"findings": view.Details,
"warnings": view.Warnings,
"disclaimer": locale.Text["assessment_disclaimer"],
})
}
func (s *Server) handleDeclarationBundle(w http.ResponseWriter, r *http.Request) {
if !s.licenses.Has(FeatureExportBundle) {
s.problem(w, http.StatusForbidden, "licensed_feature_required", "Declaration bundles require the licensed capability export_bundle.")
return
}
q := cloneValues(r.URL.Query())
q.Set("lang", s.languageFromValues(r, q))
d, err := s.declarationFromQuery(q)
if err != nil {
s.declarationError(w, err)
return
}
declarationURL, manifestURL, badgeURL := s.generatedURLs(q)
bundle := declarationBundle{
Product: ProductID, ProductVersion: ProductVersion, GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Declaration: d, DeclarationDigest: declarationDigest(d), Assessment: declaration.AssessArticle50(d), DeclarationURL: declarationURL, ManifestURL: manifestURL, BadgeURL: badgeURL,
}
w.Header().Set("Cache-Control", "no-store")
s.writeJSON(w, http.StatusOK, bundle)
}
func (s *Server) generatedURLs(q url.Values) (string, string, string) {
encoded := q.Encode()
base := s.cfg.OutputBaseURL
if base == "" {
base = s.cfg.BaseURL
}
declarationURL := base + "/declaration?" + encoded
manifestURL := base + "/v1/declaration.json?" + encoded
badgeQ := cloneValues(q)
badgeQ.Set("link", declarationURL)
badgeURL := base + "/v1/badge.svg?" + badgeQ.Encode()
return declarationURL, manifestURL, badgeURL
}
func declarationDigest(d declaration.Declaration) string {
encoded, err := json.Marshal(d)
if err != nil {
return ""
}
digest := sha256.Sum256(encoded)
return "sha256:" + hex.EncodeToString(digest[:])
}

View File

@@ -1,11 +1,8 @@
package app
import (
"errors"
"fmt"
"net/mail"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
@@ -15,19 +12,27 @@ import (
)
type Config struct {
EnvironmentErrors []string
ListenAddress string
BaseURL string
OutputBaseURL string
PublicName string
ContactURL string
SalesURL string
DefaultLanguage string
TrustProxy bool
TrustedProxies []netip.Prefix
LogClientIP bool
EnableHSTS bool
MetricsEnabled bool
MetricsToken string
ServiceMode string
BulkMaxItems int
BulkMaxBodyBytes int64
BulkRequireAPIKey bool
BulkAPIKey string
APIAllowedOrigin string
RequireLicense bool
WhiteLabel bool
MetricsEnabled bool
MetricsToken string
LegalStrict bool
LegalName string
@@ -61,66 +66,62 @@ type Config struct {
LicenseCacheFile string
LicenseRefreshEvery time.Duration
LicenseTimeout time.Duration
CommunityPrice string
ProPrice string
PublisherPrice string
AgencyPrice string
}
func ConfigFromEnv() Config {
var environmentErrors []string
contactURL := env("CONTACT_URL", "https://ai.trustednet.eu")
contactURL := env("CONTACT_URL", "https://b1tsblog.org/page/ai")
baseURL := strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/")
mode, err := licensekit.ParseMode(env("LICENSE_MODE", "offline"))
if err != nil {
environmentErrors = append(environmentErrors, "LICENSE_MODE is invalid")
mode = licensekit.ModeOffline
}
trustProxy := boolEnv("TRUST_PROXY", false, &environmentErrors)
logClientIP := boolEnv("LOG_CLIENT_IP", false, &environmentErrors)
enableHSTS := boolEnv("ENABLE_HSTS", true, &environmentErrors)
metricsEnabled := boolEnv("METRICS_ENABLED", false, &environmentErrors)
legalStrict := boolEnv("LEGAL_STRICT", false, &environmentErrors)
trustedProxies := prefixListEnv("TRUSTED_PROXY_CIDRS", &environmentErrors)
return Config{
EnvironmentErrors: environmentErrors,
ListenAddress: env("LISTEN_ADDRESS", ":8080"),
BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"),
PublicName: env("PUBLIC_NAME", "AI Usage Disclosure"),
BaseURL: baseURL,
OutputBaseURL: strings.TrimRight(env("OUTPUT_BASE_URL", baseURL), "/"),
PublicName: env("PUBLIC_NAME", "UCNG AI Usage Disclosure"),
ContactURL: contactURL,
SalesURL: env("SALES_URL", contactURL),
DefaultLanguage: env("DEFAULT_LANGUAGE", "de"),
TrustProxy: trustProxy,
TrustedProxies: trustedProxies,
LogClientIP: logClientIP,
EnableHSTS: enableHSTS,
MetricsEnabled: metricsEnabled,
MetricsToken: secretEnv("METRICS_TOKEN"),
TrustProxy: boolEnv("TRUST_PROXY", false),
TrustedProxies: cidrEnv("TRUSTED_PROXY_CIDRS"),
LogClientIP: boolEnv("LOG_CLIENT_IP", false),
EnableHSTS: boolEnv("ENABLE_HSTS", false),
ServiceMode: serviceModeEnv("SERVICE_MODE", "full"),
BulkMaxItems: intEnv("BULK_MAX_ITEMS", 500),
BulkMaxBodyBytes: int64(intEnv("BULK_MAX_BODY_BYTES", 4<<20)),
BulkRequireAPIKey: boolEnv("BULK_REQUIRE_API_KEY", false),
BulkAPIKey: secretEnv("BULK_API_KEY"),
APIAllowedOrigin: envAllowEmpty("API_ALLOWED_ORIGIN", "*"),
RequireLicense: boolEnv("REQUIRE_LICENSE", false),
WhiteLabel: boolEnv("WHITE_LABEL", false),
LegalStrict: legalStrict,
LegalName: env("LEGAL_NAME", ""),
LegalAddress: env("LEGAL_ADDRESS", ""),
LegalRepresentative: env("LEGAL_REPRESENTATIVE", ""),
LegalEmail: env("LEGAL_EMAIL", ""),
LegalPhone: env("LEGAL_PHONE", ""),
LegalRegister: env("LEGAL_REGISTER", ""),
LegalRegisterNumber: env("LEGAL_REGISTER_NUMBER", ""),
LegalVATID: env("LEGAL_VAT_ID", ""),
EditorialResponsibleName: env("EDITORIAL_RESPONSIBLE_NAME", ""),
EditorialResponsibleAddress: env("EDITORIAL_RESPONSIBLE_ADDRESS", ""),
DataProtectionContact: env("DATA_PROTECTION_CONTACT", ""),
HostingProvider: env("HOSTING_PROVIDER", ""),
HostingAddress: env("HOSTING_ADDRESS", ""),
LogRetention: env("LOG_RETENTION", ""),
DataRecipients: env("DATA_RECIPIENTS", ""),
ThirdCountryTransfers: env("THIRD_COUNTRY_TRANSFERS", ""),
SupervisoryAuthorityName: env("SUPERVISORY_AUTHORITY_NAME", ""),
SupervisoryAuthorityURL: env("SUPERVISORY_AUTHORITY_URL", ""),
ConsumerDisputeStatus: strings.ToLower(env("CONSUMER_DISPUTE_STATUS", "")),
ConsumerDisputeBody: env("CONSUMER_DISPUTE_BODY", ""),
ConsumerDisputeURL: env("CONSUMER_DISPUTE_URL", ""),
AccessibilityContact: env("ACCESSIBILITY_CONTACT", ""),
AccessibilityStatus: env("ACCESSIBILITY_STATUS", ""),
MetricsEnabled: boolEnv("METRICS_ENABLED", false),
MetricsToken: secretEnv("METRICS_TOKEN"),
LegalStrict: boolEnv("LEGAL_STRICT", false),
LegalName: envAllowEmpty("LEGAL_NAME", ""),
LegalAddress: envAllowEmpty("LEGAL_ADDRESS", ""),
LegalRepresentative: envAllowEmpty("LEGAL_REPRESENTATIVE", ""),
LegalEmail: envAllowEmpty("LEGAL_EMAIL", ""),
LegalPhone: envAllowEmpty("LEGAL_PHONE", ""),
LegalRegister: envAllowEmpty("LEGAL_REGISTER", ""),
LegalRegisterNumber: envAllowEmpty("LEGAL_REGISTER_NUMBER", ""),
LegalVATID: envAllowEmpty("LEGAL_VAT_ID", ""),
EditorialResponsibleName: envAllowEmpty("EDITORIAL_RESPONSIBLE_NAME", ""),
EditorialResponsibleAddress: envAllowEmpty("EDITORIAL_RESPONSIBLE_ADDRESS", ""),
DataProtectionContact: envAllowEmpty("DATA_PROTECTION_CONTACT", ""),
HostingProvider: envAllowEmpty("HOSTING_PROVIDER", ""),
HostingAddress: envAllowEmpty("HOSTING_ADDRESS", ""),
LogRetention: envAllowEmpty("LOG_RETENTION", ""),
DataRecipients: envAllowEmpty("DATA_RECIPIENTS", ""),
ThirdCountryTransfers: envAllowEmpty("THIRD_COUNTRY_TRANSFERS", ""),
SupervisoryAuthorityName: envAllowEmpty("SUPERVISORY_AUTHORITY_NAME", ""),
SupervisoryAuthorityURL: envAllowEmpty("SUPERVISORY_AUTHORITY_URL", ""),
ConsumerDisputeStatus: disputeStatusEnv("CONSUMER_DISPUTE_STATUS", "not_applicable"),
ConsumerDisputeBody: envAllowEmpty("CONSUMER_DISPUTE_BODY", ""),
ConsumerDisputeURL: envAllowEmpty("CONSUMER_DISPUTE_URL", ""),
AccessibilityContact: envAllowEmpty("ACCESSIBILITY_CONTACT", ""),
AccessibilityStatus: envAllowEmpty("ACCESSIBILITY_STATUS", ""),
LicenseToken: secretEnv("LICENSE_TOKEN"),
LicenseMode: mode,
@@ -129,120 +130,38 @@ func ConfigFromEnv() Config {
LicenseCacheFile: env("LICENSE_CACHE_FILE", "./data/license-lease.json"),
LicenseRefreshEvery: durationEnv("LICENSE_REFRESH_INTERVAL", 15*time.Minute),
LicenseTimeout: durationEnv("LICENSE_REQUEST_TIMEOUT", 5*time.Second),
CommunityPrice: env("PRICE_COMMUNITY", "0 €"),
ProPrice: env("PRICE_PRO", "19 €"),
PublisherPrice: env("PRICE_PUBLISHER", "79 €"),
AgencyPrice: env("PRICE_AGENCY", "199 €"),
}
}
func validateConfig(cfg Config) error {
if len(cfg.EnvironmentErrors) > 0 {
return fmt.Errorf("invalid environment configuration: %s", strings.Join(cfg.EnvironmentErrors, "; "))
func (c Config) Validate() error {
if c.TrustProxy && len(c.TrustedProxies) == 0 {
return fmt.Errorf("TRUST_PROXY=true requires at least one valid TRUSTED_PROXY_CIDRS entry")
}
u, err := url.Parse(cfg.BaseURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("BASE_URL must be an absolute http(s) origin without path, query, credentials or fragment")
if c.MetricsEnabled && len(c.MetricsToken) < 32 {
return fmt.Errorf("METRICS_ENABLED=true requires METRICS_TOKEN or METRICS_TOKEN_FILE with at least 32 characters")
}
if strings.TrimSpace(cfg.PublicName) == "" {
return errors.New("PUBLIC_NAME must not be empty")
}
if cfg.TrustProxy && len(cfg.TrustedProxies) == 0 {
return errors.New("TRUST_PROXY=true requires at least one TRUSTED_PROXY_CIDRS entry")
}
if cfg.MetricsEnabled && configValueMissing(cfg.MetricsToken) {
return errors.New("METRICS_ENABLED=true requires METRICS_TOKEN or METRICS_TOKEN_FILE")
}
for _, item := range []struct {
name, value string
}{
{"CONTACT_URL", cfg.ContactURL},
{"SALES_URL", cfg.SalesURL},
{"SUPERVISORY_AUTHORITY_URL", cfg.SupervisoryAuthorityURL},
{"CONSUMER_DISPUTE_URL", cfg.ConsumerDisputeURL},
} {
if err := validateOptionalHTTPURL(item.name, item.value); err != nil {
return err
if c.LegalStrict {
required := map[string]string{
"LEGAL_NAME": c.LegalName,
"LEGAL_ADDRESS": c.LegalAddress,
"LEGAL_EMAIL": c.LegalEmail,
"DATA_PROTECTION_CONTACT": c.DataProtectionContact,
"HOSTING_PROVIDER": c.HostingProvider,
"HOSTING_ADDRESS": c.HostingAddress,
}
}
for _, item := range []struct {
name, value string
}{
{"LEGAL_EMAIL", cfg.LegalEmail},
{"DATA_PROTECTION_CONTACT", cfg.DataProtectionContact},
{"ACCESSIBILITY_CONTACT", cfg.AccessibilityContact},
} {
if err := validateOptionalEmail(item.name, item.value); err != nil {
return err
missing := make([]string, 0)
for key, value := range required {
if placeholderOrEmpty(value) {
missing = append(missing, key)
}
}
}
if cfg.LegalStrict {
missing := legalRequiredFields(cfg)
if len(missing) > 0 {
return fmt.Errorf("LEGAL_STRICT=true but required legal configuration is missing: %s", strings.Join(missing, ", "))
return fmt.Errorf("LEGAL_STRICT=true but required operator fields are missing or placeholders: %s", strings.Join(missing, ", "))
}
}
switch cfg.ConsumerDisputeStatus {
case "", "not_applicable", "not_participating", "participating":
default:
return errors.New("CONSUMER_DISPUTE_STATUS must be empty, not_applicable, not_participating or participating")
}
if cfg.ConsumerDisputeStatus == "participating" && (cfg.ConsumerDisputeBody == "" || cfg.ConsumerDisputeURL == "") {
return errors.New("CONSUMER_DISPUTE_BODY and CONSUMER_DISPUTE_URL are required when CONSUMER_DISPUTE_STATUS=participating")
}
return nil
}
func legalRequiredFields(cfg Config) []string {
var missing []string
for _, field := range []struct{ key, value string }{
{"LEGAL_NAME", cfg.LegalName},
{"LEGAL_ADDRESS", cfg.LegalAddress},
{"LEGAL_EMAIL", cfg.LegalEmail},
} {
if configValueMissing(field.value) {
missing = append(missing, field.key)
if c.ConsumerDisputeStatus == "participating" && (placeholderOrEmpty(c.ConsumerDisputeBody) || placeholderOrEmpty(c.ConsumerDisputeURL)) {
return fmt.Errorf("CONSUMER_DISPUTE_STATUS=participating requires CONSUMER_DISPUTE_BODY and CONSUMER_DISPUTE_URL")
}
}
if configValueMissing(cfg.LogRetention) {
missing = append(missing, "LOG_RETENTION")
}
if configValueMissing(cfg.HostingProvider) {
missing = append(missing, "HOSTING_PROVIDER")
}
if configValueMissing(cfg.ConsumerDisputeStatus) {
missing = append(missing, "CONSUMER_DISPUTE_STATUS")
}
return missing
}
func configValueMissing(value string) bool {
value = strings.ToUpper(strings.TrimSpace(value))
return value == "" || value == "REPLACE_ME" || value == "CHANGEME" || value == "TODO"
}
func validateOptionalHTTPURL(name, value string) error {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
u, err := url.Parse(value)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
return fmt.Errorf("%s must be an absolute http(s) URL without credentials", name)
}
return nil
}
func validateOptionalEmail(name, value string) error {
value = strings.TrimSpace(value)
if value == "" || configValueMissing(value) {
return nil
}
address, err := mail.ParseAddress(value)
if err != nil || address.Address != value {
return fmt.Errorf("%s must be a plain valid email address", name)
}
return nil
}
@@ -253,51 +172,40 @@ func env(key, fallback string) string {
return fallback
}
func boolEnv(key string, fallback bool, environmentErrors *[]string) bool {
func envAllowEmpty(key, fallback string) string {
value, ok := os.LookupEnv(key)
if !ok {
return fallback
}
return strings.TrimSpace(value)
}
func secretEnv(key string) string {
// Explicit *_FILE configuration is authoritative. This avoids a stale
// environment value silently overriding Docker/Kubernetes secrets.
path := strings.TrimSpace(os.Getenv(key + "_FILE"))
if path != "" {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
return strings.TrimSpace(os.Getenv(key))
}
func boolEnv(key string, fallback bool) bool {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
*environmentErrors = append(*environmentErrors, key+" must be true or false")
return fallback
}
return parsed
}
func secretEnv(key string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
path := strings.TrimSpace(os.Getenv(key + "_FILE"))
if path == "" {
return ""
}
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func prefixListEnv(key string, environmentErrors *[]string) []netip.Prefix {
var out []netip.Prefix
for _, raw := range strings.Split(os.Getenv(key), ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
prefix, err := netip.ParsePrefix(raw)
if err != nil {
*environmentErrors = append(*environmentErrors, key+" contains invalid CIDR "+raw)
continue
}
out = append(out, prefix.Masked())
}
return out
}
func durationEnv(key string, fallback time.Duration) time.Duration {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
@@ -311,3 +219,71 @@ func durationEnv(key string, fallback time.Duration) time.Duration {
}
return fallback
}
func intEnv(key string, fallback int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
n, err := strconv.Atoi(value)
if err != nil || n <= 0 {
return fallback
}
return n
}
func serviceModeEnv(key, fallback string) string {
value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
switch value {
case "full", "api", "bulk":
return value
case "":
return fallback
default:
return fallback
}
}
func disputeStatusEnv(key, fallback string) string {
value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
switch value {
case "not_applicable", "not_participating", "participating":
return value
case "":
return fallback
default:
return fallback
}
}
func cidrEnv(key string) []netip.Prefix {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]netip.Prefix, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if prefix, err := netip.ParsePrefix(part); err == nil {
out = append(out, prefix.Masked())
continue
}
if addr, err := netip.ParseAddr(part); err == nil {
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
}
}
return out
}
func placeholderOrEmpty(value string) bool {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return true
}
upper := strings.ToUpper(trimmed)
return strings.Contains(upper, "REPLACE_ME") || strings.Contains(upper, "CHANGEME") || strings.Contains(upper, "TODO")
}

157
internal/app/config_test.go Normal file
View File

@@ -0,0 +1,157 @@
package app
import (
"go/ast"
"go/parser"
"go/token"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
)
func TestConfigValidationRequiresTrustedProxyCIDRs(t *testing.T) {
cfg := Config{TrustProxy: true}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "TRUSTED_PROXY_CIDRS") {
t.Fatalf("expected trusted proxy validation error, got %v", err)
}
}
func TestConfigValidationRequiresMetricsToken(t *testing.T) {
cfg := Config{MetricsEnabled: true, MetricsToken: "short"}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "METRICS_TOKEN") {
t.Fatalf("expected metrics token validation error, got %v", err)
}
}
func TestConfigValidationLegalStrict(t *testing.T) {
cfg := Config{LegalStrict: true}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "LEGAL_NAME") {
t.Fatalf("expected legal validation error, got %v", err)
}
cfg.LegalName = "Example GmbH"
cfg.LegalAddress = "Example Street 1\n12345 Example"
cfg.LegalEmail = "legal@example.org"
cfg.DataProtectionContact = "privacy@example.org"
cfg.HostingProvider = "Example Hosting GmbH"
cfg.HostingAddress = "Hosting Street 1\n12345 Example"
if err := cfg.Validate(); err != nil {
t.Fatalf("valid strict legal configuration rejected: %v", err)
}
}
func TestMetricsDisabledAndProtected(t *testing.T) {
h := testHandlerConfig(t, Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de"})
r := httptest.NewRequest(http.MethodGet, "/metrics", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Fatalf("disabled metrics status %d", w.Code)
}
cfg := Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", MetricsEnabled: true, MetricsToken: strings.Repeat("x", 40)}
h = testHandlerConfig(t, cfg)
r = httptest.NewRequest(http.MethodGet, "/metrics", nil)
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Fatalf("metrics without token status %d", w.Code)
}
r = httptest.NewRequest(http.MethodGet, "/metrics", nil)
r.Header.Set("Authorization", "Bearer "+cfg.MetricsToken)
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "ai_disclosure_uptime_seconds") {
t.Fatalf("authorized metrics failed: %d %s", w.Code, w.Body.String())
}
}
func TestOperatorPagesAndHSTS(t *testing.T) {
cfg := Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Example", DefaultLanguage: "de", EnableHSTS: true,
LegalName: "Example GmbH", LegalAddress: "Example Street 1", LegalEmail: "legal@example.org",
DataProtectionContact: "privacy@example.org", HostingProvider: "Host GmbH", HostingAddress: "Host Street 1",
AccessibilityContact: "access@example.org", AccessibilityStatus: "Teilweise konform getestet",
}
h := testHandlerConfig(t, cfg)
for _, tc := range []struct{ path, want string }{{"/legal?lang=de", "Example GmbH"}, {"/privacy?lang=en", "Privacy information"}, {"/accessibility?lang=fr", "Accessibilité"}} {
r := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), tc.want) {
t.Fatalf("%s: %d, missing %q", tc.path, w.Code, tc.want)
}
if got := w.Header().Get("Strict-Transport-Security"); got == "" {
t.Fatalf("%s missing HSTS", tc.path)
}
}
}
func TestEnvExampleDocumentsRuntimeConfiguration(t *testing.T) {
root := filepath.Join("..", "..")
data, err := os.ReadFile(filepath.Join(root, ".env.example"))
if err != nil {
t.Fatal(err)
}
text := string(data)
keys := map[string]struct{}{}
files := []string{filepath.Join("config.go"), filepath.Join(root, "cmd", "server", "main.go")}
helpers := map[string]bool{
"env": true, "envAllowEmpty": true, "boolEnv": true, "durationEnv": true, "intEnv": true,
"serviceModeEnv": true, "disputeStatusEnv": true, "cidrEnv": true, "secretEnv": true,
}
fset := token.NewFileSet()
for _, filename := range files {
file, err := parser.ParseFile(fset, filename, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", filename, err)
}
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
name := ""
switch fn := call.Fun.(type) {
case *ast.Ident:
if helpers[fn.Name] {
name = fn.Name
}
case *ast.SelectorExpr:
if ident, ok := fn.X.(*ast.Ident); ok && ident.Name == "os" && (fn.Sel.Name == "Getenv" || fn.Sel.Name == "LookupEnv") {
name = fn.Sel.Name
}
}
if name == "" {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
key, err := strconv.Unquote(lit.Value)
if err != nil || key == "" {
return true
}
keys[key] = struct{}{}
if name == "secretEnv" {
keys[key+"_FILE"] = struct{}{}
}
return true
})
}
missing := make([]string, 0)
for key := range keys {
if !strings.Contains(text, key+"=") {
missing = append(missing, key)
}
}
sort.Strings(missing)
if len(missing) > 0 {
t.Fatalf(".env.example is missing runtime variables: %s", strings.Join(missing, ", "))
}
}

View File

@@ -1,18 +1,13 @@
package app
import (
"fmt"
"sort"
"net/http"
"strings"
"github.com/b1tsblog/ai-disclosure-standard/internal/i18n"
)
type legalNav struct {
Imprint string
Privacy string
Accessibility string
}
type legalField struct {
type legalItem struct {
Label string
Value string
URL string
@@ -21,320 +16,297 @@ type legalField struct {
type legalSection struct {
Title string
Paragraphs []string
Fields []legalField
Bullets []string
Items []legalItem
}
type legalPage struct {
Title string
MetaDescription string
Eyebrow string
Intro string
Warning string
Sections []legalSection
LastUpdated string
Kind string
Title string
Lead string
Sections []legalSection
NoticeTitle string
Notice string
}
func legalNavFor(lang string) legalNav {
if lang == "de" {
return legalNav{Imprint: "Impressum", Privacy: "Datenschutz", Accessibility: "Barrierefreiheit"}
}
return legalNav{Imprint: "Legal notice", Privacy: "Privacy", Accessibility: "Accessibility"}
type legalCopy struct {
LegalLabel, PrivacyLabel, AccessibilityLabel string
LegalTitle, LegalLead string
Operator, Representative, Contact string
Address, Email, Phone, Register, RegisterNo string
VAT, Editorial, EditorialAddress string
DisputeTitle, DisputeNA, DisputeNo, DisputeYes string
PrivacyTitle, PrivacyLead string
Controller, DataProtection, Hosting string
HostingAddress, Logs, Retention, Recipients string
Transfers, Authority string
PrivacyTechTitle, PrivacyTechText string
AccessibilityTitle, AccessibilityLead string
AccessibilityContact, AccessibilityStatus string
AccessibilityTechTitle, AccessibilityTechText string
NoticeTitle, Notice string
}
func imprintPage(cfg Config, lang string) legalPage {
missing := missingNamed(cfg, map[string]string{
"LEGAL_NAME": cfg.LegalName, "LEGAL_ADDRESS": cfg.LegalAddress, "LEGAL_EMAIL": cfg.LegalEmail,
"CONSUMER_DISPUTE_STATUS": cfg.ConsumerDisputeStatus,
})
if lang == "de" {
sections := []legalSection{
{
Title: "Anbieter und Kontakt",
Fields: compactFields([]legalField{
{Label: "Name / Firma", Value: requiredValue(cfg.LegalName)},
{Label: "Ladungsfähige Anschrift", Value: requiredValue(cfg.LegalAddress)},
{Label: "Vertretungsberechtigt", Value: cfg.LegalRepresentative},
{Label: "E-Mail", Value: requiredValue(cfg.LegalEmail), URL: mailto(cfg.LegalEmail)},
{Label: "Telefon", Value: cfg.LegalPhone, URL: tel(cfg.LegalPhone)},
}),
},
{
Title: "Register und Steuerangaben",
Fields: compactFields([]legalField{
{Label: "Registergericht / Register", Value: cfg.LegalRegister},
{Label: "Registernummer", Value: cfg.LegalRegisterNumber},
{Label: "Umsatzsteuer-ID", Value: cfg.LegalVATID},
}),
Paragraphs: []string{"Es werden nur Daten angezeigt, wenn der Betreiber tatsächlich relevante Daten anzugeben hat. Die Software ist grundsätzlich nicht für gewinnorientiere Zwecke freigegeben."},
},
}
if cfg.EditorialResponsibleName != "" || cfg.EditorialResponsibleAddress != "" {
sections = append(sections, legalSection{
Title: "Inhaltlich verantwortlich",
Paragraphs: []string{"Verantwortlich für journalistisch-redaktionelle Inhalte, soweit § 18 Abs. 2 Medienstaatsvertrag anwendbar ist:"},
Fields: compactFields([]legalField{{Label: "Name", Value: cfg.EditorialResponsibleName}, {Label: "Anschrift", Value: cfg.EditorialResponsibleAddress}}),
})
}
if dispute := disputeSection(cfg, true); dispute.Title != "" {
sections = append(sections, dispute)
}
sections = append(sections,
legalSection{
Title: "Hinweise zu Inhalten und Kennzeichnungen",
Paragraphs: []string{
"Die Anwendung erzeugt technische Transparenzhinweise auf Grundlage der eingegebenen Angaben. Sie prüft nicht automatisch, ob eine konkrete Kennzeichnung gesetzlich erforderlich, vollständig oder inhaltlich richtig ist.",
"Für veröffentlichte Erklärungen, redaktionelle Inhalte und eingebundene Links bleibt die jeweils veröffentlichende Person oder Organisation verantwortlich.",
},
},
legalSection{
Title: "Rechtsgrundlagen der Anbieterkennzeichnung",
Paragraphs: []string{"Je nach Angebot können insbesondere § 5 Digitale-Dienste-Gesetz und § 18 Medienstaatsvertrag einschlägig sein. Die konkrete Pflicht hängt vom Betreiber, Geschäftsmodell und Inhalt ab."},
Fields: []legalField{
{Label: "§ 5 DDG", Value: "Allgemeine Informationspflichten", URL: "https://www.gesetze-im-internet.de/ddg/__5.html"},
{Label: "§ 18 MStV", Value: "Informationspflichten und Auskunftsrechte", URL: "https://www.gesetze-bayern.de/Content/Document/MStV-18"},
},
},
)
return legalPage{
Title: "Impressum", MetaDescription: "Anbieterkennzeichnung und Kontaktangaben.", Eyebrow: "Rechtliche Informationen",
Intro: "Betreiberangaben für diesen digitalen Dienst.",
Warning: legalWarning(missing, true), Sections: sections, LastUpdated: "20. Juli 2026",
}
func legalLabels(lang string) legalCopy {
base := legalCopy{
LegalLabel: "Legal notice", PrivacyLabel: "Privacy", AccessibilityLabel: "Accessibility",
LegalTitle: "Legal notice", LegalLead: "Operator and contact information for this service.",
Operator: "Operator", Representative: "Represented by", Contact: "Contact", Address: "Address", Email: "Email", Phone: "Phone", Register: "Register", RegisterNo: "Register number", VAT: "VAT ID", Editorial: "Editorial responsibility", EditorialAddress: "Address of editorially responsible person",
DisputeTitle: "Consumer dispute resolution", DisputeNA: "The operator has assessed the general information obligation as not applicable to this service.", DisputeNo: "The operator states that it does not participate in consumer dispute resolution proceedings unless legally required otherwise.", DisputeYes: "The operator states that it participates in consumer dispute resolution proceedings before the body listed below.",
PrivacyTitle: "Privacy information", PrivacyLead: "Information about the operation of this service and the processing of technical connection data.",
Controller: "Controller / operator", DataProtection: "Data protection contact", Hosting: "Hosting provider", HostingAddress: "Hosting address", Logs: "Server logs", Retention: "Log retention", Recipients: "Recipients / processors", Transfers: "Third-country transfers", Authority: "Supervisory authority",
PrivacyTechTitle: "Technical design", PrivacyTechText: "The application itself does not require advertising trackers or third-party analytics. Depending on the deployment, the reverse proxy, hosting platform or surrounding infrastructure may process connection and log data. The operator must keep the information on this page aligned with the actual deployment.",
AccessibilityTitle: "Accessibility", AccessibilityLead: "Information and contact options relating to the accessibility of this service.", AccessibilityContact: "Accessibility contact", AccessibilityStatus: "Accessibility status",
AccessibilityTechTitle: "Technical approach", AccessibilityTechText: "The application uses semantic HTML, keyboard-operable controls and language metadata. This statement is not a certification. The operator remains responsible for testing the deployed service, custom content, branding and integrations against the requirements applicable to it.",
NoticeTitle: "Important", Notice: "These pages are configurable operator information, not legal advice. Their completeness and legal applicability depend on the operator, jurisdiction, business model and actual technical deployment.",
}
sections := []legalSection{
{Title: "Provider and contact", Fields: compactFields([]legalField{
{Label: "Name / company", Value: requiredValue(cfg.LegalName)},
{Label: "Service address", Value: requiredValue(cfg.LegalAddress)},
{Label: "Represented by", Value: cfg.LegalRepresentative},
{Label: "Email", Value: requiredValue(cfg.LegalEmail), URL: mailto(cfg.LegalEmail)},
{Label: "Telephone", Value: cfg.LegalPhone, URL: tel(cfg.LegalPhone)},
})},
{Title: "Registration and tax details", Fields: compactFields([]legalField{
{Label: "Register / court", Value: cfg.LegalRegister}, {Label: "Registration number", Value: cfg.LegalRegisterNumber}, {Label: "VAT ID", Value: cfg.LegalVATID},
}), Paragraphs: []string{"Data is only displayed if the operator actually has relevant data to provide. The software is generally not released for profit-oriented purposes."}},
switch normalizeLanguage(lang) {
case "de":
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Impressum", "Datenschutz", "Barrierefreiheit"
base.LegalTitle, base.LegalLead = "Impressum und Anbieterinformationen", "Angaben zum Betreiber und zu den Kontaktmöglichkeiten dieses Dienstes."
base.Operator, base.Representative, base.Contact = "Anbieter / Betreiber", "Vertreten durch", "Kontakt"
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Anschrift", "E-Mail", "Telefon", "Register", "Registernummer", "Umsatzsteuer-ID"
base.Editorial, base.EditorialAddress = "Journalistisch-redaktionell verantwortlich", "Anschrift der verantwortlichen Person"
base.DisputeTitle = "Verbraucherstreitbeilegung"
base.DisputeNA = "Der Betreiber hat die allgemeine Informationspflicht zur Verbraucherstreitbeilegung für dieses Angebot als nicht anwendbar eingeordnet."
base.DisputeNo = "Der Betreiber erklärt, nicht an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen, soweit keine gesetzliche Verpflichtung besteht."
base.DisputeYes = "Der Betreiber erklärt, an Streitbeilegungsverfahren vor der nachfolgend genannten Verbraucherschlichtungsstelle teilzunehmen."
base.PrivacyTitle, base.PrivacyLead = "Datenschutzhinweise", "Informationen zum Betrieb dieses Dienstes und zur Verarbeitung technischer Verbindungsdaten."
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Verantwortlicher / Betreiber", "Datenschutzkontakt", "Hosting-Anbieter", "Anschrift des Hosting-Anbieters"
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Server-Protokolle", "Speicherdauer der Protokolle", "Empfänger / Auftragsverarbeiter", "Drittlandübermittlungen", "Aufsichtsbehörde"
base.PrivacyTechTitle = "Technische Ausgestaltung"
base.PrivacyTechText = "Die Anwendung selbst benötigt keine Werbetracker oder Drittanbieter-Analytics. Je nach Deployment können Reverse Proxy, Hosting-Plattform oder umgebende Infrastruktur Verbindungs- und Protokolldaten verarbeiten. Der Betreiber muss die Angaben auf dieser Seite mit dem tatsächlichen Betrieb abgleichen."
base.AccessibilityTitle, base.AccessibilityLead = "Barrierefreiheit", "Informationen und Kontaktmöglichkeiten zur Barrierefreiheit dieses Dienstes."
base.AccessibilityContact, base.AccessibilityStatus = "Kontakt zur Barrierefreiheit", "Stand der Barrierefreiheit"
base.AccessibilityTechTitle = "Technischer Ansatz"
base.AccessibilityTechText = "Die Anwendung verwendet semantisches HTML, tastaturbedienbare Steuerelemente und Sprachmetadaten. Diese Angabe ist keine Zertifizierung. Der Betreiber bleibt dafür verantwortlich, das konkrete Deployment, eigene Inhalte, Branding und Integrationen anhand der für ihn geltenden Anforderungen zu prüfen."
base.NoticeTitle, base.Notice = "Wichtiger Hinweis", "Diese Seiten stellen konfigurierbare Betreiberinformationen und keine Rechtsberatung dar. Vollständigkeit und rechtliche Anwendbarkeit hängen vom Betreiber, der Rechtsordnung, dem Geschäftsmodell und dem tatsächlichen technischen Betrieb ab."
case "fr":
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Mentions légales", "Confidentialité", "Accessibilité"
base.LegalTitle, base.LegalLead = "Mentions légales", "Informations sur lexploitant et les moyens de contact de ce service."
base.Operator, base.Representative, base.Contact = "Exploitant", "Représenté par", "Contact"
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Adresse", "E-mail", "Téléphone", "Registre", "Numéro denregistrement", "Numéro de TVA"
base.Editorial, base.EditorialAddress = "Responsabilité éditoriale", "Adresse de la personne responsable"
base.DisputeTitle, base.DisputeNA = "Règlement des litiges de consommation", "Lexploitant a considéré que lobligation générale dinformation relative au règlement des litiges de consommation nest pas applicable à ce service."
base.DisputeNo = "Lexploitant déclare ne pas participer à une procédure de règlement des litiges de consommation, sauf obligation légale contraire."
base.DisputeYes = "Lexploitant déclare participer à une procédure de règlement des litiges devant lorganisme indiqué ci-dessous."
base.PrivacyTitle, base.PrivacyLead = "Informations sur la confidentialité", "Informations sur lexploitation du service et le traitement des données techniques de connexion."
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Responsable / exploitant", "Contact protection des données", "Hébergeur", "Adresse de lhébergeur"
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Journaux serveur", "Durée de conservation des journaux", "Destinataires / sous-traitants", "Transferts vers des pays tiers", "Autorité de contrôle"
base.PrivacyTechTitle, base.PrivacyTechText = "Conception technique", "Lapplication elle-même ne nécessite ni traceurs publicitaires ni outils danalyse tiers. Selon le déploiement, le proxy inverse, lhébergement ou linfrastructure environnante peuvent traiter des données de connexion et de journalisation. Lexploitant doit maintenir ces informations conformes au fonctionnement réel."
base.AccessibilityTitle, base.AccessibilityLead = "Accessibilité", "Informations et contact concernant laccessibilité de ce service."
base.AccessibilityContact, base.AccessibilityStatus = "Contact accessibilité", "État daccessibilité"
base.AccessibilityTechTitle, base.AccessibilityTechText = "Approche technique", "Lapplication utilise du HTML sémantique, des contrôles utilisables au clavier et des métadonnées de langue. Cette indication nest pas une certification. Lexploitant reste responsable de lévaluation du déploiement, des contenus personnalisés, de lidentité visuelle et des intégrations."
base.NoticeTitle, base.Notice = "Remarque importante", "Ces pages fournissent des informations configurables sur lexploitant et ne constituent pas un conseil juridique. Leur exhaustivité dépend du déploiement et du droit applicable."
case "es":
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Aviso legal", "Privacidad", "Accesibilidad"
base.LegalTitle, base.LegalLead = "Aviso legal", "Información sobre el operador y los medios de contacto de este servicio."
base.Operator, base.Representative, base.Contact = "Operador", "Representado por", "Contacto"
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Dirección", "Correo electrónico", "Teléfono", "Registro", "Número registral", "NIF-IVA"
base.Editorial, base.EditorialAddress = "Responsabilidad editorial", "Dirección de la persona responsable"
base.DisputeTitle, base.DisputeNA = "Resolución de litigios de consumo", "El operador ha considerado que la obligación general de información sobre resolución de litigios de consumo no es aplicable a este servicio."
base.DisputeNo = "El operador declara que no participa en procedimientos de resolución de litigios de consumo salvo obligación legal."
base.DisputeYes = "El operador declara que participa en procedimientos de resolución de litigios ante el organismo indicado a continuación."
base.PrivacyTitle, base.PrivacyLead = "Información de privacidad", "Información sobre la operación del servicio y el tratamiento de datos técnicos de conexión."
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Responsable / operador", "Contacto de protección de datos", "Proveedor de alojamiento", "Dirección del proveedor de alojamiento"
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Registros del servidor", "Conservación de registros", "Destinatarios / encargados", "Transferencias a terceros países", "Autoridad de control"
base.PrivacyTechTitle, base.PrivacyTechText = "Diseño técnico", "La aplicación no requiere rastreadores publicitarios ni analítica de terceros. Según el despliegue, el proxy inverso, el alojamiento o la infraestructura circundante pueden procesar datos de conexión y registros. El operador debe mantener esta información alineada con el funcionamiento real."
base.AccessibilityTitle, base.AccessibilityLead = "Accesibilidad", "Información y contacto sobre la accesibilidad de este servicio."
base.AccessibilityContact, base.AccessibilityStatus = "Contacto de accesibilidad", "Estado de accesibilidad"
base.AccessibilityTechTitle, base.AccessibilityTechText = "Enfoque técnico", "La aplicación utiliza HTML semántico, controles operables mediante teclado y metadatos de idioma. Esta información no es una certificación. El operador sigue siendo responsable de evaluar el despliegue, el contenido personalizado, la marca y las integraciones."
base.NoticeTitle, base.Notice = "Aviso importante", "Estas páginas contienen información configurable del operador y no constituyen asesoramiento jurídico. Su suficiencia depende del despliegue y de la normativa aplicable."
case "it":
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Note legali", "Privacy", "Accessibilità"
base.LegalTitle, base.LegalLead = "Note legali", "Informazioni sul gestore e sui contatti di questo servizio."
base.Operator, base.Representative, base.Contact = "Gestore", "Rappresentato da", "Contatto"
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Indirizzo", "E-mail", "Telefono", "Registro", "Numero di registrazione", "Partita IVA"
base.Editorial, base.EditorialAddress = "Responsabilità editoriale", "Indirizzo del responsabile editoriale"
base.DisputeTitle, base.DisputeNA = "Risoluzione delle controversie dei consumatori", "Il gestore ha valutato come non applicabile a questo servizio lobbligo generale di informazione sulla risoluzione delle controversie dei consumatori."
base.DisputeNo = "Il gestore dichiara di non partecipare a procedure di risoluzione delle controversie dei consumatori salvo diverso obbligo di legge."
base.DisputeYes = "Il gestore dichiara di partecipare a procedure di risoluzione delle controversie dinanzi allorganismo indicato di seguito."
base.PrivacyTitle, base.PrivacyLead = "Informativa privacy", "Informazioni sul funzionamento del servizio e sul trattamento dei dati tecnici di connessione."
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Titolare / gestore", "Contatto privacy", "Fornitore di hosting", "Indirizzo del fornitore di hosting"
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Log del server", "Conservazione dei log", "Destinatari / responsabili", "Trasferimenti verso paesi terzi", "Autorità di controllo"
base.PrivacyTechTitle, base.PrivacyTechText = "Configurazione tecnica", "Lapplicazione non richiede tracker pubblicitari o analytics di terze parti. A seconda del deployment, reverse proxy, hosting o infrastruttura circostante possono trattare dati di connessione e log. Il gestore deve mantenere queste informazioni coerenti con il funzionamento effettivo."
base.AccessibilityTitle, base.AccessibilityLead = "Accessibilità", "Informazioni e contatti relativi allaccessibilità di questo servizio."
base.AccessibilityContact, base.AccessibilityStatus = "Contatto accessibilità", "Stato di accessibilità"
base.AccessibilityTechTitle, base.AccessibilityTechText = "Approccio tecnico", "Lapplicazione utilizza HTML semantico, controlli utilizzabili da tastiera e metadati linguistici. Questa indicazione non è una certificazione. Il gestore resta responsabile della verifica del deployment, dei contenuti personalizzati, del branding e delle integrazioni."
base.NoticeTitle, base.Notice = "Avviso importante", "Queste pagine contengono informazioni configurabili del gestore e non costituiscono consulenza legale. La loro completezza dipende dal deployment e dalla normativa applicabile."
case "nl":
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Juridische informatie", "Privacy", "Toegankelijkheid"
base.LegalTitle, base.LegalLead = "Juridische informatie", "Informatie over de exploitant en contactmogelijkheden van deze dienst."
base.Operator, base.Representative, base.Contact = "Exploitant", "Vertegenwoordigd door", "Contact"
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Adres", "E-mail", "Telefoon", "Register", "Registratienummer", "Btw-nummer"
base.Editorial, base.EditorialAddress = "Redactionele verantwoordelijkheid", "Adres van de redactioneel verantwoordelijke"
base.DisputeTitle, base.DisputeNA = "Consumentengeschillen", "De exploitant heeft beoordeeld dat de algemene informatieplicht over consumentengeschillen niet van toepassing is op deze dienst."
base.DisputeNo = "De exploitant verklaart niet deel te nemen aan procedures voor consumentengeschillen, tenzij dit wettelijk verplicht is."
base.DisputeYes = "De exploitant verklaart deel te nemen aan procedures voor consumentengeschillen bij de hieronder genoemde instantie."
base.PrivacyTitle, base.PrivacyLead = "Privacy-informatie", "Informatie over de werking van de dienst en verwerking van technische verbindingsgegevens."
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Verwerkingsverantwoordelijke / exploitant", "Privacycontact", "Hostingprovider", "Adres van hostingprovider"
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Serverlogs", "Bewaartermijn logs", "Ontvangers / verwerkers", "Doorgiften naar derde landen", "Toezichthouder"
base.PrivacyTechTitle, base.PrivacyTechText = "Technische inrichting", "De toepassing zelf vereist geen advertentietrackers of externe analytics. Afhankelijk van de deployment kunnen reverse proxy, hostingplatform of omliggende infrastructuur verbindings- en loggegevens verwerken. De exploitant moet deze informatie laten aansluiten op de werkelijke werking."
base.AccessibilityTitle, base.AccessibilityLead = "Toegankelijkheid", "Informatie en contact over de toegankelijkheid van deze dienst."
base.AccessibilityContact, base.AccessibilityStatus = "Contact toegankelijkheid", "Toegankelijkheidsstatus"
base.AccessibilityTechTitle, base.AccessibilityTechText = "Technische aanpak", "De toepassing gebruikt semantische HTML, toetsenbordbedienbare besturingselementen en taalmetadata. Dit is geen certificering. De exploitant blijft verantwoordelijk voor het toetsen van deployment, aangepaste inhoud, huisstijl en integraties."
base.NoticeTitle, base.Notice = "Belangrijk", "Deze paginas bevatten configureerbare exploitantinformatie en zijn geen juridisch advies. Volledigheid hangt af van deployment en toepasselijk recht."
case "pt":
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Informação legal", "Privacidade", "Acessibilidade"
base.LegalTitle, base.LegalLead = "Informação legal", "Informações sobre o operador e os contactos deste serviço."
base.Operator, base.Representative, base.Contact = "Operador", "Representado por", "Contacto"
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Morada", "E-mail", "Telefone", "Registo", "Número de registo", "N.º de IVA"
base.Editorial, base.EditorialAddress = "Responsabilidade editorial", "Morada da pessoa editorialmente responsável"
base.DisputeTitle, base.DisputeNA = "Resolução de litígios de consumo", "O operador avaliou que a obrigação geral de informação sobre resolução de litígios de consumo não se aplica a este serviço."
base.DisputeNo = "O operador declara que não participa em procedimentos de resolução de litígios de consumo, salvo obrigação legal."
base.DisputeYes = "O operador declara que participa em procedimentos de resolução de litígios perante a entidade indicada abaixo."
base.PrivacyTitle, base.PrivacyLead = "Informação de privacidade", "Informações sobre o funcionamento do serviço e o tratamento de dados técnicos de ligação."
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Responsável / operador", "Contacto de proteção de dados", "Fornecedor de alojamento", "Morada do fornecedor de alojamento"
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Registos do servidor", "Conservação dos registos", "Destinatários / subcontratantes", "Transferências para países terceiros", "Autoridade de controlo"
base.PrivacyTechTitle, base.PrivacyTechText = "Configuração técnica", "A aplicação não necessita de rastreadores publicitários nem de analytics de terceiros. Dependendo do deployment, o proxy inverso, o alojamento ou a infraestrutura envolvente podem tratar dados de ligação e registos. O operador deve manter esta informação alinhada com o funcionamento real."
base.AccessibilityTitle, base.AccessibilityLead = "Acessibilidade", "Informações e contacto sobre a acessibilidade deste serviço."
base.AccessibilityContact, base.AccessibilityStatus = "Contacto de acessibilidade", "Estado de acessibilidade"
base.AccessibilityTechTitle, base.AccessibilityTechText = "Abordagem técnica", "A aplicação utiliza HTML semântico, controlos operáveis por teclado e metadados de idioma. Esta indicação não é uma certificação. O operador continua responsável por testar o deployment, os conteúdos personalizados, a marca e as integrações."
base.NoticeTitle, base.Notice = "Aviso importante", "Estas páginas contêm informações configuráveis do operador e não constituem aconselhamento jurídico. A suficiência depende do deployment e da legislação aplicável."
case "pl":
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Informacje prawne", "Prywatność", "Dostępność"
base.LegalTitle, base.LegalLead = "Informacje prawne", "Informacje o operatorze i możliwościach kontaktu z usługą."
base.Operator, base.Representative, base.Contact = "Operator", "Reprezentowany przez", "Kontakt"
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Adres", "E-mail", "Telefon", "Rejestr", "Numer rejestracyjny", "NIP UE"
base.Editorial, base.EditorialAddress = "Odpowiedzialność redakcyjna", "Adres osoby odpowiedzialnej redakcyjnie"
base.DisputeTitle, base.DisputeNA = "Rozwiązywanie sporów konsumenckich", "Operator ocenił, że ogólny obowiązek informacyjny dotyczący rozwiązywania sporów konsumenckich nie ma zastosowania do tej usługi."
base.DisputeNo = "Operator oświadcza, że nie uczestniczy w postępowaniach dotyczących pozasądowego rozwiązywania sporów konsumenckich, chyba że obowiązek taki wynika z prawa."
base.DisputeYes = "Operator oświadcza, że uczestniczy w postępowaniach dotyczących rozwiązywania sporów przed wskazanym poniżej podmiotem."
base.PrivacyTitle, base.PrivacyLead = "Informacje o prywatności", "Informacje o działaniu usługi i przetwarzaniu technicznych danych połączenia."
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Administrator / operator", "Kontakt ds. ochrony danych", "Dostawca hostingu", "Adres dostawcy hostingu"
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Logi serwera", "Okres przechowywania logów", "Odbiorcy / podmioty przetwarzające", "Transfery do państw trzecich", "Organ nadzorczy"
base.PrivacyTechTitle, base.PrivacyTechText = "Rozwiązania techniczne", "Sama aplikacja nie wymaga trackerów reklamowych ani zewnętrznych narzędzi analitycznych. W zależności od wdrożenia reverse proxy, hosting lub infrastruktura otaczająca mogą przetwarzać dane połączeń i logi. Operator musi dostosować informacje na tej stronie do rzeczywistego działania systemu."
base.AccessibilityTitle, base.AccessibilityLead = "Dostępność", "Informacje i kontakt w sprawie dostępności tej usługi."
base.AccessibilityContact, base.AccessibilityStatus = "Kontakt ds. dostępności", "Stan dostępności"
base.AccessibilityTechTitle, base.AccessibilityTechText = "Podejście techniczne", "Aplikacja wykorzystuje semantyczny HTML, elementy obsługiwane klawiaturą oraz metadane językowe. Informacja ta nie stanowi certyfikacji. Operator nadal odpowiada za sprawdzenie konkretnego wdrożenia, własnych treści, identyfikacji wizualnej i integracji."
base.NoticeTitle, base.Notice = "Ważna informacja", "Te strony zawierają konfigurowalne informacje operatora i nie stanowią porady prawnej. Ich kompletność zależy od wdrożenia i właściwego prawa."
}
if cfg.EditorialResponsibleName != "" || cfg.EditorialResponsibleAddress != "" {
sections = append(sections, legalSection{Title: "Editorial responsibility", Paragraphs: []string{"Responsible for journalistic-editorial content where applicable under German media law:"}, Fields: compactFields([]legalField{{Label: "Name", Value: cfg.EditorialResponsibleName}, {Label: "Address", Value: cfg.EditorialResponsibleAddress}})})
}
if dispute := disputeSection(cfg, false); dispute.Title != "" {
sections = append(sections, dispute)
}
sections = append(sections, legalSection{Title: "Content and disclosure notices", Paragraphs: []string{
"The service creates technical transparency notices from the information supplied. It does not automatically determine whether a particular disclosure is legally required, complete or accurate.",
"The publishing person or organisation remains responsible for declarations, editorial content and linked material.",
}})
return legalPage{Title: "Legal notice", MetaDescription: "Provider identification and contact details.", Eyebrow: "Legal information", Intro: "Operator information for this digital service.", Warning: legalWarning(missing, false), Sections: sections, LastUpdated: "20 July 2026"}
return base
}
func privacyPage(cfg Config, lang string) legalPage {
contact := firstNonEmpty(cfg.DataProtectionContact, cfg.LegalEmail)
missing := missingNamed(cfg, map[string]string{
"LEGAL_NAME": cfg.LegalName, "LEGAL_ADDRESS": cfg.LegalAddress, "LEGAL_EMAIL": cfg.LegalEmail,
"HOSTING_PROVIDER": cfg.HostingProvider, "LOG_RETENTION": cfg.LogRetention,
})
if lang == "de" {
sections := []legalSection{
{
Title: "1. Verantwortlicher",
Fields: compactFields([]legalField{
{Label: "Verantwortlicher", Value: requiredValue(cfg.LegalName)},
{Label: "Anschrift", Value: requiredValue(cfg.LegalAddress)},
{Label: "Datenschutzkontakt", Value: requiredValue(contact), URL: mailto(contact)},
}),
},
{
Title: "2. Aufruf der Website und Server-Protokolle",
Paragraphs: []string{
"Beim Aufruf werden technisch erforderliche Verbindungsdaten verarbeitet. Dazu können IP-Adresse, Zeitpunkt, angeforderter Pfad, HTTP-Methode, Statuscode, übertragene Datenmenge, Referrer und User-Agent gehören. Welche Daten der vorgeschaltete Hosting- oder Proxy-Dienst tatsächlich protokolliert, muss der Betreiber anhand seiner Infrastruktur prüfen.",
"Die Anwendung selbst protokolliert standardmäßig Methode, Pfad ohne Query-String, Statuscode, Datenmenge, Dauer und eine Request-ID. Die Client-IP wird nur protokolliert, wenn LOG_CLIENT_IP ausdrücklich aktiviert ist.",
"Zwecke sind die sichere und stabile Bereitstellung, Fehleranalyse und Missbrauchsabwehr. Rechtsgrundlage ist regelmäßig Art. 6 Abs. 1 lit. f DSGVO; bei vertraglicher Nutzung kann zusätzlich Art. 6 Abs. 1 lit. b DSGVO einschlägig sein.",
},
Fields: []legalField{{Label: "Vorgesehene Aufbewahrungsdauer", Value: requiredValue(cfg.LogRetention)}},
},
{
Title: "3. Eingaben, URLs und öffentliche Erklärungen",
Paragraphs: []string{
"Der Generator arbeitet zustandslos und speichert Eingaben nicht in einer Anwendungsdatenbank. Angaben werden jedoch als URL-Parameter verarbeitet und können dadurch im Browser-Verlauf, in Proxy- oder Zugriffsprotokollen sowie bei Weitergabe des erzeugten Links sichtbar werden.",
"Erzeugte Erklärungs- und JSON-LD-URLs sind zur öffentlichen Einbindung bestimmt. Deshalb dürfen keine vertraulichen Informationen, besonderen Kategorien personenbezogener Daten oder unnötigen personenbezogenen Angaben in Freitextfelder und URLs eingetragen werden.",
},
},
{
Title: "4. Cookies, Tracking und lokale Speicherung",
Paragraphs: []string{
"Die mitgelieferte Weboberfläche setzt keine Cookies, verwendet kein Webtracking und speichert keine Daten in Local Storage oder Session Storage. Wird die Anwendung um Analyse-, Marketing-, Schrift-, Karten-, Video- oder andere Drittinhalte erweitert, muss die Datenschutzerklärung angepasst und eine gegebenenfalls erforderliche Einwilligung vor dem Zugriff auf das Endgerät eingeholt werden.",
},
},
{
Title: "5. Hosting und Empfänger",
Fields: compactFields([]legalField{
{Label: "Hosting-Anbieter", Value: requiredValue(cfg.HostingProvider)},
{Label: "Anschrift / Region", Value: cfg.HostingAddress},
{Label: "Weitere Empfänger", Value: cfg.DataRecipients},
{Label: "Drittlandübermittlungen", Value: cfg.ThirdCountryTransfers},
}),
Paragraphs: []string{"Mit Hosting- und sonstigen Auftragsverarbeitern sind, soweit erforderlich, Verträge nach Art. 28 DSGVO abzuschließen. Übermittlungen in Drittländer dürfen nur auf einer tragfähigen Rechtsgrundlage erfolgen."},
},
{
Title: "6. Optionale Lizenzprüfung",
Paragraphs: []string{
"Im Offline-Modus findet keine Online-Lizenzprüfung statt. Im Hybrid- oder Online-Modus übermittelt der Lizenz-Client an den konfigurierten Lizenzserver insbesondere Lizenz-Token, Produktkennung, öffentliche Basis-URL, Host, optionale Instanz-ID und Client-Version. Betreiber müssen den eingesetzten Lizenzserver, die Rollenverteilung, Speicherdauer und Rechtsgrundlage gesondert dokumentieren.",
},
},
{
Title: "7. Rechte betroffener Personen",
Paragraphs: []string{"Betroffene Personen haben nach Maßgabe der DSGVO insbesondere Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit und Widerspruch. Erteilte Einwilligungen können mit Wirkung für die Zukunft widerrufen werden. Außerdem besteht ein Beschwerderecht bei einer Datenschutzaufsichtsbehörde."},
Fields: compactFields([]legalField{{Label: "Zuständige oder benannte Aufsichtsbehörde", Value: cfg.SupervisoryAuthorityName, URL: cfg.SupervisoryAuthorityURL}}),
},
{
Title: "8. Sicherheit und Änderungen",
Paragraphs: []string{
"Die Anwendung verwendet unter anderem restriktive Browser-Sicherheitsrichtlinien, Größenlimits für JSON-Anfragen, sichere Standard-Containeroptionen und minimierte Protokollierung.",
},
},
}
return legalPage{Title: "Datenschutzerklärung", MetaDescription: "Informationen zur Verarbeitung personenbezogener Daten.", Eyebrow: "Datenschutz", Intro: "Diese Vorlage beschreibt den technischen Standardzustand der Anwendung. Weitere Informationen entnehmen Sie den Angaben auf dieser Seite.", Warning: legalWarning(missing, true), Sections: sections, LastUpdated: "20. Juli 2026"}
func normalizeLanguage(lang string) string {
lang = strings.ToLower(strings.TrimSpace(lang))
if i := strings.IndexByte(lang, '-'); i >= 0 {
lang = lang[:i]
}
sections := []legalSection{
{Title: "1. Controller", Fields: compactFields([]legalField{{Label: "Controller", Value: requiredValue(cfg.LegalName)}, {Label: "Address", Value: requiredValue(cfg.LegalAddress)}, {Label: "Privacy contact", Value: requiredValue(contact), URL: mailto(contact)}})},
{Title: "2. Website access and server logs", Paragraphs: []string{
"Technically necessary connection data may be processed when the service is accessed, including IP address, time, requested path, HTTP method, status, transferred bytes, referrer and user agent. For more information, please refer to the information on this page.",
"By default, the application logs method, path without query string, status, bytes, duration and a request ID. Client IP logging is disabled unless LOG_CLIENT_IP is explicitly enabled.",
"Purposes are secure and reliable operation, troubleshooting and abuse prevention. The legal basis is generally Article 6(1)(f) GDPR and, where relevant, Article 6(1)(b) GDPR.",
}, Fields: []legalField{{Label: "Intended retention period", Value: requiredValue(cfg.LogRetention)}}},
{Title: "3. Inputs, URLs and public declarations", Paragraphs: []string{
"The generator is stateless and does not store inputs in an application database. Inputs are nevertheless processed as URL parameters and may appear in browser history and proxy or access logs.",
"Generated declaration and JSON-LD URLs are designed for public embedding. Do not enter confidential data, special-category personal data or unnecessary personal information.",
}},
{Title: "4. Cookies and tracking", Paragraphs: []string{"The bundled interface sets no cookies, uses no web tracking and does not store data in Local Storage or Session Storage. Operators adding analytics, marketing or third-party embeds must update this notice and obtain any legally required consent before accessing the user's device."}},
{Title: "5. Hosting and recipients", Fields: compactFields([]legalField{{Label: "Hosting provider", Value: requiredValue(cfg.HostingProvider)}, {Label: "Address / region", Value: cfg.HostingAddress}, {Label: "Other recipients", Value: cfg.DataRecipients}, {Label: "Third-country transfers", Value: cfg.ThirdCountryTransfers}})},
{Title: "6. Optional licence validation", Paragraphs: []string{"Offline mode performs no online licence validation. Hybrid and online modes send the licence token, product identifier, public base URL, host, optional instance ID and client version to the configured licence server. The operator must document that service separately."}},
{Title: "7. Data-subject rights", Paragraphs: []string{"Subject to the GDPR, individuals may have rights of access, rectification, erasure, restriction, portability, objection and withdrawal of consent, as well as the right to complain to a supervisory authority."}, Fields: compactFields([]legalField{{Label: "Supervisory authority", Value: cfg.SupervisoryAuthorityName, URL: cfg.SupervisoryAuthorityURL}})},
{Title: "8. Security and changes", Paragraphs: []string{"The application includes restrictive browser policies, JSON request-size limits, hardened container defaults and minimised logging."}},
}
return legalPage{Title: "Privacy notice", MetaDescription: "Information about personal-data processing.", Eyebrow: "Privacy", Intro: "This template describes the application's default technical behaviour. The actual operator must adapt it to hosting, logs, contracts and added services.", Warning: legalWarning(missing, false), Sections: sections, LastUpdated: "20 July 2026"}
return lang
}
func accessibilityPage(cfg Config, lang string) legalPage {
contact := firstNonEmpty(cfg.AccessibilityContact, cfg.LegalEmail)
missing := missingNamed(cfg, map[string]string{"ACCESSIBILITY_CONTACT or LEGAL_EMAIL": contact})
if lang == "de" {
status := cfg.AccessibilityStatus
if status == "" {
status = "Noch nicht durch eine unabhängige Stelle geprüft; derzeit keine vollständige Konformitätserklärung."
}
return legalPage{
Title: "Barrierefreiheit", MetaDescription: "Hinweise zur barrierefreien Nutzung und Kontakt für Rückmeldungen.", Eyebrow: "Zugänglichkeit",
Intro: "Wir möchten, dass der Generator, die Erklärungsseiten und die Rechtstexte möglichst vielen Menschen zugänglich sind.",
Warning: legalWarning(missing, true), LastUpdated: "20. Juli 2026",
Sections: []legalSection{
{Title: "Stand der Vereinbarkeit", Paragraphs: []string{status, "Ob das Barrierefreiheitsstärkungsgesetz oder andere verbindliche Anforderungen auf ein konkretes Angebot anwendbar sind, muss der jeweilige Betreiber anhand seines Geschäftsmodells und seiner Zielgruppe prüfen."}},
{Title: "Umgesetzte Maßnahmen", Bullets: []string{
"semantische Überschriften, Beschriftungen und Tabellenstrukturen",
"Bedienbarkeit der Kernfunktionen per Tastatur",
"sichtbare Fokusmarkierungen und responsive Darstellung",
"Textalternativen für erzeugte Badge-Vorschauen",
"keine zwingenden Animationen, Cookies oder Tracking-Dialoge",
}},
{Title: "Bekannte Grenzen", Bullets: []string{
"Es liegt noch kein vollständiger Audit nach EN 301 549 oder WCAG 2.2 vor.",
"Breite Datentabellen können auf kleinen Bildschirmen horizontales Scrollen erfordern.",
"Die Verständlichkeit automatisch erzeugter Erklärungen hängt von den eingegebenen Angaben ab.",
"Eingebettete Badges und Erklärungen müssen auch auf der einbindenden Website barrierefrei beschriftet und positioniert werden.",
}},
{Title: "Rückmeldung und Kontakt", Paragraphs: []string{"Bitte melden Sie Barrieren mit URL, Gerät, Browser, assistiver Technologie und einer kurzen Beschreibung. Wir prüfen nachvollziehbare Hinweise und bemühen uns um eine geeignete Lösung."}, Fields: []legalField{{Label: "Kontakt", Value: requiredValue(contact), URL: mailto(contact)}}},
{Title: "Rechtlicher Kontext", Paragraphs: []string{"Das BFSG gilt seit dem 28. Juni 2025 für bestimmte Produkte und Dienstleistungen für Verbraucher. Nicht jede Website fällt automatisch darunter. Für erfasste Angebote sind die gesetzlichen Informations- und Barrierefreiheitsanforderungen gesondert zu erfüllen."}, Fields: []legalField{{Label: "Bundesfachstelle Barrierefreiheit", Value: "Informationen zum BFSG", URL: "https://www.bundesfachstelle-barrierefreiheit.de/DE/Barrierefreiheitsstaerkungsgesetz"}}},
},
}
}
status := cfg.AccessibilityStatus
if status == "" {
status = "Not yet independently audited; no claim of full conformance is made."
}
return legalPage{
Title: "Accessibility", MetaDescription: "Accessibility status and feedback contact.", Eyebrow: "Accessibility",
Intro: "We aim to make the generator, declaration pages and legal information accessible to as many people as possible.",
Warning: legalWarning(missing, false), LastUpdated: "20 July 2026",
Sections: []legalSection{
{Title: "Conformance status", Paragraphs: []string{status, "The operator must assess whether the German Accessibility Strengthening Act or other binding rules apply to its specific offering."}},
{Title: "Measures implemented", Bullets: []string{"semantic headings, labels and table structures", "keyboard access to core functions", "visible focus indicators and responsive layout", "text alternatives for generated badge previews", "no mandatory animations, cookie or tracking dialogs"}},
{Title: "Known limitations", Bullets: []string{"No complete EN 301 549 or WCAG 2.2 audit has yet been completed.", "Wide data tables may require horizontal scrolling on small screens.", "The clarity of generated declarations depends on the information entered.", "Embedded badges must also be labelled and positioned accessibly on the host website."}},
{Title: "Feedback", Paragraphs: []string{"Please report barriers with the URL, device, browser, assistive technology and a brief description."}, Fields: []legalField{{Label: "Contact", Value: requiredValue(contact), URL: mailto(contact)}}},
},
}
}
func disputeSection(cfg Config, german bool) legalSection {
switch cfg.ConsumerDisputeStatus {
case "not_applicable":
return legalSection{}
case "not_participating":
if german {
return legalSection{Title: "Verbraucherstreitbeilegung", Paragraphs: []string{"Wir sind nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen."}}
}
return legalSection{Title: "Consumer dispute resolution", Paragraphs: []string{"We are neither willing nor obliged to participate in dispute-resolution proceedings before a consumer arbitration body."}}
case "participating":
if german {
return legalSection{Title: "Verbraucherstreitbeilegung", Paragraphs: []string{"Wir nehmen an Streitbeilegungsverfahren vor der folgenden Verbraucherschlichtungsstelle teil:"}, Fields: []legalField{{Label: "Schlichtungsstelle", Value: cfg.ConsumerDisputeBody, URL: cfg.ConsumerDisputeURL}}}
}
return legalSection{Title: "Consumer dispute resolution", Paragraphs: []string{"We participate in dispute-resolution proceedings before the following consumer arbitration body:"}, Fields: []legalField{{Label: "Arbitration body", Value: cfg.ConsumerDisputeBody, URL: cfg.ConsumerDisputeURL}}}
func legalLabel(lang, kind string) string {
copy := legalLabels(lang)
switch kind {
case "privacy":
return copy.PrivacyLabel
case "accessibility":
return copy.AccessibilityLabel
default:
return legalSection{}
return copy.LegalLabel
}
}
func requiredValue(value string) string {
if configValueMissing(value) {
return "[NICHT KONFIGURIERT / NOT CONFIGURED]"
}
return value
}
func compactFields(in []legalField) []legalField {
out := make([]legalField, 0, len(in))
for _, field := range in {
if strings.TrimSpace(field.Value) != "" {
out = append(out, field)
func (s *Server) operatorPage(lang, kind string) legalPage {
c := legalLabels(lang)
nonEmpty := func(label, value string) []legalItem {
if strings.TrimSpace(value) == "" {
return nil
}
return []legalItem{{Label: label, Value: value}}
}
return out
}
func missingNamed(cfg Config, fields map[string]string) []string {
_ = cfg
var missing []string
for key, value := range fields {
if configValueMissing(value) {
missing = append(missing, key)
appendItem := func(items []legalItem, label, value, link string) []legalItem {
if strings.TrimSpace(value) == "" {
return items
}
return append(items, legalItem{Label: label, Value: value, URL: link})
}
switch kind {
case "privacy":
operator := []legalItem{}
operator = appendItem(operator, c.Controller, s.cfg.LegalName, "")
operator = appendItem(operator, c.Address, s.cfg.LegalAddress, "")
operator = appendItem(operator, c.Email, s.cfg.LegalEmail, mailto(s.cfg.LegalEmail))
operator = appendItem(operator, c.DataProtection, s.cfg.DataProtectionContact, mailtoIfEmail(s.cfg.DataProtectionContact))
hosting := []legalItem{}
hosting = appendItem(hosting, c.Hosting, s.cfg.HostingProvider, "")
hosting = appendItem(hosting, c.HostingAddress, s.cfg.HostingAddress, "")
processing := []legalItem{}
processing = appendItem(processing, c.Retention, s.cfg.LogRetention, "")
processing = appendItem(processing, c.Recipients, s.cfg.DataRecipients, "")
processing = appendItem(processing, c.Transfers, s.cfg.ThirdCountryTransfers, "")
processing = appendItem(processing, c.Authority, s.cfg.SupervisoryAuthorityName, s.cfg.SupervisoryAuthorityURL)
sections := []legalSection{{Title: c.Controller, Items: operator}, {Title: c.Hosting, Items: hosting}, {Title: c.PrivacyTechTitle, Paragraphs: []string{c.PrivacyTechText}, Items: processing}}
return legalPage{Kind: kind, Title: c.PrivacyTitle, Lead: c.PrivacyLead, Sections: sections, NoticeTitle: c.NoticeTitle, Notice: c.Notice}
case "accessibility":
items := []legalItem{}
items = appendItem(items, c.AccessibilityContact, s.cfg.AccessibilityContact, mailtoIfEmail(s.cfg.AccessibilityContact))
items = appendItem(items, c.AccessibilityStatus, s.cfg.AccessibilityStatus, "")
sections := []legalSection{{Title: c.AccessibilityTitle, Items: items}, {Title: c.AccessibilityTechTitle, Paragraphs: []string{c.AccessibilityTechText}}}
return legalPage{Kind: kind, Title: c.AccessibilityTitle, Lead: c.AccessibilityLead, Sections: sections, NoticeTitle: c.NoticeTitle, Notice: c.Notice}
default:
operator := []legalItem{}
operator = appendItem(operator, c.Operator, s.cfg.LegalName, "")
operator = appendItem(operator, c.Address, s.cfg.LegalAddress, "")
operator = appendItem(operator, c.Representative, s.cfg.LegalRepresentative, "")
operator = appendItem(operator, c.Email, s.cfg.LegalEmail, mailto(s.cfg.LegalEmail))
operator = appendItem(operator, c.Phone, s.cfg.LegalPhone, tel(s.cfg.LegalPhone))
operator = appendItem(operator, c.Register, s.cfg.LegalRegister, "")
operator = appendItem(operator, c.RegisterNo, s.cfg.LegalRegisterNumber, "")
operator = appendItem(operator, c.VAT, s.cfg.LegalVATID, "")
sections := []legalSection{{Title: c.Operator, Items: operator}}
if strings.TrimSpace(s.cfg.EditorialResponsibleName) != "" || strings.TrimSpace(s.cfg.EditorialResponsibleAddress) != "" {
items := []legalItem{}
items = appendItem(items, c.Editorial, s.cfg.EditorialResponsibleName, "")
items = appendItem(items, c.EditorialAddress, s.cfg.EditorialResponsibleAddress, "")
sections = append(sections, legalSection{Title: c.Editorial, Items: items})
}
disputeText := c.DisputeNA
if s.cfg.ConsumerDisputeStatus == "not_participating" {
disputeText = c.DisputeNo
} else if s.cfg.ConsumerDisputeStatus == "participating" {
disputeText = c.DisputeYes
}
disputeItems := nonEmpty(c.DisputeTitle, s.cfg.ConsumerDisputeBody)
if len(disputeItems) > 0 && strings.TrimSpace(s.cfg.ConsumerDisputeURL) != "" {
disputeItems[0].URL = s.cfg.ConsumerDisputeURL
}
sections = append(sections, legalSection{Title: c.DisputeTitle, Paragraphs: []string{disputeText}, Items: disputeItems})
return legalPage{Kind: "legal", Title: c.LegalTitle, Lead: c.LegalLead, Sections: sections, NoticeTitle: c.NoticeTitle, Notice: c.Notice}
}
sort.Strings(missing)
return missing
}
func legalWarning(missing []string, german bool) string {
if len(missing) == 0 {
return ""
func (s *Server) handleOperatorPage(kind string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
lang := s.language(r)
data := pageData{
Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: lang, Text: i18n.Get(lang).Text,
Languages: i18nLanguages(), LegalPage: s.operatorPage(lang, kind), License: s.licenses.Status(),
WhiteLabel: s.cfg.WhiteLabel && s.licenses.Has(FeatureWhiteLabel),
}
s.renderHTML(w, "operator.html", data)
}
if german {
return fmt.Sprintf("Diese Seite ist noch nicht produktionsreif. Folgende Betreiberangaben fehlen: %s.", strings.Join(missing, ", "))
}
return fmt.Sprintf("This page is not ready for production. Missing operator configuration: %s.", strings.Join(missing, ", "))
}
func i18nLanguages() []i18n.LanguageOption { return i18n.Languages() }
func mailto(value string) string {
value = strings.TrimSpace(value)
if value == "" || !strings.Contains(value, "@") || strings.ContainsAny(value, "\r\n") {
if value == "" {
return ""
}
return "mailto:" + value
}
func mailtoIfEmail(value string) string {
value = strings.TrimSpace(value)
if strings.Contains(value, "@") && !strings.ContainsAny(value, " \t\r\n") {
return "mailto:" + value
}
return ""
}
func tel(value string) string {
value = strings.TrimSpace(value)
if value == "" || strings.ContainsAny(value, "\r\n") {
if value == "" {
return ""
}
replacer := strings.NewReplacer(" ", "", "-", "", "(", "", ")", "", "/", "")

View File

@@ -8,11 +8,13 @@ import (
)
const (
ProductID = "ai-disclosure-standard"
ProductVersion = "1.6.3"
FeatureCustomText = "custom_text"
FeatureCustomBadge = "custom_badge"
FeatureWhiteLabel = "white_label"
ProductID = "ai-disclosure-standard"
ProductVersion = "2.0.2"
FeatureCustomText = "custom_text"
FeatureCustomBadge = "custom_badge"
FeatureWhiteLabel = "white_label"
FeatureBulkAPI = "bulk_api"
FeatureExportBundle = "export_bundle"
)
//go:embed trusted_keys.json

View File

@@ -13,6 +13,9 @@ type metrics struct {
badgeRenders atomic.Uint64
validationRequests atomic.Uint64
validationFailures atomic.Uint64
bulkRequests atomic.Uint64
bulkItems atomic.Uint64
bulkFailures atomic.Uint64
panics atomic.Uint64
}
@@ -36,6 +39,15 @@ func (m *metrics) serveHTTP(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "# HELP ai_disclosure_validation_failures_total Failed validation requests.\n")
fmt.Fprintf(w, "# TYPE ai_disclosure_validation_failures_total counter\n")
fmt.Fprintf(w, "ai_disclosure_validation_failures_total %d\n", m.validationFailures.Load())
fmt.Fprintf(w, "# HELP ai_disclosure_bulk_requests_total Total bulk API requests.\n")
fmt.Fprintf(w, "# TYPE ai_disclosure_bulk_requests_total counter\n")
fmt.Fprintf(w, "ai_disclosure_bulk_requests_total %d\n", m.bulkRequests.Load())
fmt.Fprintf(w, "# HELP ai_disclosure_bulk_items_total Total items submitted to the bulk API.\n")
fmt.Fprintf(w, "# TYPE ai_disclosure_bulk_items_total counter\n")
fmt.Fprintf(w, "ai_disclosure_bulk_items_total %d\n", m.bulkItems.Load())
fmt.Fprintf(w, "# HELP ai_disclosure_bulk_failures_total Rejected or malformed bulk API requests.\n")
fmt.Fprintf(w, "# TYPE ai_disclosure_bulk_failures_total counter\n")
fmt.Fprintf(w, "ai_disclosure_bulk_failures_total %d\n", m.bulkFailures.Load())
fmt.Fprintf(w, "# HELP ai_disclosure_panics_total Recovered handler panics.\n")
fmt.Fprintf(w, "# TYPE ai_disclosure_panics_total counter\n")
fmt.Fprintf(w, "ai_disclosure_panics_total %d\n", m.panics.Load())

File diff suppressed because it is too large Load Diff

View File

@@ -7,11 +7,9 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"testing"
"github.com/b1tsblog/license-platform/sdk/go/licenseclient"
)
func testHandler(t *testing.T) http.Handler {
@@ -67,7 +65,7 @@ func TestCommunityRejectsCustomBadge(t *testing.T) {
if w.Code != http.StatusForbidden {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "pro_feature_required") {
if !strings.Contains(w.Body.String(), "licensed_feature_required") {
t.Fatalf("unexpected response: %s", w.Body.String())
}
}
@@ -84,30 +82,6 @@ func TestCapabilities(t *testing.T) {
}
}
func TestPublicLicenseRedactsInternalMetadata(t *testing.T) {
status := publicLicense(licenseclient.Status{
Edition: "pro", Licensed: true, LicenseID: "lic-secret", Customer: "Customer Name", Product: "product",
Features: []string{"custom_text"}, Limits: map[string]int64{"seats": 3}, ExpiresAt: "2030-01-01T00:00:00Z",
Mode: "online", Source: "server", LastChecked: "2026-07-20T12:00:00Z", LeaseExpires: "2026-07-21T12:00:00Z",
Reason: "internal diagnostic", ServerURL: "https://licenses.internal.example",
})
data, err := json.Marshal(status)
if err != nil {
t.Fatal(err)
}
body := string(data)
for _, forbidden := range []string{"lic-secret", "Customer Name", "licenses.internal.example", "internal diagnostic", "lastChecked", "leaseExpiresAt", `"mode"`, `"source"`} {
if strings.Contains(body, forbidden) {
t.Fatalf("public licence status leaks %q: %s", forbidden, body)
}
}
for _, expected := range []string{`"edition":"pro"`, `"licensed":true`, `"custom_text"`, `"seats":3`} {
if !strings.Contains(body, expected) {
t.Fatalf("public licence status missing %q: %s", expected, body)
}
}
}
func TestMarketingPageGerman(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/product?lang=de", nil)
w := httptest.NewRecorder()
@@ -249,51 +223,6 @@ func TestSignedAssuranceExplainsScope(t *testing.T) {
}
}
func TestGeneratorShowsAIACTGuardrails(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/?lang=de", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
for _, expected := range []string{
`id="legal-deepfake"`,
`id="legal-public-interest"`,
`id="legal-creative"`,
`id="responsible-role"`,
`id="responsible"`,
`id="responsible-url"`,
"formale Prüfung",
"Platzierung nach Art. 50",
"erfüllt aber nicht automatisch die Anbieterpflicht",
"Emoji-Variante",
} {
if !strings.Contains(body, expected) {
t.Fatalf("generator missing AI Act guardrail %q", expected)
}
}
}
func TestDeclarationExplainsReviewAndMachineMarkingLimits(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/declaration?preset=full&review=editorial&lang=de&responsible=Beispielredaktion&responsibleUrl=https%3A%2F%2Fexample.org%2Fimpressum", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
for _, expected := range []string{
"Beispielredaktion",
"begründet für sich allein keine Ausnahme",
"wirksame, zuverlässige, robuste und interoperable maschinenlesbare Markierung",
} {
if !strings.Contains(body, expected) {
t.Fatalf("declaration missing AI Act guardrail %q", expected)
}
}
}
func TestBackgroundPageGerman(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/background?lang=de", nil)
w := httptest.NewRecorder()
@@ -344,234 +273,157 @@ func TestNavigationUsesInternalBackgroundPage(t *testing.T) {
}
}
func TestLegalPagesUseConfiguredOperatorData(t *testing.T) {
cfg := Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de",
LegalName: "Beispiel GmbH", LegalAddress: "Musterstraße 1\n10115 Berlin", LegalEmail: "datenschutz@example.org",
HostingProvider: "Beispiel Hosting GmbH", LogRetention: "7 Tage", ConsumerDisputeStatus: "not_participating",
}
h := testHandlerConfig(t, cfg)
for _, tc := range []struct {
path, expected string
}{
{"/impressum?lang=de", "Beispiel GmbH"},
{"/datenschutz?lang=de", "Die Client-IP wird nur protokolliert"},
{"/barrierefreiheit?lang=de", "Noch nicht durch eine unabhängige Stelle geprüft"},
} {
r := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("%s returned %d: %s", tc.path, w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), tc.expected) {
t.Fatalf("%s missing %q", tc.path, tc.expected)
}
if !strings.Contains(w.Body.String(), `/impressum?lang=de`) || !strings.Contains(w.Body.String(), `/datenschutz?lang=de`) {
t.Fatalf("%s missing legal footer links", tc.path)
}
}
}
func TestLegalStrictRejectsMissingConfiguration(t *testing.T) {
_, err := New(context.Background(), Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", LegalStrict: true,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil || !strings.Contains(err.Error(), "LEGAL_NAME") {
t.Fatalf("expected strict legal configuration error, got %v", err)
}
}
func TestSecurityHeadersAndRequestIDValidation(t *testing.T) {
cfg := Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", EnableHSTS: true}
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("X-Request-ID", "invalid request id\n")
w := httptest.NewRecorder()
testHandlerConfig(t, cfg).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
csp := w.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "script-src 'self' 'nonce-") || strings.Contains(csp, "'unsafe-inline'") {
t.Fatalf("unexpected CSP %q", csp)
}
if got := w.Header().Get("Strict-Transport-Security"); !strings.Contains(got, "max-age=") || strings.Contains(strings.ToLower(got), "includesubdomains") {
t.Fatalf("unexpected HSTS: %q", got)
}
if got := w.Header().Get("X-Request-ID"); got == "" || strings.Contains(got, " ") {
t.Fatalf("invalid response request id %q", got)
}
}
func TestMetricsDisabledByDefaultAndTokenProtected(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/metrics", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Fatalf("disabled metrics returned %d", w.Code)
}
cfg := Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", MetricsEnabled: true, MetricsToken: "a-long-random-test-token"}
h := testHandlerConfig(t, cfg)
w = httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if w.Code != http.StatusUnauthorized {
t.Fatalf("unprotected metrics returned %d", w.Code)
}
r = httptest.NewRequest(http.MethodGet, "/metrics", nil)
r.Header.Set("Authorization", "Bearer a-long-random-test-token")
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "ai_disclosure_http_requests_total") {
t.Fatalf("protected metrics returned %d: %s", w.Code, w.Body.String())
}
}
func TestMetricsCannotBeEnabledWithoutToken(t *testing.T) {
_, err := New(context.Background(), Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", MetricsEnabled: true,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil || !strings.Contains(err.Error(), "METRICS_TOKEN") {
t.Fatalf("expected metrics token validation error, got %v", err)
}
}
func TestInvalidBooleanEnvironmentValueFailsClosed(t *testing.T) {
t.Setenv("LEGAL_STRICT", "definitely")
cfg := ConfigFromEnv()
_, err := New(context.Background(), cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil || !strings.Contains(err.Error(), "LEGAL_STRICT must be true or false") {
t.Fatalf("expected environment validation error, got %v", err)
}
}
func TestLegalEmailMustBePlainAddress(t *testing.T) {
_, err := New(context.Background(), Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", LegalEmail: "Name <legal@example.org>",
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil || !strings.Contains(err.Error(), "LEGAL_EMAIL") {
t.Fatalf("expected legal email validation error, got %v", err)
}
}
func TestExternalURLsMustUseHTTPOrHTTPS(t *testing.T) {
_, err := New(context.Background(), Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", ContactURL: "javascript:alert(1)",
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil || !strings.Contains(err.Error(), "CONTACT_URL") {
t.Fatalf("expected contact URL validation error, got %v", err)
}
}
func TestValidateRejectsOversizedBody(t *testing.T) {
body := `{"padding":"` + strings.Repeat("x", (1<<20)+1) + `"}`
r := httptest.NewRequest(http.MethodPost, "/v1/validate", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
}
func TestValidateRejectsNonJSONContentType(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/v1/validate", strings.NewReader(`{}`))
r.Header.Set("Content-Type", "text/plain")
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
}
func TestClientIPUsesNearestUntrustedForwardedAddress(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "https://example.org/", nil)
r.RemoteAddr = "10.0.0.2:443"
r.Header.Set("X-Forwarded-For", "198.51.100.200, 203.0.113.50, 10.0.0.3")
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
if got := clientIP(r, true, trusted); got != "203.0.113.50" {
t.Fatalf("unexpected client IP %q", got)
}
}
func TestTrustProxyRequiresExplicitCIDRs(t *testing.T) {
_, err := New(context.Background(), Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", TrustProxy: true,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil || !strings.Contains(err.Error(), "TRUSTED_PROXY_CIDRS") {
t.Fatalf("expected trusted proxy validation error, got %v", err)
}
}
func TestEmojiThemeBadgeEndpoint(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/badge/summary.svg?lang=de&theme=emoji", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
for _, expected := range []string{
`width="64" height="64"`,
`data-theme="emoji"`,
`data-icon="summary"`,
`fill="#b7791f"`,
`Inhaltliche Zusammenfassung`,
} {
if !strings.Contains(body, expected) {
t.Fatalf("emoji badge missing %q: %s", expected, body)
}
}
}
func TestGeneratorOffersEmojiTheme(t *testing.T) {
func TestGeneratorExposesArticle50Context(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/?lang=de", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
const expected = `<option value="emoji">Emoji (quadratisch)</option>`
if !strings.Contains(w.Body.String(), expected) {
t.Fatalf("generator missing %q", expected)
}
}
func TestPublicInterestBadgeUsesExplicitDisclosureWithoutExemption(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/badge.svg?mode=article&textExtent=full&textReview=none&legalContext=publicInterestText&lang=de", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "KI-generierte / bearbeitete Inhalte") {
t.Fatalf("missing explicit legal disclosure wording: %s", w.Body.String())
}
}
func TestPublicInterestBadgeCanRemainVoluntaryWithSubstantiveReviewAndResponsibility(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/badge.svg?mode=article&textExtent=partial&textReview=expert&legalContext=publicInterestText&responsible=Beispielredaktion&lang=de", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "Artikeltransparenz") {
t.Fatalf("expected voluntary article wording where possible exception is documented: %s", w.Body.String())
}
}
func TestDeepfakeDeclarationShowsCautiousAssessment(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/declaration?mode=article&imageExtent=full&imageReview=none&legalContext=deepfake&lang=de", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
for _, expected := range []string{"Vorsichtige Art.-50-Einschätzung", "spricht vieles dafür", "Deepfake / realitätsähnliche KI-Manipulation"} {
if !strings.Contains(body, expected) {
t.Fatalf("missing %q: %s", expected, body)
for _, expected := range []string{
`id="public-interest-text"`, `id="deepfake"`, `id="substantial-review"`,
`id="editorial-responsibility-confirmed"`, `id="first-exposure-disclosure"`,
`Regulatorischer Kontext (EU AI Act, Artikel 50)`,
} {
if !strings.Contains(w.Body.String(), expected) {
t.Fatalf("generator missing %q", expected)
}
}
}
func TestArticle50AssessmentEndpoint(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/article50-assessment.json?preset=full&lang=de&publicInterestText=true", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
for _, expected := range []string{`"code":"public_interest_text_disclosure_relevant"`, `"potentiallyApplicable":true`, `Nicht bindende Entscheidungshilfe`} {
if !strings.Contains(w.Body.String(), expected) {
t.Fatalf("assessment response missing %q: %s", expected, w.Body.String())
}
}
}
func TestRegulatoryDeclarationUsesExplicitBadge(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/badge.svg?mode=article&textExtent=full&textReview=none&lang=de&publicInterestText=true", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "KI-generierter Text") || !strings.Contains(w.Body.String(), `fill="#b45309"`) {
t.Fatalf("regulatory badge not explicit/amber: %s", w.Body.String())
}
}
func TestDeclarationRendersRegulatoryAssessment(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/declaration?mode=article&textExtent=partial&textReview=expert&lang=de&publicInterestText=true&substantialHumanReview=true&editorialResponsibilityConfirmed=true&responsible=Example%20Redaktion", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
for _, expected := range []string{`Technische Artikel-50-Einordnung`, `Mögliche Ausnahme für redaktionell kontrollierten Text`, `Example Redaktion`, `"regulatoryContext"`} {
if !strings.Contains(w.Body.String(), expected) {
t.Fatalf("declaration missing %q", expected)
}
}
}
func TestCommunityRejectsBulkAPI(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/v1/bulk/declarations", strings.NewReader(`{"items":[{"parameters":{"preset":"research","lang":"de"}}]}`))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "bulk_api") {
t.Fatalf("unexpected bulk response %d: %s", w.Code, w.Body.String())
}
}
func TestBulkModeRequiresLicensedCapabilityForReadiness(t *testing.T) {
h := testHandlerConfig(t, Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", ServiceMode: "bulk"})
r := httptest.NewRequest(http.MethodGet, "/readyz", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("bulk readiness status=%d body=%s", w.Code, w.Body.String())
}
r = httptest.NewRequest(http.MethodGet, "/", nil)
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("bulk workspace status=%d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "Bulk Workspace") || !strings.Contains(w.Body.String(), "bulk_api") {
t.Fatalf("bulk workspace missing expected content: %s", w.Body.String())
}
}
func TestBulkAPIKeyAuthorization(t *testing.T) {
s := &Server{cfg: Config{BulkRequireAPIKey: true, BulkAPIKey: "very-secret"}}
r := httptest.NewRequest(http.MethodPost, "/v1/bulk/declarations", nil)
if s.bulkAuthorized(r) {
t.Fatal("request without API key must not be authorized")
}
r = httptest.NewRequest(http.MethodPost, "/v1/bulk/declarations", nil)
r.Header.Set("Authorization", "Bearer very-secret")
if !s.bulkAuthorized(r) {
t.Fatal("bearer API key should be authorized")
}
r = httptest.NewRequest(http.MethodPost, "/v1/bulk/declarations", nil)
r.Header.Set("X-API-Key", "very-secret")
if !s.bulkAuthorized(r) {
t.Fatal("X-API-Key should be authorized")
}
}
func TestRequireLicenseFailsClosedForApplicationRequests(t *testing.T) {
h := testHandlerConfig(t, Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", RequireLicense: true,
})
for _, path := range []string{"/", "/v1/validate"} {
r := httptest.NewRequest(http.MethodGet, path, nil)
if path == "/v1/validate" {
r = httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("%s status %d, want 503", path, w.Code)
}
}
r := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("health status %d, want 200", w.Code)
}
}
func TestConfigurableAPICORS(t *testing.T) {
h := testHandlerConfig(t, Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", APIAllowedOrigin: "https://publisher.example",
})
r := httptest.NewRequest(http.MethodGet, "/v1/capabilities", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://publisher.example" {
t.Fatalf("CORS origin = %q", got)
}
}
func TestGeneratedURLsCanTargetSeparatePublicInstance(t *testing.T) {
s := &Server{cfg: Config{BaseURL: "http://bulk.internal", OutputBaseURL: "https://ai.example.org"}}
declarationURL, manifestURL, badgeURL := s.generatedURLs(url.Values{"lang": {"de"}})
for _, got := range []string{declarationURL, manifestURL, badgeURL} {
if !strings.HasPrefix(got, "https://ai.example.org/") {
t.Fatalf("generated URL %q does not use OUTPUT_BASE_URL", got)
}
}
}

View File

@@ -5,4 +5,4 @@
"leaseKeys": {
"fUt_sPkz_FdLBKL0KnPAydlflls-PyJaViEbNCRjRIJipGwiHz-E7mPYzrnY8YotKt-aom8L61YlJEYksUZMQg": "gM8FdzFWGCfffjbPj9_2Ao8n5agKdvbRkf8xMWXdk5c"
}
}
}

View File

@@ -97,56 +97,56 @@ func officialSources(lang string) []Source {
titles := map[string][5]Source{
"de": {
{Title: "Verordnung (EU) 2024/1689 AI Act", Description: "Verbindlicher Gesetzestext, insbesondere Artikel 50 zu Transparenzpflichten für bestimmte KI-Systeme.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Leitlinien zu Transparenzpflichten", Description: "Von der Europäischen Kommission am 20. Juli 2026 veröffentlichte Leitlinien zum Anwendungsbereich von Artikel 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Leitlinien zu Transparenzpflichten", Description: "Von der Europäischen Kommission am 20. Juli 2026 veröffentlichte Leitlinien zum Anwendungsbereich von Artikel 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Fragen und Antworten zu Artikel 50", Description: "Praktische Erläuterungen zu Anwendungsbereich, Ausnahmen, menschlicher Prüfung und redaktioneller Kontrolle.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Code of Practice zu KI-generierten Inhalten", Description: "Freiwilliges Instrument zur Unterstützung der Umsetzung der Kennzeichnungs- und Markierungspflichten.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "EU-Icons für KI-generierte Inhalte", Description: "Optionale Symbole der Europäischen Union; ihre Verwendung allein begründet keine Rechtskonformität.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"en": {
{Title: "Regulation (EU) 2024/1689 AI Act", Description: "Binding legal text, in particular Article 50 on transparency obligations for certain AI systems.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Guidelines on transparency obligations", Description: "European Commission guidelines published on 20 July 2026 clarifying the scope of Article 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Guidelines on transparency obligations", Description: "European Commission guidelines published on 20 July 2026 clarifying the scope of Article 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Questions and answers on Article 50", Description: "Practical explanations of scope, exceptions, human review and editorial control.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Code of Practice on AI-generated content", Description: "A voluntary instrument supporting implementation of marking and labelling obligations.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "EU icons for AI-generated content", Description: "Optional European Union icons; using them alone does not establish legal compliance.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"fr": {
{Title: "Règlement (UE) 2024/1689 AI Act", Description: "Texte juridique contraignant, notamment larticle 50 relatif aux obligations de transparence de certains systèmes dIA.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Lignes directrices sur les obligations de transparence", Description: "Lignes directrices de la Commission européenne publiées le 20 juillet 2026 sur le champ dapplication de larticle 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Lignes directrices sur les obligations de transparence", Description: "Lignes directrices de la Commission européenne publiées le 20 juillet 2026 sur le champ dapplication de larticle 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Questions et réponses sur larticle 50", Description: "Explications pratiques sur le champ dapplication, les exceptions, lexamen humain et le contrôle éditorial.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Code de bonnes pratiques sur les contenus générés par lIA", Description: "Instrument volontaire destiné à faciliter la mise en œuvre des obligations de marquage et détiquetage.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "Icônes de lUE pour les contenus générés par lIA", Description: "Symboles facultatifs de lUnion européenne ; leur utilisation seule ne prouve pas la conformité juridique.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"es": {
{Title: "Reglamento (UE) 2024/1689 Ley de IA", Description: "Texto jurídico vinculante, en especial el artículo 50 sobre obligaciones de transparencia para determinados sistemas de IA.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Directrices sobre obligaciones de transparencia", Description: "Directrices de la Comisión Europea publicadas el 20 de julio de 2026 sobre el ámbito del artículo 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Directrices sobre obligaciones de transparencia", Description: "Directrices de la Comisión Europea publicadas el 20 de julio de 2026 sobre el ámbito del artículo 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Preguntas y respuestas sobre el artículo 50", Description: "Explicaciones prácticas sobre ámbito, excepciones, revisión humana y control editorial.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Código de buenas prácticas sobre contenido generado por IA", Description: "Instrumento voluntario para apoyar la aplicación de las obligaciones de marcado y etiquetado.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "Iconos de la UE para contenido generado por IA", Description: "Símbolos opcionales de la Unión Europea; su uso por sí solo no acredita el cumplimiento jurídico.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"it": {
{Title: "Regolamento (UE) 2024/1689 AI Act", Description: "Testo giuridico vincolante, in particolare larticolo 50 sugli obblighi di trasparenza per determinati sistemi di IA.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Orientamenti sugli obblighi di trasparenza", Description: "Orientamenti della Commissione europea pubblicati il 20 luglio 2026 sullambito di applicazione dellarticolo 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Orientamenti sugli obblighi di trasparenza", Description: "Orientamenti della Commissione europea pubblicati il 20 luglio 2026 sullambito di applicazione dellarticolo 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Domande e risposte sullarticolo 50", Description: "Chiarimenti pratici su ambito, eccezioni, revisione umana e controllo editoriale.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Codice di buone pratiche sui contenuti generati dallIA", Description: "Strumento volontario a sostegno dellattuazione degli obblighi di marcatura ed etichettatura.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "Icone UE per i contenuti generati dallIA", Description: "Simboli facoltativi dellUnione europea; il loro uso da solo non dimostra la conformità giuridica.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"nl": {
{Title: "Verordening (EU) 2024/1689 AI-verordening", Description: "Bindende wettekst, met name artikel 50 over transparantieverplichtingen voor bepaalde AI-systemen.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Richtsnoeren over transparantieverplichtingen", Description: "Richtsnoeren van de Europese Commissie van 20 juli 2026 over het toepassingsgebied van artikel 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Richtsnoeren over transparantieverplichtingen", Description: "Richtsnoeren van de Europese Commissie van 20 juli 2026 over het toepassingsgebied van artikel 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Vragen en antwoorden over artikel 50", Description: "Praktische uitleg over toepassingsgebied, uitzonderingen, menselijke beoordeling en redactionele controle.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Gedragscode voor door AI gegenereerde inhoud", Description: "Vrijwillig instrument ter ondersteuning van markerings- en etiketteringsverplichtingen.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "EU-pictogrammen voor door AI gegenereerde inhoud", Description: "Optionele symbolen van de Europese Unie; gebruik alleen bewijst geen juridische naleving.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"pt": {
{Title: "Regulamento (UE) 2024/1689 Regulamento da IA", Description: "Texto jurídico vinculativo, em especial o artigo 50 sobre obrigações de transparência para determinados sistemas de IA.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Orientações sobre obrigações de transparência", Description: "Orientações da Comissão Europeia publicadas em 20 de julho de 2026 sobre o âmbito do artigo 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Orientações sobre obrigações de transparência", Description: "Orientações da Comissão Europeia publicadas em 20 de julho de 2026 sobre o âmbito do artigo 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Perguntas e respostas sobre o artigo 50", Description: "Explicações práticas sobre âmbito, exceções, revisão humana e controlo editorial.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Código de boas práticas sobre conteúdos gerados por IA", Description: "Instrumento voluntário de apoio à execução das obrigações de marcação e rotulagem.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "Ícones da UE para conteúdos gerados por IA", Description: "Símbolos opcionais da União Europeia; a sua utilização, por si só, não comprova conformidade jurídica.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"pl": {
{Title: "Rozporządzenie (UE) 2024/1689 akt w sprawie AI", Description: "Wiążący tekst prawny, w szczególności art. 50 dotyczący obowiązków przejrzystości dla niektórych systemów AI.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Wytyczne dotyczące obowiązków przejrzystości", Description: "Wytyczne Komisji Europejskiej opublikowane 20 lipca 2026 r., wyjaśniające zakres art. 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Wytyczne dotyczące obowiązków przejrzystości", Description: "Wytyczne Komisji Europejskiej opublikowane 20 lipca 2026 r., wyjaśniające zakres art. 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Pytania i odpowiedzi dotyczące art. 50", Description: "Praktyczne wyjaśnienia zakresu, wyjątków, przeglądu przez człowieka i kontroli redakcyjnej.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Kodeks postępowania dotyczący treści generowanych przez AI", Description: "Dobrowolne narzędzie wspierające realizację obowiązków znakowania i oznaczania.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "Ikony UE dla treści generowanych przez AI", Description: "Opcjonalne symbole Unii Europejskiej; samo ich użycie nie potwierdza zgodności prawnej.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
@@ -176,14 +176,13 @@ func german() Page {
Eyebrow: "Rechtlicher und redaktioneller Hintergrund",
Title: "Warum KI-Nutzung gekennzeichnet wird",
Lead: "Der EU AI Act führt ab August 2026 konkrete Transparenzpflichten für bestimmte KI-Systeme und Inhalte ein. Dieses Projekt unterstützt darüber hinaus eine freiwillige, verständliche und maschinenlesbare Dokumentation der KI-Nutzung in Artikeln, Webseiten und Medien.",
StatusKicker: "Rechtsstand · 21. Juli 2026", StatusTitle: "Artikel 50 gilt ab 2. August 2026", StatusText: "Die Transparenzpflichten betreffen nicht pauschal jede Nutzung von KI. Entscheidend sind unter anderem die Rolle als Anbieter oder Betreiber, die Art des Systems, der Inhalt, der Veröffentlichungszweck und die vorhandene menschliche Prüfung.",
StatusKicker: "Aktueller Stand · 20. Juli 2026", StatusTitle: "Artikel 50 gilt ab 2. August 2026", StatusText: "Die Transparenzpflichten betreffen nicht pauschal jede Nutzung von KI. Entscheidend sind unter anderem die Rolle als Anbieter oder Betreiber, die Art des Systems, der Inhalt, der Veröffentlichungszweck und die vorhandene menschliche Prüfung.",
ContentsTitle: "Auf dieser Seite", Contents: []string{"Einordnung des EU AI Act", "Pflichten aus Artikel 50", "Bedeutung für Artikel und Webseiten", "Anforderungen an die Kennzeichnung", "Rolle dieses Standards", "Offizielle Quellen"},
OverviewTitle: "Einordnung: keine allgemeine Kennzeichnungspflicht für jede KI-Hilfe",
OverviewParagraphs: []string{
"Der AI Act ist die Verordnung (EU) 2024/1689. Er verfolgt einen risikobasierten Ansatz und enthält neben Regeln für verbotene oder hochriskante Systeme auch besondere Transparenzpflichten. Für die Kennzeichnung von KI-generierten oder manipulierten Inhalten ist vor allem Artikel 50 relevant.",
"Artikel 50 unterscheidet zwischen Anbietern von KI-Systemen und Personen oder Organisationen, die solche Systeme einsetzen. Ein Anbieter muss bestimmte Systeme technisch so gestalten, dass Interaktionen und synthetische Ausgaben erkennbar werden. Betreiber oder veröffentlichende Stellen müssen in bestimmten Fällen Menschen sichtbar informieren, etwa bei Deepfakes oder bestimmten Texten zu Angelegenheiten von öffentlichem Interesse.",
"Nicht jede Recherchehilfe, Rechtschreibkorrektur, Übersetzung oder redaktionelle Unterstützung löst automatisch dieselbe Pflicht aus. Die konkrete Bewertung hängt vom Anwendungsfall ab. Eine freiwillige Offenlegung kann dennoch sinnvoll sein, weil sie Erwartungen klärt, redaktionelle Verantwortung sichtbar macht und über das gesetzliche Mindestmaß hinaus Vertrauen schafft.",
"Dieses Projekt bildet nicht den gesamten AI Act ab. Andere Pflichten wie AI Literacy nach Artikel 4, Verbote nach Artikel 5, Hochrisiko-Anforderungen oder Pflichten für General-Purpose-AI-Modelle müssen unabhängig davon geprüft und erfüllt werden.",
},
ScopeTitle: "Was Artikel 50 konkret regelt", ScopeLead: "Die Vorschrift enthält mehrere eigenständige Transparenzpflichten. Für Webseiten und redaktionelle Inhalte sind insbesondere die technische Markierung durch Anbieter sowie die sichtbare Offenlegung durch Betreiber relevant.",
Obligations: []Card{
@@ -208,9 +207,8 @@ func german() Page {
},
ProjectTitle: "Wie dieser Standard einzuordnen ist",
ProjectParagraphs: []string{
"AI Usage Disclosure ist ein technisches und redaktionelles Werkzeug. Es ermöglicht, die Nutzung von KI nach Inhaltsbestandteilen zu dokumentieren: beispielsweise Text ohne KI, Titelbild KI-generiert, Recherche KI-unterstützt und nur wenn tatsächlich erfolgt eine menschliche Prüfung. Eine einzelne URL bündelt die sichtbare Erklärung, die tabellarischen Details und ein maschinenlesbares JSON-LD-Manifest.",
"AI Usage Disclosure ist ein technisches und redaktionelles Werkzeug. Es ermöglicht, die Nutzung von KI nach Inhaltsbestandteilen zu dokumentieren: beispielsweise Text ohne KI, Titelbild KI-generiert, Recherche KI-unterstützt und fachliche Prüfung durch Menschen. Eine einzelne URL bündelt die sichtbare Erklärung, die tabellarischen Details und ein maschinenlesbares JSON-LD-Manifest.",
"Der Standard ist bewusst weiter gefasst als die gesetzlichen Mindestfälle. Organisationen können damit auch freiwillige Transparenz veröffentlichen, etwa für Rechercheunterstützung oder generierte Illustrationen, die kein Deepfake darstellen. Gleichzeitig entscheidet das Programm nicht, ob im konkreten Einzelfall eine gesetzliche Pflicht besteht.",
"Das JSON-LD ist ergänzende Dokumentation. Es ersetzt nicht automatisch die wirksame, interoperable, robuste und zuverlässige technische Markierung, die Anbieter generativer KI-Systeme in den von Artikel 50 Absatz 2 erfassten Fällen gewährleisten müssen.",
},
ProjectItems: []string{
"Komponentenbezogene Angaben für Text, Bilder, Recherche, Übersetzung, Audio, Video und Code",
@@ -231,14 +229,13 @@ func english() Page {
MetaDescription: "Background on AI labelling, Article 50 of the EU AI Act and voluntary documentation of AI use in digital content.",
NavProduct: "Features", NavGenerator: "Generator", NavBackground: "Background", LanguageLabel: "Language",
Eyebrow: "Legal and editorial background", Title: "Why AI use is disclosed", Lead: "From August 2026, the EU AI Act introduces specific transparency obligations for certain AI systems and content. This project additionally supports voluntary, understandable and machine-readable documentation of AI use in articles, websites and media.",
StatusKicker: "Legal status · 21 July 2026", StatusTitle: "Article 50 applies from 2 August 2026", StatusText: "The transparency obligations do not apply indiscriminately to every use of AI. Relevant factors include the role of provider or deployer, the type of system, the content, the purpose of publication and the human review actually performed.",
StatusKicker: "Current as of 20 July 2026", StatusTitle: "Article 50 applies from 2 August 2026", StatusText: "The transparency obligations do not apply indiscriminately to every use of AI. Relevant factors include the role of provider or deployer, the type of system, the content, the purpose of publication and the human review actually performed.",
ContentsTitle: "On this page", Contents: []string{"Context of the EU AI Act", "Obligations under Article 50", "Meaning for articles and websites", "Requirements for disclosure", "Role of this standard", "Official sources"},
OverviewTitle: "Context: there is no blanket labelling duty for every AI-assisted task",
OverviewParagraphs: []string{
"The AI Act is Regulation (EU) 2024/1689. It follows a risk-based approach and, alongside rules for prohibited and high-risk systems, contains specific transparency obligations. Article 50 is particularly relevant to the identification of AI-generated or manipulated content.",
"Article 50 distinguishes between providers of AI systems and persons or organisations deploying them. Providers must design certain systems so that interactions and synthetic outputs can be identified. Deployers and publishers must visibly inform people in particular situations, including deepfakes and certain text concerning matters of public interest.",
"Not every research aid, spelling correction, translation or editorial assistance automatically triggers the same duty. The assessment depends on the circumstances. Voluntary disclosure may nevertheless be valuable because it sets expectations, makes editorial responsibility visible and can build trust beyond the legal minimum.",
"This project does not cover the entire AI Act. Other obligations, including AI literacy under Article 4, prohibited practices under Article 5, high-risk requirements and obligations for general-purpose AI models, must be assessed and met independently where applicable.",
},
ScopeTitle: "What Article 50 covers", ScopeLead: "The provision contains several distinct transparency obligations. For websites and editorial content, provider-side technical marking and deployer-side visible disclosure are especially relevant.",
Obligations: []Card{

View File

@@ -13,8 +13,6 @@ type Options struct {
Label string
Message string
Extent string
Preset string
Article bool
Style string
Theme string
Link string
@@ -26,10 +24,6 @@ var extentColors = map[string]string{
"none": "#2f855a", "assisted": "#2b6cb0", "partial": "#b7791f", "mostly": "#c05621", "full": "#c53030",
}
var emojiPresetColors = map[string]string{
"no-ai": "#2f855a", "research": "#2b6cb0", "summary": "#b7791f", "full": "#c53030",
}
func Render(o Options) ([]byte, string) {
label := truncate(strings.TrimSpace(o.Label), 40)
if label == "" {
@@ -39,10 +33,6 @@ func Render(o Options) ([]byte, string) {
if message == "" {
message = o.Extent
}
if o.Theme == "emoji" {
return renderEmoji(o, label, message)
}
style := o.Style
if style != "flat-square" {
style = "flat"
@@ -73,100 +63,17 @@ func Render(o Options) ([]byte, string) {
title := html.EscapeString(label + ": " + message)
labelEsc := html.EscapeString(label)
messageEsc := html.EscapeString(message)
openLink, closeLink := svgLink(o.Link)
openLink, closeLink := "", ""
if strings.HasPrefix(o.Link, "https://") || strings.HasPrefix(o.Link, "http://") {
openLink = `<a href="` + html.EscapeString(o.Link) + `" target="_top">`
closeLink = `</a>`
}
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="%s" width="%d" height="20" viewBox="0 0 %d 20"><title>%s</title>%s<linearGradient id="s" x2="0" y2="100%%"><stop offset="0" stop-color="#fff" stop-opacity=".12"/><stop offset="1" stop-opacity=".12"/></linearGradient><clipPath id="r"><rect width="%d" height="20" rx="%d" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="%d" height="20" fill="%s"/><rect x="%d" width="%d" height="20" fill="%s"/><rect width="%d" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"><text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text><text x="%d" y="14">%s</text><text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text><text x="%d" y="14">%s</text></g>%s</svg>`,
title, total, total, title, openLink, total, radius, leftWidth, leftColor, leftWidth, rightWidth, rightColor, total,
leftWidth/2, labelEsc, leftWidth/2, labelEsc, leftWidth+rightWidth/2, messageEsc, leftWidth+rightWidth/2, messageEsc, closeLink)
return withETag([]byte(svg))
}
func renderEmoji(o Options, label, message string) ([]byte, string) {
iconName := emojiIconName(o)
background := emojiPresetColors[o.Preset]
if o.Article {
background = "#7c3aed"
}
if background == "" {
background = extentColors[o.Extent]
}
if background == "" {
background = "#4a5568"
}
foreground := "#ffffff"
if isHexColor(o.LeftColor) {
foreground = o.LeftColor
}
if isHexColor(o.RightColor) {
background = o.RightColor
}
title := html.EscapeString(label + ": " + message)
openLink, closeLink := svgLink(o.Link)
icon := emojiIcon(iconName, foreground)
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="%s" focusable="false" width="64" height="64" viewBox="0 0 64 64" data-theme="emoji" data-icon="%s"><title>%s</title>%s<rect width="64" height="64" rx="14" fill="%s"/><path d="M3 18C14 5 35 1 57 5c3 .5 5 3 5 6v7C40 13 20 14 3 27Z" fill="#fff" opacity=".11"/><rect x=".75" y=".75" width="62.5" height="62.5" rx="13.25" fill="none" stroke="#000" stroke-opacity=".14" stroke-width="1.5"/>%s%s</svg>`,
title, iconName, title, openLink, background, icon, closeLink)
return withETag([]byte(svg))
}
func emojiIconName(o Options) string {
if o.Article {
return "article"
}
switch o.Preset {
case "no-ai":
return "human"
case "research":
return "research"
case "summary":
return "summary"
case "full":
return "bolt"
}
switch o.Extent {
case "none":
return "human"
case "partial":
return "partial"
case "mostly":
return "mostly"
case "full":
return "bolt"
default:
return "assisted"
}
}
func emojiIcon(name, color string) string {
stroke := ` fill="none" stroke="` + color + `" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"`
switch name {
case "human":
return `<circle cx="32" cy="21" r="8"` + stroke + `/><path d="M17 50c1.8-10 7.5-15 15-15s13.2 5 15 15"` + stroke + `/>`
case "research":
return `<circle cx="27" cy="27" r="12"` + stroke + `/><path d="m36 36 13 13"` + stroke + `/><path d="M27 21v12M21 27h12"` + stroke + `/>`
case "summary":
return `<rect x="17" y="12" width="30" height="40" rx="5"` + stroke + `/><path d="M24 24h16M24 32h16M24 40h11"` + stroke + `/>`
case "bolt":
return `<path d="M36 8 18 35h12l-2 21 18-29H34Z" fill="` + color + `"/>`
case "article":
return `<rect x="14" y="14" width="15" height="15" rx="4"` + stroke + `/><rect x="35" y="14" width="15" height="15" rx="4"` + stroke + `/><rect x="14" y="35" width="15" height="15" rx="4"` + stroke + `/><rect x="35" y="35" width="15" height="15" rx="4"` + stroke + `/>`
case "partial":
return `<circle cx="32" cy="32" r="18"` + stroke + `/><path d="M32 14a18 18 0 0 0 0 36Z" fill="` + color + `"/>`
case "mostly":
return `<path d="M18 45V35M32 45V27M46 45V18"` + stroke + `/><path d="M14 50h36"` + stroke + `/>`
default:
return `<path d="M32 12c1.5 8.2 5.8 12.5 14 14-8.2 1.5-12.5 5.8-14 14-1.5-8.2-5.8-12.5-14-14 8.2-1.5 12.5-5.8 14-14Z"` + stroke + `/><path d="M46 39c.7 3.8 2.7 5.8 6.5 6.5C48.7 46.2 46.7 48.2 46 52c-.7-3.8-2.7-5.8-6.5-6.5 3.8-.7 5.8-2.7 6.5-6.5Z" fill="` + color + `"/>`
}
}
func svgLink(link string) (string, string) {
if strings.HasPrefix(link, "https://") || strings.HasPrefix(link, "http://") {
return `<a href="` + html.EscapeString(link) + `" target="_top">`, `</a>`
}
return "", ""
}
func withETag(data []byte) ([]byte, string) {
data := []byte(svg)
hash := sha256.Sum256(data)
return data, `"` + hex.EncodeToString(hash[:12]) + `"`
}

View File

@@ -2,7 +2,6 @@ package badge
import (
"bytes"
"strings"
"testing"
)
@@ -18,77 +17,3 @@ func TestRenderEscapesText(t *testing.T) {
t.Fatal("missing etag")
}
}
func TestRenderEmojiIsSquareAndPresetSpecific(t *testing.T) {
data, etag := Render(Options{
Label: "KI-Nutzung",
Message: "Vollständige Inhaltserstellung",
Extent: "full",
Preset: "full",
Theme: "emoji",
})
body := string(data)
for _, expected := range []string{
`width="64" height="64"`,
`viewBox="0 0 64 64"`,
`data-theme="emoji"`,
`data-icon="bolt"`,
`fill="#c53030"`,
`<title>KI-Nutzung: Vollständige Inhaltserstellung</title>`,
} {
if !bytes.Contains(data, []byte(expected)) {
t.Fatalf("emoji SVG missing %q: %s", expected, body)
}
}
if etag == "" {
t.Fatal("missing etag")
}
}
func TestRenderEmojiUsesCustomColoursAndEscapesAccessibleText(t *testing.T) {
data, _ := Render(Options{
Label: `<AI>`,
Message: `Research & review`,
Extent: "assisted",
Preset: "research",
Theme: "emoji",
LeftColor: "#112233",
RightColor: "#abcdef",
})
body := string(data)
for _, expected := range []string{
`data-icon="research"`,
`fill="#abcdef"`,
`stroke="#112233"`,
`&lt;AI&gt;: Research &amp; review`,
} {
if !strings.Contains(body, expected) {
t.Fatalf("emoji SVG missing %q: %s", expected, body)
}
}
if strings.Contains(body, `<AI>`) {
t.Fatal("accessible text was not escaped")
}
}
func TestEmojiIconMapping(t *testing.T) {
tests := []struct {
name string
options Options
icon string
}{
{name: "no AI", options: Options{Extent: "none", Preset: "no-ai", Theme: "emoji"}, icon: "human"},
{name: "research", options: Options{Extent: "assisted", Preset: "research", Theme: "emoji"}, icon: "research"},
{name: "summary", options: Options{Extent: "assisted", Preset: "summary", Theme: "emoji"}, icon: "summary"},
{name: "full", options: Options{Extent: "full", Preset: "full", Theme: "emoji"}, icon: "bolt"},
{name: "article", options: Options{Extent: "mostly", Article: true, Theme: "emoji"}, icon: "article"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, _ := Render(tt.options)
if !bytes.Contains(data, []byte(`data-icon="`+tt.icon+`"`)) {
t.Fatalf("expected icon %q: %s", tt.icon, data)
}
})
}
}

View File

@@ -14,13 +14,13 @@ import (
const (
SchemaVersion = "1.2"
PreviousSchemaVersion = "1.1"
LegacySchemaVersion = "1.0"
LegacySchemaVersion = "1.1"
LegacySchemaVersion10 = "1.0"
)
var (
ErrCustomTextRequiresPro = errors.New("custom declaration text requires the Pro feature custom_text")
ErrCustomBadgeRequiresPro = errors.New("custom badge presentation requires the Pro feature custom_badge")
ErrCustomTextRequiresPro = errors.New("custom declaration text requires the licensed capability custom_text")
ErrCustomBadgeRequiresPro = errors.New("custom badge presentation requires the licensed capability custom_badge")
hexColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
)
@@ -32,14 +32,37 @@ type Component struct {
}
type Responsibility struct {
Assumed bool `json:"assumed,omitempty"`
Role string `json:"role,omitempty"`
Name string `json:"name"`
URL string `json:"url,omitempty"`
Name string `json:"name"`
URL string `json:"url,omitempty"`
}
type LegalContext struct {
Categories []string `json:"categories,omitempty"`
// RegulatoryContext records factual context that can be relevant to Article 50
// of Regulation (EU) 2024/1689. It deliberately does not encode a legal
// conclusion; assessment output is advisory and derived from these facts.
type RegulatoryContext struct {
Framework string `json:"framework,omitempty"`
PublicInterestText bool `json:"publicInterestText,omitempty"`
Deepfake bool `json:"deepfake,omitempty"`
ArtisticCreativeSatiricalFictional bool `json:"artisticCreativeSatiricalFictional,omitempty"`
SubstantialHumanReview bool `json:"substantialHumanReview,omitempty"`
EditorialResponsibilityConfirmed bool `json:"editorialResponsibilityConfirmed,omitempty"`
FirstExposureDisclosure bool `json:"firstExposureDisclosure,omitempty"`
AccessibilityConsidered bool `json:"accessibilityConsidered,omitempty"`
}
// Article50Assessment is a non-binding technical decision-support result.
// It must never be presented as legal advice or a legal determination.
type Article50Finding struct {
Code string `json:"code"`
Severity string `json:"severity"`
}
type Article50Assessment struct {
Code string `json:"code"`
Severity string `json:"severity"`
Applicable bool `json:"potentiallyApplicable"`
Findings []Article50Finding `json:"findings,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
type Presentation struct {
@@ -60,7 +83,7 @@ type Declaration struct {
Language string `json:"language"`
Components map[string]Component `json:"components"`
EditorialResponsibility *Responsibility `json:"editorialResponsibility,omitempty"`
LegalContext *LegalContext `json:"legalContext,omitempty"`
RegulatoryContext *RegulatoryContext `json:"regulatoryContext,omitempty"`
Assurance string `json:"assurance"`
Presentation *Presentation `json:"presentation,omitempty"`
}
@@ -79,14 +102,10 @@ type ParseOptions struct {
}
var Presets = map[string]Preset{
// Human review is deliberately not presumed by presets. Under Article 50(4)
// AI Act, substantive human review/editorial control can affect whether
// certain public-interest text must be labelled. The factual review process
// must therefore be selected explicitly by the publisher.
"no-ai": {ID: "no-ai", Extent: "none", Review: "none"},
"research": {ID: "research", Extent: "assisted", Activities: []string{"research"}, Review: "none"},
"summary": {ID: "summary", Extent: "assisted", Activities: []string{"summarisation"}, Review: "none"},
"full": {ID: "full", Extent: "full", Activities: []string{"generation"}, Review: "none"},
"research": {ID: "research", Extent: "assisted", Activities: []string{"research"}, Review: "editorial"},
"summary": {ID: "summary", Extent: "assisted", Activities: []string{"summarisation"}, Review: "editorial"},
"full": {ID: "full", Extent: "full", Activities: []string{"generation"}, Review: "editorial"},
}
var validExtents = map[string]bool{"none": true, "assisted": true, "partial": true, "mostly": true, "full": true}
@@ -98,8 +117,6 @@ var validActivities = map[string]bool{
"transcription": true, "classification": true,
}
var validComponents = map[string]bool{"text": true, "coverImage": true, "image": true, "research": true, "translation": true, "audio": true, "video": true, "code": true, "other": true}
var validLegalCategories = map[string]bool{"deepfake": true, "publicInterestText": true, "artisticCreativeSatiricalFictional": true, "otherVoluntary": true}
var validResponsibilityRoles = map[string]bool{"publisher": true, "other": true}
func NewFromQuery(values url.Values, contextURL string) (Declaration, error) {
return NewFromQueryWithOptions(values, contextURL, ParseOptions{DefaultLanguage: "de"})
@@ -148,8 +165,7 @@ func NewFromQueryWithOptions(values url.Values, contextURL string, options Parse
extent = "assisted"
}
if review == "" {
// Do not infer a legally relevant human-review process from AI usage alone.
review = "none"
review = "editorial"
}
assurance := clean(values.Get("assurance"), 32)
if assurance == "" {
@@ -165,8 +181,11 @@ func NewFromQueryWithOptions(values url.Values, contextURL string, options Parse
}
componentReview := clean(values.Get(name+"Review"), 24)
if componentReview == "" {
// Human review must be stated explicitly; never infer editorial control.
componentReview = "none"
if componentExtent == "none" {
componentReview = "none"
} else {
componentReview = "editorial"
}
}
componentActivities := splitCSV(values.Get(name + "Activities"))
if len(componentActivities) == 0 {
@@ -203,13 +222,22 @@ func NewFromQueryWithOptions(values url.Values, contextURL string, options Parse
}
responsibleName := clean(values.Get("responsible"), 200)
responsibleURL := clean(values.Get("responsibleUrl"), 2048)
responsibleRole := clean(values.Get("responsibleRole"), 24)
if responsibleName != "" || responsibleURL != "" || responsibleRole != "" {
d.EditorialResponsibility = &Responsibility{Assumed: responsibleName != "", Role: responsibleRole, Name: responsibleName, URL: responsibleURL}
if responsibleName != "" || responsibleURL != "" {
d.EditorialResponsibility = &Responsibility{Name: responsibleName, URL: responsibleURL}
}
legalCategories := splitCSV(values.Get("legalContext"))
if len(legalCategories) > 0 {
d.LegalContext = &LegalContext{Categories: legalCategories}
regulatory := &RegulatoryContext{
Framework: "EU-AI-Act-Article-50",
PublicInterestText: parseBool(values.Get("publicInterestText")),
Deepfake: parseBool(values.Get("deepfake")),
ArtisticCreativeSatiricalFictional: parseBool(values.Get("artisticCreativeSatiricalFictional")),
SubstantialHumanReview: parseBool(values.Get("substantialHumanReview")),
EditorialResponsibilityConfirmed: parseBool(values.Get("editorialResponsibilityConfirmed")),
FirstExposureDisclosure: parseBool(values.Get("firstExposureDisclosure")),
AccessibilityConsidered: parseBool(values.Get("accessibilityConsidered")),
}
if regulatory.PublicInterestText || regulatory.Deepfake || regulatory.ArtisticCreativeSatiricalFictional || regulatory.SubstantialHumanReview || regulatory.EditorialResponsibilityConfirmed || regulatory.FirstExposureDisclosure || regulatory.AccessibilityConsidered {
d.RegulatoryContext = regulatory
}
presentation := &Presentation{
@@ -237,16 +265,16 @@ func Validate(d Declaration) error {
var problems []string
switch d.SchemaVersion {
case SchemaVersion:
case PreviousSchemaVersion:
if d.LegalContext != nil {
problems = append(problems, "legalContext requires schemaVersion 1.2")
}
case LegacySchemaVersion:
if d.Presentation != nil {
problems = append(problems, "presentation requires schemaVersion 1.1 or later")
if d.RegulatoryContext != nil {
problems = append(problems, "regulatoryContext requires schemaVersion 1.2")
}
if d.LegalContext != nil {
problems = append(problems, "legalContext requires schemaVersion 1.2")
case LegacySchemaVersion10:
if d.Presentation != nil {
problems = append(problems, "presentation requires schemaVersion 1.1 or newer")
}
if d.RegulatoryContext != nil {
problems = append(problems, "regulatoryContext requires schemaVersion 1.2")
}
if d.Language != "de" && d.Language != "en" {
problems = append(problems, "schemaVersion 1.0 supports only de and en")
@@ -276,49 +304,14 @@ func Validate(d Declaration) error {
problems = append(problems, "declaredAt must be RFC3339")
}
}
if d.EditorialResponsibility != nil {
if strings.TrimSpace(d.EditorialResponsibility.Name) == "" {
problems = append(problems, "editorialResponsibility.name is required when editorialResponsibility is present")
}
if d.EditorialResponsibility.Role != "" && !validResponsibilityRoles[d.EditorialResponsibility.Role] {
problems = append(problems, "editorialResponsibility.role must be publisher or other")
}
if d.EditorialResponsibility.URL != "" {
if u, err := url.ParseRequestURI(d.EditorialResponsibility.URL); err != nil || u.Scheme == "" || u.Host == "" {
problems = append(problems, "editorialResponsibility.url must be an absolute URL")
}
if d.EditorialResponsibility != nil && d.EditorialResponsibility.URL != "" {
if u, err := url.ParseRequestURI(d.EditorialResponsibility.URL); err != nil || u.Scheme == "" || u.Host == "" {
problems = append(problems, "editorialResponsibility.url must be an absolute URL")
}
}
if d.LegalContext != nil {
seenLegal := map[string]bool{}
for _, category := range d.LegalContext.Categories {
if !validLegalCategories[category] {
problems = append(problems, "invalid legalContext category: "+category)
}
if seenLegal[category] {
problems = append(problems, "duplicate legalContext category: "+category)
}
seenLegal[category] = true
}
if seenLegal["otherVoluntary"] && len(seenLegal) > 1 {
problems = append(problems, "otherVoluntary cannot be combined with other legalContext categories")
}
if seenLegal["publicInterestText"] {
text, ok := d.Components["text"]
if !ok || text.AIExtent == "none" {
problems = append(problems, "publicInterestText requires a text component with AI involvement")
}
}
if seenLegal["deepfake"] {
hasAIMedia := false
for _, name := range []string{"coverImage", "image", "audio", "video"} {
if c, ok := d.Components[name]; ok && c.AIExtent != "none" {
hasAIMedia = true
}
}
if !hasAIMedia {
problems = append(problems, "deepfake requires an image, audio or video component with AI involvement")
}
if rc := d.RegulatoryContext; rc != nil {
if rc.Framework != "" && rc.Framework != "EU-AI-Act-Article-50" {
problems = append(problems, "regulatoryContext.framework must be EU-AI-Act-Article-50")
}
}
for name, c := range d.Components {
@@ -378,6 +371,63 @@ func (p *Presentation) empty() bool {
return p == nil || (p.Title == "" && p.Description == "" && p.BadgeLabel == "" && p.BadgeMessage == "" && p.LeftColor == "" && p.RightColor == "")
}
// AssessArticle50 derives a conservative, non-binding decision-support result
// from the factual context recorded in a declaration.
func AssessArticle50(d Declaration) Article50Assessment {
rc := d.RegulatoryContext
if rc == nil {
return Article50Assessment{Code: "not_assessed", Severity: "neutral", Applicable: false}
}
result := Article50Assessment{Code: "voluntary_transparency", Severity: "neutral", Applicable: false}
if rc.Deepfake {
result.Findings = append(result.Findings, Article50Finding{Code: "deepfake_disclosure_relevant", Severity: "attention"})
}
if rc.PublicInterestText {
if rc.SubstantialHumanReview && rc.EditorialResponsibilityConfirmed {
result.Findings = append(result.Findings, Article50Finding{Code: "public_interest_text_possible_exemption", Severity: "caution"})
} else {
result.Findings = append(result.Findings, Article50Finding{Code: "public_interest_text_disclosure_relevant", Severity: "attention"})
}
}
if len(result.Findings) == 1 {
result.Code = result.Findings[0].Code
result.Severity = result.Findings[0].Severity
result.Applicable = true
} else if len(result.Findings) > 1 {
result.Code = "multiple_article50_contexts"
result.Severity = "caution"
result.Applicable = true
for _, finding := range result.Findings {
if finding.Severity == "attention" {
result.Severity = "attention"
break
}
}
}
if result.Applicable && !rc.FirstExposureDisclosure {
result.Warnings = append(result.Warnings, "first_exposure_not_confirmed")
}
if result.Applicable && !rc.AccessibilityConsidered {
result.Warnings = append(result.Warnings, "accessibility_not_confirmed")
}
if rc.PublicInterestText && rc.SubstantialHumanReview && !rc.EditorialResponsibilityConfirmed {
result.Warnings = append(result.Warnings, "editorial_responsibility_not_confirmed")
}
if rc.Deepfake && rc.ArtisticCreativeSatiricalFictional {
result.Warnings = append(result.Warnings, "artistic_context_disclosure_manner")
}
return result
}
func parseBool(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func clean(s string, max int) string {
r := []rune(strings.TrimSpace(s))
if len(r) > max {

View File

@@ -3,7 +3,6 @@ package declaration
import (
"errors"
"net/url"
"strings"
"testing"
)
@@ -20,9 +19,6 @@ func TestPresetQuery(t *testing.T) {
if d.Language != "fr" {
t.Fatalf("unexpected language: %s", d.Language)
}
if c.HumanReview != "none" {
t.Fatalf("preset inferred human review=%q", c.HumanReview)
}
}
func TestNoAIRejectsActivities(t *testing.T) {
@@ -49,12 +45,18 @@ func TestCustomRequiresPro(t *testing.T) {
func TestLegacySchemaValidation(t *testing.T) {
d := Declaration{Context: "https://example.org/context/v1", Type: "AIUsageDeclaration", SchemaVersion: LegacySchemaVersion, Language: "en", Assurance: "selfDeclared", Components: map[string]Component{"text": {AIExtent: "assisted", HumanReview: "editorial"}}}
d.Presentation = &Presentation{Title: "Allowed since 1.1"}
if err := Validate(d); err != nil {
t.Fatalf("legacy declaration should remain valid: %v", err)
t.Fatalf("1.1 declaration should remain valid: %v", err)
}
d.Presentation = &Presentation{Title: "Not allowed in 1.0"}
d.RegulatoryContext = &RegulatoryContext{Framework: "EU-AI-Act-Article-50", Deepfake: true}
if Validate(d) == nil {
t.Fatal("expected presentation to require schema 1.1")
t.Fatal("expected regulatoryContext to require schema 1.2")
}
d.SchemaVersion = LegacySchemaVersion10
d.RegulatoryContext = nil
if Validate(d) == nil {
t.Fatal("expected presentation to require schema 1.1 or newer")
}
}
@@ -76,8 +78,8 @@ func TestArticleDeclarationFromQuery(t *testing.T) {
if d.Components["text"].HumanReview != "none" {
t.Fatalf("no-AI component review=%q, want none", d.Components["text"].HumanReview)
}
if d.Components["research"].HumanReview != "none" {
t.Fatalf("AI-assisted component review=%q, want no inferred review", d.Components["research"].HumanReview)
if d.Components["research"].HumanReview != "editorial" {
t.Fatalf("AI-assisted component review=%q, want editorial default", d.Components["research"].HumanReview)
}
if got := d.Components["research"].Activities; len(got) != 1 || got[0] != "research" {
t.Fatalf("research activities=%v", got)
@@ -117,48 +119,63 @@ func TestAssuranceFromQuery(t *testing.T) {
}
}
func TestEditorialResponsibilityRequiresName(t *testing.T) {
_, err := NewFromQuery(url.Values{
"preset": {"research"},
"responsibleUrl": {"https://example.org/impressum"},
}, "https://example.org/context")
if err == nil || !strings.Contains(err.Error(), "editorialResponsibility.name is required") {
t.Fatalf("expected editorial responsibility name validation error, got %v", err)
func TestArticle50Assessment(t *testing.T) {
base := Declaration{Context: "https://example.org/context/v1", Type: "AIUsageDeclaration", SchemaVersion: SchemaVersion, Language: "de", Assurance: "selfDeclared", Components: map[string]Component{"text": {AIExtent: "partial", HumanReview: "expert"}}}
base.RegulatoryContext = &RegulatoryContext{Framework: "EU-AI-Act-Article-50", PublicInterestText: true}
assessment := AssessArticle50(base)
if assessment.Code != "public_interest_text_disclosure_relevant" || !assessment.Applicable {
t.Fatalf("unexpected assessment: %#v", assessment)
}
base.RegulatoryContext.SubstantialHumanReview = true
base.RegulatoryContext.EditorialResponsibilityConfirmed = true
assessment = AssessArticle50(base)
if assessment.Code != "public_interest_text_possible_exemption" {
t.Fatalf("unexpected exemption assessment: %#v", assessment)
}
base.RegulatoryContext.Deepfake = true
assessment = AssessArticle50(base)
if assessment.Code != "multiple_article50_contexts" || len(assessment.Findings) != 2 {
t.Fatalf("combined article context should preserve both findings: %#v", assessment)
}
}
func TestLegalContextPublicInterestRequiresAIText(t *testing.T) {
_, err := NewFromQuery(url.Values{
"mode": {"article"}, "lang": {"de"}, "textExtent": {"none"},
"legalContext": {"publicInterestText"},
}, "https://example.org/context/v1")
if err == nil || !strings.Contains(err.Error(), "publicInterestText requires a text component with AI involvement") {
t.Fatalf("expected public-interest consistency error, got %v", err)
}
}
func TestLegalContextDeepfakeRequiresAIMedia(t *testing.T) {
_, err := NewFromQuery(url.Values{
"mode": {"article"}, "lang": {"de"}, "textExtent": {"full"},
"legalContext": {"deepfake"},
}, "https://example.org/context/v1")
if err == nil || !strings.Contains(err.Error(), "deepfake requires an image, audio or video component with AI involvement") {
t.Fatalf("expected deepfake consistency error, got %v", err)
}
}
func TestLegalContextAndResponsibilityAreSerialized(t *testing.T) {
func TestRegulatoryContextFromQuery(t *testing.T) {
d, err := NewFromQuery(url.Values{
"mode": {"article"}, "lang": {"de"}, "textExtent": {"partial"}, "textReview": {"expert"},
"legalContext": {"publicInterestText"}, "responsibleRole": {"publisher"}, "responsible": {"Beispielredaktion"},
"preset": {"research"}, "lang": {"de"}, "publicInterestText": {"true"},
"substantialHumanReview": {"1"}, "editorialResponsibilityConfirmed": {"yes"},
}, "https://example.org/context/v1")
if err != nil {
t.Fatal(err)
}
if d.SchemaVersion != "1.2" || d.LegalContext == nil || len(d.LegalContext.Categories) != 1 {
t.Fatalf("unexpected legal context: %#v", d)
}
if d.EditorialResponsibility == nil || !d.EditorialResponsibility.Assumed || d.EditorialResponsibility.Role != "publisher" {
t.Fatalf("responsibility not explicitly assumed: %#v", d.EditorialResponsibility)
if d.RegulatoryContext == nil || !d.RegulatoryContext.PublicInterestText || !d.RegulatoryContext.SubstantialHumanReview || !d.RegulatoryContext.EditorialResponsibilityConfirmed {
t.Fatalf("unexpected regulatory context: %#v", d.RegulatoryContext)
}
}
func TestNoAIPresetDoesNotClaimHumanAIReview(t *testing.T) {
values := url.Values{"preset": {"no-ai"}, "lang": {"de"}}
d, err := NewFromQuery(values, "https://example.org/context/v1")
if err != nil {
t.Fatal(err)
}
if got := d.Components["text"].HumanReview; got != "none" {
t.Fatalf("no-ai review = %q, want none", got)
}
}
func TestArticle50ArtisticDeepfakeWarning(t *testing.T) {
d := Declaration{
RegulatoryContext: &RegulatoryContext{Deepfake: true, ArtisticCreativeSatiricalFictional: true},
}
assessment := AssessArticle50(d)
found := false
for _, warning := range assessment.Warnings {
if warning == "artistic_context_disclosure_manner" {
found = true
break
}
}
if !found {
t.Fatalf("expected artistic context warning, got %#v", assessment.Warnings)
}
}

58
internal/i18n/bulk.go Normal file
View File

@@ -0,0 +1,58 @@
package i18n
func applyBulkTranslations() {
texts := map[string]map[string]string{
"de": {
"bulk_meta_description": "Professionelle Stapelverarbeitung für KI-Nutzungserklärungen.", "bulk_nav_workspace": "Arbeitsbereich", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Ergebnisse",
"bulk_eyebrow": "Bulk Workspace", "bulk_title": "Viele Inhalte. Eine konsistente Erklärung.", "bulk_intro": "Erzeuge strukturierte KI-Nutzungserklärungen für ganze Artikelbestände. Ein gemeinsames Profil kann auf viele URLs angewendet oder als individuelles JSON verarbeitet werden.",
"bulk_status": "Betriebsstatus", "bulk_license": "Bulk-Lizenz", "bulk_active": "Aktiv", "bulk_inactive": "Nicht verfügbar", "bulk_limit": "Max. Elemente", "bulk_auth": "API-Schutz", "bulk_api_key": "API-Key erforderlich", "bulk_no_api_key": "Nicht erforderlich",
"bulk_inactive_title": "Bulk-Verarbeitung ist nicht freigeschaltet.", "bulk_inactive_help": "Die Oberfläche bleibt zur Konfiguration sichtbar; die Verarbeitung benötigt die lizenzierte Capability bulk_api.",
"bulk_workspace_eyebrow": "Massenverarbeitung", "bulk_workspace_title": "Ein Profil auf viele Inhalte anwenden", "bulk_workspace_intro": "Füge Artikel oder Seiten zeilenweise ein und definiere einmal die gemeinsame KI-Nutzung. Abweichende Datensätze können anschließend über den erweiterten JSON-Modus verarbeitet werden.",
"bulk_api_key_label": "Bulk API-Key", "bulk_api_key_placeholder": "API-Key für diese Instanz", "bulk_api_key_help": "Der Schlüssel wird nur im Sitzungsspeicher dieses Browser-Tabs gehalten und nicht in die erzeugten Ergebnisse geschrieben.",
"bulk_items_label": "Artikel / Seiten", "bulk_items_placeholder": "article-1001 | https://example.org/artikel/1001\narticle-1002 | https://example.org/artikel/1002", "bulk_items_help": "Eine Zeile pro Inhalt. Unterstützt „ID | URL“, „ID<Tab>URL“ oder nur eine URL. Die ID wird andernfalls automatisch erzeugt.", "bulk_sample": "Beispieldaten einsetzen",
"bulk_profile_title": "Gemeinsames Deklarationsprofil", "bulk_profile_intro": "Diese Angaben werden auf alle Einträge der Liste angewendet.", "bulk_cover_image": "Titelbild", "bulk_images": "Weitere Bilder", "bulk_research": "Recherche", "bulk_translation": "Übersetzung",
"bulk_article50_title": "Regulatorischer Kontext / Artikel 50", "bulk_run": "Stapelverarbeitung starten",
"bulk_advanced_eyebrow": "Erweitert", "bulk_advanced_title": "Individuelle JSON-Stapel", "bulk_advanced_intro": "Für heterogene Inhalte kann jeder Datensatz seine eigenen Parameter erhalten. Das Format entspricht direkt POST /v1/bulk/declarations.", "bulk_json_label": "Bulk-Request (JSON)", "bulk_json_sample": "Aktuelles Profil als JSON übernehmen", "bulk_json_run": "JSON verarbeiten",
"bulk_results_eyebrow": "Ausgabe", "bulk_results_title": "Verarbeitungsergebnisse", "bulk_copy_json": "JSON kopieren", "bulk_download_json": "JSON herunterladen", "bulk_download_csv": "CSV herunterladen", "bulk_col_id": "ID", "bulk_col_status": "Status", "bulk_col_assessment": "Artikel-50-Einordnung", "bulk_col_links": "Ausgaben", "bulk_raw_result": "Rohdaten anzeigen", "bulk_footer_note": "Bulk-Verarbeitung · keine Rechtsberatung",
"bulk_error_no_items": "Füge mindestens einen Inhalt hinzu.", "bulk_error_too_many_items": "Die zulässige Anzahl von Elementen wurde überschritten.", "bulk_error_api_key": "Für die Bulk-Verarbeitung ist ein API-Key erforderlich.", "bulk_status_running": "Verarbeitung läuft …", "bulk_status_done": "Verarbeitung abgeschlossen.", "bulk_status_error": "Fehler", "bulk_summary_processed": "Verarbeitet", "bulk_summary_successful": "Erfolgreich", "bulk_summary_failed": "Fehlgeschlagen", "bulk_result_ok": "OK", "bulk_result_error": "Fehler", "bulk_link_declaration": "Erklärung", "bulk_link_manifest": "Manifest", "bulk_link_badge": "Badge",
},
"en": {
"bulk_meta_description": "Professional batch processing for AI usage declarations.", "bulk_nav_workspace": "Workspace", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Results",
"bulk_eyebrow": "Bulk Workspace", "bulk_title": "Many items. One consistent disclosure.", "bulk_intro": "Generate structured AI usage declarations for entire content inventories. Apply one shared profile to many URLs or process individual records as JSON.",
"bulk_status": "Service status", "bulk_license": "Bulk licence", "bulk_active": "Active", "bulk_inactive": "Unavailable", "bulk_limit": "Max. items", "bulk_auth": "API protection", "bulk_api_key": "API key required", "bulk_no_api_key": "Not required",
"bulk_inactive_title": "Bulk processing is not enabled.", "bulk_inactive_help": "The workspace remains visible for configuration; processing requires the licensed bulk_api capability.",
"bulk_workspace_eyebrow": "Batch processing", "bulk_workspace_title": "Apply one profile to many items", "bulk_workspace_intro": "Paste articles or pages line by line and define their shared AI usage once. Records that differ can be processed through the advanced JSON mode.",
"bulk_api_key_label": "Bulk API key", "bulk_api_key_placeholder": "API key for this instance", "bulk_api_key_help": "The key is kept only in this browser tab's session storage and is never written into generated results.",
"bulk_items_label": "Articles / pages", "bulk_items_placeholder": "article-1001 | https://example.org/articles/1001\narticle-1002 | https://example.org/articles/1002", "bulk_items_help": "One item per line. Supports “ID | URL”, “ID<Tab>URL” or a URL alone. An ID is generated automatically when omitted.", "bulk_sample": "Insert sample data",
"bulk_profile_title": "Shared declaration profile", "bulk_profile_intro": "These settings are applied to every item in the list.", "bulk_cover_image": "Cover image", "bulk_images": "Other images", "bulk_research": "Research", "bulk_translation": "Translation",
"bulk_article50_title": "Regulatory context / Article 50", "bulk_run": "Start batch processing",
"bulk_advanced_eyebrow": "Advanced", "bulk_advanced_title": "Individual JSON batches", "bulk_advanced_intro": "For heterogeneous content, every item can carry its own parameters. The format maps directly to POST /v1/bulk/declarations.", "bulk_json_label": "Bulk request (JSON)", "bulk_json_sample": "Use current profile as JSON", "bulk_json_run": "Process JSON",
"bulk_results_eyebrow": "Output", "bulk_results_title": "Processing results", "bulk_copy_json": "Copy JSON", "bulk_download_json": "Download JSON", "bulk_download_csv": "Download CSV", "bulk_col_id": "ID", "bulk_col_status": "Status", "bulk_col_assessment": "Article 50 assessment", "bulk_col_links": "Outputs", "bulk_raw_result": "Show raw data", "bulk_footer_note": "Bulk processing · not legal advice",
"bulk_error_no_items": "Add at least one item.", "bulk_error_too_many_items": "The permitted item count has been exceeded.", "bulk_error_api_key": "A bulk API key is required.", "bulk_status_running": "Processing …", "bulk_status_done": "Processing completed.", "bulk_status_error": "Error", "bulk_summary_processed": "Processed", "bulk_summary_successful": "Successful", "bulk_summary_failed": "Failed", "bulk_result_ok": "OK", "bulk_result_error": "Error", "bulk_link_declaration": "Declaration", "bulk_link_manifest": "Manifest", "bulk_link_badge": "Badge",
},
"fr": {
"bulk_title": "Plusieurs contenus. Une déclaration cohérente.", "bulk_intro": "Générez des déclarations structurées dutilisation de lIA pour des ensembles complets de contenus.", "bulk_nav_workspace": "Espace de travail", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Résultats", "bulk_eyebrow": "Traitement en lot", "bulk_workspace_eyebrow": "Traitement en lot", "bulk_workspace_title": "Appliquer un profil à plusieurs contenus", "bulk_workspace_intro": "Ajoutez les articles ou pages ligne par ligne puis définissez une seule fois leur utilisation commune de lIA.", "bulk_items_label": "Articles / pages", "bulk_items_help": "Un contenu par ligne : « ID | URL », « ID<Tab>URL » ou URL seule.", "bulk_sample": "Insérer un exemple", "bulk_profile_title": "Profil de déclaration commun", "bulk_profile_intro": "Ces paramètres sappliquent à tous les éléments.", "bulk_run": "Lancer le traitement", "bulk_advanced_title": "Lots JSON individuels", "bulk_advanced_intro": "Chaque élément peut utiliser ses propres paramètres.", "bulk_json_label": "Requête bulk (JSON)", "bulk_json_sample": "Reprendre le profil en JSON", "bulk_json_run": "Traiter le JSON", "bulk_results_title": "Résultats du traitement", "bulk_copy_json": "Copier le JSON", "bulk_download_json": "Télécharger JSON", "bulk_download_csv": "Télécharger CSV", "bulk_col_id": "ID", "bulk_col_status": "Statut", "bulk_col_assessment": "Évaluation article 50", "bulk_col_links": "Sorties", "bulk_raw_result": "Afficher les données brutes", "bulk_api_key_label": "Clé API bulk", "bulk_api_key_help": "La clé reste uniquement dans le stockage de session de cet onglet.", "bulk_license": "Licence bulk", "bulk_active": "Active", "bulk_inactive": "Indisponible", "bulk_limit": "Éléments max.", "bulk_auth": "Protection API", "bulk_api_key": "Clé requise", "bulk_no_api_key": "Non requise", "bulk_cover_image": "Image de couverture", "bulk_images": "Autres images", "bulk_research": "Recherche", "bulk_translation": "Traduction", "bulk_article50_title": "Contexte réglementaire / article 50", "bulk_result_ok": "OK", "bulk_result_error": "Erreur", "bulk_link_declaration": "Déclaration", "bulk_link_manifest": "Manifeste", "bulk_link_badge": "Badge",
},
"es": {"bulk_title": "Muchos contenidos. Una declaración coherente.", "bulk_intro": "Genera declaraciones estructuradas de uso de IA para inventarios completos de contenido.", "bulk_nav_workspace": "Espacio de trabajo", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Resultados", "bulk_workspace_title": "Aplicar un perfil a muchos contenidos", "bulk_items_label": "Artículos / páginas", "bulk_items_help": "Un contenido por línea: «ID | URL», «ID<Tab>URL» o solo URL.", "bulk_sample": "Insertar ejemplo", "bulk_profile_title": "Perfil común de declaración", "bulk_run": "Iniciar procesamiento", "bulk_advanced_title": "Lotes JSON individuales", "bulk_json_run": "Procesar JSON", "bulk_results_title": "Resultados", "bulk_copy_json": "Copiar JSON", "bulk_download_json": "Descargar JSON", "bulk_download_csv": "Descargar CSV", "bulk_api_key_label": "Clave API bulk", "bulk_license": "Licencia bulk", "bulk_active": "Activa", "bulk_inactive": "No disponible", "bulk_limit": "Máx. elementos", "bulk_auth": "Protección API", "bulk_api_key": "Clave requerida", "bulk_no_api_key": "No requerida", "bulk_cover_image": "Imagen de portada", "bulk_images": "Otras imágenes", "bulk_research": "Investigación", "bulk_translation": "Traducción", "bulk_article50_title": "Contexto regulatorio / artículo 50", "bulk_result_ok": "OK", "bulk_result_error": "Error", "bulk_link_declaration": "Declaración", "bulk_link_manifest": "Manifiesto", "bulk_link_badge": "Badge"},
"it": {"bulk_title": "Molti contenuti. Una dichiarazione coerente.", "bulk_intro": "Genera dichiarazioni strutturate sulluso dellIA per interi archivi di contenuti.", "bulk_nav_workspace": "Area di lavoro", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Risultati", "bulk_workspace_title": "Applicare un profilo a molti contenuti", "bulk_items_label": "Articoli / pagine", "bulk_items_help": "Un contenuto per riga: «ID | URL», «ID<Tab>URL» o solo URL.", "bulk_sample": "Inserisci esempio", "bulk_profile_title": "Profilo comune", "bulk_run": "Avvia elaborazione", "bulk_advanced_title": "Batch JSON individuali", "bulk_json_run": "Elabora JSON", "bulk_results_title": "Risultati", "bulk_copy_json": "Copia JSON", "bulk_download_json": "Scarica JSON", "bulk_download_csv": "Scarica CSV", "bulk_api_key_label": "API key bulk", "bulk_license": "Licenza bulk", "bulk_active": "Attiva", "bulk_inactive": "Non disponibile", "bulk_limit": "Elementi max.", "bulk_auth": "Protezione API", "bulk_api_key": "Chiave richiesta", "bulk_no_api_key": "Non richiesta", "bulk_cover_image": "Immagine di copertina", "bulk_images": "Altre immagini", "bulk_research": "Ricerca", "bulk_translation": "Traduzione", "bulk_article50_title": "Contesto normativo / articolo 50", "bulk_result_ok": "OK", "bulk_result_error": "Errore", "bulk_link_declaration": "Dichiarazione", "bulk_link_manifest": "Manifesto", "bulk_link_badge": "Badge"},
"nl": {"bulk_title": "Veel inhoud. Eén consistente verklaring.", "bulk_intro": "Genereer gestructureerde AI-gebruiksverklaringen voor complete contentcollecties.", "bulk_nav_workspace": "Werkruimte", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Resultaten", "bulk_workspace_title": "Eén profiel op veel inhoud toepassen", "bulk_items_label": "Artikelen / pagina's", "bulk_items_help": "Eén item per regel: ID | URL, ID<Tab>URL of alleen een URL.", "bulk_sample": "Voorbeeld invoegen", "bulk_profile_title": "Gedeeld declaratieprofiel", "bulk_run": "Batchverwerking starten", "bulk_advanced_title": "Individuele JSON-batches", "bulk_json_run": "JSON verwerken", "bulk_results_title": "Resultaten", "bulk_copy_json": "JSON kopiëren", "bulk_download_json": "JSON downloaden", "bulk_download_csv": "CSV downloaden", "bulk_api_key_label": "Bulk API-key", "bulk_license": "Bulk-licentie", "bulk_active": "Actief", "bulk_inactive": "Niet beschikbaar", "bulk_limit": "Max. items", "bulk_auth": "API-beveiliging", "bulk_api_key": "Key vereist", "bulk_no_api_key": "Niet vereist", "bulk_cover_image": "Omslagafbeelding", "bulk_images": "Andere afbeeldingen", "bulk_research": "Onderzoek", "bulk_translation": "Vertaling", "bulk_article50_title": "Regelgevingscontext / artikel 50", "bulk_result_ok": "OK", "bulk_result_error": "Fout", "bulk_link_declaration": "Verklaring", "bulk_link_manifest": "Manifest", "bulk_link_badge": "Badge"},
"pt": {"bulk_title": "Muitos conteúdos. Uma declaração consistente.", "bulk_intro": "Gere declarações estruturadas de uso de IA para conjuntos completos de conteúdos.", "bulk_nav_workspace": "Área de trabalho", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Resultados", "bulk_workspace_title": "Aplicar um perfil a muitos conteúdos", "bulk_items_label": "Artigos / páginas", "bulk_items_help": "Um conteúdo por linha: «ID | URL», «ID<Tab>URL» ou apenas URL.", "bulk_sample": "Inserir exemplo", "bulk_profile_title": "Perfil de declaração comum", "bulk_run": "Iniciar processamento", "bulk_advanced_title": "Lotes JSON individuais", "bulk_json_run": "Processar JSON", "bulk_results_title": "Resultados", "bulk_copy_json": "Copiar JSON", "bulk_download_json": "Transferir JSON", "bulk_download_csv": "Transferir CSV", "bulk_api_key_label": "Chave API bulk", "bulk_license": "Licença bulk", "bulk_active": "Ativa", "bulk_inactive": "Indisponível", "bulk_limit": "Máx. elementos", "bulk_auth": "Proteção API", "bulk_api_key": "Chave necessária", "bulk_no_api_key": "Não necessária", "bulk_cover_image": "Imagem de capa", "bulk_images": "Outras imagens", "bulk_research": "Pesquisa", "bulk_translation": "Tradução", "bulk_article50_title": "Contexto regulamentar / artigo 50", "bulk_result_ok": "OK", "bulk_result_error": "Erro", "bulk_link_declaration": "Declaração", "bulk_link_manifest": "Manifesto", "bulk_link_badge": "Badge"},
"pl": {"bulk_title": "Wiele treści. Jedna spójna deklaracja.", "bulk_intro": "Twórz ustrukturyzowane deklaracje użycia AI dla całych zbiorów treści.", "bulk_nav_workspace": "Obszar roboczy", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Wyniki", "bulk_workspace_title": "Zastosuj jeden profil do wielu treści", "bulk_items_label": "Artykuły / strony", "bulk_items_help": "Jeden element na linię: „ID | URL”, „ID<Tab>URL” lub sam URL.", "bulk_sample": "Wstaw przykład", "bulk_profile_title": "Wspólny profil deklaracji", "bulk_run": "Uruchom przetwarzanie", "bulk_advanced_title": "Indywidualne partie JSON", "bulk_json_run": "Przetwórz JSON", "bulk_results_title": "Wyniki", "bulk_copy_json": "Kopiuj JSON", "bulk_download_json": "Pobierz JSON", "bulk_download_csv": "Pobierz CSV", "bulk_api_key_label": "Klucz API bulk", "bulk_license": "Licencja bulk", "bulk_active": "Aktywna", "bulk_inactive": "Niedostępna", "bulk_limit": "Maks. elementów", "bulk_auth": "Ochrona API", "bulk_api_key": "Klucz wymagany", "bulk_no_api_key": "Niewymagany", "bulk_cover_image": "Obraz okładkowy", "bulk_images": "Inne obrazy", "bulk_research": "Badania", "bulk_translation": "Tłumaczenie", "bulk_article50_title": "Kontekst regulacyjny / artykuł 50", "bulk_result_ok": "OK", "bulk_result_error": "Błąd", "bulk_link_declaration": "Deklaracja", "bulk_link_manifest": "Manifest", "bulk_link_badge": "Badge"},
}
base := texts["en"]
for code := range catalogs {
l := catalogs[code]
for key, value := range base {
if l.Text[key] == "" {
l.Text[key] = value
}
}
if overrides, ok := texts[code]; ok {
for key, value := range overrides {
l.Text[key] = value
}
}
catalogs[code] = l
}
}

View File

@@ -36,37 +36,30 @@ var catalogs = map[string]Locale{
"generator_eyebrow": "Generator", "generator_title": "Einbettung erzeugen", "generator_intro": "Wähle eine Vorlage oder erfasse die KI-Nutzung strukturiert nach Inhaltsbestandteilen. Vorschau, Einbettungscode und maschinenlesbare Erklärung werden unmittelbar erzeugt.",
"field_preset": "Preset", "option_custom": "Benutzerdefinierte Struktur", "field_component": "Komponente", "field_extent": "KI-Anteil", "field_review": "Menschliche Prüfung",
"field_activities": "Tätigkeiten", "activities_help": "Kommagetrennte Standardwerte, zum Beispiel research, summarisation oder translation.",
"review_help": "Nur den tatsächlich durchgeführten Prüfprozess angeben. Eine formale Prüfung (z. B. Rechtschreibung) ist keine substanzielle menschliche Prüfung oder redaktionelle Kontrolle im Sinne von Art. 50 Abs. 4 AI Act.",
"field_subject": "URL des gekennzeichneten Inhalts", "legal_context_legend": "Rechtlicher Kontext (Selbsteinordnung)", "legal_context_help": "Diese Angaben helfen bei der Art.-50-Einordnung. Sie sind keine automatische Rechtsentscheidung. Mehrere Kategorien können zutreffen.", "legal_deepfake": "Deepfake / realitätsähnliche KI-Manipulation", "legal_deepfake_help": "Nur auswählen, wenn Bild, Audio oder Video bestehenden oder plausibel existierenden Personen, Objekten, Orten, Entitäten oder Ereignissen ähnelt und fälschlich authentisch oder wahr erscheinen kann.", "legal_public_interest": "KI-generierter oder manipulierter Text zu einer Angelegenheit von öffentlichem Interesse", "legal_public_interest_help": "Zum Beispiel Politik, öffentliche Verwaltung, Grundrechte, Sicherheit, Gesundheit, Umwelt, Verbrauchersicherheit oder relevante wirtschaftliche, wissenschaftliche oder kulturelle Entwicklungen.", "legal_creative": "Evident künstlerisch / kreativ / satirisch / fiktional", "legal_creative_help": "Bei Deepfakes in solchen Werken kann die Offenlegung in einer angemessenen Form erfolgen, die Darstellung oder Genuss des Werks nicht beeinträchtigt.", "legal_other_voluntary": "Andere / freiwillige Transparenz", "legal_assessment_heading": "Vorsichtige Art.-50-Einschätzung", "legal_assessment_none": "Keine besondere Art.-50-Kategorie wurde angegeben. Die Kennzeichnung wird als freiwillige Transparenz behandelt.", "legal_assessment_voluntary": "Die Angaben sprechen derzeit für eine freiwillige Transparenzkennzeichnung; daraus folgt keine Aussage, dass sonstige AI-Act-Pflichten nicht gelten.", "legal_assessment_deepfake": "Auf Grundlage der Selbsteinordnung als Deepfake spricht vieles dafür, dass eine klare und unterscheidbare Offenlegung spätestens bei der ersten Exposition erforderlich ist.", "legal_assessment_deepfake_creative": "Der Inhalt ist als Deepfake und zugleich als evident künstlerisch, kreativ, satirisch oder fiktional eingeordnet. Eine Offenlegung bleibt grundsätzlich relevant, kann aber in angemessener Weise erfolgen, die Darstellung oder Genuss des Werks nicht beeinträchtigt.", "legal_assessment_public_required": "Auf Grundlage der Angaben spricht vieles dafür, dass der KI-generierte oder manipulierte Text zu einer Angelegenheit von öffentlichem Interesse klar offengelegt werden sollte. Eine ausreichende substanzielle menschliche Prüfung/redaktionelle Kontrolle zusammen mit ausdrücklich übernommener redaktioneller Verantwortung ist nicht vollständig dokumentiert.", "legal_assessment_public_exemption": "Die Angaben dokumentieren eine substanzielle menschliche Prüfung oder redaktionelle Kontrolle sowie eine ausdrücklich benannte redaktionelle Verantwortung. Daher kann die Ausnahme für bestimmte Texte von öffentlichem Interesse nach Art. 50 Abs. 4 in Betracht kommen. Eine freiwillige Transparenzkennzeichnung bleibt möglich.", "legal_assessment_inconsistent": "Die gewählte rechtliche Kategorie passt nicht vollständig zu den strukturierten Inhaltsangaben. Bitte prüfe KI-Anteil und Inhaltsbestandteile.", "badge_legal_disclosure": "KI-generierter / manipulierter Inhalt", "badge_public_interest": "KI-generierte / bearbeitete Inhalte", "fact_legal_context": "Rechtlicher Kontext (Selbsteinordnung)", "field_responsibility_role": "Art der redaktionellen Verantwortung", "responsibility_none": "Nicht angegeben", "responsibility_publisher": "Veröffentlichende Person/Organisation übernimmt die redaktionelle Verantwortung", "responsibility_other": "Andere verantwortliche Stelle", "field_responsible": "Redaktionell verantwortliche Person/Organisation (optional)", "responsible_placeholder": "Name oder Organisation", "field_responsible_url": "Nachweis/Impressum zur redaktionellen Verantwortung (optional)", "field_language": "Ausgabesprache", "field_theme": "Darstellung", "theme_mono": "Monochrom", "theme_color": "Farbig",
"field_subject": "URL des gekennzeichneten Inhalts", "field_language": "Ausgabesprache", "field_theme": "Darstellung", "theme_mono": "Monochrom", "theme_color": "Farbig",
"preview": "Vorschau", "copy_html": "HTML kopieren", "copied": "Kopiert",
"pro_eyebrow": "Pro-Anpassung", "pro_title": "Eigene Texte und Badge-Designs", "pro_enabled": "Diese Instanz besitzt eine gültige Pro-Lizenz. Individuelle Texte und Farben können verwendet werden.",
"pro_locked": "Individuelle Titel, Beschreibungstexte, Badge-Beschriftungen und Farben sind in der Pro-Ausgabe verfügbar.",
"pro_eyebrow": "Lizenzierte Anpassung", "pro_title": "Eigene Texte und Badge-Designs", "pro_enabled": "Diese Instanz verfügt über die erforderlichen lizenzierten Funktionen. Individuelle Texte und Farben können verwendet werden.",
"pro_locked": "Individuelle Titel, Beschreibungstexte, Badge-Beschriftungen und Farben benötigen die entsprechende lizenzierte Capability.",
"custom_title": "Eigener Erklärungstitel", "custom_description": "Eigene Beschreibung", "custom_badge_label": "Eigene Badge-Beschriftung links", "custom_badge_message": "Eigene Badge-Beschriftung rechts",
"custom_left_color": "Farbe links", "custom_right_color": "Farbe rechts", "pro_required": "Pro-Lizenz erforderlich",
"custom_left_color": "Farbe links", "custom_right_color": "Farbe rechts", "pro_required": "Lizenzierte Funktion erforderlich",
"api_badge_title": "Badge-Endpunkt", "api_badge_desc": "Standard-Badges werden aus Presets und strukturierten Parametern erzeugt.",
"api_manifest_title": "Manifest", "api_manifest_desc": "Das JSON-LD-Manifest kann direkt verlinkt, validiert oder in Build-Prozesse übernommen werden.",
"api_stateless_title": "Zustandslos", "api_stateless_desc": "Keine Sessions und deterministische Antworten. Geeignet für horizontale Skalierung, Reverse Proxies und CDNs.",
"footer_no_legal": "Keine Rechtsberatung.", "back": "Zurück", "declaration_eyebrow": "KI-Nutzungserklärung", "fact_component": "Komponente", "fact_extent": "KI-Anteil",
"fact_activities": "Tätigkeiten", "fact_review": "Menschliche Prüfung", "fact_note": "Hinweis", "fact_assurance": "Nachweisniveau", "fact_subject": "Gekennzeichneter Inhalt",
"fact_responsibility": "Redaktionelle Verantwortung", "fact_responsibility_type": "Rolle der redaktionellen Verantwortung", "fact_declared_at": "Erstellt", "none_value": "keine", "transparency_label": "Transparenzhinweis:",
"transparency_text": "Diese Erklärung gibt die von der veröffentlichenden Person oder Organisation bereitgestellten Angaben zur KI-Nutzung wieder. Sie ersetzt weder eine rechtliche Bewertung noch eine Zertifizierung.",
"review_legal_label": "Menschliche Prüfung:", "review_legal_text": "Ein angegebenes Prüfungsniveau ist eine Selbstauskunft zum tatsächlichen Arbeitsprozess. Es begründet für sich allein keine Ausnahme von einer Kennzeichnungspflicht nach Art. 50 Abs. 4 AI Act; hierfür müssen die gesetzlichen Voraussetzungen, insbesondere substanzielle menschliche Prüfung oder redaktionelle Kontrolle und redaktionelle Verantwortung, tatsächlich erfüllt sein.",
"machine_marking_label": "Art. 50 Abs. 2:", "machine_marking_text": "Das verlinkte JSON-LD ist eine ergänzende Dokumentation. Es ist nicht als Ersatz für die vom Anbieter eines generativen KI-Systems geforderte wirksame, zuverlässige, robuste und interoperable maschinenlesbare Markierung der generierten oder manipulierten Ausgabe gedacht.",
"ai_act_embed_label": "Platzierung nach Art. 50:", "ai_act_embed_text": "Bei einer gesetzlich erforderlichen Offenlegung muss der sichtbare Hinweis klar und unterscheidbar spätestens bei der ersten Exposition zum betroffenen Inhalt erscheinen. Ein Badge nur im Footer oder erst hinter einem späteren Link kann dafür zu spät sein. Bei Deepfake-Medien sollte die Kennzeichnung unmittelbar am Inhalt bzw. als gleichwertiges Overlay umgesetzt werden; der Code of Practice sieht für seine Signatories zudem vor, dass sie beim Teilen oder Download sichtbar bleibt.",
"ai_act_machine_label": "Maschinenlesbarkeit:", "ai_act_machine_text": "Dieses JSON-LD dokumentiert die Nutzung ergänzend, erfüllt aber nicht automatisch die Anbieterpflicht zur technischen Markierung nach Art. 50 Abs. 2 AI Act.",
"emoji_compliance_label": "Emoji-Variante:", "emoji_compliance_text": "Das quadratische Emoji ist ein kompakter Zusatz und kein offizielles EU-Kennzeichnungssymbol. Bei einer gesetzlichen Offenlegung sollte es nicht ohne einen unmittelbar verständlichen Text oder eine gleichwertig klare Kennzeichnung verwendet werden.",
"manifest": "Maschinenlesbares Manifest",
"fact_responsibility": "Redaktionelle Verantwortung", "fact_declared_at": "Erstellt", "none_value": "keine", "transparency_label": "Transparenzhinweis:",
"transparency_text": "Diese Erklärung beschreibt die angegebene Nutzung von KI. Sie ist keine Lizenz, Zertifizierung oder Rechtsberatung.", "manifest": "Maschinenlesbares Manifest", "bundle": "Export-Bundle",
"ai_label": "KI-Nutzung", "capabilities": "Funktionen",
},
map[string]PresetText{
"no-ai": {"Kein Einsatz von KI", "Für die Erstellung des gekennzeichneten Inhalts wurde nach den gemachten Angaben keine generative KI eingesetzt."},
"research": {"Rechercheunterstützung", "KI wurde unterstützend zur Recherche oder zum Auffinden relevanter Quellen eingesetzt. Eine menschliche Prüfung wird nur ausgewiesen, wenn sie ausdrücklich ausgewählt wurde."},
"summary": {"Inhaltliche Zusammenfassung", "KI wurde zur Zusammenfassung von Quellen eingesetzt. Eine menschliche Prüfung oder redaktionelle Kontrolle wird nicht automatisch unterstellt."},
"full": {"Vollständige Inhaltserstellung", "Der gekennzeichnete Inhalt wurde überwiegend oder vollständig mit generativer KI erstellt. Ein tatsächlicher menschlicher Prüfprozess ist gesondert anzugeben."},
"no-ai": {"Kein Einsatz von KI", "Für die Erstellung des gekennzeichneten Inhalts wurde keine generative KI eingesetzt. Die inhaltliche Verantwortung und Prüfung lagen bei Menschen."},
"research": {"Rechercheunterstützung", "KI wurde unterstützend zur Recherche und zum Auffinden relevanter Quellen eingesetzt. Auswahl, Einordnung und redaktionelle Ausarbeitung erfolgten durch Menschen."},
"summary": {"Inhaltliche Zusammenfassung", "KI wurde zur Zusammenfassung von Quellen eingesetzt. Auswahl, fachliche Einordnung und Endfassung wurden durch Menschen verantwortet und geprüft."},
"full": {"Vollständige Inhaltserstellung", "Der gekennzeichnete Inhalt wurde überwiegend oder vollständig mit generativer KI erstellt. Die veröffentlichte Fassung wurde anschließend menschlich geprüft und redaktionell verantwortet."},
},
map[string]string{"none": "Kein Einsatz von KI", "assisted": "KI-unterstützt", "partial": "Teilweise KI-generiert", "mostly": "Überwiegend KI-generiert", "full": "Vollständig KI-generiert"},
map[string]string{"text": "Text", "coverImage": "Titelbild", "image": "Bild", "audio": "Audio", "video": "Video", "code": "Code", "other": "Sonstiges"},
map[string]string{"none": "Keine dokumentiert", "basic": "Grundlegend/formal", "editorial": "Inhaltlich-redaktionell", "expert": "Fachlich-inhaltlich"},
map[string]string{"none": "Keine", "basic": "Grundlegend", "editorial": "Redaktionell", "expert": "Fachlich"},
map[string]string{"research": "Recherche", "summarisation": "Zusammenfassung", "drafting": "Entwurf", "generation": "Erzeugung", "translation": "Übersetzung", "editing": "Überarbeitung", "imageGeneration": "Bilderzeugung", "codeGeneration": "Codeerzeugung", "transcription": "Transkription", "classification": "Klassifikation"},
map[string]string{"selfDeclared": "Selbsterklärung", "technicallyRecorded": "Technisch protokolliert", "signed": "Signiert", "verified": "Verifiziert"}),
"en": locale("en", "English", map[string]string{
@@ -74,18 +67,18 @@ var catalogs = map[string]Locale{
"standard_eyebrow": "Open disclosure standard", "hero_title": "Disclose how AI contributed to content.", "hero_lead": "A visible SVG badge, a human-readable declaration page and machine-readable JSON-LD without cookies, tracking or a database requirement.",
"generator_eyebrow": "Generator", "generator_title": "Create an embed", "generator_intro": "Choose a template or record AI use systematically by content component. The preview, embed code and machine-readable declaration are generated immediately.",
"field_preset": "Preset", "option_custom": "Custom structure", "field_component": "Component", "field_extent": "AI contribution", "field_review": "Human review", "field_activities": "Activities",
"activities_help": "Comma-separated standard values such as research, summarisation or translation.", "review_help": "Record only the review process that actually took place. A merely formal check (for example spelling or grammar) is not substantive human review or editorial control for the purposes of Article 50(4) AI Act.", "field_subject": "URL of the labelled content", "field_responsibility_role": "Editorial responsibility type", "responsibility_none": "Not specified", "responsibility_publisher": "Publishing person/organisation assumes editorial responsibility", "responsibility_other": "Other responsible party", "field_responsible": "Editorially responsible person/organisation (optional)", "responsible_placeholder": "Name or organisation", "field_responsible_url": "Evidence/imprint for editorial responsibility (optional)", "field_language": "Output language", "field_theme": "Appearance", "theme_mono": "Monochrome", "theme_color": "Colour",
"preview": "Preview", "copy_html": "Copy HTML", "copied": "Copied", "pro_eyebrow": "Pro customisation", "pro_title": "Custom copy and badge designs",
"pro_enabled": "This instance has a valid Pro licence. Custom copy and colours are available.", "pro_locked": "Custom titles, descriptions, badge labels and colours are available in the Pro edition.",
"custom_title": "Custom declaration title", "custom_description": "Custom description", "custom_badge_label": "Custom left badge label", "custom_badge_message": "Custom right badge label", "custom_left_color": "Left colour", "custom_right_color": "Right colour", "pro_required": "Pro licence required",
"api_badge_title": "Badge endpoint", "api_badge_desc": "Standard badges are generated from presets and structured parameters. Pro adds custom copy and colours.", "api_manifest_title": "Manifest", "api_manifest_desc": "The JSON-LD manifest can be linked, validated or included in build pipelines.",
"activities_help": "Comma-separated standard values such as research, summarisation or translation.", "field_subject": "URL of the labelled content", "field_language": "Output language", "field_theme": "Appearance", "theme_mono": "Monochrome", "theme_color": "Colour",
"preview": "Preview", "copy_html": "Copy HTML", "copied": "Copied", "pro_eyebrow": "Licensed customisation", "pro_title": "Custom copy and badge designs",
"pro_enabled": "This instance has the required licensed capabilities. Custom copy and colours are available.", "pro_locked": "Custom titles, descriptions, badge labels and colours require the corresponding licensed capability.",
"custom_title": "Custom declaration title", "custom_description": "Custom description", "custom_badge_label": "Custom left badge label", "custom_badge_message": "Custom right badge label", "custom_left_color": "Left colour", "custom_right_color": "Right colour", "pro_required": "Licensed capability required",
"api_badge_title": "Badge endpoint", "api_badge_desc": "Standard badges are generated from presets and structured parameters. Licensed capabilities can additionally enable custom copy and colours.", "api_manifest_title": "Manifest", "api_manifest_desc": "The JSON-LD manifest can be linked, validated or included in build pipelines.",
"api_stateless_title": "Stateless", "api_stateless_desc": "No sessions and deterministic responses. Designed for horizontal scaling, reverse proxies and CDNs.", "footer_no_legal": "Not legal advice.",
"back": "Back", "declaration_eyebrow": "AI usage declaration", "fact_component": "Component", "fact_extent": "AI contribution", "fact_activities": "Activities", "fact_review": "Human review", "fact_note": "Note", "fact_assurance": "Assurance level", "fact_subject": "Labelled content", "fact_responsibility": "Editorial responsibility", "fact_responsibility_type": "Editorial responsibility role", "fact_declared_at": "Declared at", "none_value": "none", "transparency_label": "Transparency notice:", "transparency_text": "This declaration presents the information on AI use supplied by the publishing person or organisation. It does not replace a legal assessment or certification.", "review_legal_label": "Human review:", "review_legal_text": "A stated review level is a self-declaration of the process that actually took place. By itself it does not establish an exemption from a labelling duty under Article 50(4) AI Act; the statutory conditions, including substantive human review or editorial control and editorial responsibility, must actually be met.", "machine_marking_label": "Article 50(2):", "machine_marking_text": "The linked JSON-LD is supplementary documentation. It is not intended to replace the effective, reliable, robust and interoperable machine-readable marking of generated or manipulated output required from the provider of an in-scope generative AI system.", "ai_act_embed_label": "Article 50 placement:", "ai_act_embed_text": "Where disclosure is legally required, the visible notice must be clear and distinguishable no later than first exposure to the affected content. A badge shown only in a footer or behind a later link may be too late. For deepfake media, the notice should be attached directly to the content or provided through an equivalent overlay; the Code of Practice additionally provides for visibility when content is reshared or downloaded for its signatories.", "ai_act_machine_label": "Machine readability:", "ai_act_machine_text": "This JSON-LD supplements documentation but does not automatically satisfy the provider-side technical marking duty under Article 50(2) AI Act.", "emoji_compliance_label": "Emoji variant:", "emoji_compliance_text": "The square emoji is a compact supplement and is not an official EU labelling icon. Where a legal disclosure is required, do not rely on it without immediately understandable text or an equivalently clear disclosure.", "manifest": "Machine-readable manifest", "ai_label": "AI use", "capabilities": "Capabilities",
"back": "Back", "declaration_eyebrow": "AI usage declaration", "fact_component": "Component", "fact_extent": "AI contribution", "fact_activities": "Activities", "fact_review": "Human review", "fact_note": "Note", "fact_assurance": "Assurance level", "fact_subject": "Labelled content", "fact_responsibility": "Editorial responsibility", "fact_declared_at": "Declared at", "none_value": "none", "transparency_label": "Transparency notice:", "transparency_text": "This declaration describes the stated use of AI. It is not a licence, certification or legal advice.", "manifest": "Machine-readable manifest", "bundle": "Export bundle", "ai_label": "AI use", "capabilities": "Capabilities",
},
map[string]PresetText{"no-ai": {"No AI use", "According to the information supplied, no generative AI was used to create the labelled content."}, "research": {"Research assistance", "AI was used to support research or identify relevant sources. Human review is shown only when explicitly selected."}, "summary": {"Content summarisation", "AI was used to summarise source material. Human review or editorial control is not presumed automatically."}, "full": {"Full content generation", "The labelled content was created mostly or entirely with generative AI. Any actual human review process must be stated separately."}},
map[string]PresetText{"no-ai": {"No AI use", "No generative AI was used to create the labelled content. Responsibility for the content and its review remained with people."}, "research": {"Research assistance", "AI was used to support research and identify relevant sources. Selection, assessment and editorial preparation were carried out by people."}, "summary": {"Content summarisation", "AI was used to summarise source material. Selection, contextual assessment and the final version were the responsibility of human editors."}, "full": {"Full content generation", "The labelled content was created mostly or entirely with generative AI. The published version was subsequently reviewed and editorially approved by people."}},
map[string]string{"none": "No AI use", "assisted": "AI-assisted", "partial": "Partially AI-generated", "mostly": "Mostly AI-generated", "full": "Fully AI-generated"},
map[string]string{"text": "Text", "coverImage": "Cover image", "image": "Image", "audio": "Audio", "video": "Video", "code": "Code", "other": "Other"},
map[string]string{"none": "None recorded", "basic": "Basic/formal", "editorial": "Substantive editorial", "expert": "Substantive expert"},
map[string]string{"none": "None", "basic": "Basic", "editorial": "Editorial", "expert": "Expert"},
map[string]string{"research": "Research", "summarisation": "Summarisation", "drafting": "Drafting", "generation": "Generation", "translation": "Translation", "editing": "Editing", "imageGeneration": "Image generation", "codeGeneration": "Code generation", "transcription": "Transcription", "classification": "Classification"},
map[string]string{"selfDeclared": "Self-declared", "technicallyRecorded": "Technically recorded", "signed": "Signed", "verified": "Verified"}),
"fr": europeanLocale("fr", "Français", "Déclarer comment lIA a contribué à un contenu.", "Utilisation de lIA", "Aucune utilisation de lIA", "Assisté par lIA", "Partiellement généré par lIA", "Majoritairement généré par lIA", "Entièrement généré par lIA",
@@ -142,25 +135,8 @@ func init() {
applyEuropeanTaxonomyTranslations()
applyArticleTranslations()
applyAssuranceTranslations()
applyThemeTranslations()
}
func applyThemeTranslations() {
values := map[string]string{
"de": "Emoji (quadratisch)",
"en": "Emoji (square)",
"fr": "Emoji (carré)",
"es": "Emoji (cuadrado)",
"it": "Emoji (quadrato)",
"nl": "Emoji (vierkant)",
"pt": "Emoji (quadrado)",
"pl": "Emoji (kwadratowe)",
}
for code, value := range values {
l := catalogs[code]
l.Text["theme_emoji"] = value
catalogs[code] = l
}
applyArticle50Translations()
applyBulkTranslations()
}
func applyArticleTranslations() {
@@ -200,32 +176,32 @@ func europeanTextOverrides() map[string]map[string]string {
"generator_eyebrow": "Générateur", "generator_title": "Créer un code dintégration", "generator_intro": "Choisissez un modèle ou composez une déclaration détaillée. Laperçu et le code sont générés directement dans le navigateur.",
"field_preset": "Modèle", "option_custom": "Structure personnalisée", "field_component": "Composant", "field_extent": "Contribution de lIA", "field_review": "Vérification humaine", "field_activities": "Activités", "activities_help": "Valeurs standard séparées par des virgules, par exemple research, summarisation ou translation.",
"field_subject": "URL du contenu identifié", "field_language": "Langue de sortie", "field_theme": "Apparence", "theme_mono": "Monochrome", "theme_color": "Couleur", "preview": "Aperçu", "copy_html": "Copier le HTML", "copied": "Copié",
"pro_eyebrow": "Personnalisation Pro", "pro_title": "Textes et badges personnalisés", "pro_enabled": "Cette instance dispose dune licence Pro valide. Les textes et couleurs personnalisés sont disponibles.", "pro_locked": "Les titres, descriptions, libellés et couleurs personnalisés sont disponibles dans lédition Pro.",
"custom_title": "Titre personnalisé", "custom_description": "Description personnalisée", "custom_badge_label": "Libellé gauche du badge", "custom_badge_message": "Libellé droit du badge", "custom_left_color": "Couleur gauche", "custom_right_color": "Couleur droite", "pro_required": "Licence Pro requise",
"api_badge_title": "Point daccès badge", "api_badge_desc": "Les badges standard utilisent les modèles et paramètres structurés. Pro ajoute des textes et couleurs personnalisés.", "api_manifest_title": "Manifeste", "api_manifest_desc": "Le manifeste JSON-LD peut être lié, validé ou intégré à un processus de build.", "api_stateless_title": "Sans état", "api_stateless_desc": "Aucune session et des réponses déterministes, adaptées à la mise à léchelle horizontale, aux proxys inverses et aux CDN.",
"footer_no_legal": "Ne constitue pas un conseil juridique.", "back": "Retour", "declaration_eyebrow": "Déclaration dutilisation de lIA", "fact_component": "Composant", "fact_extent": "Contribution de lIA", "fact_activities": "Activités", "fact_review": "Vérification humaine", "fact_note": "Note", "fact_assurance": "Niveau dassurance", "fact_subject": "Contenu identifié", "fact_responsibility": "Responsabilité éditoriale", "fact_declared_at": "Déclaré le", "none_value": "aucune", "transparency_label": "Avis de transparence :", "transparency_text": "Cette déclaration décrit lutilisation déclarée de lIA. Elle ne constitue ni une licence, ni une certification, ni un conseil juridique.", "manifest": "Manifeste lisible par machine",
"pro_eyebrow": "Personnalisation sous licence", "pro_title": "Textes et badges personnalisés", "pro_enabled": "Cette instance dispose des fonctions sous licence requises. Les textes et couleurs personnalisés sont disponibles.", "pro_locked": "Les titres, descriptions, libellés et couleurs personnalisés nécessitent la fonctionnalité sous licence correspondante.",
"custom_title": "Titre personnalisé", "custom_description": "Description personnalisée", "custom_badge_label": "Libellé gauche du badge", "custom_badge_message": "Libellé droit du badge", "custom_left_color": "Couleur gauche", "custom_right_color": "Couleur droite", "pro_required": "Fonction sous licence requise",
"api_badge_title": "Point daccès badge", "api_badge_desc": "Les badges standard utilisent les modèles et paramètres structurés. Des fonctionnalités sous licence peuvent activer des textes et couleurs personnalisés.", "api_manifest_title": "Manifeste", "api_manifest_desc": "Le manifeste JSON-LD peut être lié, validé ou intégré à un processus de build.", "api_stateless_title": "Sans état", "api_stateless_desc": "Aucune session et des réponses déterministes, adaptées à la mise à léchelle horizontale, aux proxys inverses et aux CDN.",
"footer_no_legal": "Ne constitue pas un conseil juridique.", "back": "Retour", "declaration_eyebrow": "Déclaration dutilisation de lIA", "fact_component": "Composant", "fact_extent": "Contribution de lIA", "fact_activities": "Activités", "fact_review": "Vérification humaine", "fact_note": "Note", "fact_assurance": "Niveau dassurance", "fact_subject": "Contenu identifié", "fact_responsibility": "Responsabilité éditoriale", "fact_declared_at": "Déclaré le", "none_value": "aucune", "transparency_label": "Avis de transparence :", "transparency_text": "Cette déclaration décrit lutilisation déclarée de lIA. Elle ne constitue ni une licence, ni une certification, ni un conseil juridique.", "manifest": "Manifeste lisible par machine", "bundle": "Bundle dexport",
},
"es": {
"meta_description": "Declaración abierta, legible por máquina y autoalojable del uso de IA.", "nav_generator": "Generador", "nav_background": "Contexto", "standard_eyebrow": "Estándar abierto de transparencia", "hero_lead": "Insignia SVG visible, página explicativa y JSON-LD legible por máquina, sin cookies, seguimiento ni obligación de base de datos.",
"generator_eyebrow": "Generador", "generator_title": "Crear código de inserción", "generator_intro": "Elige un preajuste o crea una declaración detallada. La vista previa y el código se generan directamente en el navegador.", "field_preset": "Preajuste", "option_custom": "Estructura personalizada", "field_component": "Componente", "field_extent": "Contribución de la IA", "field_review": "Revisión humana", "field_activities": "Actividades", "activities_help": "Valores estándar separados por comas, por ejemplo research, summarisation o translation.", "field_subject": "URL del contenido identificado", "field_language": "Idioma de salida", "field_theme": "Apariencia", "theme_mono": "Monocromo", "theme_color": "Color", "preview": "Vista previa", "copy_html": "Copiar HTML", "copied": "Copiado",
"pro_eyebrow": "Personalización Pro", "pro_title": "Textos y diseños de insignia propios", "pro_enabled": "Esta instancia tiene una licencia Pro válida. Se pueden usar textos y colores personalizados.", "pro_locked": "Los títulos, descripciones, etiquetas y colores personalizados están disponibles en la edición Pro.", "custom_title": "Título personalizado", "custom_description": "Descripción personalizada", "custom_badge_label": "Etiqueta izquierda de la insignia", "custom_badge_message": "Etiqueta derecha de la insignia", "custom_left_color": "Color izquierdo", "custom_right_color": "Color derecho", "pro_required": "Se requiere licencia Pro",
"api_badge_title": "Endpoint de insignias", "api_badge_desc": "Las insignias estándar se generan con preajustes y parámetros estructurados. Pro añade textos y colores propios.", "api_manifest_title": "Manifiesto", "api_manifest_desc": "El manifiesto JSON-LD puede enlazarse, validarse o integrarse en procesos de compilación.", "api_stateless_title": "Sin estado", "api_stateless_desc": "Sin sesiones y con respuestas deterministas. Preparado para escalado horizontal, proxies inversos y CDN.", "footer_no_legal": "No constituye asesoramiento jurídico.", "back": "Volver", "declaration_eyebrow": "Declaración de uso de IA", "fact_component": "Componente", "fact_extent": "Contribución de la IA", "fact_activities": "Actividades", "fact_review": "Revisión humana", "fact_note": "Nota", "fact_assurance": "Nivel de garantía", "fact_subject": "Contenido identificado", "fact_responsibility": "Responsabilidad editorial", "fact_declared_at": "Declarado el", "none_value": "ninguna", "transparency_label": "Aviso de transparencia:", "transparency_text": "Esta declaración describe el uso indicado de IA. No es una licencia, certificación ni asesoramiento jurídico.", "manifest": "Manifiesto legible por máquina",
"pro_eyebrow": "Personalización con licencia", "pro_title": "Textos y diseños de insignia propios", "pro_enabled": "Esta instancia dispone de las funciones con licencia necesarias. Se pueden usar textos y colores personalizados.", "pro_locked": "Los títulos, descripciones, etiquetas y colores personalizados requieren la función con licencia correspondiente.", "custom_title": "Título personalizado", "custom_description": "Descripción personalizada", "custom_badge_label": "Etiqueta izquierda de la insignia", "custom_badge_message": "Etiqueta derecha de la insignia", "custom_left_color": "Color izquierdo", "custom_right_color": "Color derecho", "pro_required": "Se requiere una función con licencia",
"api_badge_title": "Endpoint de insignias", "api_badge_desc": "Las insignias estándar se generan con preajustes y parámetros estructurados. Las funciones con licencia pueden habilitar textos y colores personalizados.", "api_manifest_title": "Manifiesto", "api_manifest_desc": "El manifiesto JSON-LD puede enlazarse, validarse o integrarse en procesos de compilación.", "api_stateless_title": "Sin estado", "api_stateless_desc": "Sin sesiones y con respuestas deterministas. Preparado para escalado horizontal, proxies inversos y CDN.", "footer_no_legal": "No constituye asesoramiento jurídico.", "back": "Volver", "declaration_eyebrow": "Declaración de uso de IA", "fact_component": "Componente", "fact_extent": "Contribución de la IA", "fact_activities": "Actividades", "fact_review": "Revisión humana", "fact_note": "Nota", "fact_assurance": "Nivel de garantía", "fact_subject": "Contenido identificado", "fact_responsibility": "Responsabilidad editorial", "fact_declared_at": "Declarado el", "none_value": "ninguna", "transparency_label": "Aviso de transparencia:", "transparency_text": "Esta declaración describe el uso indicado de IA. No es una licencia, certificación ni asesoramiento jurídico.", "manifest": "Manifiesto legible por máquina", "bundle": "Paquete de exportación",
},
"it": {
"meta_description": "Dichiarazione aperta, leggibile dalle macchine e auto-ospitabile delluso dellIA.", "nav_generator": "Generatore", "nav_background": "Contesto", "standard_eyebrow": "Standard aperto di trasparenza", "hero_lead": "Badge SVG visibile, pagina esplicativa e JSON-LD leggibile dalle macchine, senza cookie, tracciamento o obbligo di database.", "generator_eyebrow": "Generatore", "generator_title": "Crea un codice di incorporamento", "generator_intro": "Scegli un preset o componi una dichiarazione dettagliata. Anteprima e codice vengono generati direttamente nel browser.", "field_preset": "Preset", "option_custom": "Struttura personalizzata", "field_component": "Componente", "field_extent": "Contributo dellIA", "field_review": "Revisione umana", "field_activities": "Attività", "activities_help": "Valori standard separati da virgole, ad esempio research, summarisation o translation.", "field_subject": "URL del contenuto identificato", "field_language": "Lingua di output", "field_theme": "Aspetto", "theme_mono": "Monocromatico", "theme_color": "Colore", "preview": "Anteprima", "copy_html": "Copia HTML", "copied": "Copiato",
"pro_eyebrow": "Personalizzazione Pro", "pro_title": "Testi e badge personalizzati", "pro_enabled": "Questa istanza dispone di una licenza Pro valida. Sono disponibili testi e colori personalizzati.", "pro_locked": "Titoli, descrizioni, etichette e colori personalizzati sono disponibili nelledizione Pro.", "custom_title": "Titolo personalizzato", "custom_description": "Descrizione personalizzata", "custom_badge_label": "Etichetta sinistra del badge", "custom_badge_message": "Etichetta destra del badge", "custom_left_color": "Colore sinistro", "custom_right_color": "Colore destro", "pro_required": "Licenza Pro richiesta", "api_badge_title": "Endpoint badge", "api_badge_desc": "I badge standard usano preset e parametri strutturati. Pro aggiunge testi e colori personalizzati.", "api_manifest_title": "Manifesto", "api_manifest_desc": "Il manifesto JSON-LD può essere collegato, validato o inserito nei processi di build.", "api_stateless_title": "Senza stato", "api_stateless_desc": "Nessuna sessione e risposte deterministiche, adatte a scalabilità orizzontale, reverse proxy e CDN.", "footer_no_legal": "Non costituisce consulenza legale.", "back": "Indietro", "declaration_eyebrow": "Dichiarazione duso dellIA", "fact_component": "Componente", "fact_extent": "Contributo dellIA", "fact_activities": "Attività", "fact_review": "Revisione umana", "fact_note": "Nota", "fact_assurance": "Livello di garanzia", "fact_subject": "Contenuto identificato", "fact_responsibility": "Responsabilità editoriale", "fact_declared_at": "Dichiarato il", "none_value": "nessuna", "transparency_label": "Avviso di trasparenza:", "transparency_text": "Questa dichiarazione descrive luso dichiarato dellIA. Non è una licenza, una certificazione o una consulenza legale.", "manifest": "Manifesto leggibile dalle macchine",
"pro_eyebrow": "Personalizzazione con licenza", "pro_title": "Testi e badge personalizzati", "pro_enabled": "Questa istanza dispone delle funzionalità con licenza richieste. Sono disponibili testi e colori personalizzati.", "pro_locked": "Titoli, descrizioni, etichette e colori personalizzati richiedono la relativa funzionalità con licenza.", "custom_title": "Titolo personalizzato", "custom_description": "Descrizione personalizzata", "custom_badge_label": "Etichetta sinistra del badge", "custom_badge_message": "Etichetta destra del badge", "custom_left_color": "Colore sinistro", "custom_right_color": "Colore destro", "pro_required": "Funzionalità con licenza richiesta", "api_badge_title": "Endpoint badge", "api_badge_desc": "I badge standard usano preset e parametri strutturati. Le funzionalità con licenza possono abilitare testi e colori personalizzati.", "api_manifest_title": "Manifesto", "api_manifest_desc": "Il manifesto JSON-LD può essere collegato, validato o inserito nei processi di build.", "api_stateless_title": "Senza stato", "api_stateless_desc": "Nessuna sessione e risposte deterministiche, adatte a scalabilità orizzontale, reverse proxy e CDN.", "footer_no_legal": "Non costituisce consulenza legale.", "back": "Indietro", "declaration_eyebrow": "Dichiarazione duso dellIA", "fact_component": "Componente", "fact_extent": "Contributo dellIA", "fact_activities": "Attività", "fact_review": "Revisione umana", "fact_note": "Nota", "fact_assurance": "Livello di garanzia", "fact_subject": "Contenuto identificato", "fact_responsibility": "Responsabilità editoriale", "fact_declared_at": "Dichiarato il", "none_value": "nessuna", "transparency_label": "Avviso di trasparenza:", "transparency_text": "Questa dichiarazione descrive luso dichiarato dellIA. Non è una licenza, una certificazione o una consulenza legale.", "manifest": "Manifesto leggibile dalle macchine", "bundle": "Pacchetto di esportazione",
},
"nl": {
"meta_description": "Open, machineleesbare en zelf te hosten verklaring van AI-gebruik.", "nav_generator": "Generator", "nav_background": "Achtergrond", "standard_eyebrow": "Open transparantiestandaard", "hero_lead": "Zichtbare SVG-badge, begrijpelijke uitlegpagina en machineleesbare JSON-LD, zonder cookies, tracking of verplichte database.", "generator_eyebrow": "Generator", "generator_title": "Insluitcode maken", "generator_intro": "Kies een preset of stel een gedetailleerde verklaring samen. Voorbeeld en code worden direct in de browser gemaakt.", "field_preset": "Preset", "option_custom": "Aangepaste structuur", "field_component": "Onderdeel", "field_extent": "AI-bijdrage", "field_review": "Menselijke controle", "field_activities": "Activiteiten", "activities_help": "Door kommas gescheiden standaardwaarden, zoals research, summarisation of translation.", "field_subject": "URL van de gemarkeerde inhoud", "field_language": "Uitvoertaal", "field_theme": "Weergave", "theme_mono": "Monochroom", "theme_color": "Kleur", "preview": "Voorbeeld", "copy_html": "HTML kopiëren", "copied": "Gekopieerd",
"pro_eyebrow": "Pro-aanpassing", "pro_title": "Eigen teksten en badge-ontwerpen", "pro_enabled": "Deze instantie heeft een geldige Pro-licentie. Eigen teksten en kleuren zijn beschikbaar.", "pro_locked": "Eigen titels, beschrijvingen, labels en kleuren zijn beschikbaar in de Pro-editie.", "custom_title": "Eigen titel", "custom_description": "Eigen beschrijving", "custom_badge_label": "Linker badgelabel", "custom_badge_message": "Rechter badgelabel", "custom_left_color": "Linkerkleur", "custom_right_color": "Rechterkleur", "pro_required": "Pro-licentie vereist", "api_badge_title": "Badge-endpoint", "api_badge_desc": "Standaardbadges gebruiken presets en gestructureerde parameters. Pro voegt eigen teksten en kleuren toe.", "api_manifest_title": "Manifest", "api_manifest_desc": "Het JSON-LD-manifest kan worden gekoppeld, gevalideerd of in buildprocessen worden opgenomen.", "api_stateless_title": "Stateless", "api_stateless_desc": "Geen sessies en deterministische antwoorden, geschikt voor horizontale schaal, reverse proxies en CDNs.", "footer_no_legal": "Geen juridisch advies.", "back": "Terug", "declaration_eyebrow": "Verklaring van AI-gebruik", "fact_component": "Onderdeel", "fact_extent": "AI-bijdrage", "fact_activities": "Activiteiten", "fact_review": "Menselijke controle", "fact_note": "Opmerking", "fact_assurance": "Zekerheidsniveau", "fact_subject": "Gemarkeerde inhoud", "fact_responsibility": "Redactionele verantwoordelijkheid", "fact_declared_at": "Verklaard op", "none_value": "geen", "transparency_label": "Transparantiemelding:", "transparency_text": "Deze verklaring beschrijft het opgegeven AI-gebruik. Het is geen licentie, certificering of juridisch advies.", "manifest": "Machineleesbaar manifest",
"pro_eyebrow": "Gelicentieerde aanpassing", "pro_title": "Eigen teksten en badge-ontwerpen", "pro_enabled": "Deze instantie beschikt over de vereiste gelicentieerde functies. Eigen teksten en kleuren zijn beschikbaar.", "pro_locked": "Eigen titels, beschrijvingen, labels en kleuren vereisen de bijbehorende gelicentieerde functie.", "custom_title": "Eigen titel", "custom_description": "Eigen beschrijving", "custom_badge_label": "Linker badgelabel", "custom_badge_message": "Rechter badgelabel", "custom_left_color": "Linkerkleur", "custom_right_color": "Rechterkleur", "pro_required": "Gelicentieerde functie vereist", "api_badge_title": "Badge-endpoint", "api_badge_desc": "Standaardbadges gebruiken presets en gestructureerde parameters. Gelicentieerde functies kunnen eigen teksten en kleuren inschakelen.", "api_manifest_title": "Manifest", "api_manifest_desc": "Het JSON-LD-manifest kan worden gekoppeld, gevalideerd of in buildprocessen worden opgenomen.", "api_stateless_title": "Stateless", "api_stateless_desc": "Geen sessies en deterministische antwoorden, geschikt voor horizontale schaal, reverse proxies en CDNs.", "footer_no_legal": "Geen juridisch advies.", "back": "Terug", "declaration_eyebrow": "Verklaring van AI-gebruik", "fact_component": "Onderdeel", "fact_extent": "AI-bijdrage", "fact_activities": "Activiteiten", "fact_review": "Menselijke controle", "fact_note": "Opmerking", "fact_assurance": "Zekerheidsniveau", "fact_subject": "Gemarkeerde inhoud", "fact_responsibility": "Redactionele verantwoordelijkheid", "fact_declared_at": "Verklaard op", "none_value": "geen", "transparency_label": "Transparantiemelding:", "transparency_text": "Deze verklaring beschrijft het opgegeven AI-gebruik. Het is geen licentie, certificering of juridisch advies.", "manifest": "Machineleesbaar manifest", "bundle": "Exportbundel",
},
"pt": {
"meta_description": "Declaração aberta, legível por máquina e autoalojável do uso de IA.", "nav_generator": "Gerador", "nav_background": "Contexto", "standard_eyebrow": "Padrão aberto de transparência", "hero_lead": "Badge SVG visível, página explicativa e JSON-LD legível por máquina, sem cookies, rastreamento ou obrigação de base de dados.", "generator_eyebrow": "Gerador", "generator_title": "Criar código de incorporação", "generator_intro": "Escolha uma predefinição ou crie uma declaração detalhada. A pré-visualização e o código são gerados diretamente no navegador.", "field_preset": "Predefinição", "option_custom": "Estrutura personalizada", "field_component": "Componente", "field_extent": "Contributo da IA", "field_review": "Revisão humana", "field_activities": "Atividades", "activities_help": "Valores padrão separados por vírgulas, por exemplo research, summarisation ou translation.", "field_subject": "URL do conteúdo identificado", "field_language": "Idioma de saída", "field_theme": "Aspeto", "theme_mono": "Monocromático", "theme_color": "Cor", "preview": "Pré-visualização", "copy_html": "Copiar HTML", "copied": "Copiado",
"pro_eyebrow": "Personalização Pro", "pro_title": "Textos e badges personalizados", "pro_enabled": "Esta instância tem uma licença Pro válida. Estão disponíveis textos e cores personalizados.", "pro_locked": "Títulos, descrições, etiquetas e cores personalizados estão disponíveis na edição Pro.", "custom_title": "Título personalizado", "custom_description": "Descrição personalizada", "custom_badge_label": "Etiqueta esquerda do badge", "custom_badge_message": "Etiqueta direita do badge", "custom_left_color": "Cor esquerda", "custom_right_color": "Cor direita", "pro_required": "Licença Pro necessária", "api_badge_title": "Endpoint de badge", "api_badge_desc": "Os badges padrão usam predefinições e parâmetros estruturados. Pro adiciona textos e cores personalizados.", "api_manifest_title": "Manifesto", "api_manifest_desc": "O manifesto JSON-LD pode ser ligado, validado ou integrado em processos de build.", "api_stateless_title": "Sem estado", "api_stateless_desc": "Sem sessões e com respostas determinísticas, adequado a escalabilidade horizontal, proxies inversos e CDN.", "footer_no_legal": "Não constitui aconselhamento jurídico.", "back": "Voltar", "declaration_eyebrow": "Declaração de uso de IA", "fact_component": "Componente", "fact_extent": "Contributo da IA", "fact_activities": "Atividades", "fact_review": "Revisão humana", "fact_note": "Nota", "fact_assurance": "Nível de garantia", "fact_subject": "Conteúdo identificado", "fact_responsibility": "Responsabilidade editorial", "fact_declared_at": "Declarado em", "none_value": "nenhuma", "transparency_label": "Aviso de transparência:", "transparency_text": "Esta declaração descreve o uso indicado de IA. Não é uma licença, certificação ou aconselhamento jurídico.", "manifest": "Manifesto legível por máquina",
"pro_eyebrow": "Personalização licenciada", "pro_title": "Textos e badges personalizados", "pro_enabled": "Esta instância dispõe das funcionalidades licenciadas necessárias. Estão disponíveis textos e cores personalizados.", "pro_locked": "Títulos, descrições, etiquetas e cores personalizados exigem a funcionalidade licenciada correspondente.", "custom_title": "Título personalizado", "custom_description": "Descrição personalizada", "custom_badge_label": "Etiqueta esquerda do badge", "custom_badge_message": "Etiqueta direita do badge", "custom_left_color": "Cor esquerda", "custom_right_color": "Cor direita", "pro_required": "Funcionalidade licenciada necessária", "api_badge_title": "Endpoint de badge", "api_badge_desc": "Os badges padrão usam predefinições e parâmetros estruturados. Funcionalidades licenciadas podem ativar textos e cores personalizados.", "api_manifest_title": "Manifesto", "api_manifest_desc": "O manifesto JSON-LD pode ser ligado, validado ou integrado em processos de build.", "api_stateless_title": "Sem estado", "api_stateless_desc": "Sem sessões e com respostas determinísticas, adequado a escalabilidade horizontal, proxies inversos e CDN.", "footer_no_legal": "Não constitui aconselhamento jurídico.", "back": "Voltar", "declaration_eyebrow": "Declaração de uso de IA", "fact_component": "Componente", "fact_extent": "Contributo da IA", "fact_activities": "Atividades", "fact_review": "Revisão humana", "fact_note": "Nota", "fact_assurance": "Nível de garantia", "fact_subject": "Conteúdo identificado", "fact_responsibility": "Responsabilidade editorial", "fact_declared_at": "Declarado em", "none_value": "nenhuma", "transparency_label": "Aviso de transparência:", "transparency_text": "Esta declaração descreve o uso indicado de IA. Não é uma licença, certificação ou aconselhamento jurídico.", "manifest": "Manifesto legível por máquina", "bundle": "Pacote de exportação",
},
"pl": {
"meta_description": "Otwarta, maszynowo czytelna i samodzielnie hostowana deklaracja użycia AI.", "nav_generator": "Generator", "nav_background": "Informacje", "standard_eyebrow": "Otwarty standard przejrzystości", "hero_lead": "Widoczna plakietka SVG, zrozumiała strona objaśniająca i maszynowo czytelny JSON-LD, bez plików cookie, śledzenia i obowiązkowej bazy danych.", "generator_eyebrow": "Generator", "generator_title": "Utwórz kod osadzania", "generator_intro": "Wybierz ustawienie lub zbuduj szczegółową deklarację. Podgląd i kod powstają bezpośrednio w przeglądarce.", "field_preset": "Ustawienie", "option_custom": "Niestandardowa struktura", "field_component": "Element", "field_extent": "Udział AI", "field_review": "Weryfikacja człowieka", "field_activities": "Działania", "activities_help": "Standardowe wartości rozdzielone przecinkami, na przykład research, summarisation lub translation.", "field_subject": "URL oznaczonej treści", "field_language": "Język wyjściowy", "field_theme": "Wygląd", "theme_mono": "Monochromatyczny", "theme_color": "Kolorowy", "preview": "Podgląd", "copy_html": "Kopiuj HTML", "copied": "Skopiowano",
"pro_eyebrow": "Personalizacja Pro", "pro_title": "Własne teksty i wygląd plakietek", "pro_enabled": "Ta instancja ma ważną licencję Pro. Dostępne są własne teksty i kolory.", "pro_locked": "Własne tytuły, opisy, etykiety i kolory są dostępne w edycji Pro.", "custom_title": "Własny tytuł", "custom_description": "Własny opis", "custom_badge_label": "Lewa etykieta plakietki", "custom_badge_message": "Prawa etykieta plakietki", "custom_left_color": "Lewy kolor", "custom_right_color": "Prawy kolor", "pro_required": "Wymagana licencja Pro", "api_badge_title": "Endpoint plakietki", "api_badge_desc": "Standardowe plakietki korzystają z ustawień i parametrów strukturalnych. Pro dodaje własne teksty i kolory.", "api_manifest_title": "Manifest", "api_manifest_desc": "Manifest JSON-LD można linkować, walidować lub wykorzystywać w procesach budowania.", "api_stateless_title": "Bezstanowy", "api_stateless_desc": "Brak sesji i deterministyczne odpowiedzi, odpowiednie do skalowania poziomego, reverse proxy i CDN.", "footer_no_legal": "To nie jest porada prawna.", "back": "Wstecz", "declaration_eyebrow": "Deklaracja użycia AI", "fact_component": "Element", "fact_extent": "Udział AI", "fact_activities": "Działania", "fact_review": "Weryfikacja człowieka", "fact_note": "Uwaga", "fact_assurance": "Poziom wiarygodności", "fact_subject": "Oznaczona treść", "fact_responsibility": "Odpowiedzialność redakcyjna", "fact_declared_at": "Zadeklarowano", "none_value": "brak", "transparency_label": "Informacja o przejrzystości:", "transparency_text": "Ta deklaracja opisuje wskazane użycie AI. Nie jest licencją, certyfikatem ani poradą prawną.", "manifest": "Manifest maszynowo czytelny",
"pro_eyebrow": "Personalizacja licencjonowana", "pro_title": "Własne teksty i wygląd plakietek", "pro_enabled": "Ta instancja ma wymagane licencjonowane funkcje. Dostępne są własne teksty i kolory.", "pro_locked": "Własne tytuły, opisy, etykiety i kolory wymagają odpowiedniej licencjonowanej funkcji.", "custom_title": "Własny tytuł", "custom_description": "Własny opis", "custom_badge_label": "Lewa etykieta plakietki", "custom_badge_message": "Prawa etykieta plakietki", "custom_left_color": "Lewy kolor", "custom_right_color": "Prawy kolor", "pro_required": "Wymagana licencjonowana funkcja", "api_badge_title": "Endpoint plakietki", "api_badge_desc": "Standardowe plakietki korzystają z ustawień i parametrów strukturalnych. Licencjonowane funkcje mogą włączyć własne teksty i kolory.", "api_manifest_title": "Manifest", "api_manifest_desc": "Manifest JSON-LD można linkować, walidować lub wykorzystywać w procesach budowania.", "api_stateless_title": "Bezstanowy", "api_stateless_desc": "Brak sesji i deterministyczne odpowiedzi, odpowiednie do skalowania poziomego, reverse proxy i CDN.", "footer_no_legal": "To nie jest porada prawna.", "back": "Wstecz", "declaration_eyebrow": "Deklaracja użycia AI", "fact_component": "Element", "fact_extent": "Udział AI", "fact_activities": "Działania", "fact_review": "Weryfikacja człowieka", "fact_note": "Uwaga", "fact_assurance": "Poziom wiarygodności", "fact_subject": "Oznaczona treść", "fact_responsibility": "Odpowiedzialność redakcyjna", "fact_declared_at": "Zadeklarowano", "none_value": "brak", "transparency_label": "Informacja o przejrzystości:", "transparency_text": "Ta deklaracja opisuje wskazane użycie AI. Nie jest licencją, certyfikatem ani poradą prawną.", "manifest": "Manifest maszynowo czytelny", "bundle": "Pakiet eksportowy",
},
}
}
@@ -379,6 +355,196 @@ func applyAssuranceTranslations() {
}
}
func applyArticle50Translations() {
texts := map[string]map[string]string{
"de": {
"article50_section_title": "Regulatorischer Kontext (EU AI Act, Artikel 50)",
"article50_section_intro": "Diese Angaben erfassen Tatsachen, die für Transparenzpflichten nach Artikel 50 relevant sein können. Die daraus abgeleitete Einordnung ist eine technische Entscheidungshilfe und keine Rechtsberatung.",
"field_public_interest_text": "Text zu einer Angelegenheit von öffentlichem Interesse",
"help_public_interest_text": "Aktivieren, wenn der Text mit dem Zweck veröffentlicht wird, die Öffentlichkeit über eine Angelegenheit von öffentlichem Interesse zu informieren.",
"field_deepfake": "Deepfake / realitätsähnliche KI-Manipulation",
"help_deepfake": "Aktivieren, wenn Bild, Audio oder Video bestehende Personen, Orte, Objekte oder Ereignisse täuschend echt darstellt oder manipuliert.",
"field_artistic_context": "Künstlerischer, kreativer, satirischer oder fiktionaler Kontext",
"help_artistic_context": "Dokumentiert einen Kontext, in dem die Offenlegung in geeigneter Weise erfolgen kann, ohne Darstellung oder Genuss unangemessen zu beeinträchtigen.",
"field_substantial_review": "Substanzielle menschliche Prüfung oder redaktionelle Kontrolle durchgeführt",
"help_substantial_review": "Nur aktivieren, wenn die Kontrolle über bloße Rechtschreib-, Stil- oder Formatkorrekturen hinausgeht.",
"field_editorial_responsibility_confirmed": "Redaktionelle Verantwortung wird übernommen",
"help_editorial_responsibility_confirmed": "Bestätigt, dass eine natürliche oder juristische Person die redaktionelle Verantwortung für die Veröffentlichung trägt.",
"field_responsible_name": "Verantwortliche Person oder Organisation",
"field_responsible_url": "URL der verantwortlichen Stelle",
"field_first_exposure": "Hinweis ist spätestens bei der ersten Exposition sichtbar",
"field_accessibility": "Barrierefreiheit der Kennzeichnung wurde berücksichtigt",
"assessment_heading": "Technische Artikel-50-Einordnung",
"assessment_disclaimer": "Nicht bindende Entscheidungshilfe. Die tatsächliche rechtliche Bewertung hängt vom konkreten Einsatz und weiteren Rechtsvorschriften ab.",
"assessment_not_assessed_title": "Keine regulatorische Einordnung angefordert",
"assessment_not_assessed_text": "Die Erklärung dokumentiert die KI-Nutzung, enthält aber keine zusätzlichen Angaben zum regulatorischen Kontext nach Artikel 50.",
"assessment_voluntary_title": "Freiwillige Transparenz im Vordergrund",
"assessment_voluntary_text": "Auf Grundlage der angegebenen Tatsachen wurde kein typischer Artikel-50-Fall für Deepfakes oder Texte zu Angelegenheiten von öffentlichem Interesse markiert.",
"assessment_deepfake_title": "Offenlegung bei Deepfake-Inhalten besonders relevant",
"assessment_deepfake_text": "Die Angaben markieren einen Deepfake- oder realitätsähnlich manipulierten Inhalt. Eine klare und unterscheidbare Offenlegung sollte unmittelbar am Inhalt vorgesehen werden.",
"assessment_public_text_title": "Offenlegung für Text zu öffentlichem Interesse relevant",
"assessment_public_text_text": "Der Text wurde als Information zu einer Angelegenheit von öffentlichem Interesse markiert. Die dokumentierten Voraussetzungen für die Ausnahme aufgrund menschlicher Prüfung und redaktioneller Verantwortung sind nicht vollständig bestätigt.",
"assessment_possible_exemption_title": "Mögliche Ausnahme für redaktionell kontrollierten Text",
"assessment_possible_exemption_text": "Für den Text zu einer Angelegenheit von öffentlichem Interesse sind substanzielle menschliche Prüfung beziehungsweise redaktionelle Kontrolle und redaktionelle Verantwortung bestätigt. Dies kann für die Ausnahme in Artikel 50 Absatz 4 relevant sein.",
"assessment_multiple_title": "Mehrere Artikel-50-Kontexte erkannt",
"assessment_multiple_text": "Die Angaben enthalten mehrere regulatorisch relevante Kontexte. Die einzelnen Hinweise sollten gemeinsam betrachtet und für die jeweiligen Inhaltsbestandteile umgesetzt werden.",
"warning_first_exposure": "Die Sichtbarkeit spätestens bei der ersten Exposition wurde nicht bestätigt.",
"warning_accessibility": "Die Berücksichtigung anwendbarer Barrierefreiheitsanforderungen wurde nicht bestätigt.",
"warning_editorial_responsibility": "Eine substanzielle Prüfung ist angegeben, die redaktionelle Verantwortung wurde jedoch nicht bestätigt.",
"warning_artistic_context": "Der Inhalt ist als künstlerisch, kreativ, satirisch oder fiktional markiert. Die Offenlegung sollte in einer geeigneten Form erfolgen, die den Werkgenuss nicht unnötig beeinträchtigt.",
"badge_article50_deepfake": "KI-manipulierter Inhalt",
"badge_article50_text": "KI-generierter Text",
"regulatory_context": "Regulatorischer Kontext",
"yes_value": "Ja",
"no_value": "Nein",
},
"en": {
"article50_section_title": "Regulatory context (EU AI Act, Article 50)",
"article50_section_intro": "These fields record facts that may be relevant to Article 50 transparency obligations. The resulting assessment is technical decision support, not legal advice.",
"field_public_interest_text": "Text on a matter of public interest",
"help_public_interest_text": "Select when the text is published for the purpose of informing the public on a matter of public interest.",
"field_deepfake": "Deepfake / realistic AI manipulation",
"help_deepfake": "Select when image, audio or video realistically depicts or manipulates existing persons, places, objects or events in a way that may appear authentic.",
"field_artistic_context": "Artistic, creative, satirical or fictional context",
"help_artistic_context": "Records a context in which disclosure may be provided in an appropriate manner without unduly hampering display or enjoyment.",
"field_substantial_review": "Substantial human review or editorial control performed",
"help_substantial_review": "Select only where the review goes beyond spelling, style or formatting corrections.",
"field_editorial_responsibility_confirmed": "Editorial responsibility is assumed",
"help_editorial_responsibility_confirmed": "Confirms that a natural or legal person holds editorial responsibility for publication.",
"field_responsible_name": "Responsible person or organisation",
"field_responsible_url": "Responsible party URL",
"field_first_exposure": "Disclosure is visible no later than first exposure",
"field_accessibility": "Accessibility of the disclosure has been considered",
"assessment_heading": "Technical Article 50 assessment",
"assessment_disclaimer": "Non-binding decision support. The actual legal assessment depends on the concrete use and other applicable law.",
"assessment_not_assessed_title": "No regulatory assessment requested",
"assessment_not_assessed_text": "The declaration records AI use but does not include additional Article 50 regulatory context.",
"assessment_voluntary_title": "Voluntary transparency is the primary use case",
"assessment_voluntary_text": "Based on the supplied facts, no typical Article 50 case for deepfakes or public-interest text has been marked.",
"assessment_deepfake_title": "Disclosure is particularly relevant for deepfake content",
"assessment_deepfake_text": "The content is marked as a deepfake or realistic AI manipulation. A clear and distinguishable disclosure should be provided directly with the content.",
"assessment_public_text_title": "Disclosure is relevant for public-interest text",
"assessment_public_text_text": "The text is marked as informing the public on a matter of public interest. The documented conditions relating to human review and editorial responsibility are not both confirmed.",
"assessment_possible_exemption_title": "Possible exception for editorially controlled text",
"assessment_possible_exemption_text": "Substantial human review or editorial control and editorial responsibility are confirmed for the public-interest text. This may be relevant to the exception in Article 50(4).",
"assessment_multiple_title": "Multiple Article 50 contexts detected",
"assessment_multiple_text": "The supplied facts contain multiple regulatory contexts. Each finding should be considered together and applied to the relevant content components.",
"warning_first_exposure": "Visibility no later than first exposure has not been confirmed.",
"warning_accessibility": "Consideration of applicable accessibility requirements has not been confirmed.",
"warning_editorial_responsibility": "Substantial review is stated, but editorial responsibility has not been confirmed.",
"warning_artistic_context": "The content is marked as artistic, creative, satirical or fictional. Disclosure should use an appropriate manner that does not unnecessarily hamper enjoyment of the work.",
"badge_article50_deepfake": "AI-manipulated content",
"badge_article50_text": "AI-generated text",
"regulatory_context": "Regulatory context",
"yes_value": "Yes",
"no_value": "No",
},
"fr": {
"article50_section_title": "Contexte réglementaire (règlement IA de lUE, article 50)", "article50_section_intro": "Ces informations consignent des faits pouvant être pertinents au regard des obligations de transparence de larticle 50. Lévaluation est une aide technique et non un conseil juridique.",
"field_public_interest_text": "Texte concernant une question dintérêt public", "help_public_interest_text": "À sélectionner lorsque le texte vise à informer le public sur une question dintérêt public.",
"field_deepfake": "Deepfake / manipulation réaliste par IA", "help_deepfake": "À sélectionner pour une image, un son ou une vidéo réaliste susceptible de paraître authentique.",
"field_artistic_context": "Contexte artistique, créatif, satirique ou fictionnel", "help_artistic_context": "Documente un contexte dans lequel linformation peut être adaptée sans nuire indûment à lœuvre.",
"field_substantial_review": "Révision humaine substantielle ou contrôle éditorial effectué", "help_substantial_review": "Uniquement si le contrôle dépasse les corrections de forme, de style ou dorthographe.",
"field_editorial_responsibility_confirmed": "Responsabilité éditoriale assumée", "help_editorial_responsibility_confirmed": "Confirme quune personne physique ou morale assume la responsabilité éditoriale.",
"field_responsible_name": "Personne ou organisation responsable", "field_responsible_url": "URL du responsable", "field_first_exposure": "Information visible au plus tard lors de la première exposition", "field_accessibility": "Accessibilité de linformation prise en compte",
"assessment_heading": "Évaluation technique de larticle 50", "assessment_disclaimer": "Aide à la décision non contraignante; lanalyse juridique dépend du cas concret.",
"assessment_not_assessed_title": "Aucune évaluation réglementaire demandée", "assessment_not_assessed_text": "La déclaration documente lusage de lIA sans contexte réglementaire supplémentaire.", "assessment_voluntary_title": "Transparence volontaire au premier plan", "assessment_voluntary_text": "Aucun cas typique de deepfake ou de texte dintérêt public na été indiqué.",
"assessment_deepfake_title": "Information particulièrement pertinente pour les deepfakes", "assessment_deepfake_text": "Le contenu est indiqué comme deepfake ou manipulation réaliste. Une information claire et distincte devrait accompagner directement le contenu.",
"assessment_public_text_title": "Information pertinente pour un texte dintérêt public", "assessment_public_text_text": "Les conditions documentées relatives au contrôle humain et à la responsabilité éditoriale ne sont pas toutes confirmées.",
"assessment_possible_exemption_title": "Exception possible pour un texte sous contrôle éditorial", "assessment_possible_exemption_text": "Un contrôle humain substantiel et une responsabilité éditoriale sont confirmés; cela peut être pertinent pour lexception de larticle 50, paragraphe 4.", "assessment_multiple_title": "Plusieurs contextes de larticle 50 détectés", "assessment_multiple_text": "Les informations fournies contiennent plusieurs contextes réglementaires. Chaque constat doit être examiné avec les autres et appliqué aux éléments de contenu concernés.",
"warning_first_exposure": "La visibilité lors de la première exposition nest pas confirmée.", "warning_accessibility": "La prise en compte des exigences daccessibilité nest pas confirmée.", "warning_editorial_responsibility": "Le contrôle substantiel est indiqué, mais la responsabilité éditoriale nest pas confirmée.", "warning_artistic_context": "Le contenu est indiqué comme artistique, créatif, satirique ou fictionnel. Linformation devrait être présentée dune manière appropriée qui ne gêne pas inutilement lappréciation de lœuvre.",
"badge_article50_deepfake": "Contenu manipulé par IA", "badge_article50_text": "Texte généré par IA", "regulatory_context": "Contexte réglementaire", "yes_value": "Oui", "no_value": "Non",
},
"es": {
"article50_section_title": "Contexto normativo (Reglamento de IA de la UE, artículo 50)", "article50_section_intro": "Estos datos recogen hechos que pueden ser relevantes para las obligaciones de transparencia del artículo 50. La evaluación es una ayuda técnica, no asesoramiento jurídico.",
"field_public_interest_text": "Texto sobre un asunto de interés público", "help_public_interest_text": "Seleccionar cuando el texto se publica para informar al público sobre un asunto de interés público.",
"field_deepfake": "Deepfake / manipulación realista con IA", "help_deepfake": "Seleccionar para imágenes, audio o vídeo realistas que puedan parecer auténticos.",
"field_artistic_context": "Contexto artístico, creativo, satírico o ficticio", "help_artistic_context": "Documenta un contexto en el que la divulgación puede adaptarse sin perjudicar indebidamente la obra.",
"field_substantial_review": "Revisión humana sustancial o control editorial realizado", "help_substantial_review": "Solo cuando el control vaya más allá de correcciones de ortografía, estilo o formato.",
"field_editorial_responsibility_confirmed": "Se asume la responsabilidad editorial", "help_editorial_responsibility_confirmed": "Confirma que una persona física o jurídica asume la responsabilidad editorial.",
"field_responsible_name": "Persona u organización responsable", "field_responsible_url": "URL del responsable", "field_first_exposure": "Aviso visible como máximo en la primera exposición", "field_accessibility": "Se ha considerado la accesibilidad del aviso",
"assessment_heading": "Evaluación técnica del artículo 50", "assessment_disclaimer": "Ayuda no vinculante; la valoración jurídica depende del caso concreto.",
"assessment_not_assessed_title": "No se solicitó evaluación normativa", "assessment_not_assessed_text": "La declaración documenta el uso de IA sin contexto normativo adicional.", "assessment_voluntary_title": "Predomina la transparencia voluntaria", "assessment_voluntary_text": "No se ha marcado un caso típico de deepfake o texto de interés público.",
"assessment_deepfake_title": "La divulgación es especialmente relevante para deepfakes", "assessment_deepfake_text": "El contenido está marcado como deepfake o manipulación realista. Debe preverse un aviso claro junto al contenido.",
"assessment_public_text_title": "La divulgación es relevante para texto de interés público", "assessment_public_text_text": "No se confirman conjuntamente las condiciones documentadas de revisión humana y responsabilidad editorial.",
"assessment_possible_exemption_title": "Posible excepción para texto bajo control editorial", "assessment_possible_exemption_text": "Se confirman revisión humana sustancial y responsabilidad editorial; puede ser relevante para la excepción del artículo 50.4.", "assessment_multiple_title": "Se detectaron varios contextos del artículo 50", "assessment_multiple_text": "Los datos proporcionados contienen varios contextos normativos. Cada resultado debe considerarse conjuntamente y aplicarse a los componentes de contenido correspondientes.",
"warning_first_exposure": "No se ha confirmado la visibilidad en la primera exposición.", "warning_accessibility": "No se ha confirmado la consideración de accesibilidad.", "warning_editorial_responsibility": "Se indica revisión sustancial, pero no responsabilidad editorial.", "warning_artistic_context": "El contenido está marcado como artístico, creativo, satírico o ficticio. La divulgación debe presentarse de forma adecuada sin perjudicar innecesariamente el disfrute de la obra.",
"badge_article50_deepfake": "Contenido manipulado por IA", "badge_article50_text": "Texto generado por IA", "regulatory_context": "Contexto normativo", "yes_value": "Sí", "no_value": "No",
},
"it": {
"article50_section_title": "Contesto normativo (AI Act UE, articolo 50)", "article50_section_intro": "Questi dati registrano fatti potenzialmente rilevanti per gli obblighi di trasparenza dellarticolo 50. La valutazione è un supporto tecnico e non consulenza legale.",
"field_public_interest_text": "Testo su una questione di interesse pubblico", "help_public_interest_text": "Selezionare quando il testo informa il pubblico su una questione di interesse pubblico.",
"field_deepfake": "Deepfake / manipolazione realistica con IA", "help_deepfake": "Selezionare per immagini, audio o video realistici che possono apparire autentici.",
"field_artistic_context": "Contesto artistico, creativo, satirico o fittizio", "help_artistic_context": "Documenta un contesto in cui linformativa può essere adattata senza compromettere indebitamente lopera.",
"field_substantial_review": "Revisione umana sostanziale o controllo editoriale effettuato", "help_substantial_review": "Solo se il controllo supera semplici correzioni di ortografia, stile o formato.",
"field_editorial_responsibility_confirmed": "Responsabilità editoriale assunta", "help_editorial_responsibility_confirmed": "Conferma che una persona fisica o giuridica assume la responsabilità editoriale.",
"field_responsible_name": "Persona o organizzazione responsabile", "field_responsible_url": "URL del responsabile", "field_first_exposure": "Informativa visibile entro la prima esposizione", "field_accessibility": "Accessibilità dellinformativa considerata",
"assessment_heading": "Valutazione tecnica dellarticolo 50", "assessment_disclaimer": "Supporto non vincolante; la valutazione giuridica dipende dal caso concreto.",
"assessment_not_assessed_title": "Nessuna valutazione normativa richiesta", "assessment_not_assessed_text": "La dichiarazione documenta luso dellIA senza ulteriore contesto normativo.", "assessment_voluntary_title": "Prevale la trasparenza volontaria", "assessment_voluntary_text": "Non è stato indicato un tipico caso di deepfake o testo di interesse pubblico.",
"assessment_deepfake_title": "Informativa particolarmente rilevante per i deepfake", "assessment_deepfake_text": "Il contenuto è indicato come deepfake o manipolazione realistica; è opportuno un avviso chiaro direttamente con il contenuto.",
"assessment_public_text_title": "Informativa rilevante per testo di interesse pubblico", "assessment_public_text_text": "Non risultano entrambe confermate revisione umana e responsabilità editoriale.",
"assessment_possible_exemption_title": "Possibile eccezione per testo con controllo editoriale", "assessment_possible_exemption_text": "Sono confermati controllo umano sostanziale e responsabilità editoriale; ciò può rilevare per leccezione dellarticolo 50(4).", "assessment_multiple_title": "Rilevati più contesti dellarticolo 50", "assessment_multiple_text": "Le informazioni fornite contengono più contesti normativi. Ogni risultato dovrebbe essere considerato insieme agli altri e applicato ai relativi componenti di contenuto.",
"warning_first_exposure": "La visibilità alla prima esposizione non è confermata.", "warning_accessibility": "La considerazione dellaccessibilità non è confermata.", "warning_editorial_responsibility": "È indicata una revisione sostanziale, ma non la responsabilità editoriale.", "warning_artistic_context": "Il contenuto è indicato come artistico, creativo, satirico o fittizio. Linformativa dovrebbe essere fornita in modo appropriato senza compromettere inutilmente la fruizione dellopera.",
"badge_article50_deepfake": "Contenuto manipolato da IA", "badge_article50_text": "Testo generato da IA", "regulatory_context": "Contesto normativo", "yes_value": "Sì", "no_value": "No",
},
"nl": {
"article50_section_title": "Regelgevingscontext (EU AI Act, artikel 50)", "article50_section_intro": "Deze gegevens leggen feiten vast die relevant kunnen zijn voor de transparantieverplichtingen van artikel 50. De beoordeling is technische beslissingsondersteuning en geen juridisch advies.",
"field_public_interest_text": "Tekst over een onderwerp van openbaar belang", "help_public_interest_text": "Selecteer wanneer de tekst het publiek informeert over een onderwerp van openbaar belang.",
"field_deepfake": "Deepfake / realistische AI-manipulatie", "help_deepfake": "Selecteer voor realistische beeld-, audio- of video-inhoud die authentiek kan lijken.",
"field_artistic_context": "Artistieke, creatieve, satirische of fictieve context", "help_artistic_context": "Legt een context vast waarin de melding passend kan worden vormgegeven zonder het werk onnodig te verstoren.",
"field_substantial_review": "Inhoudelijke menselijke beoordeling of redactionele controle uitgevoerd", "help_substantial_review": "Alleen wanneer de controle verder gaat dan spelling, stijl of opmaak.",
"field_editorial_responsibility_confirmed": "Redactionele verantwoordelijkheid wordt aanvaard", "help_editorial_responsibility_confirmed": "Bevestigt dat een natuurlijke of rechtspersoon redactionele verantwoordelijkheid draagt.",
"field_responsible_name": "Verantwoordelijke persoon of organisatie", "field_responsible_url": "URL verantwoordelijke", "field_first_exposure": "Melding uiterlijk bij eerste blootstelling zichtbaar", "field_accessibility": "Toegankelijkheid van de melding is meegenomen",
"assessment_heading": "Technische beoordeling artikel 50", "assessment_disclaimer": "Niet-bindende ondersteuning; de juridische beoordeling hangt af van het concrete geval.",
"assessment_not_assessed_title": "Geen regelgevingsbeoordeling gevraagd", "assessment_not_assessed_text": "De verklaring documenteert AI-gebruik zonder aanvullende regelgevingscontext.", "assessment_voluntary_title": "Vrijwillige transparantie staat centraal", "assessment_voluntary_text": "Er is geen typisch deepfake- of openbaar-belangtekstgeval gemarkeerd.",
"assessment_deepfake_title": "Melding is bijzonder relevant bij deepfakes", "assessment_deepfake_text": "De inhoud is gemarkeerd als deepfake of realistische manipulatie. Een duidelijke melding hoort direct bij de inhoud.",
"assessment_public_text_title": "Melding is relevant voor tekst van openbaar belang", "assessment_public_text_text": "Menselijke beoordeling en redactionele verantwoordelijkheid zijn niet beide bevestigd.",
"assessment_possible_exemption_title": "Mogelijke uitzondering voor redactioneel gecontroleerde tekst", "assessment_possible_exemption_text": "Inhoudelijke menselijke beoordeling en redactionele verantwoordelijkheid zijn bevestigd; dit kan relevant zijn voor de uitzondering in artikel 50(4).", "assessment_multiple_title": "Meerdere contexten van artikel 50 gedetecteerd", "assessment_multiple_text": "De opgegeven feiten bevatten meerdere regelgevingscontexten. Elk resultaat moet gezamenlijk worden beoordeeld en op de relevante inhoudsonderdelen worden toegepast.",
"warning_first_exposure": "Zichtbaarheid bij de eerste blootstelling is niet bevestigd.", "warning_accessibility": "Aandacht voor toegankelijkheid is niet bevestigd.", "warning_editorial_responsibility": "Inhoudelijke beoordeling is vermeld, maar redactionele verantwoordelijkheid niet.", "warning_artistic_context": "De inhoud is gemarkeerd als artistiek, creatief, satirisch of fictief. De melding hoort op een passende manier te gebeuren zonder het genieten van het werk onnodig te belemmeren.",
"badge_article50_deepfake": "AI-gemanipuleerde inhoud", "badge_article50_text": "AI-gegenereerde tekst", "regulatory_context": "Regelgevingscontext", "yes_value": "Ja", "no_value": "Nee",
},
"pt": {
"article50_section_title": "Contexto regulamentar (Regulamento de IA da UE, artigo 50)", "article50_section_intro": "Estes dados registam factos potencialmente relevantes para as obrigações de transparência do artigo 50. A avaliação é apoio técnico e não aconselhamento jurídico.",
"field_public_interest_text": "Texto sobre matéria de interesse público", "help_public_interest_text": "Selecionar quando o texto é publicado para informar o público sobre matéria de interesse público.",
"field_deepfake": "Deepfake / manipulação realista por IA", "help_deepfake": "Selecionar para imagem, áudio ou vídeo realista que possa parecer autêntico.",
"field_artistic_context": "Contexto artístico, criativo, satírico ou ficcional", "help_artistic_context": "Regista um contexto em que a divulgação pode ser adaptada sem prejudicar indevidamente a obra.",
"field_substantial_review": "Revisão humana substancial ou controlo editorial realizado", "help_substantial_review": "Apenas quando o controlo vai além de correções de ortografia, estilo ou formatação.",
"field_editorial_responsibility_confirmed": "Responsabilidade editorial assumida", "help_editorial_responsibility_confirmed": "Confirma que uma pessoa singular ou coletiva assume responsabilidade editorial.",
"field_responsible_name": "Pessoa ou organização responsável", "field_responsible_url": "URL do responsável", "field_first_exposure": "Aviso visível o mais tardar na primeira exposição", "field_accessibility": "A acessibilidade do aviso foi considerada",
"assessment_heading": "Avaliação técnica do artigo 50", "assessment_disclaimer": "Apoio não vinculativo; a avaliação jurídica depende do caso concreto.",
"assessment_not_assessed_title": "Nenhuma avaliação regulamentar solicitada", "assessment_not_assessed_text": "A declaração documenta o uso de IA sem contexto regulamentar adicional.", "assessment_voluntary_title": "A transparência voluntária é o foco", "assessment_voluntary_text": "Não foi assinalado um caso típico de deepfake ou texto de interesse público.",
"assessment_deepfake_title": "A divulgação é particularmente relevante para deepfakes", "assessment_deepfake_text": "O conteúdo está marcado como deepfake ou manipulação realista; deve existir um aviso claro junto do conteúdo.",
"assessment_public_text_title": "A divulgação é relevante para texto de interesse público", "assessment_public_text_text": "Revisão humana e responsabilidade editorial não estão ambas confirmadas.",
"assessment_possible_exemption_title": "Possível exceção para texto sob controlo editorial", "assessment_possible_exemption_text": "Revisão humana substancial e responsabilidade editorial estão confirmadas; isto pode ser relevante para a exceção do artigo 50(4).", "assessment_multiple_title": "Foram detetados vários contextos do artigo 50", "assessment_multiple_text": "Os dados fornecidos contêm vários contextos regulamentares. Cada conclusão deve ser considerada em conjunto e aplicada aos componentes de conteúdo relevantes.",
"warning_first_exposure": "A visibilidade na primeira exposição não foi confirmada.", "warning_accessibility": "A consideração da acessibilidade não foi confirmada.", "warning_editorial_responsibility": "É indicada revisão substancial, mas não responsabilidade editorial.", "warning_artistic_context": "O conteúdo está assinalado como artístico, criativo, satírico ou ficcional. A divulgação deve ser feita de forma adequada sem prejudicar desnecessariamente a fruição da obra.",
"badge_article50_deepfake": "Conteúdo manipulado por IA", "badge_article50_text": "Texto gerado por IA", "regulatory_context": "Contexto regulamentar", "yes_value": "Sim", "no_value": "Não",
},
"pl": {
"article50_section_title": "Kontekst regulacyjny (unijny AI Act, art. 50)", "article50_section_intro": "Dane te zapisują fakty, które mogą mieć znaczenie dla obowiązków przejrzystości z art. 50. Ocena stanowi techniczne wsparcie decyzji, a nie poradę prawną.",
"field_public_interest_text": "Tekst dotyczący sprawy interesu publicznego", "help_public_interest_text": "Zaznacz, gdy tekst służy informowaniu opinii publicznej o sprawie interesu publicznego.",
"field_deepfake": "Deepfake / realistyczna manipulacja AI", "help_deepfake": "Zaznacz dla realistycznego obrazu, dźwięku lub wideo, które może wyglądać na autentyczne.",
"field_artistic_context": "Kontekst artystyczny, twórczy, satyryczny lub fikcyjny", "help_artistic_context": "Dokumentuje kontekst, w którym informację można podać odpowiednio bez nadmiernego zakłócania utworu.",
"field_substantial_review": "Przeprowadzono istotną kontrolę człowieka lub redakcji", "help_substantial_review": "Tylko gdy kontrola wykracza poza korektę pisowni, stylu lub formatowania.",
"field_editorial_responsibility_confirmed": "Przyjęto odpowiedzialność redakcyjną", "help_editorial_responsibility_confirmed": "Potwierdza, że osoba fizyczna lub prawna ponosi odpowiedzialność redakcyjną.",
"field_responsible_name": "Odpowiedzialna osoba lub organizacja", "field_responsible_url": "URL podmiotu odpowiedzialnego", "field_first_exposure": "Informacja widoczna najpóźniej przy pierwszym kontakcie", "field_accessibility": "Uwzględniono dostępność informacji",
"assessment_heading": "Techniczna ocena art. 50", "assessment_disclaimer": "Niewiążące wsparcie decyzji; ocena prawna zależy od konkretnego przypadku.",
"assessment_not_assessed_title": "Nie zażądano oceny regulacyjnej", "assessment_not_assessed_text": "Deklaracja dokumentuje użycie AI bez dodatkowego kontekstu regulacyjnego.", "assessment_voluntary_title": "Głównym celem jest dobrowolna przejrzystość", "assessment_voluntary_text": "Nie oznaczono typowego przypadku deepfake ani tekstu interesu publicznego.",
"assessment_deepfake_title": "Ujawnienie jest szczególnie istotne dla deepfake", "assessment_deepfake_text": "Treść oznaczono jako deepfake lub realistyczną manipulację. Jasna informacja powinna towarzyszyć bezpośrednio treści.",
"assessment_public_text_title": "Ujawnienie jest istotne dla tekstu interesu publicznego", "assessment_public_text_text": "Nie potwierdzono łącznie kontroli człowieka i odpowiedzialności redakcyjnej.",
"assessment_possible_exemption_title": "Możliwy wyjątek dla tekstu pod kontrolą redakcyjną", "assessment_possible_exemption_text": "Potwierdzono istotną kontrolę człowieka i odpowiedzialność redakcyjną; może to mieć znaczenie dla wyjątku z art. 50 ust. 4.", "assessment_multiple_title": "Wykryto wiele kontekstów art. 50", "assessment_multiple_text": "Podane informacje obejmują kilka kontekstów regulacyjnych. Każde ustalenie należy rozpatrywać łącznie i odnosić do właściwych elementów treści.",
"warning_first_exposure": "Nie potwierdzono widoczności przy pierwszym kontakcie.", "warning_accessibility": "Nie potwierdzono uwzględnienia dostępności.", "warning_editorial_responsibility": "Wskazano istotną kontrolę, ale nie potwierdzono odpowiedzialności redakcyjnej.", "warning_artistic_context": "Treść oznaczono jako artystyczną, kreatywną, satyryczną lub fikcyjną. Informację należy przekazać w odpowiedni sposób, bez niepotrzebnego utrudniania odbioru utworu.",
"badge_article50_deepfake": "Treść zmanipulowana przez AI", "badge_article50_text": "Tekst wygenerowany przez AI", "regulatory_context": "Kontekst regulacyjny", "yes_value": "Tak", "no_value": "Nie",
},
}
for code, values := range texts {
l := catalogs[code]
for key, value := range values {
l.Text[key] = value
}
catalogs[code] = l
}
}
func cloneLocale(in Locale) Locale {
out := in
out.Text = cloneMap(in.Text)

View File

@@ -1,247 +0,0 @@
package licensing
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
)
const (
FeatureCustomText = "custom_text"
FeatureCustomBadge = "custom_badge"
FeatureWhiteLabel = "white_label"
)
type Claims struct {
Version int `json:"version"`
Customer string `json:"customer"`
Plan string `json:"plan"`
Features []string `json:"features"`
Domains []string `json:"domains,omitempty"`
IssuedAt int64 `json:"issuedAt"`
ExpiresAt int64 `json:"expiresAt"`
}
type Status struct {
Edition string `json:"edition"`
Licensed bool `json:"licensed"`
Customer string `json:"customer,omitempty"`
Plan string `json:"plan,omitempty"`
Features []string `json:"features"`
ExpiresAt string `json:"expiresAt,omitempty"`
Reason string `json:"reason,omitempty"`
}
type Manager struct {
status Status
features map[string]bool
}
func Community() *Manager {
return &Manager{status: Status{Edition: "community", Features: []string{}}, features: map[string]bool{}}
}
func New(publicKeyEncoded, token, baseURL string, allowInsecurePro bool, now time.Time) *Manager {
if allowInsecurePro {
features := []string{FeatureCustomBadge, FeatureCustomText, FeatureWhiteLabel}
return &Manager{
status: Status{Edition: "pro", Licensed: true, Customer: "development", Plan: "pro-dev", Features: features, Reason: "insecure development override"},
features: featureSet(features),
}
}
if strings.TrimSpace(publicKeyEncoded) == "" || strings.TrimSpace(token) == "" {
return Community()
}
claims, err := Verify(publicKeyEncoded, token, now)
if err != nil {
m := Community()
m.status.Reason = err.Error()
return m
}
if err := validateDomain(claims.Domains, baseURL); err != nil {
m := Community()
m.status.Reason = err.Error()
return m
}
features := uniqueSorted(claims.Features)
return &Manager{
status: Status{
Edition: "pro", Licensed: true, Customer: claims.Customer, Plan: claims.Plan,
Features: features, ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC().Format(time.RFC3339),
},
features: featureSet(features),
}
}
func (m *Manager) Has(feature string) bool { return m != nil && m.features[feature] }
func (m *Manager) Status() Status {
if m == nil {
return Community().status
}
out := m.status
out.Features = make([]string, len(m.status.Features))
copy(out.Features, m.status.Features)
return out
}
func Sign(privateKey ed25519.PrivateKey, claims Claims) (string, error) {
if len(privateKey) != ed25519.PrivateKeySize {
return "", errors.New("invalid Ed25519 private key")
}
if err := validateClaims(claims, time.Unix(claims.IssuedAt, 0)); err != nil {
return "", err
}
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("marshal claims: %w", err)
}
payloadPart := base64.RawURLEncoding.EncodeToString(payload)
sig := ed25519.Sign(privateKey, []byte(payloadPart))
return payloadPart + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}
func Verify(publicKeyEncoded, token string, now time.Time) (Claims, error) {
publicKeyBytes, err := decodeKey(publicKeyEncoded)
if err != nil {
return Claims{}, fmt.Errorf("decode public key: %w", err)
}
if len(publicKeyBytes) != ed25519.PublicKeySize {
return Claims{}, errors.New("public key must be an Ed25519 public key")
}
parts := strings.Split(token, ".")
if len(parts) != 2 {
return Claims{}, errors.New("license token has invalid format")
}
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return Claims{}, errors.New("license signature is not valid base64url")
}
if !ed25519.Verify(ed25519.PublicKey(publicKeyBytes), []byte(parts[0]), sig) {
return Claims{}, errors.New("license signature verification failed")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return Claims{}, errors.New("license payload is not valid base64url")
}
var claims Claims
dec := json.NewDecoder(strings.NewReader(string(payload)))
dec.DisallowUnknownFields()
if err := dec.Decode(&claims); err != nil {
return Claims{}, fmt.Errorf("decode license payload: %w", err)
}
if err := validateClaims(claims, now); err != nil {
return Claims{}, err
}
return claims, nil
}
func validateClaims(c Claims, now time.Time) error {
if c.Version != 1 {
return errors.New("unsupported license version")
}
if strings.TrimSpace(c.Customer) == "" {
return errors.New("license customer is required")
}
if strings.TrimSpace(c.Plan) == "" {
return errors.New("license plan is required")
}
if c.IssuedAt <= 0 || c.ExpiresAt <= 0 || c.ExpiresAt <= c.IssuedAt {
return errors.New("license timestamps are invalid")
}
if now.Unix() < c.IssuedAt-300 {
return errors.New("license is not active yet")
}
if now.Unix() >= c.ExpiresAt {
return errors.New("license has expired")
}
allowed := map[string]bool{FeatureCustomText: true, FeatureCustomBadge: true, FeatureWhiteLabel: true}
for _, feature := range c.Features {
if !allowed[feature] {
return fmt.Errorf("unknown license feature %q", feature)
}
}
return nil
}
func validateDomain(domains []string, baseURL string) error {
if len(domains) == 0 {
return nil
}
u, err := url.Parse(baseURL)
if err != nil || u.Hostname() == "" {
return errors.New("BASE_URL has no valid host for licensed domain validation")
}
host := strings.ToLower(u.Hostname())
for _, allowed := range domains {
allowed = strings.ToLower(strings.TrimSpace(allowed))
if allowed == "*" {
return nil
}
if host == allowed {
return nil
}
if strings.HasPrefix(allowed, "*.") {
suffix := strings.TrimPrefix(allowed, "*")
if strings.HasSuffix(host, suffix) && host != strings.TrimPrefix(suffix, ".") {
return nil
}
}
}
return fmt.Errorf("host %q is not covered by the license", host)
}
func DecodePrivateKey(encoded string) (ed25519.PrivateKey, error) {
b, err := decodeKey(encoded)
if err != nil {
return nil, err
}
if len(b) == ed25519.SeedSize {
return ed25519.NewKeyFromSeed(b), nil
}
if len(b) != ed25519.PrivateKeySize {
return nil, errors.New("private key must contain an Ed25519 seed or private key")
}
return ed25519.PrivateKey(b), nil
}
func EncodeKey(key []byte) string { return base64.RawURLEncoding.EncodeToString(key) }
func decodeKey(value string) ([]byte, error) {
value = strings.TrimSpace(value)
if b, err := base64.RawURLEncoding.DecodeString(value); err == nil {
return b, nil
}
if b, err := base64.StdEncoding.DecodeString(value); err == nil {
return b, nil
}
return nil, errors.New("key is not valid base64")
}
func featureSet(features []string) map[string]bool {
out := make(map[string]bool, len(features))
for _, f := range features {
out[f] = true
}
return out
}
func uniqueSorted(values []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v != "" && !seen[v] {
seen[v] = true
out = append(out, v)
}
}
sort.Strings(out)
return out
}

View File

@@ -1,63 +0,0 @@
package licensing
import (
"crypto/ed25519"
"crypto/rand"
"testing"
"time"
)
func TestSignedLicense(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
now := time.Unix(1_800_000_000, 0)
claims := Claims{Version: 1, Customer: "Example", Plan: "pro", Features: []string{FeatureCustomText}, Domains: []string{"*.example.org"}, IssuedAt: now.Add(-time.Hour).Unix(), ExpiresAt: now.Add(24 * time.Hour).Unix()}
token, err := Sign(priv, claims)
if err != nil {
t.Fatal(err)
}
m := New(EncodeKey(pub), token, "https://ai.example.org", false, now)
if !m.Has(FeatureCustomText) || m.Status().Edition != "pro" {
t.Fatalf("unexpected status: %#v", m.Status())
}
}
func TestExpiredLicenseFallsBackToCommunity(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
now := time.Unix(1_800_000_000, 0)
claims := Claims{Version: 1, Customer: "Example", Plan: "pro", Features: []string{FeatureCustomBadge}, IssuedAt: now.Add(-2 * time.Hour).Unix(), ExpiresAt: now.Add(-time.Hour).Unix()}
// Sign validates relative to issuedAt, so an already-expired token can still be created for the verification test.
token, err := Sign(priv, claims)
if err != nil {
t.Fatal(err)
}
m := New(EncodeKey(pub), token, "https://example.org", false, now)
if m.Status().Edition != "community" || m.Has(FeatureCustomBadge) {
t.Fatalf("unexpected status: %#v", m.Status())
}
}
func TestGlobalDomainWildcardAllowsAnyHost(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
now := time.Unix(1_800_000_000, 0)
claims := Claims{
Version: 1, Customer: "Global", Plan: "pro",
Features: []string{FeatureCustomBadge}, Domains: []string{"*"},
IssuedAt: now.Add(-time.Hour).Unix(), ExpiresAt: now.Add(24 * time.Hour).Unix(),
}
token, err := Sign(priv, claims)
if err != nil {
t.Fatal(err)
}
for _, baseURL := range []string{"https://example.org", "https://ai.customer.test", "http://localhost:8080"} {
m := New(EncodeKey(pub), token, baseURL, false, now)
if !m.Has(FeatureCustomBadge) || m.Status().Edition != "pro" {
t.Fatalf("global wildcard did not allow %s: %#v", baseURL, m.Status())
}
}
}

View File

@@ -43,65 +43,44 @@ type FAQ struct {
Answer string
}
type Prices struct {
Community string
Pro string
Publisher string
Agency string
}
type Page struct {
MetaDescription string
GeneratorURL string
SalesURL string
NavFeatures string
NavPricing string
NavInstall string
NavGenerator string
NavBackground string
LanguageLabel string
HeroEyebrow string
HeroTitle string
HeroLead string
PrimaryCTA string
SecondaryCTA string
Proof []string
FeaturesEyebrow string
FeaturesTitle string
FeaturesLead string
Features []Feature
CompareEyebrow string
CompareTitle string
CompareLead string
CompareFeature string
CompareCommunity string
ComparePro string
Comparison []ComparisonRow
PricingEyebrow string
PricingTitle string
PricingLead string
PricePeriod string
PriceNote string
Plans []Plan
InstallEyebrow string
InstallTitle string
InstallLead string
InstallMethods []InstallMethod
CopyLabel string
CopiedLabel string
ConfigTitle string
ConfigVariable string
ConfigDefault string
ConfigMeaning string
Config []ConfigRow
FAQEyebrow string
FAQTitle string
FAQs []FAQ
FinalTitle string
FinalLead string
FinalPrimary string
FinalSecondary string
Footer string
MetaDescription string
GeneratorURL string
NavFeatures string
NavPricing string
NavInstall string
NavGenerator string
NavBackground string
LanguageLabel string
HeroEyebrow string
HeroTitle string
HeroLead string
PrimaryCTA string
SecondaryCTA string
Proof []string
FeaturesEyebrow string
FeaturesTitle string
FeaturesLead string
Features []Feature
InstallEyebrow string
InstallTitle string
InstallLead string
InstallMethods []InstallMethod
CopyLabel string
CopiedLabel string
ConfigTitle string
ConfigVariable string
ConfigDefault string
ConfigMeaning string
Config []ConfigRow
FAQEyebrow string
FAQTitle string
FAQs []FAQ
FinalTitle string
FinalLead string
FinalPrimary string
FinalSecondary string
Footer string
}
type copySet struct {
@@ -128,7 +107,7 @@ type copySet struct {
EnterprisePrice string
}
func Build(lang, productName, baseURL, salesURL, contactURL string, prices Prices) Page {
func Build(lang, productName, baseURL, contactURL string) Page {
lang = normalize(lang)
copy := copies()[lang]
if copy.HeroTitle == "" {
@@ -137,68 +116,32 @@ func Build(lang, productName, baseURL, salesURL, contactURL string, prices Price
}
copy = neutralize(copy, lang)
baseURL = strings.TrimRight(baseURL, "/")
if salesURL == "" {
salesURL = contactURL
}
if salesURL == "" {
salesURL = baseURL + "/?lang=" + lang
}
if contactURL == "" {
contactURL = baseURL + "/?lang=" + lang
}
if prices.Community == "" {
prices.Community = "0 €"
}
if prices.Pro == "" {
prices.Pro = "19 €"
}
if prices.Publisher == "" {
prices.Publisher = "79 €"
}
if prices.Agency == "" {
prices.Agency = "199 €"
}
generatorURL := baseURL + "/?lang=" + lang + "#generator"
planNames := []string{"Community", "Pro", "Publisher", "Agency", "Enterprise"}
planPrices := []string{prices.Community, prices.Pro, prices.Publisher, prices.Agency, copy.EnterprisePrice}
plans := make([]Plan, 0, len(planNames))
for i := range planNames {
url := salesURL
if i == 0 {
url = generatorURL
}
period := copy.PricePeriod
if i == 4 {
period = ""
}
plans = append(plans, Plan{
Name: planNames[i], Price: planPrices[i], Period: period,
Description: copy.PlanDescriptions[i], Features: copy.PlanFeatures[i], CTA: copy.PlanCTA[i], URL: url, Featured: i == 1,
})
}
install := []InstallMethod{
{ID: "compose", Title: copy.InstallTitles[0], Summary: copy.InstallSummaries[0], Code: `cp .env.example .env
# Adjust BASE_URL and PUBLIC_NAME in .env
docker compose up -d --build
curl -fsS http://localhost:8080/readyz`},
{ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:1.6.3 .
{ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:2.0.2 .
docker run -d --name ai-disclosure \
-p 8080:8080 \
-e BASE_URL=https://ai.example.org \
-e PUBLIC_NAME="AI Usage Disclosure" \
--read-only --tmpfs /tmp \
ai-disclosure-standard:1.6.3`},
{ID: "kubernetes", Title: copy.InstallTitles[2], Summary: copy.InstallSummaries[2], Code: `# Image, Domain und TLS-Secret in deploy/kubernetes.yaml ersetzen
kubectl create secret generic ai-disclosure-license \
--from-literal=token='...'
ai-disclosure-standard:2.0.2`},
{ID: "kubernetes", Title: copy.InstallTitles[2], Summary: copy.InstallSummaries[2], Code: `# Image, domain and TLS secret in deploy/kubernetes.yaml ersetzen
kubectl apply -f deploy/kubernetes.yaml
kubectl rollout status deployment/ai-disclosure`},
kubectl rollout status deployment/ai-disclosure
# Licensed bulk service (optional)
kubectl apply -f deploy/kubernetes-bulk.yaml`},
{ID: "swarm", Title: copy.InstallTitles[3], Summary: copy.InstallSummaries[3], Code: `docker swarm init
export LICENSE_TOKEN='...'
export LICENSE_MODE='offline'
docker stack deploy -c deploy/swarm-stack.yaml ai-disclosure`},
docker stack deploy -c deploy/swarm-stack.yaml ai-disclosure
# Optional licensed bulk deployment: deploy/swarm-bulk-stack.yaml`},
{ID: "go", Title: copy.InstallTitles[4], Summary: copy.InstallSummaries[4], Code: `go test ./...
go build -trimpath -o bin/server ./cmd/server
BASE_URL=http://localhost:8080 \
@@ -206,21 +149,23 @@ PUBLIC_NAME="AI Usage Disclosure" \
./bin/server`},
}
configNames := []string{"LISTEN_ADDRESS", "BASE_URL", "PUBLIC_NAME", "CONTACT_URL", "DEFAULT_LANGUAGE", "LICENSE_TOKEN", "LICENSE_MODE", "LICENSE_SERVER_URL"}
configDefaults := []string{":8080", "http://localhost:8080", productName, contactURL, "de", "-", "offline", "-"}
configNames := []string{"LISTEN_ADDRESS", "BASE_URL", "PUBLIC_NAME", "CONTACT_URL", "DEFAULT_LANGUAGE", "SERVICE_MODE", "LICENSE_TOKEN", "LICENSE_MODE", "LICENSE_SERVER_URL", "API_ALLOWED_ORIGIN"}
configDefaults := []string{":8080", "http://localhost:8080", productName, contactURL, "de", "full", "", "offline", "", "*"}
configDescriptions := configurationDescriptions(lang)
configRows := make([]ConfigRow, 0, len(configNames))
for i := range configNames {
configRows = append(configRows, ConfigRow{Name: configNames[i], Default: configDefaults[i], Description: configDescriptions[i]})
description := ""
if i < len(configDescriptions) {
description = configDescriptions[i]
}
configRows = append(configRows, ConfigRow{Name: configNames[i], Default: configDefaults[i], Description: description})
}
return Page{
MetaDescription: copy.MetaDescription, GeneratorURL: generatorURL, SalesURL: salesURL,
NavFeatures: copy.NavFeatures, NavPricing: copy.NavPricing, NavInstall: copy.NavInstall, NavGenerator: copy.NavGenerator, NavBackground: copy.NavBackground, LanguageLabel: copy.LanguageLabel,
MetaDescription: copy.MetaDescription, GeneratorURL: generatorURL,
NavFeatures: copy.NavFeatures, NavInstall: copy.NavInstall, NavGenerator: copy.NavGenerator, NavBackground: copy.NavBackground, LanguageLabel: copy.LanguageLabel,
HeroEyebrow: copy.HeroEyebrow, HeroTitle: copy.HeroTitle, HeroLead: copy.HeroLead, PrimaryCTA: copy.PrimaryCTA, SecondaryCTA: copy.SecondaryCTA, Proof: copy.Proof,
FeaturesEyebrow: copy.FeaturesEyebrow, FeaturesTitle: copy.FeaturesTitle, FeaturesLead: copy.FeaturesLead, Features: copy.Features,
CompareEyebrow: copy.CompareEyebrow, CompareTitle: copy.CompareTitle, CompareLead: copy.CompareLead, CompareFeature: copy.CompareFeature, CompareCommunity: copy.CompareCommunity, ComparePro: copy.ComparePro, Comparison: copy.Comparison,
PricingEyebrow: copy.PricingEyebrow, PricingTitle: copy.PricingTitle, PricingLead: copy.PricingLead, PricePeriod: copy.PricePeriod, PriceNote: copy.PriceNote, Plans: plans,
InstallEyebrow: copy.InstallEyebrow, InstallTitle: copy.InstallTitle, InstallLead: copy.InstallLead, InstallMethods: install, CopyLabel: copy.CopyLabel, CopiedLabel: copy.CopiedLabel,
ConfigTitle: copy.ConfigTitle, ConfigVariable: copy.ConfigVariable, ConfigDefault: copy.ConfigDefault, ConfigMeaning: copy.ConfigMeaning, Config: configRows,
FAQEyebrow: copy.FAQEyebrow, FAQTitle: copy.FAQTitle, FAQs: copy.FAQs,
@@ -228,16 +173,16 @@ PUBLIC_NAME="AI Usage Disclosure" \
}
}
func configurationDescriptions(lang string) [8]string {
values := map[string][8]string{
"de": {"Bind-Adresse des HTTP-Servers.", "Öffentliche Basis-URL ohne abschließenden Slash.", "Produkt- oder Seitentitel.", "Optionale externe Kontakt- oder Projektseite.", "Rückfallsprache der Ausgabe.", "Vom Anbieter signierter Lizenz-Token.", "Mindestmodus: offline, hybrid oder online.", "Optionaler zentraler Prüfserver für Hybrid- und Online-Modus."},
"en": {"HTTP server bind address.", "Public base URL without a trailing slash.", "Product or site title.", "Optional external contact or project page.", "Fallback output language.", "Vendor-signed licence token.", "Minimum mode: offline, hybrid or online.", "Optional central verification server for hybrid and online mode."},
"fr": {"Adresse d'écoute du serveur HTTP.", "URL publique sans barre oblique finale.", "Nom du produit ou du site.", "Page externe facultative de contact ou de projet.", "Langue de secours.", "Jeton de licence signé par l'éditeur.", "Mode minimal : offline, hybrid ou online.", "Serveur central facultatif pour les modes hybride et en ligne."},
"es": {"Dirección de escucha del servidor HTTP.", "URL pública sin barra final.", "Nombre del producto o sitio.", "Página externa opcional de contacto o del proyecto.", "Idioma de reserva.", "Token de licencia firmado por el proveedor.", "Modo mínimo: offline, hybrid u online.", "Servidor central opcional para los modos híbrido y en línea."},
"it": {"Indirizzo di ascolto del server HTTP.", "URL pubblica senza slash finale.", "Nome del prodotto o sito.", "Pagina esterna facoltativa di contatto o del progetto.", "Lingua di fallback.", "Token di licenza firmato dal fornitore.", "Modalità minima: offline, hybrid o online.", "Server centrale opzionale per modalità ibrida e online."},
"nl": {"Luisteradres van de HTTP-server.", "Publieke basis-URL zonder afsluitende slash.", "Product- of sitenaam.", "Optionele externe contact- of projectpagina.", "Terugvaltaal.", "Door de leverancier ondertekend licentietoken.", "Minimale modus: offline, hybrid of online.", "Optionele centrale controleserver voor hybride en online modus."},
"pt": {"Endereço de escuta do servidor HTTP.", "URL pública sem barra final.", "Nome do produto ou site.", "Página externa opcional de contacto ou do projeto.", "Idioma de fallback.", "Token de licença assinado pelo fornecedor.", "Modo mínimo: offline, hybrid ou online.", "Servidor central opcional para os modos híbrido e online."},
"pl": {"Adres nasłuchiwania serwera HTTP.", "Publiczny bazowy URL bez końcowego ukośnika.", "Nazwa produktu lub witryny.", "Opcjonalna zewnętrzna strona kontaktowa lub projektu.", "Język zapasowy.", "Token licencji podpisany przez dostawcę.", "Minimalny tryb: offline, hybrid lub online.", "Opcjonalny centralny serwer weryfikacji dla trybu hybrydowego i online."},
func configurationDescriptions(lang string) []string {
values := map[string][]string{
"de": {"Bind-Adresse des HTTP-Servers.", "Öffentliche Basis-URL ohne abschließenden Slash.", "Produkt- oder Seitentitel.", "Optionale externe Kontakt- oder Projektseite.", "Rückfallsprache der Ausgabe.", "Betriebsmodus: full, api oder bulk.", "Vom Anbieter signierter Lizenz-Token.", "Mindestmodus: offline, hybrid oder online.", "Optionaler zentraler Prüfserver für Hybrid- und Online-Modus.", "Erlaubter CORS-Origin für API-Endpunkte; leer deaktiviert CORS."},
"en": {"HTTP server bind address.", "Public base URL without a trailing slash.", "Product or site title.", "Optional external contact or project page.", "Fallback output language.", "Runtime mode: full, api or bulk.", "Vendor-signed licence token.", "Minimum mode: offline, hybrid or online.", "Optional central verification server for hybrid and online mode.", "Allowed CORS origin for API endpoints; empty disables CORS."},
"fr": {"Adresse découte du serveur HTTP.", "URL publique sans barre oblique finale.", "Nom du produit ou du site.", "Page externe facultative de contact ou de projet.", "Langue de secours.", "Mode dexécution : full, api ou bulk.", "Jeton de licence signé par léditeur.", "Mode minimal : offline, hybrid ou online.", "Serveur central facultatif pour les modes hybride et en ligne.", "Origine CORS autorisée pour lAPI ; vide désactive CORS."},
"es": {"Dirección de escucha del servidor HTTP.", "URL pública sin barra final.", "Nombre del producto o sitio.", "Página externa opcional de contacto o del proyecto.", "Idioma de reserva.", "Modo de ejecución: full, api o bulk.", "Token de licencia firmado por el proveedor.", "Modo mínimo: offline, hybrid u online.", "Servidor central opcional para los modos híbrido y en línea.", "Origen CORS permitido para la API; vacío desactiva CORS."},
"it": {"Indirizzo di ascolto del server HTTP.", "URL pubblica senza slash finale.", "Nome del prodotto o sito.", "Pagina esterna facoltativa di contatto o del progetto.", "Lingua di fallback.", "Modalità di esecuzione: full, api o bulk.", "Token di licenza firmato dal fornitore.", "Modalità minima: offline, hybrid o online.", "Server centrale opzionale per modalità ibrida e online.", "Origine CORS consentita per lAPI; vuoto disabilita CORS."},
"nl": {"Luisteradres van de HTTP-server.", "Publieke basis-URL zonder afsluitende slash.", "Product- of sitenaam.", "Optionele externe contact- of projectpagina.", "Terugvaltaal.", "Uitvoermodus: full, api of bulk.", "Door de leverancier ondertekend licentietoken.", "Minimale modus: offline, hybrid of online.", "Optionele centrale controleserver voor hybride en online modus.", "Toegestane CORS-origin voor API-endpoints; leeg schakelt CORS uit."},
"pt": {"Endereço de escuta do servidor HTTP.", "URL pública sem barra final.", "Nome do produto ou site.", "Página externa opcional de contacto ou do projeto.", "Idioma de fallback.", "Modo de execução: full, api ou bulk.", "Token de licença assinado pelo fornecedor.", "Modo mínimo: offline, hybrid ou online.", "Servidor central opcional para os modos híbrido e online.", "Origem CORS permitida para a API; vazio desativa CORS."},
"pl": {"Adres nasłuchiwania serwera HTTP.", "Publiczny bazowy URL bez końcowego ukośnika.", "Nazwa produktu lub witryny.", "Opcjonalna zewnętrzna strona kontaktowa lub projektu.", "Język zapasowy.", "Tryb pracy: full, api lub bulk.", "Token licencji podpisany przez dostawcę.", "Minimalny tryb: offline, hybrid lub online.", "Opcjonalny centralny serwer weryfikacji dla trybu hybrydowego i online.", "Dozwolony origin CORS dla API; pusty wyłącza CORS."},
}
if descriptions, ok := values[normalize(lang)]; ok {
return descriptions
@@ -260,9 +205,9 @@ func neutralize(c copySet, lang string) copySet {
case "de":
c.MetaDescription = "Funktionen und Installation des offenen Standards für KI-Nutzungserklärungen."
c.HeroEyebrow = "Open Source · Selbst hostbar"
c.HeroLead = "Ein zustandsloser Go-Dienst für SVG-Badges, verständliche Erklärungseiten und JSON-LD - internationalisiert und für hochverfügbare Deployments ausgelegt."
c.HeroLead = "Ein zustandsloser Go-Dienst für SVG-Badges, verständliche Erklärungseiten, JSON-LD und nicht bindende Artikel-50-Entscheidungsunterstützung internationalisiert und für hochverfügbare Deployments ausgelegt."
c.FeaturesLead = "Sichtbare Hinweise, verständliche Zusammenfassungen und maschinenlesbare Deklarationen in einer schlanken Anwendung."
c.InstallLead = "Alle Varianten verwenden dasselbe Go-Binary und können ohne externe Dienste betrieben werden."
c.InstallLead = "Die offene Anwendung kann vollständig selbst gehostet werden; für lizenzierte Betriebsfunktionen stehen API-, Bulk- und Hybrid-Verifikationsmodi bereit."
c.FinalTitle = "Direkt mit einer eigenen Erklärung starten."
c.FinalLead = "Der Generator funktioniert ohne Registrierung, Cookies oder externe Ressourcen."
case "fr":
@@ -291,7 +236,7 @@ func neutralize(c copySet, lang string) copySet {
c.InstallLead = "Wszystkie warianty korzystają z tego samego pliku binarnego Go i działają bez usług zewnętrznych."
default:
c.HeroEyebrow = "Open source · Self-hostable"
c.FeaturesLead = "Visible notices, readable summaries and machine-readable declarations in one lightweight application."
c.FeaturesLead = "Visible notices, readable summaries, machine-readable declarations and non-binding Article 50 decision support in one lightweight application."
c.InstallLead = "Every deployment uses the same Go binary and can run without external services."
c.FinalTitle = "Start with your own declaration."
c.FinalLead = "The generator works without registration, cookies or external resources."
@@ -321,7 +266,7 @@ func commonComparison(lang, yes, no, included, proOnly string) []ComparisonRow {
"fr": {"Badges SVG", "Pages de déclaration HTML", "JSON-LD et validation de schéma", "8 langues", "Docker, Kubernetes et Swarm", "Textes de déclaration personnalisés", "Libellés et couleurs personnalisés", "Licence hors ligne signée", "Activation liée au domaine", "Support commercial"},
"es": {"Insignias SVG", "Páginas de declaración HTML", "JSON-LD y validación de esquema", "8 idiomas", "Docker, Kubernetes y Swarm", "Textos de declaración propios", "Etiquetas y colores propios", "Licencia offline firmada", "Activación ligada al dominio", "Soporte comercial"},
"it": {"Badge SVG", "Pagine di dichiarazione HTML", "JSON-LD e validazione schema", "8 lingue", "Docker, Kubernetes e Swarm", "Testi di dichiarazione personalizzati", "Etichette e colori personalizzati", "Licenza offline firmata", "Attivazione legata al dominio", "Supporto commerciale"},
"nl": {"SVG-badges", "HTML-verklaringspagina's", "JSON-LD en schemavalidatie", "8 talen", "Docker, Kubernetes en Swarm", "Eigen verklaringsteksten", "Eigen labels en kleuren", "Ondertekende offline licentie", "Domeingebonden activatie", "Commerciële support"},
"nl": {"SVG-badges", "HTML-verklaringspaginas", "JSON-LD en schemavalidatie", "8 talen", "Docker, Kubernetes en Swarm", "Eigen verklaringsteksten", "Eigen labels en kleuren", "Ondertekende offline licentie", "Domeingebonden activatie", "Commerciële support"},
"pt": {"Badges SVG", "Páginas de declaração HTML", "JSON-LD e validação de esquema", "8 idiomas", "Docker, Kubernetes e Swarm", "Textos de declaração próprios", "Etiquetas e cores próprias", "Licença offline assinada", "Ativação ligada ao domínio", "Suporte comercial"},
"pl": {"Plakietki SVG", "Strony deklaracji HTML", "JSON-LD i walidacja schematu", "8 języków", "Docker, Kubernetes i Swarm", "Własne teksty deklaracji", "Własne etykiety i kolory", "Podpisana licencja offline", "Aktywacja związana z domeną", "Wsparcie komercyjne"},
}
@@ -342,7 +287,7 @@ func german() copySet {
return copySet{
MetaDescription: "Produkt, Preise und Installation für den offenen KI-Nutzungsstandard.",
NavFeatures: "Features", NavPricing: "Preise", NavInstall: "Installation", NavGenerator: "Generator", NavBackground: "Hintergrund", LanguageLabel: "Sprache",
HeroEyebrow: "Open Source im Kern · Pro bei Bedarf", HeroTitle: "KI-Nutzung transparent kennzeichnen - ohne Plattformzwang.",
HeroEyebrow: "Open Source im Kern · Pro bei Bedarf", HeroTitle: "KI-Nutzung transparent kennzeichnen ohne Plattformzwang.",
HeroLead: "Ein zustandsloser Go-Dienst für SVG-Badges, verständliche Erklärungseiten und JSON-LD. Selbst hostbar, internationalisiert und für hochverfügbare Deployments gebaut.",
PrimaryCTA: "Badge erstellen", SecondaryCTA: "Pro anfragen", Proof: []string{"8 Sprachen", "Keine Cookies", "Keine Datenbank", "Docker & Kubernetes"},
FeaturesEyebrow: "Funktionsumfang", FeaturesTitle: "Vom sichtbaren Hinweis bis zum maschinenlesbaren Nachweis.", FeaturesLead: "Die Community-Ausgabe deckt die offene Integration ab. Pro ergänzt individuelle Darstellung und kommerzielle Lizenzierung.",
@@ -351,13 +296,13 @@ func german() copySet {
{"Verständlich", "Erklärungseiten", "Menschenlesbare Seiten erläutern KI-Anteil, Tätigkeit, Prüfung und Verantwortung."},
{"Maschinenlesbar", "JSON-LD und Schema", "Manifeste lassen sich verlinken, validieren und in Build- oder CMS-Prozesse übernehmen."},
{"International", "Acht Sprachen", "Deutsch, Englisch, Französisch, Spanisch, Italienisch, Niederländisch, Portugiesisch und Polnisch."},
{"Pro", "Eigene Texte und Designs", "Eigene Titel, Beschreibungen, Badge-Texte und Farben - serverseitig geschützt."},
{"Pro", "Eigene Texte und Designs", "Eigene Titel, Beschreibungen, Badge-Texte und Farben serverseitig geschützt."},
{"Offline", "Signierte Lizenz", "Ed25519-Lizenzen werden lokal geprüft; ein externer Lizenzserver ist nicht erforderlich."},
{"Skalierbar", "High Availability", "Zustandslose Replikate, Readiness-Checks, HPA, Swarm-Replikation und CDN-freundliches Caching."},
{"Datensparsam", "Privacy by default", "Keine Cookies, keine Sessions, keine externen Assets und kein verpflichtendes Tracking."},
},
CompareEyebrow: "Editionen", CompareTitle: "Offener Standard oder individuelle Markenintegration.", CompareLead: "Alle Kernformate bleiben frei nutzbar. Pro schaltet ausschließlich die kommerzielle Anpassung frei.", CompareFeature: "Funktion", CompareCommunity: "Community", ComparePro: "Pro",
Comparison: commonComparison("de", "Enthalten", "-", "Enthalten", "Je nach Tarif"),
Comparison: commonComparison("de", "Enthalten", "", "Enthalten", "Je nach Tarif"),
PricingEyebrow: "Preise", PricingTitle: "Einfach nach Einsatzbreite skalieren.", PricingLead: "Die Software bleibt selbst hostbar. Bezahlte Tarife lizenzieren Custom-Funktionen, Domains und Support.", PricePeriod: "/ Monat", PriceNote: "Einführungspreise bei jährlicher Abrechnung, zuzüglich gesetzlicher Steuern. Enterprise-Angebote werden individuell vereinbart.",
PlanDescriptions: [5]string{"Für Open-Source-Nutzung und Standardkennzeichnungen.", "Für einzelne professionelle Websites mit eigener Darstellung.", "Für Publisher mit mehreren Marken oder Portalen.", "Für Agenturen und wiederkehrende Kundendeployments.", "Für individuelle Vertrags-, SLA- und Deployment-Anforderungen."},
PlanFeatures: [5][]string{
@@ -401,7 +346,7 @@ func english() copySet {
{"Scalable", "High availability", "Stateless replicas, readiness checks, HPA, Swarm replicas and CDN-friendly caching."},
{"Privacy-first", "No tracking required", "No cookies, sessions, external assets or mandatory analytics."},
},
CompareEyebrow: "Editions", CompareTitle: "Open standard or branded integration.", CompareLead: "All core formats remain free. Pro only unlocks commercial customisation.", CompareFeature: "Feature", CompareCommunity: "Community", ComparePro: "Pro", Comparison: commonComparison("en", "Included", "-", "Included", "By plan"),
CompareEyebrow: "Editions", CompareTitle: "Open standard or branded integration.", CompareLead: "All core formats remain free. Pro only unlocks commercial customisation.", CompareFeature: "Feature", CompareCommunity: "Community", ComparePro: "Pro", Comparison: commonComparison("en", "Included", "", "Included", "By plan"),
PricingEyebrow: "Pricing", PricingTitle: "Scale with the breadth of your deployment.", PricingLead: "The software remains self-hostable. Paid plans license custom capabilities, domains and support.", PricePeriod: "/ month", PriceNote: "Introductory prices with annual billing, excluding applicable taxes. Enterprise terms are agreed individually.",
PlanDescriptions: [5]string{"For open-source use and standard declarations.", "For individual professional sites with custom presentation.", "For publishers operating several brands or portals.", "For agencies deploying repeatedly for clients.", "For custom contract, SLA and deployment requirements."},
PlanFeatures: [5][]string{{"Standard presets", "All 8 languages", "SVG, HTML and JSON-LD", "Self-hosting"}, {"Up to 3 domains", "Custom declaration copy", "Custom badge labels and colours", "Signed offline licence"}, {"Up to 20 domains", "All Pro capabilities", "Priority support", "Migration assistance"}, {"Up to 100 domains", "Client domains", "Commercial agency use", "Technical onboarding"}, {"Custom domain count", "SLA and support windows", "Private distribution", "Custom licensing terms"}},
@@ -416,32 +361,32 @@ func english() copySet {
func french() copySet {
c := english()
c.MetaDescription = "Produit, tarifs et installation de la norme ouverte de déclaration d'utilisation de l'IA."
c.MetaDescription = "Produit, tarifs et installation de la norme ouverte de déclaration dutilisation de lIA."
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Fonctions", "Tarifs", "Installation", "Générateur", "Contexte", "Langue"
c.HeroEyebrow, c.HeroTitle = "Cœur open source · Pro si nécessaire", "Déclarez lusage de lIA sans dépendre dune plateforme."
c.HeroLead = "Un service Go sans état pour badges SVG, pages explicatives et JSON-LD. Auto-hébergeable, internationalisé et conçu pour la haute disponibilité."
c.PrimaryCTA, c.SecondaryCTA = "Créer un badge", "Contacter Pro"
c.Proof = []string{"8 langues", "Sans cookies", "Sans base de données", "Docker & Kubernetes"}
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Fonctions", "Du signal visible à la déclaration lisible par machine.", "Community couvre l'intégration ouverte. Pro ajoute la personnalisation et la licence commerciale."
c.Features = []Feature{{"Visible", "Badges SVG", "Badges déterministes et mis en cache à partir de modèles ou de paramètres structurés."}, {"Compréhensible", "Pages de déclaration", "Elles expliquent la contribution de l'IA, les activités, la vérification et la responsabilité."}, {"Lisible par machine", "JSON-LD et schéma", "Manifeste utilisable dans les workflows de build et de CMS."}, {"International", "Huit langues", "Allemand, anglais, français, espagnol, italien, néerlandais, portugais et polonais."}, {"Pro", "Textes et design personnalisés", "Titres, descriptions, libellés et couleurs protégés côté serveur."}, {"Hors ligne", "Licence signée", "Vérification locale Ed25519 sans serveur de licence externe."}, {"Évolutif", "Haute disponibilité", "Répliques sans état, probes, HPA, Swarm et cache CDN."}, {"Respectueux", "Sans suivi obligatoire", "Sans cookies, sessions, ressources externes ni analytics obligatoires."}}
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Fonctions", "Du signal visible à la déclaration lisible par machine.", "Community couvre lintégration ouverte. Pro ajoute la personnalisation et la licence commerciale."
c.Features = []Feature{{"Visible", "Badges SVG", "Badges déterministes et mis en cache à partir de modèles ou de paramètres structurés."}, {"Compréhensible", "Pages de déclaration", "Elles expliquent la contribution de lIA, les activités, la vérification et la responsabilité."}, {"Lisible par machine", "JSON-LD et schéma", "Manifeste utilisable dans les workflows de build et de CMS."}, {"International", "Huit langues", "Allemand, anglais, français, espagnol, italien, néerlandais, portugais et polonais."}, {"Pro", "Textes et design personnalisés", "Titres, descriptions, libellés et couleurs protégés côté serveur."}, {"Hors ligne", "Licence signée", "Vérification locale Ed25519 sans serveur de licence externe."}, {"Évolutif", "Haute disponibilité", "Répliques sans état, probes, HPA, Swarm et cache CDN."}, {"Respectueux", "Sans suivi obligatoire", "Sans cookies, sessions, ressources externes ni analytics obligatoires."}}
c.CompareEyebrow, c.CompareTitle, c.CompareLead = "Éditions", "Norme ouverte ou intégration à votre marque.", "Les formats essentiels restent gratuits. Pro déverrouille uniquement la personnalisation commerciale."
c.CompareFeature, c.CompareCommunity, c.ComparePro = "Fonction", "Community", "Pro"
c.Comparison = commonComparison("fr", "Inclus", "-", "Inclus", "Selon l'offre")
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Tarifs", "Adaptez le prix à l'étendue du déploiement.", "Le logiciel reste auto-hébergeable. Les offres payantes couvrent les personnalisations, domaines et support."
c.Comparison = commonComparison("fr", "Inclus", "", "Inclus", "Selon loffre")
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Tarifs", "Adaptez le prix à létendue du déploiement.", "Le logiciel reste auto-hébergeable. Les offres payantes couvrent les personnalisations, domaines et support."
c.PricePeriod, c.PriceNote = "/ mois", "Tarifs de lancement avec facturation annuelle, hors taxes applicables. Conditions Enterprise sur devis."
c.PlanDescriptions = [5]string{"Pour l'open source et les déclarations standard.", "Pour quelques sites professionnels personnalisés.", "Pour les éditeurs avec plusieurs marques ou portails.", "Pour les agences et les déploiements clients répétés.", "Pour les exigences contractuelles, SLA et déploiements spécifiques."}
c.PlanFeatures = [5][]string{{"Modèles standard", "8 langues", "SVG, HTML et JSON-LD", "Auto-hébergement"}, {"Jusqu'à 3 domaines", "Textes personnalisés", "Libellés et couleurs personnalisés", "Licence hors ligne signée"}, {"Jusqu'à 20 domaines", "Toutes les fonctions Pro", "Support prioritaire", "Aide à la migration"}, {"Jusqu'à 100 domaines", "Domaines clients", "Usage commercial agence", "Onboarding technique"}, {"Nombre de domaines sur mesure", "SLA et fenêtres de support", "Distribution privée", "Conditions sur mesure"}}
c.PlanDescriptions = [5]string{"Pour lopen source et les déclarations standard.", "Pour quelques sites professionnels personnalisés.", "Pour les éditeurs avec plusieurs marques ou portails.", "Pour les agences et les déploiements clients répétés.", "Pour les exigences contractuelles, SLA et déploiements spécifiques."}
c.PlanFeatures = [5][]string{{"Modèles standard", "8 langues", "SVG, HTML et JSON-LD", "Auto-hébergement"}, {"Jusquà 3 domaines", "Textes personnalisés", "Libellés et couleurs personnalisés", "Licence hors ligne signée"}, {"Jusquà 20 domaines", "Toutes les fonctions Pro", "Support prioritaire", "Aide à la migration"}, {"Jusquà 100 domaines", "Domaines clients", "Usage commercial agence", "Onboarding technique"}, {"Nombre de domaines sur mesure", "SLA et fenêtres de support", "Distribution privée", "Conditions sur mesure"}}
c.PlanCTA = [5]string{"Commencer gratuitement", "Contacter Pro", "Contacter Publisher", "Contacter Agency", "Contacter les ventes"}
c.EnterprisePrice = "Sur devis"
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Déploiement", "Prêt pour la production en quelques commandes.", "Toutes les options exécutent le même binaire Go. Community et Pro ne diffèrent que par les secrets de licence facultatifs."
c.CopyLabel, c.CopiedLabel = "Copier", "Copié"
c.InstallSummaries = [5]string{"Configuration déclarative locale ou mono-serveur.", "Construire l'image et lancer directement un conteneur renforcé.", "Trois répliques, probes, rolling updates, HPA et PodDisruptionBudget.", "Plusieurs répliques avec l'orchestrateur natif Docker.", "Pour le développement, les tests ou un service systemd natif."}
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Variables d'environnement importantes", "Variable", "Valeur par défaut", "Rôle"
c.ConfigDescriptions = [8]string{"Adresse d'écoute HTTP.", "URL publique sans barre oblique finale.", "Nom du produit ou du site.", "Page externe facultative de contact ou de projet.", "Destination des demandes Pro et commerciales.", "Langue de repli.", "Clé publique Ed25519 pour Pro.", "Jeton Pro signé et éventuellement lié au domaine."}
c.InstallSummaries = [5]string{"Configuration déclarative locale ou mono-serveur.", "Construire limage et lancer directement un conteneur renforcé.", "Trois répliques, probes, rolling updates, HPA et PodDisruptionBudget.", "Plusieurs répliques avec lorchestrateur natif Docker.", "Pour le développement, les tests ou un service systemd natif."}
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Variables denvironnement importantes", "Variable", "Valeur par défaut", "Rôle"
c.ConfigDescriptions = [8]string{"Adresse découte HTTP.", "URL publique sans barre oblique finale.", "Nom du produit ou du site.", "Page externe facultative de contact ou de projet.", "Destination des demandes Pro et commerciales.", "Langue de repli.", "Clé publique Ed25519 pour Pro.", "Jeton Pro signé et éventuellement lié au domaine."}
c.FAQEyebrow, c.FAQTitle = "FAQ", "Questions fréquentes"
c.FAQs = []FAQ{{"Community est-elle limitée artificiellement ?", "Non. Les modèles, huit langues, SVG, pages, JSON-LD, validation et fichiers de déploiement sont complets. Seule la personnalisation commerciale nécessite Pro."}, {"Pro dépend-il d'un serveur de licence ?", "Non. Chaque réplique vérifie localement la signature Ed25519."}, {"Puis-je utiliser un CDN ?", "Oui. Les badges et manifestes sont déterministes et utilisent ETag et Cache-Control."}, {"Cela garantit-il la conformité juridique ?", "Non. Le service fournit une infrastructure technique et ne remplace ni l'analyse juridique ni la responsabilité éditoriale."}}
c.FAQs = []FAQ{{"Community est-elle limitée artificiellement ?", "Non. Les modèles, huit langues, SVG, pages, JSON-LD, validation et fichiers de déploiement sont complets. Seule la personnalisation commerciale nécessite Pro."}, {"Pro dépend-il dun serveur de licence ?", "Non. Chaque réplique vérifie localement la signature Ed25519."}, {"Puis-je utiliser un CDN ?", "Oui. Les badges et manifestes sont déterministes et utilisent ETag et Cache-Control."}, {"Cela garantit-il la conformité juridique ?", "Non. Le service fournit une infrastructure technique et ne remplace ni lanalyse juridique ni la responsabilité éditoriale."}}
c.FinalTitle, c.FinalLead, c.FinalPrimary, c.FinalSecondary = "Commencez ouvert. Ajoutez votre marque uniquement si nécessaire.", "Le générateur fonctionne sans inscription. Pro ajoute les textes, couleurs et domaines personnalisés.", "Ouvrir le générateur", "Discuter de Pro"
c.Footer = "Norme ouverte de déclaration d'utilisation de l'IA · Pas un conseil juridique."
c.Footer = "Norme ouverte de déclaration dutilisation de lIA · Pas un conseil juridique."
return c
}
@@ -457,7 +402,7 @@ func spanish() copySet {
c.Features = []Feature{{"Visible", "Insignias SVG", "Insignias deterministas y cacheables desde preajustes o parámetros estructurados."}, {"Comprensible", "Páginas de declaración", "Explican la contribución de la IA, las actividades, la revisión y la responsabilidad."}, {"Legible por máquina", "JSON-LD y esquema", "Manifiestos para procesos de build y CMS."}, {"Internacional", "Ocho idiomas", "Alemán, inglés, francés, español, italiano, neerlandés, portugués y polaco."}, {"Pro", "Textos y diseño propios", "Títulos, descripciones, etiquetas y colores protegidos en el servidor."}, {"Sin conexión", "Licencia firmada", "Verificación Ed25519 local sin servidor externo."}, {"Escalable", "Alta disponibilidad", "Réplicas sin estado, probes, HPA, Swarm y caché CDN."}, {"Privacidad", "Sin seguimiento obligatorio", "Sin cookies, sesiones, recursos externos ni analítica obligatoria."}}
c.CompareEyebrow, c.CompareTitle, c.CompareLead = "Ediciones", "Estándar abierto o integración de marca.", "Los formatos básicos siguen siendo gratuitos. Pro solo desbloquea la personalización comercial."
c.CompareFeature, c.CompareCommunity, c.ComparePro = "Función", "Community", "Pro"
c.Comparison = commonComparison("es", "Incluido", "-", "Incluido", "Según plan")
c.Comparison = commonComparison("es", "Incluido", "", "Incluido", "Según plan")
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Precios", "Escala según el alcance del despliegue.", "El software sigue siendo autoalojable. Los planes de pago licencian personalización, dominios y soporte."
c.PricePeriod, c.PriceNote = "/ mes", "Precios de lanzamiento con facturación anual, impuestos no incluidos. Enterprise se acuerda individualmente."
c.PlanDescriptions = [5]string{"Para uso open source y declaraciones estándar.", "Para sitios profesionales con presentación propia.", "Para editores con varias marcas o portales.", "Para agencias con despliegues repetidos para clientes.", "Para requisitos de contrato, SLA y despliegue personalizados."}
@@ -478,18 +423,18 @@ func spanish() copySet {
func italian() copySet {
c := english()
c.MetaDescription = "Prodotto, prezzi e installazione dello standard aperto per dichiarare l'uso dell'IA."
c.MetaDescription = "Prodotto, prezzi e installazione dello standard aperto per dichiarare luso dellIA."
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Funzioni", "Prezzi", "Installazione", "Generatore", "Contesto", "Lingua"
c.HeroEyebrow, c.HeroTitle = "Core open source · Pro quando serve", "Dichiara l'uso dell'IA senza vincoli di piattaforma."
c.HeroLead = "Un servizio Go stateless per badge SVG, pagine esplicative e JSON-LD. Self-hosted, internazionale e pronto per l'alta disponibilità."
c.HeroEyebrow, c.HeroTitle = "Core open source · Pro quando serve", "Dichiara luso dellIA senza vincoli di piattaforma."
c.HeroLead = "Un servizio Go stateless per badge SVG, pagine esplicative e JSON-LD. Self-hosted, internazionale e pronto per lalta disponibilità."
c.PrimaryCTA, c.SecondaryCTA = "Crea un badge", "Contatta Pro"
c.Proof = []string{"8 lingue", "Nessun cookie", "Nessun database", "Docker e Kubernetes"}
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Funzioni", "Dall'avviso visibile alla dichiarazione leggibile dalle macchine.", "Community copre l'integrazione aperta. Pro aggiunge personalizzazione e licenza commerciale."
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Funzioni", "Dallavviso visibile alla dichiarazione leggibile dalle macchine.", "Community copre lintegrazione aperta. Pro aggiunge personalizzazione e licenza commerciale."
c.Features = []Feature{{"Visibile", "Badge SVG", "Badge deterministici e cacheabili da preset o parametri strutturati."}, {"Comprensibile", "Pagine di dichiarazione", "Spiegano contributo IA, attività, revisione e responsabilità."}, {"Machine-readable", "JSON-LD e schema", "Manifesti integrabili in build e CMS."}, {"Internazionale", "Otto lingue", "Tedesco, inglese, francese, spagnolo, italiano, olandese, portoghese e polacco."}, {"Pro", "Testi e design personalizzati", "Titoli, descrizioni, etichette e colori protetti lato server."}, {"Offline", "Licenza firmata", "Verifica Ed25519 locale senza server esterno."}, {"Scalabile", "Alta disponibilità", "Repliche stateless, probe, HPA, Swarm e cache CDN."}, {"Privacy", "Nessun tracking obbligatorio", "Nessun cookie, sessione, asset esterno o analytics obbligatorio."}}
c.CompareEyebrow, c.CompareTitle, c.CompareLead = "Edizioni", "Standard aperto o integrazione del brand.", "I formati essenziali restano gratuiti. Pro abilita solo la personalizzazione commerciale."
c.CompareFeature, c.CompareCommunity, c.ComparePro = "Funzione", "Community", "Pro"
c.Comparison = commonComparison("it", "Incluso", "-", "Incluso", "Secondo il piano")
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Prezzi", "Scala in base all'ampiezza del deployment.", "Il software resta self-hosted. I piani a pagamento licenziano personalizzazioni, domini e supporto."
c.Comparison = commonComparison("it", "Incluso", "", "Incluso", "Secondo il piano")
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Prezzi", "Scala in base allampiezza del deployment.", "Il software resta self-hosted. I piani a pagamento licenziano personalizzazioni, domini e supporto."
c.PricePeriod, c.PriceNote = "/ mese", "Prezzi introduttivi con fatturazione annuale, imposte escluse. Enterprise su accordo individuale."
c.PlanDescriptions = [5]string{"Per open source e dichiarazioni standard.", "Per siti professionali con presentazione personalizzata.", "Per publisher con più brand o portali.", "Per agenzie con deployment ripetuti per i clienti.", "Per requisiti contrattuali, SLA e deployment personalizzati."}
c.PlanFeatures = [5][]string{{"Preset standard", "8 lingue", "SVG, HTML e JSON-LD", "Self-hosting"}, {"Fino a 3 domini", "Testi personalizzati", "Etichette e colori personalizzati", "Licenza offline firmata"}, {"Fino a 20 domini", "Tutte le funzioni Pro", "Supporto prioritario", "Assistenza migrazione"}, {"Fino a 100 domini", "Domini clienti", "Uso commerciale agenzia", "Onboarding tecnico"}, {"Domini personalizzati", "SLA e finestre di supporto", "Distribuzione privata", "Termini personalizzati"}}
@@ -497,13 +442,13 @@ func italian() copySet {
c.EnterprisePrice = "Personalizzato"
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Deployment", "In produzione con pochi comandi.", "Ogni opzione esegue lo stesso binario Go. Community e Pro differiscono solo per secret di licenza opzionali."
c.CopyLabel, c.CopiedLabel = "Copia", "Copiato"
c.InstallSummaries = [5]string{"Configurazione dichiarativa locale o su server singolo.", "Costruisci l'immagine ed esegui un container hardened.", "Tre repliche, probe, rolling update, HPA e PodDisruptionBudget.", "Più repliche con l'orchestratore nativo Docker.", "Per sviluppo, test o deployment nativo systemd."}
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Variabili d'ambiente importanti", "Variabile", "Default", "Scopo"
c.InstallSummaries = [5]string{"Configurazione dichiarativa locale o su server singolo.", "Costruisci limmagine ed esegui un container hardened.", "Tre repliche, probe, rolling update, HPA e PodDisruptionBudget.", "Più repliche con lorchestratore nativo Docker.", "Per sviluppo, test o deployment nativo systemd."}
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Variabili dambiente importanti", "Variabile", "Default", "Scopo"
c.ConfigDescriptions = [8]string{"Indirizzo di ascolto HTTP.", "URL pubblico senza slash finale.", "Titolo del prodotto o sito.", "Pagina esterna facoltativa di contatto o del progetto.", "Destinazione delle richieste Pro e commerciali.", "Lingua di fallback.", "Chiave pubblica Ed25519 per Pro.", "Token Pro firmato e opzionalmente legato ai domini."}
c.FAQEyebrow, c.FAQTitle = "FAQ", "Domande frequenti"
c.FAQs = []FAQ{{"Community è limitata artificialmente?", "No. Preset, otto lingue, SVG, pagine, JSON-LD, validazione e file di deployment sono completi. Solo la personalizzazione commerciale richiede Pro."}, {"Pro dipende da un server di licenze?", "No. Ogni replica verifica localmente la firma Ed25519."}, {"Posso usare un CDN?", "Sì. Badge e manifesti sono deterministici e includono ETag e Cache-Control."}, {"Garantisce conformità legale?", "No. Fornisce infrastruttura tecnica e non sostituisce la revisione legale o la responsabilità editoriale."}}
c.FinalTitle, c.FinalLead, c.FinalPrimary, c.FinalSecondary = "Inizia aperto. Aggiungi il brand solo quando serve.", "Il generatore funziona senza registrazione. Pro aggiunge testi, colori e domini personalizzati.", "Apri generatore", "Parla di Pro"
c.Footer = "Standard aperto per dichiarare l'uso dell'IA · Non è consulenza legale."
c.Footer = "Standard aperto per dichiarare luso dellIA · Non è consulenza legale."
return c
}
@@ -512,27 +457,27 @@ func dutch() copySet {
c.MetaDescription = "Product, prijzen en installatie voor de open standaard voor AI-gebruiksverklaringen."
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Functies", "Prijzen", "Installatie", "Generator", "Achtergrond", "Taal"
c.HeroEyebrow, c.HeroTitle = "Open-source kern · Pro waar nodig", "Maak AI-gebruik transparant zonder platformlock-in."
c.HeroLead = "Een stateless Go-service voor SVG-badges, begrijpelijke verklaringspagina's en JSON-LD. Zelf te hosten, internationaal en gebouwd voor hoge beschikbaarheid."
c.HeroLead = "Een stateless Go-service voor SVG-badges, begrijpelijke verklaringspaginas en JSON-LD. Zelf te hosten, internationaal en gebouwd voor hoge beschikbaarheid."
c.PrimaryCTA, c.SecondaryCTA = "Badge maken", "Pro bespreken"
c.Proof = []string{"8 talen", "Geen cookies", "Geen database", "Docker & Kubernetes"}
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Functies", "Van zichtbare melding tot machineleesbare verklaring.", "Community biedt de open integratie. Pro voegt maatwerk en commerciële licenties toe."
c.Features = []Feature{{"Zichtbaar", "SVG-badges", "Deterministische, cachebare badges uit presets of gestructureerde parameters."}, {"Begrijpelijk", "Verklaringspagina's", "Leggen AI-bijdrage, activiteiten, menselijke controle en verantwoordelijkheid uit."}, {"Machineleesbaar", "JSON-LD en schema", "Manifesten voor build- en CMS-workflows."}, {"Internationaal", "Acht talen", "Duits, Engels, Frans, Spaans, Italiaans, Nederlands, Portugees en Pools."}, {"Pro", "Eigen teksten en ontwerp", "Titels, beschrijvingen, labels en kleuren met server-side handhaving."}, {"Offline", "Ondertekende licentie", "Lokale Ed25519-controle zonder externe licentieserver."}, {"Schaalbaar", "Hoge beschikbaarheid", "Stateless replicas, probes, HPA, Swarm en CDN-caching."}, {"Privacy", "Geen verplichte tracking", "Geen cookies, sessies, externe assets of verplichte analytics."}}
c.Features = []Feature{{"Zichtbaar", "SVG-badges", "Deterministische, cachebare badges uit presets of gestructureerde parameters."}, {"Begrijpelijk", "Verklaringspaginas", "Leggen AI-bijdrage, activiteiten, menselijke controle en verantwoordelijkheid uit."}, {"Machineleesbaar", "JSON-LD en schema", "Manifesten voor build- en CMS-workflows."}, {"Internationaal", "Acht talen", "Duits, Engels, Frans, Spaans, Italiaans, Nederlands, Portugees en Pools."}, {"Pro", "Eigen teksten en ontwerp", "Titels, beschrijvingen, labels en kleuren met server-side handhaving."}, {"Offline", "Ondertekende licentie", "Lokale Ed25519-controle zonder externe licentieserver."}, {"Schaalbaar", "Hoge beschikbaarheid", "Stateless replicas, probes, HPA, Swarm en CDN-caching."}, {"Privacy", "Geen verplichte tracking", "Geen cookies, sessies, externe assets of verplichte analytics."}}
c.CompareEyebrow, c.CompareTitle, c.CompareLead = "Edities", "Open standaard of integratie in eigen huisstijl.", "Alle kernformaten blijven gratis. Pro ontgrendelt alleen commerciële aanpassing."
c.CompareFeature, c.CompareCommunity, c.ComparePro = "Functie", "Community", "Pro"
c.Comparison = commonComparison("nl", "Inbegrepen", "-", "Inbegrepen", "Volgens abonnement")
c.Comparison = commonComparison("nl", "Inbegrepen", "", "Inbegrepen", "Volgens abonnement")
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Prijzen", "Schaal mee met de omvang van je deployment.", "De software blijft zelf te hosten. Betaalde plannen licentiëren maatwerk, domeinen en support."
c.PricePeriod, c.PriceNote = "/ maand", "Introductieprijzen bij jaarlijkse facturatie, exclusief belastingen. Enterprise op maat."
c.PlanDescriptions = [5]string{"Voor open source en standaardverklaringen.", "Voor professionele sites met eigen presentatie.", "Voor uitgevers met meerdere merken of portals.", "Voor bureaus met herhaalde klantdeployments.", "Voor maatwerkcontracten, SLA's en deployments."}
c.PlanDescriptions = [5]string{"Voor open source en standaardverklaringen.", "Voor professionele sites met eigen presentatie.", "Voor uitgevers met meerdere merken of portals.", "Voor bureaus met herhaalde klantdeployments.", "Voor maatwerkcontracten, SLAs en deployments."}
c.PlanFeatures = [5][]string{{"Standaardpresets", "8 talen", "SVG, HTML en JSON-LD", "Self-hosting"}, {"Tot 3 domeinen", "Eigen teksten", "Eigen labels en kleuren", "Ondertekende offline licentie"}, {"Tot 20 domeinen", "Alle Pro-functies", "Prioriteitssupport", "Migratiehulp"}, {"Tot 100 domeinen", "Klantdomeinen", "Commercieel bureaugebruik", "Technische onboarding"}, {"Domeinen op maat", "SLA en supportvensters", "Private distributie", "Aangepaste licentievoorwaarden"}}
c.PlanCTA = [5]string{"Gratis starten", "Pro aanvragen", "Publisher aanvragen", "Agency aanvragen", "Contact opnemen"}
c.EnterprisePrice = "Op maat"
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Deployment", "Productieklaar in enkele commando's.", "Elke optie draait hetzelfde Go-binary. Community en Pro verschillen alleen door optionele licentie-secrets."
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Deployment", "Productieklaar in enkele commandos.", "Elke optie draait hetzelfde Go-binary. Community en Pro verschillen alleen door optionele licentie-secrets."
c.CopyLabel, c.CopiedLabel = "Kopiëren", "Gekopieerd"
c.InstallSummaries = [5]string{"Declaratieve setup voor lokaal of één server.", "Bouw het image en start een hardened container.", "Drie replicas, probes, rolling updates, HPA en PodDisruptionBudget.", "Meerdere replicas met Docker Swarm.", "Voor ontwikkeling, tests of een native systemd-deployment."}
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Belangrijke omgevingsvariabelen", "Variabele", "Standaard", "Doel"
c.ConfigDescriptions = [8]string{"HTTP-luisteradres.", "Publieke basis-URL zonder afsluitende slash.", "Product- of sitetitel.", "Optionele externe contact- of projectpagina.", "Bestemming voor Pro- en verkoopvragen.", "Fallbacktaal.", "Publieke Ed25519-sleutel voor Pro.", "Ondertekende en optioneel domeingebonden Pro-token."}
c.FAQEyebrow, c.FAQTitle = "FAQ", "Veelgestelde vragen"
c.FAQs = []FAQ{{"Is Community kunstmatig beperkt?", "Nee. Presets, acht talen, SVG, pagina's, JSON-LD, validatie en deploymentbestanden zijn volledig. Alleen commercieel maatwerk vereist Pro."}, {"Heeft Pro een licentieserver nodig?", "Nee. Elke replica controleert de Ed25519-handtekening lokaal."}, {"Kan dit achter een CDN?", "Ja. Badges en manifesten zijn deterministisch en gebruiken ETag en Cache-Control."}, {"Garandeert dit juridische naleving?", "Nee. Het is technische infrastructuur en vervangt geen juridische beoordeling of redactionele verantwoordelijkheid."}}
c.FAQs = []FAQ{{"Is Community kunstmatig beperkt?", "Nee. Presets, acht talen, SVG, paginas, JSON-LD, validatie en deploymentbestanden zijn volledig. Alleen commercieel maatwerk vereist Pro."}, {"Heeft Pro een licentieserver nodig?", "Nee. Elke replica controleert de Ed25519-handtekening lokaal."}, {"Kan dit achter een CDN?", "Ja. Badges en manifesten zijn deterministisch en gebruiken ETag en Cache-Control."}, {"Garandeert dit juridische naleving?", "Nee. Het is technische infrastructuur en vervangt geen juridische beoordeling of redactionele verantwoordelijkheid."}}
c.FinalTitle, c.FinalLead, c.FinalPrimary, c.FinalSecondary = "Begin open. Voeg huisstijl toe waar nodig.", "De generator werkt zonder registratie. Pro voegt eigen teksten, kleuren en domeinen toe.", "Open generator", "Pro bespreken"
c.Footer = "Open standaard voor AI-gebruiksverklaringen · Geen juridisch advies."
return c
@@ -550,7 +495,7 @@ func portuguese() copySet {
c.Features = []Feature{{"Visível", "Badges SVG", "Badges determinísticos e cacheáveis a partir de presets ou parâmetros estruturados."}, {"Compreensível", "Páginas de declaração", "Explicam contribuição da IA, atividades, revisão e responsabilidade."}, {"Legível por máquina", "JSON-LD e esquema", "Manifestos para pipelines de build e CMS."}, {"Internacional", "Oito idiomas", "Alemão, inglês, francês, espanhol, italiano, neerlandês, português e polaco."}, {"Pro", "Textos e design próprios", "Títulos, descrições, etiquetas e cores protegidos no servidor."}, {"Offline", "Licença assinada", "Validação Ed25519 local sem servidor externo."}, {"Escalável", "Alta disponibilidade", "Réplicas stateless, probes, HPA, Swarm e cache CDN."}, {"Privacidade", "Sem tracking obrigatório", "Sem cookies, sessões, recursos externos ou analytics obrigatórios."}}
c.CompareEyebrow, c.CompareTitle, c.CompareLead = "Edições", "Padrão aberto ou integração de marca.", "Os formatos essenciais continuam gratuitos. Pro desbloqueia apenas personalização comercial."
c.CompareFeature, c.CompareCommunity, c.ComparePro = "Funcionalidade", "Community", "Pro"
c.Comparison = commonComparison("pt", "Incluído", "-", "Incluído", "Conforme o plano")
c.Comparison = commonComparison("pt", "Incluído", "", "Incluído", "Conforme o plano")
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Preços", "Escale com a dimensão do deployment.", "O software continua autoalojável. Os planos pagos licenciam personalização, domínios e suporte."
c.PricePeriod, c.PriceNote = "/ mês", "Preços de lançamento com faturação anual, impostos não incluídos. Enterprise sob proposta."
c.PlanDescriptions = [5]string{"Para open source e declarações padrão.", "Para sites profissionais com apresentação própria.", "Para publishers com várias marcas ou portais.", "Para agências com deployments repetidos para clientes.", "Para requisitos contratuais, SLA e deployments personalizados."}
@@ -581,7 +526,7 @@ func polish() copySet {
c.Features = []Feature{{"Widoczne", "Plakietki SVG", "Deterministyczne, buforowalne plakietki z presetów lub parametrów."}, {"Zrozumiałe", "Strony deklaracji", "Opisują udział AI, działania, kontrolę człowieka i odpowiedzialność."}, {"Maszynowe", "JSON-LD i schemat", "Manifesty do procesów build i CMS."}, {"Międzynarodowe", "Osiem języków", "Niemiecki, angielski, francuski, hiszpański, włoski, niderlandzki, portugalski i polski."}, {"Pro", "Własne teksty i wygląd", "Tytuły, opisy, etykiety i kolory chronione po stronie serwera."}, {"Offline", "Podpisana licencja", "Lokalna weryfikacja Ed25519 bez zewnętrznego serwera."}, {"Skalowalne", "Wysoka dostępność", "Bezstanowe repliki, probes, HPA, Swarm i cache CDN."}, {"Prywatność", "Bez obowiązkowego śledzenia", "Bez cookies, sesji, zewnętrznych zasobów i obowiązkowej analityki."}}
c.CompareEyebrow, c.CompareTitle, c.CompareLead = "Edycje", "Otwarty standard albo integracja z marką.", "Wszystkie podstawowe formaty pozostają bezpłatne. Pro odblokowuje wyłącznie personalizację komercyjną."
c.CompareFeature, c.CompareCommunity, c.ComparePro = "Funkcja", "Community", "Pro"
c.Comparison = commonComparison("pl", "W cenie", "-", "W cenie", "Zależnie od planu")
c.Comparison = commonComparison("pl", "W cenie", "", "W cenie", "Zależnie od planu")
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Cennik", "Skaluj wraz z zakresem wdrożenia.", "Oprogramowanie pozostaje do samodzielnego hostowania. Płatne plany licencjonują personalizację, domeny i wsparcie."
c.PricePeriod, c.PriceNote = "/ miesiąc", "Ceny wprowadzające przy rozliczeniu rocznym, bez podatków. Enterprise wyceniany indywidualnie."
c.PlanDescriptions = [5]string{"Dla open source i standardowych deklaracji.", "Dla profesjonalnych stron z własnym wyglądem.", "Dla wydawców z wieloma markami lub portalami.", "Dla agencji wdrażających rozwiązanie u klientów.", "Dla indywidualnych umów, SLA i wdrożeń."}

View File

@@ -4,22 +4,12 @@ import "testing"
func TestBuildAllLanguages(t *testing.T) {
for _, lang := range []string{"de", "en", "fr", "es", "it", "nl", "pt", "pl"} {
page := Build(lang, "Test Product", "https://example.org", "https://example.org/sales", "https://example.org/about", Prices{})
if page.HeroTitle == "" || page.PricingTitle == "" || page.InstallTitle == "" {
page := Build(lang, "Test Product", "https://example.org", "https://example.org/about")
if page.HeroTitle == "" || page.InstallTitle == "" {
t.Fatalf("%s has incomplete primary copy", lang)
}
if len(page.Features) != 7 || len(page.Comparison) != 10 || len(page.Plans) != 5 || len(page.InstallMethods) != 5 || len(page.Config) != 8 || len(page.FAQs) != 2 {
t.Fatalf("%s has incomplete marketing data: features=%d comparison=%d plans=%d install=%d config=%d faq=%d", lang, len(page.Features), len(page.Comparison), len(page.Plans), len(page.InstallMethods), len(page.Config), len(page.FAQs))
}
if page.Plans[1].Price != "19 €" || page.Plans[4].Period != "" {
t.Fatalf("%s has unexpected default pricing", lang)
if len(page.Features) != 7 || len(page.InstallMethods) != 5 || len(page.Config) != 10 || len(page.FAQs) != 2 {
t.Fatalf("%s has incomplete product data: features=%d install=%d config=%d faq=%d", lang, len(page.Features), len(page.InstallMethods), len(page.Config), len(page.FAQs))
}
}
}
func TestBuildPriceOverrides(t *testing.T) {
page := Build("en", "Test", "https://example.org", "https://example.org/sales", "", Prices{Pro: "$29", Publisher: "$99", Agency: "$249"})
if page.Plans[1].Price != "$29" || page.Plans[2].Price != "$99" || page.Plans[3].Price != "$249" {
t.Fatalf("price overrides not applied: %#v", page.Plans)
}
}

View File

@@ -1,8 +1,10 @@
openapi: 3.1.0
info:
title: AI Usage Disclosure API
version: 1.6.3
description: Stateless multilingual badge, declaration and validation API with optional licensed presentation capabilities.
version: 2.0.2
description: >-
Stateless multilingual API for visible and machine-readable AI usage declarations,
non-binding Article 50 decision support, validation and optional licensed bulk/export capabilities.
servers:
- url: https://ai.example.org
paths:
@@ -17,7 +19,9 @@ paths:
- {$ref: '#/components/parameters/language'}
- {$ref: '#/components/parameters/theme'}
- {$ref: '#/components/parameters/style'}
- {name: link, in: query, schema: {type: string, description: Use auto to link to the generated declaration page.}}
- name: link
in: query
schema: {type: string, description: Use auto to link to the generated declaration page.}
responses:
"200":
description: SVG badge
@@ -25,42 +29,54 @@ paths:
image/svg+xml: {schema: {type: string}}
/v1/badge.svg:
get:
summary: Render an SVG badge from structured parameters
description: Custom labels and colours require the custom_badge capability.
parameters:
- {$ref: '#/components/parameters/extent'}
- {$ref: '#/components/parameters/language'}
- {name: preset, in: query, schema: {type: string, enum: [no-ai, research, summary, full]}}
- {$ref: '#/components/parameters/legalContext'}
- {$ref: '#/components/parameters/theme'}
- {$ref: '#/components/parameters/style'}
- {name: link, in: query, schema: {type: string}}
- {name: badgeLabel, in: query, description: Licensed feature custom_badge, schema: {type: string, maxLength: 40}}
- {name: badgeMessage, in: query, description: Licensed feature custom_badge, schema: {type: string, maxLength: 80}}
- {name: leftColor, in: query, description: Licensed feature custom_badge, schema: {type: string, pattern: '^#[0-9A-Fa-f]{6}$'}}
- {name: rightColor, in: query, description: Licensed feature custom_badge, schema: {type: string, pattern: '^#[0-9A-Fa-f]{6}$'}}
responses:
"200":
description: SVG badge
content:
image/svg+xml: {schema: {type: string}}
"403": {$ref: '#/components/responses/ProRequired'}
/declaration:
get:
summary: Render a human-readable declaration page
summary: Render an SVG badge from declaration parameters
description: Custom labels and colours require the licensed capability `custom_badge`.
parameters:
- {$ref: '#/components/parameters/commonMode'}
- {$ref: '#/components/parameters/preset'}
- {$ref: '#/components/parameters/extent'}
- {$ref: '#/components/parameters/language'}
- {$ref: '#/components/parameters/component'}
- {$ref: '#/components/parameters/activities'}
- {$ref: '#/components/parameters/review'}
- {$ref: '#/components/parameters/legalContext'}
- {name: responsibleRole, in: query, schema: {type: string, enum: [publisher, other]}}
- {$ref: '#/components/parameters/assurance'}
- {$ref: '#/components/parameters/publicInterestText'}
- {$ref: '#/components/parameters/deepfake'}
- {$ref: '#/components/parameters/theme'}
- {$ref: '#/components/parameters/style'}
- {name: link, in: query, schema: {type: string}}
- {$ref: '#/components/parameters/badgeLabel'}
- {$ref: '#/components/parameters/badgeMessage'}
- {$ref: '#/components/parameters/leftColor'}
- {$ref: '#/components/parameters/rightColor'}
responses:
"200":
description: SVG badge
content:
image/svg+xml: {schema: {type: string}}
"403": {$ref: '#/components/responses/LicensedCapabilityRequired'}
/declaration:
get:
summary: Render a human-readable declaration page
parameters:
- {$ref: '#/components/parameters/commonMode'}
- {$ref: '#/components/parameters/preset'}
- {$ref: '#/components/parameters/extent'}
- {$ref: '#/components/parameters/language'}
- {$ref: '#/components/parameters/component'}
- {$ref: '#/components/parameters/activities'}
- {$ref: '#/components/parameters/review'}
- {name: subject, in: query, schema: {type: string, format: uri}}
- {name: responsible, in: query, schema: {type: string, maxLength: 200}}
- {name: responsibleUrl, in: query, schema: {type: string, format: uri}}
- {$ref: '#/components/parameters/legalContext'}
- {$ref: '#/components/parameters/assurance'}
- {$ref: '#/components/parameters/publicInterestText'}
- {$ref: '#/components/parameters/deepfake'}
- {$ref: '#/components/parameters/artisticContext'}
- {$ref: '#/components/parameters/substantialHumanReview'}
- {$ref: '#/components/parameters/editorialResponsibilityConfirmed'}
- {$ref: '#/components/parameters/firstExposureDisclosure'}
- {$ref: '#/components/parameters/accessibilityConsidered'}
- {$ref: '#/components/parameters/customTitle'}
- {$ref: '#/components/parameters/customDescription'}
- {$ref: '#/components/parameters/badgeLabel'}
@@ -69,11 +85,12 @@ paths:
- {$ref: '#/components/parameters/rightColor'}
responses:
"200": {description: Human-readable HTML declaration}
"403": {$ref: '#/components/responses/ProRequired'}
"403": {$ref: '#/components/responses/LicensedCapabilityRequired'}
/v1/declaration.json:
get:
summary: Generate a JSON-LD declaration from query parameters
summary: Generate a JSON-LD declaration
parameters:
- {$ref: '#/components/parameters/commonMode'}
- {$ref: '#/components/parameters/preset'}
- {$ref: '#/components/parameters/extent'}
- {$ref: '#/components/parameters/language'}
@@ -81,10 +98,16 @@ paths:
- {$ref: '#/components/parameters/activities'}
- {$ref: '#/components/parameters/review'}
- {name: subject, in: query, schema: {type: string, format: uri}}
- {name: responsibleRole, in: query, schema: {type: string, enum: [publisher, other]}}
- {name: responsible, in: query, schema: {type: string, maxLength: 200}}
- {name: responsibleUrl, in: query, schema: {type: string, format: uri}}
- {$ref: '#/components/parameters/assurance'}
- {$ref: '#/components/parameters/publicInterestText'}
- {$ref: '#/components/parameters/deepfake'}
- {$ref: '#/components/parameters/artisticContext'}
- {$ref: '#/components/parameters/substantialHumanReview'}
- {$ref: '#/components/parameters/editorialResponsibilityConfirmed'}
- {$ref: '#/components/parameters/firstExposureDisclosure'}
- {$ref: '#/components/parameters/accessibilityConsidered'}
- {$ref: '#/components/parameters/customTitle'}
- {$ref: '#/components/parameters/customDescription'}
- {$ref: '#/components/parameters/badgeLabel'}
@@ -97,7 +120,126 @@ paths:
content:
application/ld+json:
schema: {$ref: './schema/declaration.schema.json'}
"403": {$ref: '#/components/responses/ProRequired'}
"403": {$ref: '#/components/responses/LicensedCapabilityRequired'}
/v1/article50-assessment.json:
get:
summary: Return non-binding technical Article 50 decision support
description: >-
Evaluates the factual regulatory context supplied with a declaration. The result is
technical decision support, not legal advice or a legal determination.
parameters:
- {$ref: '#/components/parameters/commonMode'}
- {$ref: '#/components/parameters/preset'}
- {$ref: '#/components/parameters/extent'}
- {$ref: '#/components/parameters/language'}
- {$ref: '#/components/parameters/component'}
- {$ref: '#/components/parameters/activities'}
- {$ref: '#/components/parameters/review'}
- {name: subject, in: query, schema: {type: string, format: uri}}
- {name: responsible, in: query, schema: {type: string, maxLength: 200}}
- {name: responsibleUrl, in: query, schema: {type: string, format: uri}}
- {$ref: '#/components/parameters/assurance'}
- {$ref: '#/components/parameters/publicInterestText'}
- {$ref: '#/components/parameters/deepfake'}
- {$ref: '#/components/parameters/artisticContext'}
- {$ref: '#/components/parameters/substantialHumanReview'}
- {$ref: '#/components/parameters/editorialResponsibilityConfirmed'}
- {$ref: '#/components/parameters/firstExposureDisclosure'}
- {$ref: '#/components/parameters/accessibilityConsidered'}
- {$ref: '#/components/parameters/customTitle'}
- {$ref: '#/components/parameters/customDescription'}
- {$ref: '#/components/parameters/badgeLabel'}
- {$ref: '#/components/parameters/badgeMessage'}
- {$ref: '#/components/parameters/leftColor'}
- {$ref: '#/components/parameters/rightColor'}
responses:
"200":
description: Assessment with localized summary and warnings
content:
application/json:
schema:
type: object
required: [assessment, title, summary, disclaimer]
properties:
assessment: {$ref: '#/components/schemas/Article50Assessment'}
title: {type: string}
summary: {type: string}
findings: {type: array, items: {type: string}}
warnings: {type: array, items: {type: string}}
disclaimer: {type: string}
/v1/declaration.bundle.json:
get:
summary: Export a declaration evidence bundle
description: Requires the licensed capability `export_bundle`.
parameters:
- {$ref: '#/components/parameters/commonMode'}
- {$ref: '#/components/parameters/preset'}
- {$ref: '#/components/parameters/extent'}
- {$ref: '#/components/parameters/language'}
- {$ref: '#/components/parameters/component'}
- {$ref: '#/components/parameters/activities'}
- {$ref: '#/components/parameters/review'}
- {name: subject, in: query, schema: {type: string, format: uri}}
- {name: responsible, in: query, schema: {type: string, maxLength: 200}}
- {name: responsibleUrl, in: query, schema: {type: string, format: uri}}
- {$ref: '#/components/parameters/assurance'}
- {$ref: '#/components/parameters/publicInterestText'}
- {$ref: '#/components/parameters/deepfake'}
- {$ref: '#/components/parameters/artisticContext'}
- {$ref: '#/components/parameters/substantialHumanReview'}
- {$ref: '#/components/parameters/editorialResponsibilityConfirmed'}
- {$ref: '#/components/parameters/firstExposureDisclosure'}
- {$ref: '#/components/parameters/accessibilityConsidered'}
- {$ref: '#/components/parameters/customTitle'}
- {$ref: '#/components/parameters/customDescription'}
- {$ref: '#/components/parameters/badgeLabel'}
- {$ref: '#/components/parameters/badgeMessage'}
- {$ref: '#/components/parameters/leftColor'}
- {$ref: '#/components/parameters/rightColor'}
responses:
"200":
description: Declaration, Article 50 assessment, SHA-256 declaration digest and canonical output URLs
content:
application/json:
schema: {$ref: '#/components/schemas/DeclarationBundle'}
"403": {$ref: '#/components/responses/LicensedCapabilityRequired'}
/v1/bulk/declarations:
post:
summary: Process multiple declarations in one server-to-server request
description: >-
Requires the licensed capability `bulk_api`. The dedicated bulk container additionally
requires an API key by default and enforces configured/licensed item limits.
security:
- bulkBearer: []
- bulkApiKey: []
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [items]
properties:
items:
type: array
minItems: 1
items:
type: object
additionalProperties: false
properties:
id: {type: string}
parameters:
type: object
additionalProperties: {type: string}
required: [parameters]
responses:
"200":
description: Per-item declaration and assessment results
"401":
description: Bulk API authentication required when configured
"403": {$ref: '#/components/responses/LicensedCapabilityRequired'}
"413": {description: Configured or licensed bulk limit exceeded}
/v1/validate:
post:
summary: Validate an AI usage declaration
@@ -111,7 +253,7 @@ paths:
"422": {description: Validation failed}
/v1/capabilities:
get:
summary: Return edition, licensed features and supported languages
summary: Return runtime mode, licence state, licensed capabilities and languages
responses:
"200":
description: Runtime capabilities
@@ -120,23 +262,17 @@ paths:
schema:
type: object
properties:
license:
type: object
required: [edition, licensed, features]
additionalProperties: false
properties:
edition: {type: string}
licensed: {type: boolean}
features:
type: array
items: {type: string}
limits:
type: object
additionalProperties: {type: integer, format: int64}
expiresAt: {type: string, format: date-time}
product: {type: string, const: ai-disclosure-standard}
productVersion: {type: string}
schemaVersion: {type: string}
serviceMode: {type: string, enum: [full, api, bulk]}
license: {type: object}
supportedLanguages:
type: array
items: {type: string}
items: {type: string, enum: [de, en, fr, es, it, nl, pt, pl]}
licensedCapabilities:
type: object
additionalProperties: {type: boolean}
/healthz:
get:
summary: Liveness endpoint
@@ -144,9 +280,28 @@ paths:
/readyz:
get:
summary: Readiness endpoint
responses: {"200": {description: Ready}}
description: In bulk mode this also checks the bulk licence capability and required API-key configuration.
responses:
"200": {description: Ready}
"503": {description: Runtime is not ready}
/metrics:
get:
summary: Prometheus metrics
responses: {"200": {description: Prometheus text exposition}}
components:
securitySchemes:
bulkBearer:
type: http
scheme: bearer
bulkApiKey:
type: apiKey
in: header
name: X-API-Key
parameters:
commonMode:
name: mode
in: query
schema: {type: string, enum: [single, article]}
preset:
name: preset
in: query
@@ -162,7 +317,7 @@ components:
component:
name: component
in: query
schema: {type: string, enum: [text, coverImage, image, audio, video, code, other], default: text}
schema: {type: string, enum: [text, coverImage, image, research, translation, audio, video, code, other], default: text}
activities:
name: activities
in: query
@@ -170,21 +325,46 @@ components:
review:
name: review
in: query
schema: {type: string, enum: [none, basic, editorial, expert], default: none}
legalContext:
name: legalContext
in: query
description: Comma-separated self-classification for Article 50 context. This is supporting metadata, not an automatic legal determination.
schema: {type: string, example: publicInterestText}
schema: {type: string, enum: [none, basic, editorial, expert], default: editorial}
assurance:
name: assurance
in: query
description: Evidence basis for the declaration.
schema: {type: string, enum: [selfDeclared, technicallyRecorded, signed, verified], default: selfDeclared}
publicInterestText:
name: publicInterestText
in: query
description: Factual flag that the text informs the public on a matter of public interest.
schema: {type: boolean}
deepfake:
name: deepfake
in: query
description: Factual flag for deepfake or realistic AI-generated/manipulated content.
schema: {type: boolean}
artisticContext:
name: artisticCreativeSatiricalFictional
in: query
schema: {type: boolean}
substantialHumanReview:
name: substantialHumanReview
in: query
schema: {type: boolean}
editorialResponsibilityConfirmed:
name: editorialResponsibilityConfirmed
in: query
schema: {type: boolean}
firstExposureDisclosure:
name: firstExposureDisclosure
in: query
schema: {type: boolean}
accessibilityConsidered:
name: accessibilityConsidered
in: query
schema: {type: boolean}
theme:
name: theme
in: query
schema: {type: string, enum: [mono, color, emoji], default: color}
schema: {type: string, enum: [mono, color], default: color}
style:
name: style
in: query
@@ -192,41 +372,71 @@ components:
customTitle:
name: customTitle
in: query
description: Licensed feature custom_text
description: Licensed capability `custom_text`.
schema: {type: string, maxLength: 120}
customDescription:
name: customDescription
in: query
description: Licensed feature custom_text
description: Licensed capability `custom_text`.
schema: {type: string, maxLength: 500}
badgeLabel:
name: badgeLabel
in: query
description: Licensed feature custom_badge
description: Licensed capability `custom_badge`.
schema: {type: string, maxLength: 40}
badgeMessage:
name: badgeMessage
in: query
description: Licensed feature custom_badge
description: Licensed capability `custom_badge`.
schema: {type: string, maxLength: 80}
leftColor:
name: leftColor
in: query
description: Licensed feature custom_badge
description: Licensed capability `custom_badge`.
schema: {type: string, pattern: '^#[0-9A-Fa-f]{6}$'}
rightColor:
name: rightColor
in: query
description: Licensed feature custom_badge
description: Licensed capability `custom_badge`.
schema: {type: string, pattern: '^#[0-9A-Fa-f]{6}$'}
schemas:
Article50Assessment:
type: object
required: [code, severity, potentiallyApplicable]
properties:
code: {type: string}
severity: {type: string, enum: [neutral, caution, attention]}
potentiallyApplicable: {type: boolean}
findings:
type: array
items:
type: object
required: [code, severity]
properties:
code: {type: string}
severity: {type: string, enum: [neutral, caution, attention]}
warnings: {type: array, items: {type: string}}
DeclarationBundle:
type: object
required: [product, productVersion, generatedAt, declaration, declarationDigest, article50Assessment, declarationUrl, manifestUrl, badgeUrl]
properties:
product: {type: string, const: ai-disclosure-standard}
productVersion: {type: string}
generatedAt: {type: string, format: date-time}
declaration: {$ref: './schema/declaration.schema.json'}
declarationDigest: {type: string, pattern: '^sha256:[0-9a-f]{64}$'}
article50Assessment: {$ref: '#/components/schemas/Article50Assessment'}
declarationUrl: {type: string, format: uri}
manifestUrl: {type: string, format: uri}
badgeUrl: {type: string, format: uri}
responses:
ProRequired:
LicensedCapabilityRequired:
description: A valid licensed capability is required
content:
application/problem+json:
schema:
type: object
properties:
code: {const: pro_feature_required}
code: {const: licensed_feature_required}
status: {const: 403}
detail: {type: string}

View File

@@ -1,391 +0,0 @@
package licenseclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/b1tsblog/ai-disclosure-standard/pkg/licensekit"
)
type Status struct {
Edition string `json:"edition"`
Licensed bool `json:"licensed"`
LicenseID string `json:"licenseId,omitempty"`
Customer string `json:"customer,omitempty"`
Product string `json:"product,omitempty"`
Features []string `json:"features"`
Limits map[string]int64 `json:"limits,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
Mode string `json:"mode,omitempty"`
Source string `json:"source,omitempty"`
LastChecked string `json:"lastChecked,omitempty"`
LeaseExpires string `json:"leaseExpiresAt,omitempty"`
Reason string `json:"reason,omitempty"`
}
type Config struct {
Product string
Token string
TrustStore licensekit.TrustStore
BaseURL string
InstanceID string
Mode licensekit.VerificationMode
ServerURL string
CacheFile string
RefreshEvery time.Duration
RequestTimeout time.Duration
ClientVersion string
HTTPClient *http.Client
}
type Client struct {
cfg Config
mu sync.RWMutex
status Status
claims licensekit.Claims
features map[string]bool
stopOnce sync.Once
stop chan struct{}
}
type introspectRequest struct {
Token string `json:"token"`
Product string `json:"product"`
BaseURL string `json:"baseUrl"`
Host string `json:"host"`
InstanceID string `json:"instanceId,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
}
type introspectResponse struct {
Valid bool `json:"valid"`
LeaseToken string `json:"leaseToken,omitempty"`
Reason string `json:"reason,omitempty"`
}
type cacheDocument struct {
LicenseID string `json:"licenseId"`
Lease string `json:"lease"`
SavedAt int64 `json:"savedAt"`
}
func New(ctx context.Context, cfg Config) *Client {
if cfg.RefreshEvery <= 0 {
cfg.RefreshEvery = 15 * time.Minute
}
if cfg.RequestTimeout <= 0 {
cfg.RequestTimeout = 5 * time.Second
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: cfg.RequestTimeout}
}
c := &Client{cfg: cfg, stop: make(chan struct{}), status: communityStatus(), features: map[string]bool{}}
c.refresh(ctx)
return c
}
func (c *Client) Start(ctx context.Context) {
c.mu.RLock()
mode := c.status.Mode
c.mu.RUnlock()
if mode == string(licensekit.ModeOffline) || strings.TrimSpace(c.cfg.Token) == "" {
return
}
go func() {
ticker := time.NewTicker(c.cfg.RefreshEvery)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-c.stop:
return
case <-ticker.C:
refreshCtx, cancel := context.WithTimeout(context.Background(), c.cfg.RequestTimeout)
c.refresh(refreshCtx)
cancel()
}
}
}()
}
func (c *Client) Close() { c.stopOnce.Do(func() { close(c.stop) }) }
func (c *Client) Refresh(ctx context.Context) Status {
c.refresh(ctx)
return c.Status()
}
func (c *Client) Has(feature string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.isCurrentlyLicensedLocked(time.Now().UTC()) && c.features[feature]
}
func (c *Client) Limit(name string) (int64, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.isCurrentlyLicensedLocked(time.Now().UTC()) {
return 0, false
}
value, ok := c.status.Limits[name]
return value, ok
}
func (c *Client) Status() Status {
c.mu.RLock()
defer c.mu.RUnlock()
out := c.status
if out.Licensed && !c.isCurrentlyLicensedLocked(time.Now().UTC()) {
out.Licensed = false
out.Edition = "community"
if out.Reason == "" {
out.Reason = "license or online lease is no longer valid"
}
}
out.Features = append([]string{}, c.status.Features...)
if c.status.Limits != nil {
out.Limits = make(map[string]int64, len(c.status.Limits))
for key, value := range c.status.Limits {
out.Limits[key] = value
}
}
return out
}
func (c *Client) isCurrentlyLicensedLocked(now time.Time) bool {
if !c.status.Licensed {
return false
}
if c.claims.ExpiresAt > 0 && now.Unix() >= c.claims.ExpiresAt {
return false
}
mode := licensekit.VerificationMode(c.status.Mode)
if mode == licensekit.ModeOffline || c.status.LeaseExpires == "" {
return true
}
leaseExpiry, err := time.Parse(time.RFC3339, c.status.LeaseExpires)
if err != nil {
return false
}
if mode == licensekit.ModeHybrid {
leaseExpiry = leaseExpiry.Add(time.Duration(c.claims.Verification.OfflineGraceSeconds) * time.Second)
}
return now.Before(leaseExpiry)
}
func (c *Client) refresh(ctx context.Context) {
now := time.Now().UTC()
if strings.TrimSpace(c.cfg.Token) == "" {
c.apply(communityStatus(), licensekit.Claims{})
return
}
verified, err := licensekit.VerifyLicense(c.cfg.TrustStore, c.cfg.Token, now)
if err != nil {
c.apply(failedStatus(err.Error(), now), licensekit.Claims{})
return
}
claims := verified.Claims
if err := licensekit.ValidateLicenseContext(claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil {
c.apply(failedStatus(err.Error(), now), claims)
return
}
mode := licensekit.StricterMode(claims.Verification.Mode, c.cfg.Mode)
base := statusFromClaims(claims, mode, now)
if mode == licensekit.ModeOffline {
base.Licensed = true
base.Source = "offline"
c.apply(base, claims)
return
}
if strings.TrimSpace(c.cfg.ServerURL) == "" {
base.Edition = "community"
base.Reason = "online verification is required but LICENSE_SERVER_URL is empty"
c.apply(base, claims)
return
}
lease, source, err := c.obtainLease(ctx, claims, mode, now)
if err != nil {
base.Edition = "community"
base.Reason = err.Error()
c.apply(base, claims)
return
}
base.Licensed = true
base.Source = source
base.LeaseExpires = time.Unix(lease.ExpiresAt, 0).UTC().Format(time.RFC3339)
base.Features = licensekit.UniqueSorted(intersection(claims.Features, lease.Features))
c.apply(base, claims)
}
func (c *Client) obtainLease(ctx context.Context, claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time) (licensekit.LeaseClaims, string, error) {
leaseToken, err := c.requestLease(ctx)
if err == nil {
lease, verifyErr := licensekit.VerifyLease(c.cfg.TrustStore, leaseToken, now, 0)
if verifyErr != nil {
return licensekit.LeaseClaims{}, "", fmt.Errorf("online lease verification failed: %w", verifyErr)
}
if verifyErr = licensekit.ValidateLeaseContext(lease.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); verifyErr != nil {
return licensekit.LeaseClaims{}, "", verifyErr
}
_ = c.writeCache(claims.LicenseID, leaseToken)
return lease.Claims, "online", nil
}
if mode == licensekit.ModeOnline {
return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed: %w", err)
}
cached, cacheErr := c.readCache(claims, now)
if cacheErr != nil {
return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed (%v) and no usable cached lease exists (%v)", err, cacheErr)
}
return cached, "cached-lease", nil
}
func (c *Client) requestLease(ctx context.Context) (string, error) {
host, err := licensekit.HostFromBaseURL(c.cfg.BaseURL)
if err != nil {
return "", err
}
body, err := json.Marshal(introspectRequest{Token: c.cfg.Token, Product: c.cfg.Product, BaseURL: c.cfg.BaseURL, Host: host, InstanceID: c.cfg.InstanceID, ClientVersion: c.cfg.ClientVersion})
if err != nil {
return "", err
}
endpoint := strings.TrimRight(c.cfg.ServerURL, "/") + "/v1/introspect"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.cfg.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
var result introspectResponse
if err := json.Unmarshal(payload, &result); err != nil {
return "", fmt.Errorf("decode verification response: %w", err)
}
if resp.StatusCode != http.StatusOK || !result.Valid || result.LeaseToken == "" {
if result.Reason == "" {
result.Reason = resp.Status
}
return "", errors.New(result.Reason)
}
return result.LeaseToken, nil
}
func (c *Client) readCache(claims licensekit.Claims, now time.Time) (licensekit.LeaseClaims, error) {
if strings.TrimSpace(c.cfg.CacheFile) == "" {
return licensekit.LeaseClaims{}, errors.New("cache file is not configured")
}
data, err := os.ReadFile(c.cfg.CacheFile)
if err != nil {
return licensekit.LeaseClaims{}, err
}
var doc cacheDocument
if err := json.Unmarshal(data, &doc); err != nil {
return licensekit.LeaseClaims{}, err
}
if doc.LicenseID != claims.LicenseID {
return licensekit.LeaseClaims{}, errors.New("cached lease belongs to another license")
}
grace := time.Duration(claims.Verification.OfflineGraceSeconds) * time.Second
verified, err := licensekit.VerifyLease(c.cfg.TrustStore, doc.Lease, now, grace)
if err != nil {
return licensekit.LeaseClaims{}, err
}
if err := licensekit.ValidateLeaseContext(verified.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil {
return licensekit.LeaseClaims{}, err
}
return verified.Claims, nil
}
func (c *Client) writeCache(licenseID, lease string) error {
if strings.TrimSpace(c.cfg.CacheFile) == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(c.cfg.CacheFile), 0o700); err != nil && filepath.Dir(c.cfg.CacheFile) != "." {
return err
}
data, err := json.Marshal(cacheDocument{LicenseID: licenseID, Lease: lease, SavedAt: time.Now().UTC().Unix()})
if err != nil {
return err
}
temp := c.cfg.CacheFile + ".tmp"
if err := os.WriteFile(temp, data, 0o600); err != nil {
return err
}
return os.Rename(temp, c.cfg.CacheFile)
}
func (c *Client) apply(status Status, claims licensekit.Claims) {
status.Features = licensekit.UniqueSorted(status.Features)
c.mu.Lock()
defer c.mu.Unlock()
c.status = status
c.claims = claims
c.features = make(map[string]bool, len(status.Features))
for _, feature := range status.Features {
c.features[feature] = true
}
}
func communityStatus() Status {
return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}}
}
func failedStatus(reason string, now time.Time) Status {
return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}, Reason: reason, LastChecked: now.Format(time.RFC3339)}
}
func statusFromClaims(claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time) Status {
limits := map[string]int64{}
for key, value := range claims.Limits {
limits[key] = value
}
return Status{
Edition: claims.Edition, LicenseID: claims.LicenseID, Customer: claims.Customer, Product: claims.Product,
Features: append([]string(nil), claims.Features...), Limits: limits,
ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC().Format(time.RFC3339), Mode: string(mode), LastChecked: now.Format(time.RFC3339),
}
}
func intersection(a, b []string) []string {
allowed := make(map[string]bool, len(b))
for _, value := range b {
allowed[value] = true
}
out := make([]string, 0, len(a))
for _, value := range a {
if allowed[value] {
out = append(out, value)
}
}
return out
}
// NewDevelopment returns an in-memory licensed client for local development.
// Production applications should not expose this path without an explicit development switch.
func NewDevelopment(product, edition string, features []string) *Client {
status := Status{Edition: edition, Licensed: true, Customer: "development", Product: product, Features: licensekit.UniqueSorted(features), Limits: map[string]int64{}, Mode: string(licensekit.ModeOffline), Source: "development", Reason: "insecure development override"}
c := &Client{status: status, features: map[string]bool{}, stop: make(chan struct{})}
for _, feature := range status.Features {
c.features[feature] = true
}
return c
}

View File

@@ -1,81 +0,0 @@
package licenseclient
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/b1tsblog/ai-disclosure-standard/pkg/licensekit"
)
func keys(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return pub, priv
}
func licenseToken(t *testing.T, mode licensekit.VerificationMode, store *licensekit.TrustStore) (string, licensekit.Claims) {
t.Helper()
pub, priv := keys(t)
store.LicenseKeys["issuer"] = licensekit.EncodeKey(pub)
now := time.Now().UTC()
claims := licensekit.Claims{Version: 1, LicenseID: "lic_1", Issuer: "vendor", Customer: "customer", Product: "product", Edition: "pro", Features: []string{"feature_a", "feature_b"}, Domains: []string{"*"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: licensekit.VerificationPolicy{Mode: mode, LeaseTTLSeconds: 600, OfflineGraceSeconds: 3600}}
token, err := licensekit.SignLicense(priv, "issuer", claims)
if err != nil {
t.Fatal(err)
}
return token, claims
}
func TestOfflineClient(t *testing.T) {
store := licensekit.NewTrustStore()
token, _ := licenseToken(t, licensekit.ModeOffline, &store)
c := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org"})
if !c.Status().Licensed || !c.Has("feature_a") {
t.Fatalf("unexpected status %#v", c.Status())
}
}
func TestHybridClientUsesOnlineLeaseAndCache(t *testing.T) {
store := licensekit.NewTrustStore()
token, claims := licenseToken(t, licensekit.ModeHybrid, &store)
leasePub, leasePriv := keys(t)
store.LeaseKeys["lease"] = licensekit.EncodeKey(leasePub)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC()
lease := licensekit.LeaseClaims{Version: 1, LeaseID: "lease_1", LicenseID: claims.LicenseID, Product: claims.Product, Customer: claims.Customer, Edition: claims.Edition, Features: claims.Features, Host: "example.org", IssuedAt: now.Unix(), ExpiresAt: now.Add(5 * time.Minute).Unix()}
leaseToken, err := licensekit.SignLease(leasePriv, "lease", lease)
if err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(map[string]any{"valid": true, "leaseToken": leaseToken})
}))
cache := filepath.Join(t.TempDir(), "lease.json")
c := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org", Mode: licensekit.ModeHybrid, ServerURL: server.URL, CacheFile: cache})
if !c.Status().Licensed || c.Status().Source != "online" {
t.Fatalf("unexpected online status %#v", c.Status())
}
server.Close()
c.Refresh(context.Background())
if !c.Status().Licensed || c.Status().Source != "cached-lease" {
t.Fatalf("unexpected cached status %#v", c.Status())
}
}
func TestOnlineModeFailsWithoutServer(t *testing.T) {
store := licensekit.NewTrustStore()
token, _ := licenseToken(t, licensekit.ModeOnline, &store)
c := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org", Mode: licensekit.ModeOnline})
if c.Status().Licensed {
t.Fatalf("online license unexpectedly active %#v", c.Status())
}
}

View File

@@ -1,4 +0,0 @@
// Package licenseclient implements the runtime side of product licensing. It
// supports offline verification, hybrid signed-lease caching and mandatory
// online introspection without allowing customers to replace trusted keys.
package licenseclient

View File

@@ -1,3 +0,0 @@
// Package licensekit implements product-neutral Ed25519 licence and lease
// tokens, embedded trust stores, context validation and key rotation by key ID.
package licensekit

View File

@@ -1,472 +0,0 @@
package licensekit
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
)
const (
TokenTypeLicense = "LICENSE"
TokenTypeLease = "LEASE"
AlgorithmEdDSA = "EdDSA"
SchemaVersion = 1
)
type VerificationMode string
const (
ModeOffline VerificationMode = "offline"
ModeHybrid VerificationMode = "hybrid"
ModeOnline VerificationMode = "online"
)
type Header struct {
Algorithm string `json:"alg"`
Type string `json:"typ"`
KeyID string `json:"kid"`
Version int `json:"v"`
}
type VerificationPolicy struct {
Mode VerificationMode `json:"mode"`
LeaseTTLSeconds int64 `json:"leaseTtlSeconds,omitempty"`
OfflineGraceSeconds int64 `json:"offlineGraceSeconds,omitempty"`
}
type Claims struct {
Version int `json:"version"`
LicenseID string `json:"licenseId"`
Issuer string `json:"issuer"`
Customer string `json:"customer"`
Product string `json:"product"`
Edition string `json:"edition"`
Features []string `json:"features,omitempty"`
Limits map[string]int64 `json:"limits,omitempty"`
Domains []string `json:"domains,omitempty"`
InstanceIDs []string `json:"instanceIds,omitempty"`
IssuedAt int64 `json:"issuedAt"`
NotBefore int64 `json:"notBefore,omitempty"`
ExpiresAt int64 `json:"expiresAt"`
Verification VerificationPolicy `json:"verification"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type LeaseClaims struct {
Version int `json:"version"`
LeaseID string `json:"leaseId"`
LicenseID string `json:"licenseId"`
Product string `json:"product"`
Customer string `json:"customer"`
Edition string `json:"edition"`
Features []string `json:"features,omitempty"`
Host string `json:"host,omitempty"`
InstanceID string `json:"instanceId,omitempty"`
IssuedAt int64 `json:"issuedAt"`
ExpiresAt int64 `json:"expiresAt"`
}
type TrustStore struct {
LicenseKeys map[string]string `json:"licenseKeys"`
LeaseKeys map[string]string `json:"leaseKeys"`
}
type VerifiedLicense struct {
Header Header
Claims Claims
}
type VerifiedLease struct {
Header Header
Claims LeaseClaims
}
func NewTrustStore() TrustStore {
return TrustStore{LicenseKeys: map[string]string{}, LeaseKeys: map[string]string{}}
}
func ParseTrustStore(data []byte) (TrustStore, error) {
var store TrustStore
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
if err := dec.Decode(&store); err != nil {
return TrustStore{}, fmt.Errorf("decode trust store: %w", err)
}
if store.LicenseKeys == nil {
store.LicenseKeys = map[string]string{}
}
if store.LeaseKeys == nil {
store.LeaseKeys = map[string]string{}
}
for kid, encoded := range store.LicenseKeys {
if strings.TrimSpace(kid) == "" {
return TrustStore{}, errors.New("license trust store contains an empty key id")
}
if _, err := DecodePublicKey(encoded); err != nil {
return TrustStore{}, fmt.Errorf("license key %q: %w", kid, err)
}
}
for kid, encoded := range store.LeaseKeys {
if strings.TrimSpace(kid) == "" {
return TrustStore{}, errors.New("lease trust store contains an empty key id")
}
if _, err := DecodePublicKey(encoded); err != nil {
return TrustStore{}, fmt.Errorf("lease key %q: %w", kid, err)
}
}
return store, nil
}
func MarshalTrustStore(store TrustStore) ([]byte, error) {
if store.LicenseKeys == nil {
store.LicenseKeys = map[string]string{}
}
if store.LeaseKeys == nil {
store.LeaseKeys = map[string]string{}
}
return json.MarshalIndent(store, "", " ")
}
func SignLicense(privateKey ed25519.PrivateKey, keyID string, claims Claims) (string, error) {
if err := validateLicenseClaims(claims, time.Unix(claims.IssuedAt, 0), false); err != nil {
return "", err
}
return sign(TokenTypeLicense, keyID, privateKey, claims)
}
func SignLease(privateKey ed25519.PrivateKey, keyID string, claims LeaseClaims) (string, error) {
if err := validateLeaseClaims(claims, time.Unix(claims.IssuedAt, 0), false); err != nil {
return "", err
}
return sign(TokenTypeLease, keyID, privateKey, claims)
}
func sign(tokenType, keyID string, privateKey ed25519.PrivateKey, claims any) (string, error) {
if len(privateKey) != ed25519.PrivateKeySize {
return "", errors.New("invalid Ed25519 private key")
}
keyID = strings.TrimSpace(keyID)
if keyID == "" {
return "", errors.New("key id is required")
}
header := Header{Algorithm: AlgorithmEdDSA, Type: tokenType, KeyID: keyID, Version: SchemaVersion}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", fmt.Errorf("marshal token header: %w", err)
}
payloadJSON, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("marshal token payload: %w", err)
}
headerPart := base64.RawURLEncoding.EncodeToString(headerJSON)
payloadPart := base64.RawURLEncoding.EncodeToString(payloadJSON)
signingInput := headerPart + "." + payloadPart
signature := ed25519.Sign(privateKey, []byte(signingInput))
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil
}
func VerifyLicense(store TrustStore, token string, now time.Time) (VerifiedLicense, error) {
header, payload, err := verifyToken(store.LicenseKeys, TokenTypeLicense, token)
if err != nil {
return VerifiedLicense{}, err
}
var claims Claims
if err := decodeStrict(payload, &claims); err != nil {
return VerifiedLicense{}, fmt.Errorf("decode license payload: %w", err)
}
if err := validateLicenseClaims(claims, now, true); err != nil {
return VerifiedLicense{}, err
}
claims.Features = UniqueSorted(claims.Features)
claims.Domains = UniqueSorted(claims.Domains)
claims.InstanceIDs = UniqueSorted(claims.InstanceIDs)
return VerifiedLicense{Header: header, Claims: claims}, nil
}
func VerifyLease(store TrustStore, token string, now time.Time, allowGrace time.Duration) (VerifiedLease, error) {
header, payload, err := verifyToken(store.LeaseKeys, TokenTypeLease, token)
if err != nil {
return VerifiedLease{}, err
}
var claims LeaseClaims
if err := decodeStrict(payload, &claims); err != nil {
return VerifiedLease{}, fmt.Errorf("decode lease payload: %w", err)
}
if err := validateLeaseClaims(claims, now, false); err != nil {
return VerifiedLease{}, err
}
if now.Unix() >= claims.ExpiresAt+int64(allowGrace.Seconds()) {
return VerifiedLease{}, errors.New("lease has expired")
}
claims.Features = UniqueSorted(claims.Features)
return VerifiedLease{Header: header, Claims: claims}, nil
}
func verifyToken(keys map[string]string, expectedType, token string) (Header, []byte, error) {
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) != 3 {
return Header{}, nil, errors.New("token has invalid format")
}
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return Header{}, nil, errors.New("token header is not valid base64url")
}
var header Header
if err := decodeStrict(headerBytes, &header); err != nil {
return Header{}, nil, fmt.Errorf("decode token header: %w", err)
}
if header.Algorithm != AlgorithmEdDSA || header.Type != expectedType || header.Version != SchemaVersion {
return Header{}, nil, errors.New("unsupported token header")
}
encodedKey, ok := keys[header.KeyID]
if !ok {
return Header{}, nil, fmt.Errorf("token is signed by unknown key %q", header.KeyID)
}
publicKey, err := DecodePublicKey(encodedKey)
if err != nil {
return Header{}, nil, fmt.Errorf("decode trusted key %q: %w", header.KeyID, err)
}
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return Header{}, nil, errors.New("token signature is not valid base64url")
}
signingInput := parts[0] + "." + parts[1]
if !ed25519.Verify(publicKey, []byte(signingInput), signature) {
return Header{}, nil, errors.New("token signature verification failed")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return Header{}, nil, errors.New("token payload is not valid base64url")
}
return header, payload, nil
}
func ValidateLicenseContext(claims Claims, product, baseURL, instanceID string) error {
if strings.TrimSpace(product) == "" {
return errors.New("client product id is required")
}
if claims.Product != product {
return fmt.Errorf("license is for product %q, not %q", claims.Product, product)
}
if err := ValidateDomain(claims.Domains, baseURL); err != nil {
return err
}
if len(claims.InstanceIDs) > 0 {
instanceID = strings.TrimSpace(instanceID)
if instanceID == "" {
return errors.New("license requires an instance id")
}
allowed := false
for _, candidate := range claims.InstanceIDs {
if candidate == "*" || candidate == instanceID {
allowed = true
break
}
}
if !allowed {
return fmt.Errorf("instance %q is not covered by the license", instanceID)
}
}
return nil
}
func ValidateLeaseContext(claims LeaseClaims, license Claims, product, baseURL, instanceID string) error {
if claims.LicenseID != license.LicenseID {
return errors.New("lease does not belong to the configured license")
}
if claims.Product != product || claims.Product != license.Product {
return errors.New("lease product does not match")
}
host, err := HostFromBaseURL(baseURL)
if err != nil {
return err
}
if claims.Host != "" && !strings.EqualFold(claims.Host, host) {
return errors.New("lease host does not match")
}
if claims.InstanceID != "" && claims.InstanceID != instanceID {
return errors.New("lease instance does not match")
}
return nil
}
func ValidateDomain(domains []string, baseURL string) error {
if len(domains) == 0 {
return nil
}
host, err := HostFromBaseURL(baseURL)
if err != nil {
return err
}
for _, allowed := range domains {
allowed = strings.ToLower(strings.TrimSpace(allowed))
if allowed == "*" || host == allowed {
return nil
}
if strings.HasPrefix(allowed, "*.") {
root := strings.TrimPrefix(allowed, "*.")
if host != root && strings.HasSuffix(host, "."+root) {
return nil
}
}
}
return fmt.Errorf("host %q is not covered by the license", host)
}
func HostFromBaseURL(baseURL string) (string, error) {
u, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil || u.Hostname() == "" {
return "", errors.New("base URL has no valid host")
}
return strings.ToLower(u.Hostname()), nil
}
func StricterMode(a, b VerificationMode) VerificationMode {
rank := map[VerificationMode]int{ModeOffline: 0, ModeHybrid: 1, ModeOnline: 2}
if rank[b] > rank[a] {
return b
}
if _, ok := rank[a]; !ok {
return ModeOffline
}
return a
}
func ParseMode(value string) (VerificationMode, error) {
mode := VerificationMode(strings.ToLower(strings.TrimSpace(value)))
switch mode {
case "", ModeOffline:
return ModeOffline, nil
case ModeHybrid, ModeOnline:
return mode, nil
default:
return "", fmt.Errorf("unknown verification mode %q", value)
}
}
func TokenHash(token string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
return hex.EncodeToString(sum[:])
}
func DecodePrivateKey(encoded string) (ed25519.PrivateKey, error) {
b, err := decodeKey(encoded)
if err != nil {
return nil, err
}
if len(b) == ed25519.SeedSize {
return ed25519.NewKeyFromSeed(b), nil
}
if len(b) != ed25519.PrivateKeySize {
return nil, errors.New("private key must contain an Ed25519 seed or private key")
}
return ed25519.PrivateKey(b), nil
}
func DecodePublicKey(encoded string) (ed25519.PublicKey, error) {
b, err := decodeKey(encoded)
if err != nil {
return nil, err
}
if len(b) != ed25519.PublicKeySize {
return nil, errors.New("public key must contain an Ed25519 public key")
}
return ed25519.PublicKey(b), nil
}
func EncodeKey(key []byte) string { return base64.RawURLEncoding.EncodeToString(key) }
func decodeKey(value string) ([]byte, error) {
value = strings.TrimSpace(value)
if b, err := base64.RawURLEncoding.DecodeString(value); err == nil {
return b, nil
}
if b, err := base64.StdEncoding.DecodeString(value); err == nil {
return b, nil
}
return nil, errors.New("key is not valid base64")
}
func UniqueSorted(values []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" && !seen[value] {
seen[value] = true
out = append(out, value)
}
}
sort.Strings(out)
return out
}
func validateLicenseClaims(c Claims, now time.Time, checkTime bool) error {
if c.Version != SchemaVersion {
return errors.New("unsupported license version")
}
if strings.TrimSpace(c.LicenseID) == "" || strings.TrimSpace(c.Issuer) == "" {
return errors.New("license id and issuer are required")
}
if strings.TrimSpace(c.Customer) == "" || strings.TrimSpace(c.Product) == "" || strings.TrimSpace(c.Edition) == "" {
return errors.New("customer, product and edition are required")
}
if c.IssuedAt <= 0 || c.ExpiresAt <= c.IssuedAt {
return errors.New("license timestamps are invalid")
}
notBefore := c.NotBefore
if notBefore == 0 {
notBefore = c.IssuedAt
}
if checkTime {
if now.Unix() < notBefore-300 {
return errors.New("license is not active yet")
}
if now.Unix() >= c.ExpiresAt {
return errors.New("license has expired")
}
}
if _, err := ParseMode(string(c.Verification.Mode)); err != nil {
return err
}
if c.Verification.LeaseTTLSeconds < 0 || c.Verification.OfflineGraceSeconds < 0 {
return errors.New("verification durations cannot be negative")
}
return nil
}
func validateLeaseClaims(c LeaseClaims, now time.Time, checkExpiration bool) error {
if c.Version != SchemaVersion {
return errors.New("unsupported lease version")
}
if strings.TrimSpace(c.LeaseID) == "" || strings.TrimSpace(c.LicenseID) == "" || strings.TrimSpace(c.Product) == "" {
return errors.New("lease id, license id and product are required")
}
if c.IssuedAt <= 0 || c.ExpiresAt <= c.IssuedAt {
return errors.New("lease timestamps are invalid")
}
if now.Unix() < c.IssuedAt-300 {
return errors.New("lease is not active yet")
}
if checkExpiration && now.Unix() >= c.ExpiresAt {
return errors.New("lease has expired")
}
return nil
}
func decodeStrict(data []byte, target any) error {
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
if err := dec.Decode(target); err != nil {
return err
}
return nil
}

View File

@@ -1,84 +0,0 @@
package licensekit
import (
"crypto/ed25519"
"crypto/rand"
"testing"
"time"
)
func testKeys(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return pub, priv
}
func TestLicenseRoundTripAndContext(t *testing.T) {
pub, priv := testKeys(t)
now := time.Now().UTC()
claims := Claims{Version: 1, LicenseID: "lic_test", Issuer: "vendor", Customer: "customer", Product: "product-a", Edition: "pro", Features: []string{"b", "a"}, Domains: []string{"*.example.org"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: VerificationPolicy{Mode: ModeOffline}}
token, err := SignLicense(priv, "issuer-1", claims)
if err != nil {
t.Fatal(err)
}
store := NewTrustStore()
store.LicenseKeys["issuer-1"] = EncodeKey(pub)
verified, err := VerifyLicense(store, token, now)
if err != nil {
t.Fatal(err)
}
if verified.Claims.Features[0] != "a" {
t.Fatalf("features not sorted: %#v", verified.Claims.Features)
}
if err := ValidateLicenseContext(verified.Claims, "product-a", "https://app.example.org", ""); err != nil {
t.Fatal(err)
}
if err := ValidateLicenseContext(verified.Claims, "product-b", "https://app.example.org", ""); err == nil {
t.Fatal("expected product mismatch")
}
}
func TestGlobalWildcardAllowsAllHosts(t *testing.T) {
if err := ValidateDomain([]string{"*"}, "http://localhost:8080"); err != nil {
t.Fatal(err)
}
if err := ValidateDomain([]string{"*"}, "https://anything.invalid"); err != nil {
t.Fatal(err)
}
}
func TestUnknownKeyIsRejected(t *testing.T) {
_, priv := testKeys(t)
now := time.Now().UTC()
claims := Claims{Version: 1, LicenseID: "lic_test", Issuer: "vendor", Customer: "customer", Product: "product", Edition: "pro", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: VerificationPolicy{Mode: ModeOffline}}
token, err := SignLicense(priv, "self-chosen", claims)
if err != nil {
t.Fatal(err)
}
if _, err := VerifyLicense(NewTrustStore(), token, now); err == nil {
t.Fatal("untrusted user key must not be accepted")
}
}
func TestLeaseRoundTrip(t *testing.T) {
pub, priv := testKeys(t)
now := time.Now().UTC()
claims := LeaseClaims{Version: 1, LeaseID: "lease_1", LicenseID: "lic_1", Product: "product", Customer: "customer", Edition: "pro", Features: []string{"x"}, Host: "example.org", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix()}
token, err := SignLease(priv, "lease-1", claims)
if err != nil {
t.Fatal(err)
}
store := NewTrustStore()
store.LeaseKeys["lease-1"] = EncodeKey(pub)
verified, err := VerifyLease(store, token, now, 0)
if err != nil {
t.Fatal(err)
}
license := Claims{LicenseID: "lic_1", Product: "product"}
if err := ValidateLeaseContext(verified.Claims, license, "product", "https://example.org", ""); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,4 +0,0 @@
// Package licenseserver implements the optional central introspection,
// registration, revocation and signed short-lived lease service. Its Registry
// interface can be backed by a transactional shared database for HA use.
package licenseserver

View File

@@ -1,271 +0,0 @@
package licenseserver
import (
"crypto/ed25519"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/b1tsblog/ai-disclosure-standard/pkg/licensekit"
)
type Config struct {
TrustStore licensekit.TrustStore
LeasePrivateKey ed25519.PrivateKey
LeaseKeyID string
AdminToken string
DefaultLeaseTTL time.Duration
MaxLeaseTTL time.Duration
}
type Registry interface {
Get(id string) (Record, bool)
List() []Record
Put(record Record) error
SetRevoked(id string, revoked bool, reason string) error
}
type Server struct {
cfg Config
store Registry
logger *slog.Logger
mux *http.ServeMux
}
type introspectRequest struct {
Token string `json:"token"`
Product string `json:"product"`
BaseURL string `json:"baseUrl"`
Host string `json:"host"`
InstanceID string `json:"instanceId,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
}
type introspectResponse struct {
Valid bool `json:"valid"`
LeaseToken string `json:"leaseToken,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
Reason string `json:"reason,omitempty"`
}
type registerRequest struct {
Token string `json:"token"`
}
type revokeRequest struct {
Reason string `json:"reason"`
}
func New(cfg Config, store Registry, logger *slog.Logger) (*Server, error) {
if store == nil {
return nil, errors.New("license registry is required")
}
if len(cfg.LeasePrivateKey) != ed25519.PrivateKeySize {
return nil, errors.New("a valid Ed25519 lease signing private key is required")
}
if strings.TrimSpace(cfg.LeaseKeyID) == "" {
return nil, errors.New("lease key id is required")
}
if _, ok := cfg.TrustStore.LeaseKeys[cfg.LeaseKeyID]; !ok {
return nil, fmt.Errorf("lease public key %q is not present in the trust store", cfg.LeaseKeyID)
}
if cfg.DefaultLeaseTTL <= 0 {
cfg.DefaultLeaseTTL = time.Hour
}
if cfg.MaxLeaseTTL <= 0 {
cfg.MaxLeaseTTL = 24 * time.Hour
}
if logger == nil {
logger = slog.Default()
}
s := &Server{cfg: cfg, store: store, logger: logger, mux: http.NewServeMux()}
s.routes()
return s, nil
}
func (s *Server) Handler() http.Handler { return s.securityHeaders(s.mux) }
func (s *Server) routes() {
s.mux.HandleFunc("GET /healthz", s.health)
s.mux.HandleFunc("POST /v1/introspect", s.introspect)
s.mux.HandleFunc("GET /v1/admin/licenses", s.admin(s.list))
s.mux.HandleFunc("POST /v1/admin/licenses", s.admin(s.register))
s.mux.HandleFunc("POST /v1/admin/licenses/{id}/revoke", s.admin(s.revoke))
s.mux.HandleFunc("POST /v1/admin/licenses/{id}/restore", s.admin(s.restore))
}
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
s.writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}
func (s *Server) introspect(w http.ResponseWriter, r *http.Request) {
var request introspectRequest
if err := decodeBody(r, &request); err != nil {
s.writeJSON(w, http.StatusBadRequest, introspectResponse{Reason: err.Error()})
return
}
now := time.Now().UTC()
verified, err := licensekit.VerifyLicense(s.cfg.TrustStore, request.Token, now)
if err != nil {
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: err.Error()})
return
}
claims := verified.Claims
if err := licensekit.ValidateLicenseContext(claims, request.Product, request.BaseURL, request.InstanceID); err != nil {
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: err.Error()})
return
}
record, ok := s.store.Get(claims.LicenseID)
if !ok {
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: "license is not registered"})
return
}
if record.TokenHash != licensekit.TokenHash(request.Token) {
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: "registered token does not match"})
return
}
if record.Revoked {
reason := "license is revoked"
if record.Reason != "" {
reason += ": " + record.Reason
}
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: reason})
return
}
ttl := s.cfg.DefaultLeaseTTL
if claims.Verification.LeaseTTLSeconds > 0 {
ttl = time.Duration(claims.Verification.LeaseTTLSeconds) * time.Second
}
if ttl > s.cfg.MaxLeaseTTL {
ttl = s.cfg.MaxLeaseTTL
}
if remaining := time.Until(time.Unix(claims.ExpiresAt, 0)); ttl > remaining {
ttl = remaining
}
if ttl <= 0 {
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: "license has expired"})
return
}
host, _ := licensekit.HostFromBaseURL(request.BaseURL)
leaseID := randomID("lease")
leaseClaims := licensekit.LeaseClaims{
Version: 1, LeaseID: leaseID, LicenseID: claims.LicenseID, Product: claims.Product,
Customer: claims.Customer, Edition: claims.Edition, Features: claims.Features,
Host: host, InstanceID: request.InstanceID, IssuedAt: now.Unix(), ExpiresAt: now.Add(ttl).Unix(),
}
token, err := licensekit.SignLease(s.cfg.LeasePrivateKey, s.cfg.LeaseKeyID, leaseClaims)
if err != nil {
s.logger.Error("lease signing failed", "error", err, "license_id", claims.LicenseID)
s.writeJSON(w, http.StatusInternalServerError, introspectResponse{Reason: "lease signing failed"})
return
}
s.writeJSON(w, http.StatusOK, introspectResponse{Valid: true, LeaseToken: token, ExpiresAt: time.Unix(leaseClaims.ExpiresAt, 0).UTC().Format(time.RFC3339)})
}
func (s *Server) list(w http.ResponseWriter, _ *http.Request) {
s.writeJSON(w, http.StatusOK, map[string]any{"licenses": s.store.List()})
}
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
var request registerRequest
if err := decodeBody(r, &request); err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
verified, err := licensekit.VerifyLicense(s.cfg.TrustStore, request.Token, time.Now().UTC())
if err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
claims := verified.Claims
record := Record{LicenseID: claims.LicenseID, TokenHash: licensekit.TokenHash(request.Token), Product: claims.Product, Customer: claims.Customer, Edition: claims.Edition, ExpiresAt: claims.ExpiresAt}
if err := s.store.Put(record); err != nil {
s.writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
s.writeJSON(w, http.StatusCreated, record)
}
func (s *Server) revoke(w http.ResponseWriter, r *http.Request) {
var request revokeRequest
_ = decodeBodyAllowEmpty(r, &request)
if err := s.store.SetRevoked(r.PathValue("id"), true, strings.TrimSpace(request.Reason)); err != nil {
if errors.Is(err, os.ErrNotExist) {
s.writeJSON(w, http.StatusNotFound, map[string]any{"error": "license not found"})
return
}
s.writeJSON(w, http.StatusNotFound, map[string]any{"error": "license not found"})
return
}
s.writeJSON(w, http.StatusOK, map[string]any{"status": "revoked"})
}
func (s *Server) restore(w http.ResponseWriter, r *http.Request) {
if err := s.store.SetRevoked(r.PathValue("id"), false, ""); err != nil {
s.writeJSON(w, http.StatusNotFound, map[string]any{"error": "license not found"})
return
}
s.writeJSON(w, http.StatusOK, map[string]any{"status": "active"})
}
func (s *Server) admin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
expected := strings.TrimSpace(s.cfg.AdminToken)
actual := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if expected == "" || subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) != 1 {
w.Header().Set("WWW-Authenticate", "Bearer")
s.writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
return
}
next(w, r)
}
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func (s *Server) writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func decodeBody(r *http.Request, target any) error {
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(target); err != nil {
return err
}
return nil
}
func decodeBodyAllowEmpty(r *http.Request, target any) error {
err := decodeBody(r, target)
if errors.Is(err, io.EOF) {
return nil
}
return err
}
func randomID(prefix string) string {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return prefix + "_" + fmt.Sprint(time.Now().UnixNano())
}
return prefix + "_" + hex.EncodeToString(raw[:])
}

View File

@@ -1,91 +0,0 @@
package licenseserver
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/b1tsblog/ai-disclosure-standard/pkg/licensekit"
)
func keyPair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return pub, priv
}
func TestRegisterIntrospectAndRevoke(t *testing.T) {
issuerPub, issuerPriv := keyPair(t)
leasePub, leasePriv := keyPair(t)
storeKeys := licensekit.NewTrustStore()
storeKeys.LicenseKeys["issuer"] = licensekit.EncodeKey(issuerPub)
storeKeys.LeaseKeys["lease"] = licensekit.EncodeKey(leasePub)
registry, err := OpenStore("")
if err != nil {
t.Fatal(err)
}
server, err := New(Config{TrustStore: storeKeys, LeasePrivateKey: leasePriv, LeaseKeyID: "lease", AdminToken: "secret", DefaultLeaseTTL: time.Hour}, registry, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
httpServer := httptest.NewServer(server.Handler())
defer httpServer.Close()
now := time.Now().UTC()
claims := licensekit.Claims{Version: 1, LicenseID: "lic_1", Issuer: "vendor", Customer: "customer", Product: "product", Edition: "pro", Features: []string{"feature"}, Domains: []string{"*"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: licensekit.VerificationPolicy{Mode: licensekit.ModeHybrid, LeaseTTLSeconds: 300}}
token, err := licensekit.SignLicense(issuerPriv, "issuer", claims)
if err != nil {
t.Fatal(err)
}
registerBody, _ := json.Marshal(map[string]string{"token": token})
req, _ := http.NewRequest(http.MethodPost, httpServer.URL+"/v1/admin/licenses", bytes.NewReader(registerBody))
req.Header.Set("Authorization", "Bearer secret")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("register status %d", resp.StatusCode)
}
introBody, _ := json.Marshal(map[string]string{"token": token, "product": "product", "baseUrl": "https://example.org"})
resp, err = http.Post(httpServer.URL+"/v1/introspect", "application/json", bytes.NewReader(introBody))
if err != nil {
t.Fatal(err)
}
var intro map[string]any
_ = json.NewDecoder(resp.Body).Decode(&intro)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || intro["valid"] != true {
t.Fatalf("introspection failed: %d %#v", resp.StatusCode, intro)
}
req, _ = http.NewRequest(http.MethodPost, httpServer.URL+"/v1/admin/licenses/lic_1/revoke", bytes.NewBufferString(`{"reason":"payment"}`))
req.Header.Set("Authorization", "Bearer secret")
req.Header.Set("Content-Type", "application/json")
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("revoke status %d", resp.StatusCode)
}
resp, err = http.Post(httpServer.URL+"/v1/introspect", "application/json", bytes.NewReader(introBody))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected revoked status, got %d", resp.StatusCode)
}
}

View File

@@ -1,130 +0,0 @@
package licenseserver
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
type Record struct {
LicenseID string `json:"licenseId"`
TokenHash string `json:"tokenHash"`
Product string `json:"product"`
Customer string `json:"customer"`
Edition string `json:"edition"`
ExpiresAt int64 `json:"expiresAt"`
Revoked bool `json:"revoked"`
Reason string `json:"reason,omitempty"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
type fileDocument struct {
Version int `json:"version"`
Records []Record `json:"records"`
}
type Store struct {
mu sync.RWMutex
path string
records map[string]Record
}
func OpenStore(path string) (*Store, error) {
s := &Store{path: path, records: map[string]Record{}}
if path == "" {
return s, nil
}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
var doc fileDocument
if err := json.Unmarshal(data, &doc); err != nil {
return nil, err
}
if doc.Version != 1 {
return nil, errors.New("unsupported license registry version")
}
for _, record := range doc.Records {
s.records[record.LicenseID] = record
}
return s, nil
}
func (s *Store) Get(id string) (Record, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
record, ok := s.records[id]
return record, ok
}
func (s *Store) List() []Record {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Record, 0, len(s.records))
for _, record := range s.records {
out = append(out, record)
}
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt })
return out
}
func (s *Store) Put(record Record) error {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UTC().Unix()
if existing, ok := s.records[record.LicenseID]; ok {
record.CreatedAt = existing.CreatedAt
}
if record.CreatedAt == 0 {
record.CreatedAt = now
}
record.UpdatedAt = now
s.records[record.LicenseID] = record
return s.persistLocked()
}
func (s *Store) SetRevoked(id string, revoked bool, reason string) error {
s.mu.Lock()
defer s.mu.Unlock()
record, ok := s.records[id]
if !ok {
return os.ErrNotExist
}
record.Revoked = revoked
record.Reason = reason
record.UpdatedAt = time.Now().UTC().Unix()
s.records[id] = record
return s.persistLocked()
}
func (s *Store) persistLocked() error {
if s.path == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil && filepath.Dir(s.path) != "." {
return err
}
records := make([]Record, 0, len(s.records))
for _, record := range s.records {
records = append(records, record)
}
sort.Slice(records, func(i, j int) bool { return records[i].LicenseID < records[j].LicenseID })
data, err := json.MarshalIndent(fileDocument{Version: 1, Records: records}, "", " ")
if err != nil {
return err
}
temp := s.path + ".tmp"
if err := os.WriteFile(temp, data, 0o600); err != nil {
return err
}
return os.Rename(temp, s.path)
}

View File

@@ -52,9 +52,6 @@
"editorialResponsibility": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
@@ -62,15 +59,38 @@
"url": {
"type": "string",
"format": "uri"
}
}
},
"regulatoryContext": {
"type": "object",
"additionalProperties": false,
"properties": {
"framework": {
"enum": [
"EU-AI-Act-Article-50"
]
},
"assumed": {
"publicInterestText": {
"type": "boolean"
},
"role": {
"enum": [
"publisher",
"other"
]
"deepfake": {
"type": "boolean"
},
"artisticCreativeSatiricalFictional": {
"type": "boolean"
},
"substantialHumanReview": {
"type": "boolean"
},
"editorialResponsibilityConfirmed": {
"type": "boolean"
},
"firstExposureDisclosure": {
"type": "boolean"
},
"accessibilityConsidered": {
"type": "boolean"
}
}
},
@@ -111,24 +131,6 @@
"pattern": "^#[0-9A-Fa-f]{6}$"
}
}
},
"legalContext": {
"type": "object",
"additionalProperties": false,
"properties": {
"categories": {
"type": "array",
"uniqueItems": true,
"items": {
"enum": [
"deepfake",
"publicInterestText",
"artisticCreativeSatiricalFictional",
"otherVoluntary"
]
}
}
}
}
},
"$defs": {

View File

@@ -1,34 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.org/schema/license-v1.json",
"title": "Universal Product Licence Claims",
"type": "object",
"additionalProperties": false,
"required": ["version", "licenseId", "issuer", "customer", "product", "edition", "issuedAt", "expiresAt", "verification"],
"properties": {
"version": {"const": 1},
"licenseId": {"type": "string", "minLength": 1, "maxLength": 200},
"issuer": {"type": "string", "minLength": 1, "maxLength": 200},
"customer": {"type": "string", "minLength": 1, "maxLength": 300},
"product": {"type": "string", "minLength": 1, "maxLength": 200},
"edition": {"type": "string", "minLength": 1, "maxLength": 100},
"features": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true},
"limits": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}},
"domains": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true},
"instanceIds": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true},
"issuedAt": {"type": "integer", "minimum": 1},
"notBefore": {"type": "integer", "minimum": 1},
"expiresAt": {"type": "integer", "minimum": 1},
"verification": {
"type": "object",
"additionalProperties": false,
"required": ["mode"],
"properties": {
"mode": {"enum": ["offline", "hybrid", "online"]},
"leaseTtlSeconds": {"type": "integer", "minimum": 0},
"offlineGraceSeconds": {"type": "integer", "minimum": 0}
}
},
"metadata": {"type": "object", "additionalProperties": {"type": "string"}}
}
}

View File

@@ -1,12 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.org/schema/license-trust-store-v1.json",
"title": "Licence Trust Store",
"type": "object",
"additionalProperties": false,
"required": ["licenseKeys", "leaseKeys"],
"properties": {
"licenseKeys": {"type": "object", "additionalProperties": {"type": "string", "minLength": 40}},
"leaseKeys": {"type": "object", "additionalProperties": {"type": "string", "minLength": 40}}
}
}

10
secrets/README.md Normal file
View File

@@ -0,0 +1,10 @@
# Runtime secrets
Do not commit real secrets in this directory.
For `compose.commercial.yaml`, create:
- `secrets/license_token.txt` — token issued by the Universal License Platform
- `secrets/bulk_api_key.txt` — a long random bearer/API key for the bulk endpoint
Both paths are ignored by `.gitignore`.

View File

@@ -134,7 +134,7 @@ func (c *Client) Refresh(ctx context.Context) Status {
func (c *Client) Has(feature string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.isCurrentlyLicensedLocked(time.Now().UTC()) && c.features[feature]
return c.isCurrentlyLicensedLocked(time.Now().UTC()) && (c.features[feature] || c.features["*"])
}
func (c *Client) Limit(name string) (int64, bool) {
@@ -425,6 +425,26 @@ func statusFromClaims(claims licensekit.Claims, mode licensekit.VerificationMode
}
func intersection(a, b []string) []string {
aAll, bAll := false, false
for _, value := range a {
if value == "*" {
aAll = true
}
}
for _, value := range b {
if value == "*" {
bAll = true
}
}
if aAll && bAll {
return []string{"*"}
}
if aAll {
return licensekit.UniqueSorted(b)
}
if bAll {
return licensekit.UniqueSorted(a)
}
allowed := make(map[string]bool, len(b))
for _, value := range b {
allowed[value] = true
@@ -435,5 +455,5 @@ func intersection(a, b []string) []string {
out = append(out, value)
}
}
return out
return licensekit.UniqueSorted(out)
}

View File

@@ -62,3 +62,14 @@ func TestHybridUsesPlatformValidateEndpoint(t *testing.T) {
t.Fatalf("unexpected endpoint %q", requestedPath)
}
}
func TestFeatureWildcardIntersection(t *testing.T) {
got := intersection([]string{"*"}, []string{"custom_text", "bulk_api"})
if len(got) != 2 || got[0] != "bulk_api" || got[1] != "custom_text" {
t.Fatalf("unexpected wildcard intersection: %v", got)
}
got = intersection([]string{"custom_text", "bulk_api"}, []string{"*"})
if len(got) != 2 || got[0] != "bulk_api" || got[1] != "custom_text" {
t.Fatalf("unexpected inverse wildcard intersection: %v", got)
}
}

View File

@@ -6,12 +6,10 @@
if (!form) return;
const $ = id => document.getElementById(id);
const presets = {
// Never assume human/editorial review. It can be legally relevant under
// Article 50(4) AI Act and must reflect the publisher's actual workflow.
'no-ai': {extent:'none', activities:'', review:'none'},
research: {extent:'assisted', activities:'research', review:'none'},
summary: {extent:'assisted', activities:'summarisation', review:'none'},
full: {extent:'full', activities:'generation', review:'none'}
'no-ai': {extent:'none', activities:'', review:'editorial'},
research: {extent:'assisted', activities:'research', review:'editorial'},
summary: {extent:'assisted', activities:'summarisation', review:'editorial'},
full: {extent:'full', activities:'generation', review:'editorial'}
};
function value(id) {
@@ -27,6 +25,8 @@
image: 'article-image',
research: 'article-research',
translation: 'article-translation',
audio: 'article-audio',
video: 'article-video',
code: 'article-code'
};
Object.entries(fields).forEach(([name, id]) => {
@@ -35,36 +35,6 @@
});
}
function legalCategories() {
const values = [];
if ($('legal-deepfake')?.checked) values.push('deepfake');
if ($('legal-public-interest')?.checked) values.push('publicInterestText');
if ($('legal-creative')?.checked) values.push('artisticCreativeSatiricalFictional');
if ($('legal-other-voluntary')?.checked) values.push('otherVoluntary');
return values;
}
function substantiveReview(mode) {
return mode === 'editorial' || mode === 'expert';
}
function legalAssessment(locale, articleMode) {
const categories = legalCategories();
const deepfake = categories.includes('deepfake');
const publicInterest = categories.includes('publicInterestText');
const creative = categories.includes('artisticCreativeSatiricalFictional');
if (categories.length === 0) return {text: locale.text.legal_assessment_none, legal: false, badge: locale.text.badge_article || 'Article transparency'};
if (categories.includes('otherVoluntary')) return {text: locale.text.legal_assessment_voluntary, legal: false, badge: locale.text.badge_article || 'Article transparency'};
if (deepfake) return {text: creative ? locale.text.legal_assessment_deepfake_creative : locale.text.legal_assessment_deepfake, legal: true, badge: locale.text.badge_legal_disclosure || 'AI-generated / manipulated content'};
if (publicInterest) {
const review = articleMode ? $('article-text-review').value : ($('component').value === 'text' ? $('review').value : 'none');
const hasResponsibility = Boolean(value('responsible'));
if (substantiveReview(review) && hasResponsibility) return {text: locale.text.legal_assessment_public_exemption, legal: false, badge: locale.text.badge_article || 'Article transparency'};
return {text: locale.text.legal_assessment_public_required, legal: true, badge: locale.text.badge_public_interest || 'AI-generated / edited content'};
}
return {text: locale.text.legal_assessment_voluntary, legal: false, badge: locale.text.badge_article || 'Article transparency'};
}
function update(applyPreset = false) {
const mode = $('declaration-mode').value;
const articleMode = mode === 'article';
@@ -98,11 +68,17 @@
params.set('lang', lang);
params.set('assurance', assurance);
if ($('subject').value.trim()) params.set('subject', $('subject').value.trim());
if (value('responsible-role')) params.set('responsibleRole', value('responsible-role'));
const boolParam = (id, key) => { const el = $(id); if (el && el.checked) params.set(key, 'true'); };
boolParam('public-interest-text', 'publicInterestText');
boolParam('deepfake', 'deepfake');
boolParam('artistic-context', 'artisticCreativeSatiricalFictional');
boolParam('substantial-review', 'substantialHumanReview');
boolParam('editorial-responsibility-confirmed', 'editorialResponsibilityConfirmed');
boolParam('first-exposure-disclosure', 'firstExposureDisclosure');
boolParam('accessibility-considered', 'accessibilityConsidered');
if (value('responsible')) params.set('responsible', value('responsible'));
if (value('responsible-url')) params.set('responsibleUrl', value('responsible-url'));
const legal = legalCategories();
if (legal.length) params.set('legalContext', legal.join(','));
if (cfg.capabilities.custom_text) {
if (value('custom-title')) params.set('customTitle', value('custom-title'));
@@ -121,28 +97,17 @@
badgeParams.set('link', declarationURL);
const badgeURL = `${base}/v1/badge.svg?${badgeParams}`;
const manifestURL = `${base}/v1/declaration.json?${params}`;
const assessment = legalAssessment(locale, articleMode);
const alt = assessment.legal
? assessment.badge
: (articleMode
? (locale.text.badge_article || 'Article transparency')
: ((locale.presets[preset] && locale.presets[preset].title) || locale.extents[$('extent').value] || 'AI usage disclosure'));
if ($('legal-assessment')) {
$('legal-assessment').innerHTML = `<strong>${locale.text.legal_assessment_heading || 'Article 50 assessment'}</strong><span>${assessment.text || ''}</span>`;
$('legal-assessment').dataset.level = assessment.legal ? 'attention' : 'info';
}
let alt = articleMode ? (locale.text.badge_article || 'Article transparency') : (locale.text.ai_label || 'AI usage disclosure');
if ($('deepfake')?.checked) alt = locale.text.badge_article50_deepfake || alt;
else if ($('public-interest-text')?.checked && !($('substantial-review')?.checked && $('editorial-responsibility-confirmed')?.checked)) alt = locale.text.badge_article50_text || alt;
$('preview-badge').src = badgeURL;
$('preview-badge').alt = alt;
$('preview-link').href = declarationURL;
const imageMarkup = `<a href="${declarationURL}"><img src="${badgeURL}" alt="${alt}"></a>`;
$('html-code').value = assessment.legal && $('theme').value === 'emoji'
? `${imageMarkup} <span>${assessment.badge}</span>`
: imageMarkup;
$('markdown-code').value = assessment.legal && $('theme').value === 'emoji'
? `[![${alt}](${badgeURL})](${declarationURL}) **${assessment.badge}**`
: `[![${alt}](${badgeURL})](${declarationURL})`;
$('html-code').value = `<a href="${declarationURL}"><img src="${badgeURL}" alt="${alt}"></a>`;
$('markdown-code').value = `[![${alt}](${badgeURL})](${declarationURL})`;
$('json-code').value = manifestURL;
if ($('assessment-code')) $('assessment-code').value = `${base}/v1/article50-assessment.json?${params}`;
}
@@ -152,59 +117,13 @@
if (!reviewSelect) return;
if (extentSelect.value === 'none') {
reviewSelect.value = 'none';
} else if (reviewSelect.value === 'none') {
reviewSelect.value = 'editorial';
}
// For AI-assisted/generated components keep "none" until the publisher
// explicitly selects the review process that actually took place.
update(false);
});
});
function alignLegalContextWithContent(changedId) {
if (changedId === 'legal-deepfake' && $('legal-deepfake')?.checked) {
if ($('legal-other-voluntary')) $('legal-other-voluntary').checked = false;
if ($('declaration-mode').value === 'single') {
if ($('legal-public-interest')?.checked) {
$('declaration-mode').value = 'article';
} else if (!['coverImage', 'image', 'audio', 'video'].includes($('component').value)) {
$('preset').value = '';
$('component').value = 'image';
if ($('extent').value === 'none') $('extent').value = 'partial';
}
}
}
if (changedId === 'legal-public-interest' && $('legal-public-interest')?.checked) {
if ($('legal-other-voluntary')) $('legal-other-voluntary').checked = false;
if ($('declaration-mode').value === 'single') {
if ($('legal-deepfake')?.checked) {
$('declaration-mode').value = 'article';
} else {
$('preset').value = '';
$('component').value = 'text';
if ($('extent').value === 'none') $('extent').value = 'partial';
}
}
if ($('declaration-mode').value === 'article' && $('article-text').value === 'none') {
$('article-text').value = 'partial';
}
}
if (changedId === 'legal-creative' && $('legal-creative')?.checked && $('legal-other-voluntary')) {
$('legal-other-voluntary').checked = false;
}
if ($('declaration-mode').value === 'article' && $('legal-deepfake')?.checked) {
const media = ['article-cover-image', 'article-image'];
const hasMedia = media.some(id => $(id)?.value !== 'none');
if (!hasMedia && $('article-image')) $('article-image').value = 'partial';
}
}
['legal-deepfake', 'legal-public-interest', 'legal-creative'].forEach(id => {
$(id)?.addEventListener('change', () => alignLegalContextWithContent(id));
});
$('legal-other-voluntary')?.addEventListener('change', () => {
if (!$('legal-other-voluntary').checked) return;
['legal-deepfake', 'legal-public-interest', 'legal-creative'].forEach(id => { if ($(id)) $(id).checked = false; });
});
form.addEventListener('input', event => update(event.target.id === 'preset'));
form.addEventListener('change', event => update(event.target.id === 'preset'));
document.querySelector('.pro-panel')?.addEventListener('input', () => update(false));

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

258
web/static/bulk.js Normal file
View File

@@ -0,0 +1,258 @@
(() => {
const cfg = window.BULK_CONFIG;
if (!cfg) return;
const $ = id => document.getElementById(id);
const t = (key, fallback = key) => (cfg.texts && cfg.texts[key]) || fallback;
let lastResult = null;
const apiKeyInput = $('bulk-api-key');
if (apiKeyInput) {
apiKeyInput.value = sessionStorage.getItem('aiDisclosureBulkApiKey') || '';
apiKeyInput.addEventListener('input', () => sessionStorage.setItem('aiDisclosureBulkApiKey', apiKeyInput.value));
}
function parseLines() {
const raw = ($('bulk-items')?.value || '').split(/\r?\n/).map(v => v.trim()).filter(Boolean);
return raw.map((line, index) => {
let id = '';
let subject = '';
if (line.includes('|')) {
const [left, ...right] = line.split('|');
id = left.trim();
subject = right.join('|').trim();
} else if (line.includes('\t')) {
const [left, ...right] = line.split('\t');
id = left.trim();
subject = right.join('\t').trim();
} else {
subject = line;
}
if (!id) id = `item-${index + 1}`;
return {id, subject};
});
}
function updateCount() {
const count = parseLines().length;
const el = $('bulk-item-count');
if (el) {
el.textContent = `${count} / ${cfg.maxItems}`;
el.classList.toggle('over-limit', count > cfg.maxItems);
}
}
function normalizeReview(extentId, reviewId) {
const extent = $(extentId);
const review = $(reviewId);
if (!extent || !review) return;
if (extent.value === 'none') review.value = 'none';
else if (review.value === 'none') review.value = 'editorial';
}
const componentPairs = [
['bulk-text-extent', 'bulk-text-review'],
['bulk-cover-extent', 'bulk-cover-review'],
['bulk-image-extent', 'bulk-image-review'],
['bulk-research-extent', 'bulk-research-review'],
['bulk-translation-extent', 'bulk-translation-review'],
['bulk-code-extent', 'bulk-code-review']
];
componentPairs.forEach(([extentId, reviewId]) => {
$(extentId)?.addEventListener('change', () => normalizeReview(extentId, reviewId));
});
function commonParameters(subject) {
const params = {
mode: 'article',
lang: $('bulk-output-language').value,
assurance: $('bulk-assurance').value,
textExtent: $('bulk-text-extent').value,
textReview: $('bulk-text-review').value,
coverImageExtent: $('bulk-cover-extent').value,
coverImageReview: $('bulk-cover-review').value,
imageExtent: $('bulk-image-extent').value,
imageReview: $('bulk-image-review').value,
researchExtent: $('bulk-research-extent').value,
researchReview: $('bulk-research-review').value,
translationExtent: $('bulk-translation-extent').value,
translationReview: $('bulk-translation-review').value,
codeExtent: $('bulk-code-extent').value,
codeReview: $('bulk-code-review').value
};
if (subject) params.subject = subject;
const bools = {
'bulk-public-interest': 'publicInterestText',
'bulk-deepfake': 'deepfake',
'bulk-substantial-review': 'substantialHumanReview',
'bulk-editorial-responsibility': 'editorialResponsibilityConfirmed',
'bulk-first-exposure': 'firstExposureDisclosure',
'bulk-accessibility': 'accessibilityConsidered'
};
Object.entries(bools).forEach(([id, key]) => { if ($(id)?.checked) params[key] = 'true'; });
const responsible = $('bulk-responsible')?.value.trim();
const responsibleURL = $('bulk-responsible-url')?.value.trim();
if (responsible) params.responsible = responsible;
if (responsibleURL) params.responsibleUrl = responsibleURL;
return params;
}
function buildVisualRequest() {
const items = parseLines();
if (!items.length) throw new Error(t('error_no_items', 'Add at least one item.'));
if (items.length > cfg.maxItems) throw new Error(t('error_too_many_items', 'The configured item limit has been exceeded.'));
return {items: items.map(item => ({id: item.id, parameters: commonParameters(item.subject)}))};
}
function headers() {
const h = {'Content-Type': 'application/json'};
const key = apiKeyInput?.value.trim();
if (cfg.requireAPIKey) {
if (!key) throw new Error(t('error_api_key', 'A bulk API key is required.'));
h.Authorization = `Bearer ${key}`;
} else if (key) {
h.Authorization = `Bearer ${key}`;
}
return h;
}
async function run(request) {
const status = $('bulk-run-status');
if (status) status.textContent = t('status_running', 'Processing…');
try {
const response = await fetch(cfg.endpoint, {method: 'POST', headers: headers(), body: JSON.stringify(request)});
const data = await response.json().catch(() => ({code: 'invalid_response', detail: response.statusText}));
if (!response.ok) throw new Error(data.detail || data.title || `${response.status} ${response.statusText}`);
lastResult = data;
renderResults(data);
if (status) status.textContent = t('status_done', 'Completed.');
} catch (err) {
if (status) status.textContent = `${t('status_error', 'Error')}: ${err.message}`;
throw err;
}
}
function renderResults(data) {
const section = $('results');
const body = $('bulk-result-body');
const raw = $('bulk-result-json');
if (!section || !body || !raw) return;
section.hidden = false;
body.textContent = '';
const items = Array.isArray(data.items) ? data.items : [];
const valid = items.filter(item => item.valid).length;
const failed = items.length - valid;
$('bulk-result-summary').textContent = `${t('summary_processed', 'Processed')}: ${items.length} · ${t('summary_successful', 'Successful')}: ${valid} · ${t('summary_failed', 'Failed')}: ${failed}`;
items.forEach(item => {
const tr = document.createElement('tr');
const id = document.createElement('td');
id.textContent = item.id || '—';
const status = document.createElement('td');
const pill = document.createElement('span');
pill.className = `bulk-result-pill ${item.valid ? 'ok' : 'error'}`;
pill.textContent = item.valid ? t('result_ok', 'OK') : t('result_error', 'Error');
status.appendChild(pill);
if (!item.valid && item.error?.detail) {
const detail = document.createElement('small');
detail.className = 'bulk-error-detail';
detail.textContent = item.error.detail;
status.appendChild(detail);
}
const assessment = document.createElement('td');
assessment.textContent = item.article50Assessment?.code || '—';
const links = document.createElement('td');
if (item.valid) {
const linkData = [
[t('link_declaration', 'Declaration'), item.declarationUrl],
[t('link_manifest', 'Manifest'), item.manifestUrl],
[t('link_badge', 'Badge'), item.badgeUrl]
];
linkData.forEach(([label, href], index) => {
if (!href) return;
if (index) links.appendChild(document.createTextNode(' · '));
const a = document.createElement('a');
a.href = href;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = label;
links.appendChild(a);
});
} else links.textContent = '—';
tr.append(id, status, assessment, links);
body.appendChild(tr);
});
raw.textContent = JSON.stringify(data, null, 2);
section.scrollIntoView({behavior: 'smooth', block: 'start'});
}
function download(filename, content, type) {
const blob = new Blob([content], {type});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function csvEscape(value) {
const s = String(value ?? '');
return /[",\n\r;]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
}
function resultCSV() {
const rows = [['id', 'valid', 'assessment', 'declaration_url', 'manifest_url', 'badge_url', 'error']];
(lastResult?.items || []).forEach(item => rows.push([
item.id || '', item.valid ? 'true' : 'false', item.article50Assessment?.code || '', item.declarationUrl || '', item.manifestUrl || '', item.badgeUrl || '', item.error?.detail || ''
]));
return rows.map(row => row.map(csvEscape).join(';')).join('\n');
}
$('bulk-items')?.addEventListener('input', updateCount);
$('bulk-sample-items')?.addEventListener('click', () => {
$('bulk-items').value = 'article-1001 | https://example.org/articles/1001\narticle-1002 | https://example.org/articles/1002\narticle-1003 | https://example.org/articles/1003';
updateCount();
});
$('bulk-run')?.addEventListener('click', async () => {
try { await run(buildVisualRequest()); } catch (_) { /* status already rendered */ }
});
$('bulk-json-sample')?.addEventListener('click', () => {
try {
if (!parseLines().length) {
$('bulk-items').value = 'article-1001 | https://example.org/articles/1001';
updateCount();
}
$('bulk-json').value = JSON.stringify(buildVisualRequest(), null, 2);
} catch (err) {
$('bulk-run-status').textContent = `${t('status_error', 'Error')}: ${err.message}`;
}
});
$('bulk-json-run')?.addEventListener('click', async () => {
try {
const request = JSON.parse($('bulk-json').value);
await run(request);
} catch (err) {
$('bulk-run-status').textContent = `${t('status_error', 'Error')}: ${err.message}`;
}
});
$('bulk-copy-json')?.addEventListener('click', async () => {
if (!lastResult) return;
await navigator.clipboard.writeText(JSON.stringify(lastResult, null, 2));
});
$('bulk-download-json')?.addEventListener('click', () => {
if (lastResult) download('ai-disclosure-bulk-results.json', JSON.stringify(lastResult, null, 2), 'application/json');
});
$('bulk-download-csv')?.addEventListener('click', () => {
if (lastResult) download('ai-disclosure-bulk-results.csv', resultCSV(), 'text/csv;charset=utf-8');
});
$('bulk-language')?.addEventListener('change', event => {
const url = new URL(window.location.href);
url.searchParams.set('lang', event.target.value);
window.location.href = url.toString();
});
updateCount();
componentPairs.forEach(([extentId, reviewId]) => normalizeReview(extentId, reviewId));
})();

View File

@@ -7,13 +7,6 @@
});
});
document.querySelectorAll('[data-url-switch]').forEach(select => {
select.addEventListener('change', event => {
const target = event.target.value;
if (target) window.location.assign(target);
});
});
document.querySelectorAll('.copy-code').forEach(button => {
button.addEventListener('click', async () => {
const target = document.getElementById(button.dataset.copyTarget);

File diff suppressed because one or more lines are too long

View File

@@ -9,11 +9,22 @@
<link rel="canonical" href="{{.BaseURL}}/background?lang={{.Lang}}">
{{range .LanguageLinks}}<link rel="alternate" hreflang="{{.Code}}" href="{{.AbsoluteURL}}">{{end}}
<link rel="alternate" hreflang="x-default" href="{{.DefaultLanguageURL}}">
{{if not .WhiteLabel}}
<meta name="theme-color" content="#F8FAFC">
<link rel="icon" href="/static/brand/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/ucng-icon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/static/brand/ucng-icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/static/brand/ucng-icon-512.png">
<link rel="apple-touch-icon" sizes="192x192" href="/static/brand/ucng-icon-192.png">
<meta property="og:image" content="{{.BaseURL}}/static/brand/ucng-open-graph.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{{end}}
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="background-page">
<header class="site-header background-header">
<a class="brand" href="/?lang={{.Lang}}">{{.Name}}</a>
<a class="brand" href="/?lang={{.Lang}}">{{if .WhiteLabel}}<span class="brand-text">{{.Name}}</span>{{else}}<img src="/static/brand/ucng-logo.png" width="800" height="233" alt="UCNG — Universal Content Notation Guidelines">{{end}}</a>
<nav>
<a href="/product?lang={{.Lang}}">{{.Background.NavProduct}}</a>
<a href="/?lang={{.Lang}}#generator">{{.Background.NavGenerator}}</a>
@@ -158,7 +169,7 @@
</section>
</main>
<footer><span>{{.Background.Footer}} · App 1.6.3 · Schema 1.2 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<footer><span>{{if not .WhiteLabel}}{{.Background.Footer}}{{end}}</span><span><a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a> · <a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a> · <a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a></span></footer>
<script src="/static/marketing.js" defer></script>
</body>
</html>

174
web/templates/bulk.html Normal file
View File

@@ -0,0 +1,174 @@
{{define "bulk.html"}}
<!doctype html>
<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Name}} · {{index .Text "bulk_title"}}</title>
<meta name="description" content="{{index .Text "bulk_meta_description"}}">
<meta name="robots" content="noindex,nofollow">
{{if not .WhiteLabel}}
<meta name="theme-color" content="#F8FAFC">
<link rel="icon" href="/static/brand/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/ucng-icon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/static/brand/ucng-icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/static/brand/ucng-icon-512.png">
<link rel="apple-touch-icon" sizes="192x192" href="/static/brand/ucng-icon-192.png">
<meta property="og:image" content="{{.BaseURL}}/static/brand/ucng-open-graph.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{{end}}
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="bulk-page">
<header class="site-header bulk-header">
<a class="brand" href="/?lang={{.Lang}}">{{if .WhiteLabel}}<span class="brand-text">{{.Name}}</span>{{else}}<img src="/static/brand/ucng-logo.png" width="800" height="233" alt="UCNG — Universal Content Notation Guidelines">{{end}}</a>
<nav>
<a href="#workspace">{{index .Text "bulk_nav_workspace"}}</a>
<a href="#advanced">{{index .Text "bulk_nav_advanced"}}</a>
<a href="#results">{{index .Text "bulk_nav_results"}}</a>
</nav>
<label class="language-switch"><span class="sr-only">{{index .Text "field_language"}}</span><select id="bulk-language">
{{range .Languages}}<option value="{{.Code}}" {{if eq $.Lang .Code}}selected{{end}}>{{.Name}}</option>{{end}}
</select></label>
</header>
<main class="bulk-main">
<section class="bulk-hero">
<div>
<p class="eyebrow">{{index .Text "bulk_eyebrow"}}</p>
<h1>{{index .Text "bulk_title"}}</h1>
<p class="lead">{{index .Text "bulk_intro"}}</p>
</div>
<div class="bulk-status-grid" aria-label="{{index .Text "bulk_status"}}">
<div class="bulk-status-card"><span>{{index .Text "bulk_license"}}</span><strong>{{if .BulkAPI}}{{index .Text "bulk_active"}}{{else}}{{index .Text "bulk_inactive"}}{{end}}</strong></div>
<div class="bulk-status-card"><span>{{index .Text "bulk_limit"}}</span><strong>{{.BulkMaxItems}}</strong></div>
<div class="bulk-status-card"><span>{{index .Text "bulk_auth"}}</span><strong>{{if .BulkRequireAPIKey}}{{index .Text "bulk_api_key"}}{{else}}{{index .Text "bulk_no_api_key"}}{{end}}</strong></div>
</div>
</section>
{{if not .BulkAPI}}
<section class="notice bulk-license-warning">
<strong>{{index .Text "bulk_inactive_title"}}</strong>
<p>{{index .Text "bulk_inactive_help"}}</p>
</section>
{{end}}
<section id="workspace" class="panel bulk-workspace">
<div class="section-heading-row bulk-section-heading">
<div>
<p class="eyebrow">{{index .Text "bulk_workspace_eyebrow"}}</p>
<h2>{{index .Text "bulk_workspace_title"}}</h2>
<p>{{index .Text "bulk_workspace_intro"}}</p>
</div>
</div>
{{if .BulkRequireAPIKey}}
<div class="bulk-auth-box">
<label>{{index .Text "bulk_api_key_label"}}
<input id="bulk-api-key" type="password" autocomplete="off" placeholder="{{index .Text "bulk_api_key_placeholder"}}">
<small>{{index .Text "bulk_api_key_help"}}</small>
</label>
</div>
{{end}}
<div class="bulk-two-column">
<div class="bulk-source-panel">
<label>{{index .Text "bulk_items_label"}}
<textarea id="bulk-items" rows="12" placeholder="{{index .Text "bulk_items_placeholder"}}"></textarea>
<small>{{index .Text "bulk_items_help"}}</small>
</label>
<div class="bulk-inline-actions">
<button type="button" class="button secondary" id="bulk-sample-items">{{index .Text "bulk_sample"}}</button>
<span id="bulk-item-count" class="bulk-counter">0 / {{.BulkMaxItems}}</span>
</div>
</div>
<div class="bulk-profile-panel">
<h3>{{index .Text "bulk_profile_title"}}</h3>
<p>{{index .Text "bulk_profile_intro"}}</p>
<div class="bulk-profile-grid">
<label>{{index .Text "field_language"}}
<select id="bulk-output-language">{{range .Languages}}<option value="{{.Code}}" {{if eq $.Lang .Code}}selected{{end}}>{{.Name}}</option>{{end}}</select>
</label>
<label>{{index .Text "field_assurance"}}
<select id="bulk-assurance">{{range .Assurances}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
</label>
<div class="bulk-component-row"><strong>Text</strong><select id="bulk-text-extent">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select><select id="bulk-text-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></div>
<div class="bulk-component-row"><strong>{{index .Text "bulk_cover_image"}}</strong><select id="bulk-cover-extent">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select><select id="bulk-cover-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></div>
<div class="bulk-component-row"><strong>{{index .Text "bulk_images"}}</strong><select id="bulk-image-extent">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select><select id="bulk-image-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></div>
<div class="bulk-component-row"><strong>{{index .Text "bulk_research"}}</strong><select id="bulk-research-extent">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "assisted"}}selected{{end}}>{{.Label}}</option>{{end}}</select><select id="bulk-research-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "editorial"}}selected{{end}}>{{.Label}}</option>{{end}}</select></div>
<div class="bulk-component-row"><strong>{{index .Text "bulk_translation"}}</strong><select id="bulk-translation-extent">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select><select id="bulk-translation-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></div>
<div class="bulk-component-row"><strong>Code</strong><select id="bulk-code-extent">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select><select id="bulk-code-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></div>
</div>
</div>
</div>
<details class="bulk-legal-details">
<summary>{{index .Text "bulk_article50_title"}}</summary>
<div class="legal-check-grid bulk-legal-grid">
<label class="check-card"><input id="bulk-public-interest" type="checkbox"><span><strong>{{index .Text "field_public_interest_text"}}</strong></span></label>
<label class="check-card"><input id="bulk-deepfake" type="checkbox"><span><strong>{{index .Text "field_deepfake"}}</strong></span></label>
<label class="check-card"><input id="bulk-substantial-review" type="checkbox"><span><strong>{{index .Text "field_substantial_review"}}</strong></span></label>
<label class="check-card"><input id="bulk-editorial-responsibility" type="checkbox"><span><strong>{{index .Text "field_editorial_responsibility_confirmed"}}</strong></span></label>
<label class="check-card"><input id="bulk-first-exposure" type="checkbox"><span><strong>{{index .Text "field_first_exposure"}}</strong></span></label>
<label class="check-card"><input id="bulk-accessibility" type="checkbox"><span><strong>{{index .Text "field_accessibility"}}</strong></span></label>
</div>
<div class="bulk-profile-grid bulk-responsibility-grid">
<label>{{index .Text "field_responsible_name"}}<input id="bulk-responsible" type="text"></label>
<label>{{index .Text "field_responsible_url"}}<input id="bulk-responsible-url" type="url"></label>
</div>
</details>
<div class="bulk-primary-actions">
<button type="button" id="bulk-run" {{if not .BulkAPI}}disabled{{end}}>{{index .Text "bulk_run"}}</button>
<span id="bulk-run-status" role="status" aria-live="polite"></span>
</div>
</section>
<section id="advanced" class="panel bulk-advanced">
<p class="eyebrow">{{index .Text "bulk_advanced_eyebrow"}}</p>
<h2>{{index .Text "bulk_advanced_title"}}</h2>
<p>{{index .Text "bulk_advanced_intro"}}</p>
<label>{{index .Text "bulk_json_label"}}
<textarea id="bulk-json" class="bulk-json-editor" rows="16"></textarea>
</label>
<div class="bulk-inline-actions">
<button type="button" class="button secondary" id="bulk-json-sample">{{index .Text "bulk_json_sample"}}</button>
<button type="button" id="bulk-json-run" {{if not .BulkAPI}}disabled{{end}}>{{index .Text "bulk_json_run"}}</button>
</div>
</section>
<section id="results" class="panel bulk-results" hidden>
<div class="section-heading-row bulk-section-heading">
<div>
<p class="eyebrow">{{index .Text "bulk_results_eyebrow"}}</p>
<h2>{{index .Text "bulk_results_title"}}</h2>
</div>
<div class="bulk-inline-actions">
<button type="button" class="button secondary" id="bulk-copy-json">{{index .Text "bulk_copy_json"}}</button>
<button type="button" class="button secondary" id="bulk-download-json">{{index .Text "bulk_download_json"}}</button>
<button type="button" class="button secondary" id="bulk-download-csv">{{index .Text "bulk_download_csv"}}</button>
</div>
</div>
<div id="bulk-result-summary" class="bulk-result-summary"></div>
<div class="bulk-table-wrap">
<table class="bulk-result-table">
<thead><tr><th>{{index .Text "bulk_col_id"}}</th><th>{{index .Text "bulk_col_status"}}</th><th>{{index .Text "bulk_col_assessment"}}</th><th>{{index .Text "bulk_col_links"}}</th></tr></thead>
<tbody id="bulk-result-body"></tbody>
</table>
</div>
<details class="bulk-raw-result"><summary>{{index .Text "bulk_raw_result"}}</summary><pre id="bulk-result-json"></pre></details>
</section>
</main>
<footer>
<span>{{if .WhiteLabel}}{{.Name}}{{else}}UCNG{{end}} · {{index .Text "bulk_footer_note"}}</span>
<span><a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a> · <a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a> · <a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a></span>
</footer>
<script>window.BULK_CONFIG={{.AppConfig}};</script>
<script src="/static/bulk.js" defer></script>
</body>
</html>
{{end}}

View File

@@ -10,11 +10,22 @@
<link rel="describedby" type="application/ld+json" href="{{.ManifestURL}}">
{{range .LanguageLinks}}<link rel="alternate" hreflang="{{.Code}}" href="{{.AbsoluteURL}}">{{end}}
<link rel="alternate" hreflang="x-default" href="{{.DefaultLanguageURL}}">
{{if not .WhiteLabel}}
<meta name="theme-color" content="#F8FAFC">
<link rel="icon" href="/static/brand/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/ucng-icon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/static/brand/ucng-icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/static/brand/ucng-icon-512.png">
<link rel="apple-touch-icon" sizes="192x192" href="/static/brand/ucng-icon-192.png">
<meta property="og:image" content="{{.BaseURL}}/static/brand/ucng-open-graph.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{{end}}
<link rel="stylesheet" href="/static/style.css">
<script nonce="{{.CSPNonce}}" type="application/ld+json">{{.JSONLD}}</script>
<script type="application/ld+json">{{.JSONLD}}</script>
</head>
<body>
<header class="site-header"><a class="brand" href="/?lang={{.Lang}}">{{.Name}}</a><nav><a href="/?lang={{.Lang}}">{{index .Text "nav_generator"}}</a><a href="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a><a href="{{.ManifestURL}}">JSON-LD</a></nav></header>
<header class="site-header"><a class="brand" href="/?lang={{.Lang}}">{{if .WhiteLabel}}<span class="brand-text">{{.Name}}</span>{{else}}<img src="/static/brand/ucng-logo.png" width="800" height="233" alt="UCNG — Universal Content Notation Guidelines">{{end}}</a><nav><a href="/?lang={{.Lang}}">{{index .Text "nav_generator"}}</a><a href="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a><a href="{{.ManifestURL}}">JSON-LD</a></nav></header>
<main class="declaration-page">
<a class="back" href="/?lang={{.Lang}}">← {{index .Text "back"}}</a>
<article class="declaration-card">
@@ -22,7 +33,7 @@
<p class="eyebrow">{{index .Text "declaration_eyebrow"}} · Schema {{.Declaration.SchemaVersion}}</p>
<form class="declaration-language" method="get" action="/declaration">
<label for="declaration-language">{{index .Text "field_language"}}</label>
<select id="declaration-language" aria-label="{{index .Text "field_language"}}" data-url-switch>
<select id="declaration-language" aria-label="{{index .Text "field_language"}}" onchange="window.location.assign(this.value)">
{{range .LanguageLinks}}<option value="{{.URL}}" lang="{{.Code}}"{{if .Current}} selected{{end}}>{{.Name}}</option>{{end}}
</select>
<noscript><div class="language-link-list">{{range .LanguageLinks}}<a href="{{.URL}}" lang="{{.Code}}"{{if .Current}} aria-current="page"{{end}}>{{.Name}}</a>{{end}}</div></noscript>
@@ -37,6 +48,28 @@
<div class="summary-copy">{{range .Summary}}<p>{{.}}</p>{{end}}</div>
</section>
{{if .RegulatoryFacts}}
<section class="regulatory-section" aria-labelledby="regulatory-title">
<div class="section-heading-row">
<div>
<p class="eyebrow">EU AI Act · Article 50</p>
<h2 id="regulatory-title">{{index .Text "assessment_heading"}}</h2>
</div>
<span class="assessment-status assessment-{{.Assessment.Severity}}">{{.Assessment.Title}}</span>
</div>
<div class="assessment-card assessment-{{.Assessment.Severity}}">
<p class="assessment-summary">{{.Assessment.Summary}}</p>
{{if .Assessment.Details}}<ul class="assessment-findings">{{range .Assessment.Details}}<li>{{.}}</li>{{end}}</ul>{{end}}
{{if .Assessment.Warnings}}<ul class="assessment-warnings">{{range .Assessment.Warnings}}<li>{{.}}</li>{{end}}</ul>{{end}}
<p class="assessment-disclaimer">{{index .Text "assessment_disclaimer"}}</p>
</div>
<h3>{{index .Text "regulatory_context"}}</h3>
<dl class="facts regulatory-facts">
{{range .RegulatoryFacts}}<div><dt>{{.Label}}</dt><dd>{{.Value}}</dd></div>{{end}}
</dl>
</section>
{{end}}
<section class="component-section" aria-labelledby="component-title">
<h2 id="component-title">{{index .Text "article_table_heading"}}</h2>
<div class="component-table-wrap">
@@ -48,23 +81,13 @@
</div>
</section>
{{if .LegalAssessment.Text}}
<section class="legal-assessment-section" aria-labelledby="legal-assessment-title">
<h2 id="legal-assessment-title">{{.LegalAssessment.Heading}}</h2>
<div class="legal-assessment-card {{.LegalAssessment.Level}}">{{.LegalAssessment.Text}}</div>
</section>
{{end}}
<dl class="facts metadata-facts">
{{range .Facts}}<div><dt>{{.Label}}</dt><dd>{{if .Link}}<a href="{{.Link}}">{{.Value}}</a>{{else}}{{.Value}}{{end}}</dd></div>{{end}}
</dl>
<div class="notice"><strong>{{index .Text "transparency_label"}}</strong> {{index .Text "transparency_text"}}</div>
<div class="notice"><strong>{{index .Text "review_legal_label"}}</strong> {{index .Text "review_legal_text"}}</div>
<div class="notice"><strong>{{index .Text "machine_marking_label"}}</strong> {{index .Text "machine_marking_text"}}</div>
</article>
</main>
<footer><span><a href="{{.ManifestURL}}">{{index .Text "manifest"}}</a> · <a href="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a></span>{{template "legal-links" .}}</footer>
<script src="/static/marketing.js" defer></script>
<footer><span><a href="{{.ManifestURL}}">{{index .Text "manifest"}}</a>{{if .ExportBundle}} · <a href="{{.BundleURL}}">{{index .Text "bundle"}}</a>{{end}}</span><span><a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a> · <a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a> · <a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a></span></footer>
</body>
</html>
{{end}}

View File

@@ -6,11 +6,22 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Name}}</title>
<meta name="description" content="{{index .Text "meta_description"}}">
{{if not .WhiteLabel}}
<meta name="theme-color" content="#F8FAFC">
<link rel="icon" href="/static/brand/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/ucng-icon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/static/brand/ucng-icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/static/brand/ucng-icon-512.png">
<link rel="apple-touch-icon" sizes="192x192" href="/static/brand/ucng-icon-192.png">
<meta property="og:image" content="{{.BaseURL}}/static/brand/ucng-open-graph.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{{end}}
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="/?lang={{.Lang}}">{{.Name}}</a>
<a class="brand" href="/?lang={{.Lang}}">{{if .WhiteLabel}}<span class="brand-text">{{.Name}}</span>{{else}}<img src="/static/brand/ucng-logo.png" width="800" height="233" alt="UCNG — Universal Content Notation Guidelines">{{end}}</a>
<nav><a href="/product?lang={{.Lang}}">{{index .Text "nav_product"}}</a><a href="#generator">{{index .Text "nav_generator"}}</a><a href="#api">{{index .Text "nav_api"}}</a><a href="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a></nav>
<label class="language-switch"><span class="sr-only">{{index .Text "field_language"}}</span><select id="page-language">
{{range .Languages}}<option value="{{.Code}}" {{if eq $.Lang .Code}}selected{{end}}>{{.Name}}</option>{{end}}
@@ -51,7 +62,6 @@
</label>
<label>{{index .Text "field_review"}}
<select id="review" name="review">{{range .Reviews}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
<small>{{index .Text "review_help"}}</small>
</label>
<label class="wide">{{index .Text "field_activities"}}
<input id="activities" name="activities" value="research" placeholder="research,summarisation">
@@ -61,30 +71,22 @@
<fieldset id="article-fields" class="article-builder wide" hidden>
<legend>{{index .Text "article_fields"}}</legend>
<p>{{index .Text "article_help"}}</p>
<p class="form-warning">{{index .Text "review_help"}}</p>
<div class="article-component-grid">
<div class="article-component"><label>Text<select id="article-text" data-review="article-text-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-text-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
<div class="article-component"><label>{{index .Text "fact_activities"}} / Recherche<select id="article-research" data-review="article-research-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "assisted"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-research-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
<div class="article-component"><label>{{index .Text "fact_activities"}} / Recherche<select id="article-research" data-review="article-research-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "assisted"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-research-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "editorial"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
<div class="article-component"><label>Cover / {{index .Text "fact_component"}}<select id="article-cover-image" data-review="article-cover-image-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-cover-image-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
<div class="article-component"><label>Images<select id="article-image" data-review="article-image-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "full"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-image-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
<div class="article-component"><label>Images<select id="article-image" data-review="article-image-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "full"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-image-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "editorial"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
<div class="article-component"><label>Translation<select id="article-translation" data-review="article-translation-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-translation-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
<div class="article-component"><label>Audio<select id="article-audio" data-review="article-audio-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-audio-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
<div class="article-component"><label>Video<select id="article-video" data-review="article-video-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-video-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
<div class="article-component"><label>Code<select id="article-code" data-review="article-code-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-code-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
</div>
</fieldset>
<fieldset class="legal-context-builder wide">
<legend>{{index .Text "legal_context_legend"}}</legend>
<p>{{index .Text "legal_context_help"}}</p>
<label class="check-row"><input id="legal-deepfake" type="checkbox" value="deepfake"><span><strong>{{index .Text "legal_deepfake"}}</strong><small>{{index .Text "legal_deepfake_help"}}</small></span></label>
<label class="check-row"><input id="legal-public-interest" type="checkbox" value="publicInterestText"><span><strong>{{index .Text "legal_public_interest"}}</strong><small>{{index .Text "legal_public_interest_help"}}</small></span></label>
<label class="check-row"><input id="legal-creative" type="checkbox" value="artisticCreativeSatiricalFictional"><span><strong>{{index .Text "legal_creative"}}</strong><small>{{index .Text "legal_creative_help"}}</small></span></label>
<label class="check-row"><input id="legal-other-voluntary" type="checkbox" value="otherVoluntary"><span><strong>{{index .Text "legal_other_voluntary"}}</strong></span></label>
<div id="legal-assessment" class="legal-assessment" role="status" aria-live="polite"></div>
</fieldset>
<label>{{index .Text "field_language"}}
<select id="lang" name="lang">{{range .Languages}}<option value="{{.Code}}" {{if eq $.Lang .Code}}selected{{end}}>{{.Name}}</option>{{end}}</select>
</label>
<label>{{index .Text "field_theme"}}
<select id="theme" name="theme"><option value="mono">{{index .Text "theme_mono"}}</option><option value="color">{{index .Text "theme_color"}}</option><option value="emoji">{{index .Text "theme_emoji"}}</option></select>
<select id="theme" name="theme"><option value="mono">{{index .Text "theme_mono"}}</option><option value="color">{{index .Text "theme_color"}}</option></select>
</label>
<label class="wide">{{index .Text "field_assurance"}}
<select id="assurance" name="assurance">{{range .Assurances}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
@@ -93,20 +95,24 @@
<label class="wide">{{index .Text "field_subject"}}
<input id="subject" name="subject" type="url" placeholder="https://example.org/article">
</label>
<label class="wide">{{index .Text "field_responsibility_role"}}
<select id="responsible-role" name="responsibleRole">
<option value="">{{index .Text "responsibility_none"}}</option>
<option value="publisher">{{index .Text "responsibility_publisher"}}</option>
<option value="other">{{index .Text "responsibility_other"}}</option>
</select>
</label>
<label>{{index .Text "field_responsible"}}
<input id="responsible" name="responsible" maxlength="200" placeholder="{{index .Text "responsible_placeholder"}}">
</label>
<label>{{index .Text "field_responsible_url"}}
<input id="responsible-url" name="responsibleUrl" type="url" placeholder="https://example.org/impressum">
</label>
<p class="form-warning wide">{{index .Text "review_legal_text"}}</p>
<fieldset class="legal-context-builder wide">
<legend>{{index .Text "article50_section_title"}}</legend>
<p class="fieldset-intro">{{index .Text "article50_section_intro"}}</p>
<div class="legal-check-grid">
<label class="check-card"><input id="public-interest-text" type="checkbox"><span><strong>{{index .Text "field_public_interest_text"}}</strong><small>{{index .Text "help_public_interest_text"}}</small></span></label>
<label class="check-card"><input id="deepfake" type="checkbox"><span><strong>{{index .Text "field_deepfake"}}</strong><small>{{index .Text "help_deepfake"}}</small></span></label>
<label class="check-card"><input id="artistic-context" type="checkbox"><span><strong>{{index .Text "field_artistic_context"}}</strong><small>{{index .Text "help_artistic_context"}}</small></span></label>
<label class="check-card"><input id="substantial-review" type="checkbox"><span><strong>{{index .Text "field_substantial_review"}}</strong><small>{{index .Text "help_substantial_review"}}</small></span></label>
<label class="check-card"><input id="editorial-responsibility-confirmed" type="checkbox"><span><strong>{{index .Text "field_editorial_responsibility_confirmed"}}</strong><small>{{index .Text "help_editorial_responsibility_confirmed"}}</small></span></label>
<label class="check-card"><input id="first-exposure-disclosure" type="checkbox"><span><strong>{{index .Text "field_first_exposure"}}</strong></span></label>
<label class="check-card"><input id="accessibility-considered" type="checkbox"><span><strong>{{index .Text "field_accessibility"}}</strong></span></label>
</div>
<div class="form-subgrid legal-responsibility-fields">
<label>{{index .Text "field_responsible_name"}}<input id="responsible" maxlength="200"></label>
<label>{{index .Text "field_responsible_url"}}<input id="responsible-url" type="url" placeholder="https://example.org/imprint"></label>
</div>
</fieldset>
</form>
{{if or .CustomText .CustomBadge}}
@@ -146,11 +152,9 @@
<label>HTML<textarea id="html-code" readonly></textarea></label>
<label>Markdown<textarea id="markdown-code" readonly></textarea></label>
<label>JSON-LD URL<textarea id="json-code" readonly></textarea></label>
<label>Article 50 API<textarea id="assessment-code" readonly></textarea></label>
</div>
<button id="copy-html" type="button">{{index .Text "copy_html"}}</button>
<div class="notice compliance-notice"><strong>{{index .Text "ai_act_embed_label"}}</strong> {{index .Text "ai_act_embed_text"}}</div>
<div class="notice compliance-notice"><strong>{{index .Text "ai_act_machine_label"}}</strong> {{index .Text "ai_act_machine_text"}}</div>
<div class="notice compliance-notice"><strong>{{index .Text "emoji_compliance_label"}}</strong> {{index .Text "emoji_compliance_text"}}</div>
</div>
</section>
@@ -160,8 +164,8 @@
<article><p class="eyebrow">HA</p><h2>{{index .Text "api_stateless_title"}}</h2><p>{{index .Text "api_stateless_desc"}}</p></article>
</section>
</main>
<footer><span>AI Usage Disclosure · App 1.6.3 · Schema 1.2 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<script nonce="{{.CSPNonce}}">window.APP_CONFIG={{.AppConfig}};</script>
<footer><span>{{if not .WhiteLabel}}UCNG · {{end}}App 2.0.2 · Schema 1.2 · {{.License.Edition}}</span><span><a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a> · <a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a> · <a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a></span></footer>
<script>window.APP_CONFIG={{.AppConfig}};</script>
<script src="/static/app.js" defer></script>
</body>
</html>

View File

@@ -1,69 +0,0 @@
{{define "legal-links"}}
<nav class="footer-legal" aria-label="Legal">
<a href="/impressum?lang={{.Lang}}">{{.LegalNav.Imprint}}</a>
<a href="/datenschutz?lang={{.Lang}}">{{.LegalNav.Privacy}}</a>
<a href="/barrierefreiheit?lang={{.Lang}}">{{.LegalNav.Accessibility}}</a>
</nav>
{{end}}
{{define "legal.html"}}
<!doctype html>
<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Legal.Title}} · {{.Name}}</title>
<meta name="description" content="{{.Legal.MetaDescription}}">
<link rel="canonical" href="{{.CanonicalURL}}">
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="legal-page">
<header class="site-header">
<a class="brand" href="/?lang={{.Lang}}">{{.Name}}</a>
<nav>
<a href="/?lang={{.Lang}}">{{if eq .Lang "de"}}Generator{{else}}Generator{{end}}</a>
<a href="/background?lang={{.Lang}}">{{if eq .Lang "de"}}Hintergrund{{else}}Background{{end}}</a>
</nav>
<div class="legal-language" aria-label="Language">
<a href="{{.CanonicalURL}}?lang=de" lang="de">DE</a>
<a href="{{.CanonicalURL}}?lang=en" lang="en">EN</a>
</div>
</header>
<main class="legal-main">
<header class="legal-hero">
<p class="eyebrow">{{.Legal.Eyebrow}}</p>
<h1>{{.Legal.Title}}</h1>
<p class="lead">{{.Legal.Intro}}</p>
<p class="legal-updated">{{if eq .Lang "de"}}Stand{{else}}Last updated{{end}}: {{.Legal.LastUpdated}}</p>
</header>
{{if .Legal.Warning}}
<aside class="configuration-warning" role="alert">
<strong>{{if eq .Lang "de"}}Konfiguration erforderlich{{else}}Configuration required{{end}}</strong>
<p>{{.Legal.Warning}}</p>
</aside>
{{end}}
<article class="legal-content">
{{range .Legal.Sections}}
<section class="legal-section">
<h2>{{.Title}}</h2>
{{range .Paragraphs}}<p>{{.}}</p>{{end}}
{{if .Fields}}
<dl class="legal-fields">
{{range .Fields}}
<div><dt>{{.Label}}</dt><dd>{{if .URL}}<a href="{{.URL}}">{{.Value}}</a>{{else}}{{.Value}}{{end}}</dd></div>
{{end}}
</dl>
{{end}}
{{if .Bullets}}<ul>{{range .Bullets}}<li>{{.}}</li>{{end}}</ul>{{end}}
</section>
{{end}}
</article>
</main>
<footer><span>{{.Name}}</span>{{template "legal-links" .}}</footer>
</body>
</html>
{{end}}

View File

@@ -7,11 +7,22 @@
<title>{{.Name}} · {{.Marketing.NavFeatures}}</title>
<meta name="description" content="{{.Marketing.MetaDescription}}">
<link rel="canonical" href="{{.BaseURL}}/product?lang={{.Lang}}">
{{if not .WhiteLabel}}
<meta name="theme-color" content="#F8FAFC">
<link rel="icon" href="/static/brand/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/ucng-icon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/static/brand/ucng-icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/static/brand/ucng-icon-512.png">
<link rel="apple-touch-icon" sizes="192x192" href="/static/brand/ucng-icon-192.png">
<meta property="og:image" content="{{.BaseURL}}/static/brand/ucng-open-graph.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{{end}}
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="marketing-page">
<header class="site-header marketing-header">
<a class="brand" href="/product?lang={{.Lang}}">{{.Name}}</a>
<a class="brand" href="/product?lang={{.Lang}}">{{if .WhiteLabel}}<span class="brand-text">{{.Name}}</span>{{else}}<img src="/static/brand/ucng-logo.png" width="800" height="233" alt="UCNG — Universal Content Notation Guidelines">{{end}}</a>
<nav>
<a href="#features">{{.Marketing.NavFeatures}}</a>
<a href="#install">{{.Marketing.NavInstall}}</a>
@@ -119,7 +130,7 @@
</section>
</main>
<footer><span>{{.Marketing.Footer}} · App 1.6.3 · Schema 1.2 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<footer><span>{{if not .WhiteLabel}}{{.Marketing.Footer}}{{end}}</span><span><a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a> · <a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a> · <a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a></span></footer>
<script src="/static/marketing.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,86 @@
{{define "operator.html"}}
<!doctype html>
<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.LegalPage.Title}} · {{.Name}}</title>
<meta name="description" content="{{.LegalPage.Lead}}">
<link rel="canonical" href="{{.BaseURL}}/{{.LegalPage.Kind}}?lang={{.Lang}}">
{{range .Languages}}<link rel="alternate" hreflang="{{.Code}}" href="{{$.BaseURL}}/{{$.LegalPage.Kind}}?lang={{.Code}}">{{end}}
<link rel="alternate" hreflang="x-default" href="{{.BaseURL}}/{{.LegalPage.Kind}}">
{{if not .WhiteLabel}}
<meta name="theme-color" content="#F8FAFC">
<link rel="icon" href="/static/brand/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/ucng-icon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/static/brand/ucng-icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/static/brand/ucng-icon-512.png">
<link rel="apple-touch-icon" sizes="192x192" href="/static/brand/ucng-icon-192.png">
<meta property="og:image" content="{{.BaseURL}}/static/brand/ucng-open-graph.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{{end}}
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="operator-page">
<header class="site-header">
<a class="brand" href="/?lang={{.Lang}}">{{if .WhiteLabel}}<span class="brand-text">{{.Name}}</span>{{else}}<img src="/static/brand/ucng-logo.png" width="800" height="233" alt="UCNG — Universal Content Notation Guidelines">{{end}}</a>
<nav>
<a href="/?lang={{.Lang}}">{{index .Text "nav_generator"}}</a>
<a href="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a>
<a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a>
<a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a>
<a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a>
</nav>
<label class="language-switch"><span class="sr-only">{{index .Text "field_language"}}</span><select id="operator-language">
{{range .Languages}}<option value="{{.Code}}" {{if eq $.Lang .Code}}selected{{end}}>{{.Name}}</option>{{end}}
</select></label>
</header>
<main class="operator-main">
<section class="operator-hero">
<p class="eyebrow">{{if .WhiteLabel}}{{.Name}}{{else}}UCNG{{end}}</p>
<h1>{{.LegalPage.Title}}</h1>
<p class="lead">{{.LegalPage.Lead}}</p>
</section>
<div class="operator-grid">
{{range .LegalPage.Sections}}
<section class="operator-card">
<h2>{{.Title}}</h2>
{{range .Paragraphs}}<p>{{.}}</p>{{end}}
{{if .Items}}
<dl class="operator-facts">
{{range .Items}}
<div><dt>{{.Label}}</dt><dd>{{if .URL}}<a href="{{.URL}}">{{.Value}}</a>{{else}}{{.Value}}{{end}}</dd></div>
{{end}}
</dl>
{{end}}
</section>
{{end}}
</div>
<aside class="operator-notice">
<strong>{{.LegalPage.NoticeTitle}}</strong>
<p>{{.LegalPage.Notice}}</p>
</aside>
</main>
<footer>
<span>{{if not .WhiteLabel}}UCNG · {{end}}App 2.0.2 · Schema 1.2</span>
<span><a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a> · <a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a> · <a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a></span>
</footer>
<script>
(() => {
const select = document.getElementById('operator-language');
if (!select) return;
select.addEventListener('change', () => {
const url = new URL(window.location.href);
url.searchParams.set('lang', select.value);
window.location.assign(url.toString());
});
})();
</script>
</body>
</html>
{{end}}