2.0.0 Production-RC
release-tag / release-image (push) Has been cancelled

This commit is contained in:
2026-07-24 07:23:59 +02:00
parent db817eabe4
commit 4e2c648ede
80 changed files with 2793 additions and 8111 deletions
+15 -58
View File
@@ -1,71 +1,28 @@
# Public origin only: scheme + host, no path/query/fragment.
BASE_URL=https://ai.trustednet.eu
BASE_URL=http://localhost:8080
OUTPUT_BASE_URL=http://localhost:8080
PUBLIC_NAME=AI Usage Disclosure
CONTACT_URL=https://ai.trustednet.eu
SALES_URL=https://ai.trustednet.eu/product
DEFAULT_LANGUAGE=de
BULK_URL=http://localhost:8081
TRUST_PROXY=false
# Optional second container for batch rendering.
BULK_MAX_URLS=500
BULK_WORKERS=4
BULK_REQUEST_TIMEOUT=8s
# Proxy and logging. Enable proxy headers only with explicit trusted CIDRs.
TRUST_PROXY=true
TRUSTED_PROXY_CIDRS=
LOG_CLIENT_IP=false
ENABLE_HSTS=true
# 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.
METRICS_ENABLED=false
METRICS_TOKEN=
# ---------------------------------------------------------------------------
# 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
LEGAL_REPRESENTATIVE=
LEGAL_EMAIL=REPLACE_ME
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.
EDITORIAL_RESPONSIBLE_NAME=
EDITORIAL_RESPONSIBLE_ADDRESS=
DATA_PROTECTION_CONTACT=
HOSTING_PROVIDER=REPLACE_ME
HOSTING_ADDRESS=
LOG_RETENTION=REPLACE_ME
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
CONSUMER_DISPUTE_BODY=
CONSUMER_DISPUTE_URL=
ACCESSIBILITY_CONTACT=
ACCESSIBILITY_STATUS=
# Runtime mode: full (UI + API), api (API without public product/generator pages), bulk.
SERVICE_MODE=full
API_ALLOWED_ORIGIN=*
# Runtime license issued by the standalone Universal License Platform.
# No private keys or configurable public keys belong in this application.
LICENSE_TOKEN=
LICENSE_INSTANCE_ID=
LICENSE_MODE=offline
LICENSE_SERVER_URL=https://license.trustednet.eu
LICENSE_INSTANCE_ID=
LICENSE_CACHE_FILE=/data/license-lease.json
LICENSE_REFRESH_INTERVAL=15m
LICENSE_REQUEST_TIMEOUT=5s
REQUIRE_LICENSE=false
WHITE_LABEL=false
# Bulk API. The dedicated bulk image forces BULK_REQUIRE_API_KEY=true.
BULK_MAX_ITEMS=500
BULK_MAX_BODY_BYTES=4194304
BULK_REQUIRE_API_KEY=false
BULK_API_KEY=
+3
View File
@@ -6,3 +6,6 @@
*.key
*.private
.DS_Store
secrets/*.txt
!secrets/README.md
+45 -146
View File
@@ -1,164 +1,63 @@
# Changelog
## 1.8.0 - 2026-07-22
## 2.0.0
- Added the optional `ai-disclosure-bulk` container without accounts, database or separate legal decision logic.
- Added browser-only website profiles for recurring author, editorial-responsibility, imprint/reference and complaint-contact data.
- Added browser-only saved disclosure templates with JSON export/import and complete local-data deletion.
- Added direct generator hand-off via a URL fragment so a configured disclosure can be opened as a bulk template without sending the template fragment to the server.
- Added line-based URL/path import with optional base URL, duplicate removal, validation and a configurable batch limit.
- Added bulk HTML, Markdown, JSON-LD array, JSONL and CSV exports.
- Added core endpoint `GET /v1/render` so all bulk markup and JSON-LD are rendered by the existing disclosure core.
- Bulk processing never fetches the supplied content URLs; only the configured core origin is contacted.
- Updated the privacy notice to document the optional bulk container's Local Storage use.
### Article 50 decision support
## 1.7.0 - 2026-07-22
- 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.
- Rebuilt the generator as a dependency-driven workflow: fields are shown only when the selected content and legal context make them relevant.
- Added explicit AI Act role (`deployer`, `provider`, `both`, `unsure`) and professional/personal use-context inputs.
- Added optional output date handling for the Article 50 application date and the Commission FAQ's non-retroactivity guidance.
- Deepfake questions now appear only for AI-involved image/audio/video; public-interest text questions appear only for AI-involved text; the creative-work modifier appears only for deepfakes.
- Replaced ambiguous legal checkboxes with conditional required `yes` / `no` / `unsure` assessments and a pending state that prevents unanswered questions from being interpreted as “no duty”.
- Human review fields are hidden for components without AI involvement. Article mode now includes audio and video and starts components at no AI use instead of pre-populating AI involvement.
- Editorial-responsibility fields are shown and required only when a public-interest-text exception is actually being assessed after substantive review.
- Added separate author/byline metadata so authorship is not conflated with editorial legal responsibility.
- Added a distinct complaint/feedback contact as optional best-practice metadata, explicitly not presented as a general Article 50 requirement.
- Added dynamic plausibility warnings for formal-only review, missing editorial responsibility, provider/deployer mismatch, personal use, pre-application outputs and incomplete complaint contacts.
- Added consequence-oriented Article 50 guidance for first-exposure disclosure and provider-side machine-readable marking.
- Added the Article 50(4) exception for qualifying legally authorised criminal-law use as an explicit, conditionally visible assessment field.
- Added a cautious Article 99(4) enforcement/penalty-frame notice and clarified that any actual infringement or fine is determined by the competent authority.
- Declaration Schema 1.3 adds `author`, `complaintsContact`, and extended `legalContext` (`actorRole`, `useContext`, `outputDate`, `deepfakeAssessment`, `publicInterestAssessment`, `creativeWorkAssessment`, `lawEnforcementAuthorization`).
### Professional runtime
## 1.6.3 - 2026-07-21
- `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`.
- 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.
### Bulk & export
## Unreleased
- 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.
- No unreleased changes.
### 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.
+16 -3
View File
@@ -1,5 +1,6 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26-alpine AS build
ARG GO_IMAGE=golang:1.23-alpine
FROM ${GO_IMAGE} AS build
WORKDIR /src
RUN apk add --no-cache ca-certificates tzdata
COPY go.mod ./
@@ -7,13 +8,25 @@ 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.0
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"]
+23 -7
View File
@@ -1,19 +1,35 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26-alpine AS build
ARG GO_IMAGE=golang:1.23-alpine
FROM ${GO_IMAGE} AS build
WORKDIR /src
RUN apk add --no-cache ca-certificates tzdata
COPY go.mod ./
COPY third_party ./third_party
COPY cmd ./cmd
COPY internal ./internal
COPY bulkweb ./bulkweb
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ai-disclosure-bulk ./cmd/bulk
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.0
ARG REVISION=unknown
ARG CREATED=unknown
LABEL org.opencontainers.image.title="AI Disclosure Standard Bulk" \
org.opencontainers.image.description="Licensed server-to-server bulk processing service for AI usage declarations" \
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-bulk /ai-disclosure-bulk
COPY --from=build /out/ai-disclosure /ai-disclosure
USER 65532:65532
EXPOSE 8081
ENV BULK_LISTEN_ADDRESS=:8081 CORE_INTERNAL_URL=http://app:8080 DISCLOSURE_BASE_URL=http://localhost:8080 GENERATOR_URL=http://localhost:8080
ENTRYPOINT ["/ai-disclosure-bulk"]
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"]
+8 -11
View File
@@ -1,11 +1,10 @@
.PHONY: run run-bulk test test-license-client check build build-bulk docker-build docker-build-bulk
.PHONY: run test test-license-client check build docker-build docker-build-bulk docker-build-all
VERSION ?= 2.0.0
run:
go run ./cmd/server
run-bulk:
go run ./cmd/bulk
test:
go test ./...
cd third_party/license-platform-client && go test ./...
@@ -14,7 +13,7 @@ test-license-client:
cd third_party/license-platform-client && go test ./...
check:
gofmt -w $$(find cmd internal web bulkweb third_party/license-platform-client -name '*.go' -type f)
gofmt -w $$(find cmd internal web third_party/license-platform-client -name '*.go' -type f)
go vet ./...
go test -race ./...
cd third_party/license-platform-client && go vet ./... && go test -race ./...
@@ -23,12 +22,10 @@ build:
mkdir -p bin
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/ai-disclosure ./cmd/server
build-bulk:
mkdir -p bin
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/ai-disclosure-bulk ./cmd/bulk
docker-build:
docker build -t ai-disclosure-standard:1.8.0-local .
docker build --build-arg VERSION=$(VERSION) -t ai-disclosure-standard:$(VERSION)-local .
docker-build-bulk:
docker build -f Dockerfile.bulk -t ai-disclosure-bulk:1.0.0-local .
docker build -f Dockerfile.bulk --build-arg VERSION=$(VERSION) -t ai-disclosure-standard-bulk:$(VERSION)-local .
docker-build-all: docker-build docker-build-bulk
+278 -196
View File
@@ -1,26 +1,73 @@
# AI Disclosure Standard 1.8.0
# AI Disclosure Standard 2.0.0
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;
- optionaler zweiter Bulk-Container für wiederverwendbare Vorlagen und bis zu 500 Inhalts-URLs pro Lauf, ohne Benutzerkonten oder Datenbank.
- 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
@@ -29,91 +76,163 @@ 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
Bulk: http://localhost:8081/
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
```
## Betriebsmodi
## Bulk-Generator
`SERVICE_MODE` trennt öffentliche Oberfläche und Server-to-Server-Betrieb:
Der optionale Container `ai-disclosure-bulk` ist bewusst klein und zustandslos. Er enthält **keine eigene rechtliche Entscheidungslogik**. Stattdessen ruft er für jede Inhalts-URL den Core-Endpunkt `/v1/render` auf und setzt ausschließlich `subject` neu. Dadurch bleiben normaler Generator, API und Bulk-Ausgabe auf demselben Regel- und Renderingstand.
| Modus | Zweck |
|---|---|
| `full` | Generator, Produkt-/Hintergrundseiten, Deklarationen und APIs |
| `api` | Deklarations-, Badge- und Maschinen-APIs ohne öffentliche Generator-/Produktseite |
| `bulk` | ausschließlich Betriebs-/Validierungs-/Bulk-Endpunkte |
Typischer Ablauf:
Der dedizierte Bulk-Container setzt bereits:
1. Kennzeichnung im normalen Generator konfigurieren.
2. **Als Bulk-Vorlage öffnen** auswählen.
3. Optional ein Website-Profil mit wiederkehrenden Angaben wie Autor, Impressums-/Verantwortlichkeits-URL und Beschwerdestelle im Browser speichern.
4. Absolute URLs oder relative Pfade einfügen. Für relative Pfade kann einmalig eine Basis-URL gesetzt werden.
5. HTML, Markdown, JSON-LD, JSONL oder CSV erzeugen und kopieren bzw. herunterladen.
```text
SERVICE_MODE=bulk
REQUIRE_LICENSE=true
BULK_REQUIRE_API_KEY=true
API_ALLOWED_ORIGIN=
```
Der Bulk-Container ruft die eingegebenen Inhalts-URLs **nicht** ab. Website-Profile und gespeicherte Kennzeichnungsvorlagen werden ausschließlich im `localStorage` des Browsers gespeichert. URL-Listen und erzeugte Ergebnisse werden weder im Browser dauerhaft gespeichert noch serverseitig persistiert. Profile und Vorlagen können als JSON exportiert, importiert oder vollständig gelöscht werden.
## Professioneller Bulk-Betrieb
Relevante Variablen:
Build:
| Variable | Standard | Bedeutung |
|---|---|---|
| `BULK_URL` | leer / in der Beispielkonfiguration `http://localhost:8081` | öffentliche URL des Bulk-Generators; aktiviert den Übergabe-Button im normalen Generator |
| `CORE_INTERNAL_URL` | `http://app:8080` | feste interne Origin des Disclosure-Core; der Bulk-Dienst folgt keinen frei eingegebenen Ziel-URLs |
| `DISCLOSURE_BASE_URL` | `http://localhost:8080` | öffentliche Core-Origin für Links in der Bulk-Oberfläche |
| `GENERATOR_URL` | wie `DISCLOSURE_BASE_URL` | öffentlicher Link zurück zum normalen Generator |
| `BULK_MAX_URLS` | `500` | maximale Zahl an Inhalts-URLs je Lauf, maximal 5000 |
| `BULK_WORKERS` | `4` | parallele Core-Render-Aufrufe, maximal 32 |
| `BULK_REQUEST_TIMEOUT` | `8s` | Timeout je Core-Aufruf |
```bash
docker build -f Dockerfile.bulk -t ai-disclosure-standard-bulk:2.0.0 .
```
Die Vorlagenübergabe vom normalen Generator zum Bulk-Generator erfolgt im URL-Fragment (`#template=...`). Dieses Fragment wird vom Browser nicht als Teil der HTTP-Anfrage an den Server gesendet und nach dem Import aus der Adresszeile entfernt.
Der 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
```
Siehe [`docs/BULK-API.md`](docs/BULK-API.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 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**.
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.
Der Produktserver enthält nur den Laufzeit-Client und akzeptiert:
```env
LICENSE_TOKEN=...
LICENSE_MODE=offline
LICENSE_SERVER_URL=
LICENSE_INSTANCE_ID=
```
Der Produktname ist fest verdrahtet:
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 `
@@ -123,188 +242,151 @@ 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, obwohl der Bulk-Container selbst keine Deklarationsseiten ausliefert. 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
| Variable | Standard | Bedeutung |
|---|---|---|
| `LISTEN_ADDRESS` | `:8080` | HTTP-Adresse |
| `BASE_URL` | `http://localhost:8080` | öffentliche Origin ohne Pfad, Query oder Fragment |
| `BASE_URL` | `http://localhost:8080` | kanonische URL dieser Instanz und Grundlage der Lizenz-Domainprüfung |
| `OUTPUT_BASE_URL` | `BASE_URL` | Basis-URL für generierte Deklarations-/Manifest-/Badge-Links; besonders für getrennte Bulk-Dienste |
| `PUBLIC_NAME` | `AI Usage Disclosure` | sichtbarer Produktname |
| `CONTACT_URL` | Projektseite | Kontakt-/Informationsseite |
| `CONTACT_URL` | Projektseite | externe Kontakt-/Projektseite |
| `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` |
### Betreiber- und Datenschutzangaben
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.
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.
## Lizenzprüfung
| Variable | Standard | Bedeutung |
|---|---|---|
| `LICENSE_TOKEN` | leer | von der Universal License Platform ausgestellter Token |
| `LICENSE_MODE` | `offline` | Mindestmodus `offline`, `hybrid` oder `online` |
| `TRUST_PROXY` | `false` | Proxy-Header für Client-IP berücksichtigen |
| `SERVICE_MODE` | `full` | `full`, `api` oder `bulk` |
| `API_ALLOWED_ORIGIN` | `*` | CORS-Origin für API-Endpunkte; leer deaktiviert CORS |
| `REQUIRE_LICENSE` | `false` | ohne gültige Runtime-Lizenz fachliche Endpunkte mit 503 sperren |
| `WHITE_LABEL` | `false` | Standardattribution ausblenden, wenn Capability `white_label` vorhanden |
| `LICENSE_TOKEN` | leer | Runtime-Lizenz |
| `LICENSE_TOKEN_FILE` | leer | Datei-Alternative für Runtime-Lizenz |
| `LICENSE_MODE` | `offline` | `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 |
| `BULK_MAX_ITEMS` | `500` | serverseitige Bulk-Obergrenze |
| `BULK_MAX_BODY_BYTES` | `4194304` | maximale Bulk-Requestgröße |
| `BULK_REQUIRE_API_KEY` | `false` | Bulk-Endpunkt zusätzlich per API-Key schützen |
| `BULK_API_KEY` | leer | Bulk-API-Key |
| `BULK_API_KEY_FILE` | leer | Datei-Alternative für Bulk-API-Key |
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/render HTML, Markdown und JSON-LD aus denselben Parametern
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
```
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;
- 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.3)
Der Generator trennt seit Schema 1.3 bewusst zwischen **Inhalt/KI-Nutzung**, **Art.-50-Selbsteinordnung**, **Veröffentlichungs-/Verantwortlichkeitsangaben** und **Ausgabe**. Abhängige Eingabefelder werden nur eingeblendet, wenn sie zu den zuvor angegebenen Tatsachen passen. Rolle und Nutzungskontext bilden dabei den ersten Filter; deployerspezifische Deepfake-/Public-Interest-Fragen erscheinen nicht bei rein persönlicher nicht-beruflicher Nutzung oder einer ausschließlich providerseitigen Rolle. So erscheint die Deepfake-Prüfung nur bei KI-beteiligten Bild-, Audio- oder Videoinhalten, die Public-Interest-Prüfung nur bei KI-beteiligtem Text und die redaktionelle Verantwortung nur dann, wenn sie für die mögliche Ausnahme bei Public-Interest-Texten tatsächlich relevant werden kann.
`legalContext` kann `categories` (`deepfake`, `publicInterestText`, `artisticCreativeSatiricalFictional`, `otherVoluntary`) sowie `actorRole`, `useContext`, `outputDate`, `deepfakeAssessment`, `publicInterestAssessment`, `creativeWorkAssessment` und `lawEnforcementAuthorization` dokumentieren. Die inhaltlichen Prüfungen verwenden bewusst `yes` / `no` / `unsure`, damit „nicht angeklickt“ nicht mit „nein“ verwechselt wird. Die Angaben sind eine vorsichtige Selbsteinordnung und keine automatische Rechtsentscheidung. Der Generator berücksichtigt insbesondere Provider-/Deployer-Rolle, rein persönliche nicht-berufliche Nutzung, den Anwendungsbeginn am 2. August 2026, die besondere Strafverfolgungs-Ausnahme in Art. 50 Abs. 4 sowie bei Public-Interest-Texten die Kombination aus substantieller menschlicher Prüfung/redaktioneller Kontrolle und ausdrücklich benannter redaktioneller Verantwortung.
Autor/Byline (`author`) und redaktionelle Verantwortung (`editorialResponsibility`) sind getrennte Metadaten. Eine freiwillige Beschwerde-/Rückmeldestelle (`complaintsContact`) kann als Best Practice angegeben werden, wird aber ausdrücklich nicht als allgemeine Pflicht aus Art. 50 dargestellt. Der Generator zeigt außerdem Plausibilitäts- und Rechtshinweise sowie die möglichen Folgen einer Kennzeichnungspflicht; Details stehen in [`docs/EU-AI-ACT-COMPLIANCE.md`](docs/EU-AI-ACT-COMPLIANCE.md).
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.
+15 -24
View File
@@ -1,30 +1,21 @@
# 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.
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.
## License integration
- 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.
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.
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.
-8
View File
@@ -1,8 +0,0 @@
package bulkweb
import "embed"
// Files contains the self-contained bulk user interface.
//
//go:embed templates/*.html static/*
var Files embed.FS
File diff suppressed because one or more lines are too long
-526
View File
@@ -1,526 +0,0 @@
(() => {
'use strict';
const PROFILE_KEY = 'ai-disclosure.bulk.profiles.v1';
const TEMPLATE_KEY = 'ai-disclosure.bulk.templates.v1';
const EXPORT_FORMAT = 'ai-disclosure-bulk-browser-data';
const $ = id => document.getElementById(id);
const profileFields = [
['profile-lang', 'lang'],
['profile-role', 'legalRole'],
['profile-use-context', 'useContext'],
['profile-assurance', 'assurance'],
['profile-author', 'author'],
['profile-author-url', 'authorUrl'],
['profile-responsible-role', 'responsibleRole'],
['profile-responsible', 'responsible'],
['profile-responsible-url', 'responsibleUrl'],
['profile-complaint-name', 'complaintName'],
['profile-complaint-email', 'complaintEmail'],
['profile-complaint-url', 'complaintUrl']
];
let cfg = null;
let activeTemplate = null;
let currentResults = [];
let exportsCache = {};
function readStore(key) {
try {
const parsed = JSON.parse(localStorage.getItem(key) || '{}');
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (_) {
return {};
}
}
function writeStore(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
function setStatus(text, level = '') {
const target = $('template-status');
target.textContent = text;
target.className = `status ${level}`.trim();
}
function addMessage(text, level = '') {
const p = document.createElement('p');
p.className = `message ${level}`.trim();
p.textContent = text;
$('url-messages').appendChild(p);
}
function safeHTTPURL(raw) {
try {
const url = new URL(raw);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null;
return url;
} catch (_) {
return null;
}
}
function parseTemplate(raw) {
const value = raw.trim();
if (!value) throw new Error('Bitte zuerst eine Declaration-URL oder einen Query-String einfügen.');
let params;
const absolute = safeHTTPURL(value);
if (absolute) {
params = new URLSearchParams(absolute.search);
} else {
const query = value.startsWith('?') ? value.slice(1) : value;
params = new URLSearchParams(query);
}
params.delete('subject');
params.delete('link');
if ([...params.keys()].length === 0) throw new Error('Die Vorlage enthält keine Parameter.');
return params;
}
function activateTemplate(params, label = 'Vorlage geladen') {
activeTemplate = new URLSearchParams(params);
activeTemplate.delete('subject');
activeTemplate.delete('link');
$('template-input').value = activeTemplate.toString();
renderTemplateParams();
setStatus(`${label}: ${[...activeTemplate.keys()].length} Parameter aktiv.`, 'good');
}
function renderTemplateParams() {
const list = $('template-params');
list.replaceChildren();
if (!activeTemplate) return;
const entries = [...activeTemplate.entries()].sort(([a], [b]) => a.localeCompare(b));
for (const [key, value] of entries) {
const row = document.createElement('div');
const dt = document.createElement('dt');
const dd = document.createElement('dd');
dt.textContent = key;
dd.textContent = value;
row.append(dt, dd);
list.appendChild(row);
}
}
function refreshTemplateSelect(selected = '') {
const templates = readStore(TEMPLATE_KEY);
const select = $('saved-template-select');
select.replaceChildren(new Option('Gespeicherte Vorlage wählen …', ''));
Object.keys(templates).sort((a, b) => a.localeCompare(b)).forEach(name => select.add(new Option(name, name)));
if (selected && templates[selected]) select.value = selected;
}
function saveTemplate() {
if (!activeTemplate) {
setStatus('Bitte zuerst eine gültige Vorlage laden.', 'error');
return;
}
const name = $('template-name').value.trim();
if (!name) {
setStatus('Bitte einen Namen für die Vorlage angeben.', 'error');
$('template-name').focus();
return;
}
const templates = readStore(TEMPLATE_KEY);
templates[name] = {query: activeTemplate.toString(), updatedAt: new Date().toISOString()};
writeStore(TEMPLATE_KEY, templates);
refreshTemplateSelect(name);
setStatus(`Vorlage „${name}“ wurde nur in diesem Browser gespeichert.`, 'good');
}
function loadSavedTemplate(name) {
if (!name) return;
const item = readStore(TEMPLATE_KEY)[name];
if (!item || typeof item.query !== 'string') return;
$('template-name').value = name;
activateTemplate(new URLSearchParams(item.query), `Vorlage „${name}“ geladen`);
}
function deleteTemplate() {
const name = $('saved-template-select').value;
if (!name) return;
const templates = readStore(TEMPLATE_KEY);
delete templates[name];
writeStore(TEMPLATE_KEY, templates);
refreshTemplateSelect();
if ($('template-name').value === name) $('template-name').value = '';
setStatus(`Vorlage „${name}“ wurde aus diesem Browser gelöscht.`);
}
function emptyProfileForm() {
$('profile-name').value = '';
for (const [id] of profileFields) $(id).value = '';
$('profile-select').value = '';
}
function profileFromForm() {
const profile = {};
for (const [id, key] of profileFields) {
const value = $(id).value.trim();
if (value) profile[key] = value;
}
return profile;
}
function validateProfile(profile) {
const urlKeys = ['authorUrl', 'responsibleUrl', 'complaintUrl'];
for (const key of urlKeys) {
if (profile[key] && !safeHTTPURL(profile[key])) return `${key} muss eine absolute http(s)-URL ohne Zugangsdaten sein.`;
}
if (profile.complaintEmail && !$('profile-complaint-email').checkValidity()) return 'Die E-Mail-Adresse der Rückmeldestelle ist nicht gültig.';
return '';
}
function validateMergedParams(params) {
if (params.get('authorUrl') && !params.get('author')) return 'Zu einer Autoren-URL muss auch ein Autor bzw. eine Autorin angegeben werden.';
const responsibilityStarted = params.get('responsibleRole') || params.get('responsible') || params.get('responsibleUrl');
if (responsibilityStarted && (!params.get('responsibleRole') || !params.get('responsible'))) return 'Bei redaktioneller Verantwortung sind Rolle und Name/Organisation gemeinsam erforderlich.';
const complaintStarted = params.get('complaintName') || params.get('complaintEmail') || params.get('complaintUrl');
if (complaintStarted && (!params.get('complaintName') || (!params.get('complaintEmail') && !params.get('complaintUrl')))) return 'Die Rückmeldestelle benötigt einen Namen und mindestens E-Mail oder Kontakt-URL.';
return '';
}
function refreshProfileSelect(selected = '') {
const profiles = readStore(PROFILE_KEY);
const select = $('profile-select');
select.replaceChildren(new Option('Website-Profil wählen …', ''));
Object.keys(profiles).sort((a, b) => a.localeCompare(b)).forEach(name => select.add(new Option(name, name)));
if (selected && profiles[selected]) select.value = selected;
}
function saveProfile() {
const name = $('profile-name').value.trim();
if (!name) {
alert('Bitte einen Profilnamen angeben.');
$('profile-name').focus();
return;
}
const profile = profileFromForm();
const error = validateProfile(profile);
if (error) {
alert(error);
return;
}
const profiles = readStore(PROFILE_KEY);
profiles[name] = {values: profile, updatedAt: new Date().toISOString()};
writeStore(PROFILE_KEY, profiles);
refreshProfileSelect(name);
}
function loadProfile(name) {
if (!name) {
emptyProfileForm();
return;
}
const item = readStore(PROFILE_KEY)[name];
if (!item || !item.values) return;
$('profile-name').value = name;
for (const [id, key] of profileFields) $(id).value = item.values[key] || '';
}
function deleteProfile() {
const name = $('profile-select').value;
if (!name) return;
const profiles = readStore(PROFILE_KEY);
delete profiles[name];
writeStore(PROFILE_KEY, profiles);
refreshProfileSelect();
emptyProfileForm();
}
function activeQuery() {
if (!activeTemplate) throw new Error('Bitte zuerst eine Kennzeichnungsvorlage laden.');
const params = new URLSearchParams(activeTemplate);
params.delete('subject');
params.delete('link');
const profile = profileFromForm();
const error = validateProfile(profile);
if (error) throw new Error(error);
for (const [key, value] of Object.entries(profile)) params.set(key, value);
const mergedError = validateMergedParams(params);
if (mergedError) throw new Error(mergedError);
return params;
}
function csvCellLine(line) {
let value = line.trim();
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
value = value.slice(1, -1).replaceAll('""', '"');
}
return value;
}
function parseSubjects() {
$('url-messages').replaceChildren();
let lines = $('url-input').value.split(/\r?\n/).map(csvCellLine).filter(Boolean);
if (lines[0]?.toLowerCase() === 'url') lines = lines.slice(1);
const baseRaw = $('base-url').value.trim();
let base = null;
if (baseRaw) {
base = safeHTTPURL(baseRaw);
if (!base) addMessage('Die Basis-URL ist ungültig und wird nicht verwendet.', 'error');
}
const unique = new Map();
const invalid = [];
let duplicates = 0;
for (const raw of lines) {
let parsed = safeHTTPURL(raw);
if (!parsed && base) {
try {
const candidate = new URL(raw, base);
if (['http:', 'https:'].includes(candidate.protocol) && !candidate.username && !candidate.password) parsed = candidate;
} catch (_) {}
}
if (!parsed) {
invalid.push(raw);
continue;
}
const normalized = parsed.href;
if (unique.has(normalized)) {
duplicates++;
continue;
}
unique.set(normalized, normalized);
}
const subjects = [...unique.values()];
$('url-counter').querySelector('strong').textContent = String(subjects.length);
if (duplicates) addMessage(`${duplicates} Duplikat${duplicates === 1 ? '' : 'e'} entfernt.`, 'info');
if (invalid.length) addMessage(`${invalid.length} ungültige Zeile${invalid.length === 1 ? '' : 'n'} ignoriert: ${invalid.slice(0, 3).join(', ')}${invalid.length > 3 ? ' …' : ''}`, 'error');
if (cfg && subjects.length > cfg.maxURLs) addMessage(`Maximal ${cfg.maxURLs} URLs pro Lauf. Bitte die Liste aufteilen.`, 'error');
return subjects;
}
function csvEscape(value) {
const text = String(value ?? '');
return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}
function buildExports(results) {
const successful = results.filter(item => item.ok && item.data);
const html = successful.map(item => `<!-- ${item.subject} -->\n${item.data.html}`).join('\n\n');
const markdown = successful.map(item => `### ${item.subject}\n\n${item.data.markdown}`).join('\n\n');
const jsonArray = successful.map(item => item.data.jsonLd);
const json = JSON.stringify(jsonArray, null, 2);
const jsonl = successful.map(item => JSON.stringify(item.data.jsonLd)).join('\n');
const csvRows = [['subject', 'declaration', 'manifest', 'badge']];
successful.forEach(item => csvRows.push([item.subject, item.data.declarationUrl, item.data.manifestUrl, item.data.badgeUrl]));
const csv = csvRows.map(row => row.map(csvEscape).join(',')).join('\n');
return {html, markdown, json, jsonl, csv};
}
function renderResults(results) {
currentResults = results;
exportsCache = buildExports(results);
const successful = results.filter(item => item.ok).length;
const failed = results.length - successful;
$('results-section').hidden = false;
$('result-summary').textContent = `${successful} Kennzeichnung${successful === 1 ? '' : 'en'} erzeugt${failed ? `, ${failed} Fehler` : ''}.`;
const tbody = $('result-rows');
tbody.replaceChildren();
results.forEach(item => {
const tr = document.createElement('tr');
const subjectCell = document.createElement('td');
const code = document.createElement('code');
code.textContent = item.subject;
subjectCell.appendChild(code);
const statusCell = document.createElement('td');
statusCell.className = item.ok ? 'ok' : 'failed';
statusCell.textContent = item.ok ? '✓ erzeugt' : `Fehler: ${item.error || 'unbekannt'}`;
const linksCell = document.createElement('td');
if (item.ok && item.data) {
const links = document.createElement('div');
links.className = 'result-links';
[['Declaration', item.data.declarationUrl], ['JSON-LD', item.data.manifestUrl], ['Badge', item.data.badgeUrl]].forEach(([label, href]) => {
const a = document.createElement('a');
a.textContent = label;
a.href = href;
a.target = '_blank';
a.rel = 'noopener';
links.appendChild(a);
});
linksCell.appendChild(links);
}
tr.append(subjectCell, statusCell, linksCell);
tbody.appendChild(tr);
});
$('output-html').value = exportsCache.html;
$('output-markdown').value = exportsCache.markdown;
$('output-json').value = exportsCache.json;
$('output-csv').value = exportsCache.csv;
$('results-section').scrollIntoView({behavior: 'smooth', block: 'start'});
}
async function generate() {
let query;
try {
query = activeQuery();
} catch (error) {
setStatus(error.message, 'error');
return;
}
const subjects = parseSubjects();
if (!subjects.length) {
addMessage('Bitte mindestens eine gültige URL einfügen.', 'error');
return;
}
if (subjects.length > cfg.maxURLs) return;
const button = $('generate');
const original = button.textContent;
button.disabled = true;
button.textContent = `${subjects.length} Kennzeichnungen werden erzeugt …`;
try {
const response = await fetch('/api/render-batch', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
body: JSON.stringify({template: query.toString(), subjects})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || `HTTP ${response.status}`);
renderResults(payload.results || []);
} catch (error) {
addMessage(`Bulk-Lauf fehlgeschlagen: ${error.message}`, 'error');
} finally {
button.disabled = false;
button.textContent = original;
}
}
async function copyExport(kind, button) {
const value = exportsCache[kind] || '';
if (!value) return;
await navigator.clipboard.writeText(value);
const old = button.textContent;
button.textContent = 'Kopiert';
setTimeout(() => { button.textContent = old; }, 1300);
}
function downloadExport(kind) {
const spec = {
html: ['ai-disclosure-bulk.html', 'text/html;charset=utf-8'],
markdown: ['ai-disclosure-bulk.md', 'text/markdown;charset=utf-8'],
json: ['ai-disclosure-bulk.json', 'application/ld+json;charset=utf-8'],
jsonl: ['ai-disclosure-bulk.jsonl', 'application/x-ndjson;charset=utf-8'],
csv: ['ai-disclosure-bulk.csv', 'text/csv;charset=utf-8']
}[kind];
const value = exportsCache[kind] || '';
if (!spec || !value) return;
downloadBlob(spec[0], value, spec[1]);
}
function downloadBlob(filename, content, type) {
const blob = new Blob([content], {type});
const href = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = href;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(href), 500);
}
function exportBrowserData() {
const payload = {
format: EXPORT_FORMAT,
version: 1,
exportedAt: new Date().toISOString(),
profiles: readStore(PROFILE_KEY),
templates: readStore(TEMPLATE_KEY)
};
downloadBlob('ai-disclosure-bulk-browser-data.json', JSON.stringify(payload, null, 2), 'application/json;charset=utf-8');
}
async function importBrowserData(file) {
if (!file) return;
try {
const payload = JSON.parse(await file.text());
if (payload.format !== EXPORT_FORMAT || payload.version !== 1) throw new Error('Unbekanntes Exportformat.');
if (!payload.profiles || typeof payload.profiles !== 'object' || !payload.templates || typeof payload.templates !== 'object') throw new Error('Export ist unvollständig.');
writeStore(PROFILE_KEY, payload.profiles);
writeStore(TEMPLATE_KEY, payload.templates);
refreshProfileSelect();
refreshTemplateSelect();
alert('Profile und Vorlagen wurden importiert.');
} catch (error) {
alert(`Import fehlgeschlagen: ${error.message}`);
} finally {
$('import-browser-data').value = '';
}
}
function importFragmentTemplate() {
if (!location.hash.startsWith('#')) return;
const hash = new URLSearchParams(location.hash.slice(1));
const raw = hash.get('template');
if (!raw) return;
try {
const payload = JSON.parse(raw);
if (payload.version !== 1 || typeof payload.query !== 'string') throw new Error('Unbekanntes Vorlagenformat.');
const params = new URLSearchParams(payload.query);
if (payload.theme) params.set('theme', payload.theme);
activateTemplate(params, 'Aus dem Generator übernommen');
$('template-name').value = payload.name || '';
} catch (error) {
setStatus(`Vorlage aus dem Generator konnte nicht übernommen werden: ${error.message}`, 'error');
} finally {
history.replaceState(null, '', location.pathname + location.search);
}
}
function clearBrowserData() {
if (!confirm('Alle lokal gespeicherten Website-Profile und Kennzeichnungsvorlagen in diesem Browser löschen?')) return;
localStorage.removeItem(PROFILE_KEY);
localStorage.removeItem(TEMPLATE_KEY);
refreshProfileSelect();
refreshTemplateSelect();
emptyProfileForm();
setStatus('Lokale Profile und Vorlagen wurden gelöscht.');
}
async function init() {
try {
const response = await fetch('/api/config', {headers: {'Accept': 'application/json'}});
cfg = await response.json();
} catch (_) {
cfg = {maxURLs: 500};
}
refreshTemplateSelect();
refreshProfileSelect();
importFragmentTemplate();
parseSubjects();
$('load-template').addEventListener('click', () => {
try {
activateTemplate(parseTemplate($('template-input').value));
} catch (error) {
setStatus(error.message, 'error');
}
});
$('save-template').addEventListener('click', saveTemplate);
$('saved-template-select').addEventListener('change', event => loadSavedTemplate(event.target.value));
$('delete-template').addEventListener('click', deleteTemplate);
$('profile-select').addEventListener('change', event => loadProfile(event.target.value));
$('new-profile').addEventListener('click', emptyProfileForm);
$('save-profile').addEventListener('click', saveProfile);
$('delete-profile').addEventListener('click', deleteProfile);
$('export-browser-data').addEventListener('click', exportBrowserData);
$('import-browser-data').addEventListener('change', event => importBrowserData(event.target.files?.[0]));
$('clear-browser-data').addEventListener('click', clearBrowserData);
$('url-input').addEventListener('input', parseSubjects);
$('base-url').addEventListener('input', parseSubjects);
$('clear-urls').addEventListener('click', () => {
$('url-input').value = '';
$('url-messages').replaceChildren();
parseSubjects();
});
$('generate').addEventListener('click', generate);
document.querySelectorAll('[data-copy]').forEach(button => button.addEventListener('click', () => copyExport(button.dataset.copy, button)));
document.querySelectorAll('[data-download]').forEach(button => button.addEventListener('click', () => downloadExport(button.dataset.download)));
}
init();
})();
-247
View File
@@ -1,247 +0,0 @@
{{define "index.html"}}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Name}}</title>
<meta name="description" content="Mehrere AI-Disclosure-Kennzeichnungen aus einer gemeinsamen Vorlage erzeugen.">
<link rel="stylesheet" href="/static/bulk.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="/">{{.Name}}</a>
<nav>
<a href="{{.GeneratorURL}}">Generator</a>
<a href="{{.DisclosureBaseURL}}/datenschutz">Datenschutz</a>
<a href="{{.DisclosureBaseURL}}/impressum">Impressum</a>
</nav>
</header>
<main>
<section class="hero">
<p class="eyebrow">Bulk Generator</p>
<h1>Einmal definieren.<br>Viele Inhalte kennzeichnen.</h1>
<p class="lead">Übernimm eine Kennzeichnung aus dem normalen Generator, ergänze wiederkehrende Website-Daten und erzeuge HTML, Markdown und JSON-LD für bis zu {{.MaxURLs}} Inhalts-URLs in einem Lauf.</p>
<div class="notice privacy-note"><strong>Datensparsam:</strong> Inhalts-URLs werden nicht abgerufen. Website-Profile und gespeicherte Vorlagen bleiben ausschließlich im Browser-Speicher dieses Geräts. URL-Listen und Ergebnisse werden nicht dauerhaft gespeichert.</div>
</section>
<section class="panel workflow" aria-labelledby="workflow-title">
<div class="section-heading">
<p class="eyebrow">1 · Kennzeichnung</p>
<h2 id="workflow-title">Vorlage festlegen</h2>
<p>Am sichersten ist die Übernahme direkt aus dem bestehenden Generator. Alternativ kannst du eine Declaration-URL oder deren Query-String einfügen.</p>
</div>
<div class="toolbar wrap">
<a class="button secondary" id="open-generator" href="{{.GeneratorURL}}" target="_blank" rel="noopener">Kennzeichnung im Generator definieren</a>
<select id="saved-template-select" aria-label="Gespeicherte Vorlage">
<option value="">Gespeicherte Vorlage wählen …</option>
</select>
<button type="button" class="button subtle" id="delete-template">Vorlage löschen</button>
</div>
<label class="field wide">Declaration-URL oder Query-String
<textarea id="template-input" rows="5" spellcheck="false" placeholder="https://…/declaration?mode=article&textExtent=partial&…"></textarea>
<small>`subject` wird beim Laden entfernt; die jeweilige Inhalts-URL setzt der Bulk-Lauf später selbst.</small>
</label>
<div class="toolbar wrap">
<button type="button" id="load-template">Vorlage laden</button>
<label class="inline-field">Vorlagenname
<input id="template-name" maxlength="80" placeholder="z. B. Artikel redaktionell geprüft">
</label>
<button type="button" class="button secondary" id="save-template">Im Browser speichern</button>
</div>
<div id="template-status" class="status" role="status" aria-live="polite">Noch keine Vorlage geladen.</div>
<details class="details-box">
<summary>Aktive Parameter anzeigen</summary>
<dl id="template-params" class="parameter-list"></dl>
</details>
</section>
<section class="panel" aria-labelledby="profile-title">
<div class="section-heading">
<p class="eyebrow">2 · Wiederkehrende Daten</p>
<h2 id="profile-title">Website-Profil</h2>
<p>Hier gehören Angaben hinein, die du nicht bei jedem Batch erneut eingeben möchtest. Nicht ausgefüllte Profilfelder verändern die geladene Kennzeichnungsvorlage nicht.</p>
</div>
<div class="toolbar wrap">
<select id="profile-select" aria-label="Website-Profil">
<option value="">Website-Profil wählen …</option>
</select>
<button type="button" class="button subtle" id="new-profile">Neues Profil</button>
<button type="button" class="button subtle danger" id="delete-profile">Profil löschen</button>
</div>
<div class="profile-grid">
<label>Profilname
<input id="profile-name" maxlength="80" placeholder="z. B. example.org">
</label>
<label>Sprache
<select id="profile-lang">
<option value="">Aus Vorlage übernehmen</option>
<option value="de">Deutsch</option><option value="en">English</option><option value="fr">Français</option>
<option value="es">Español</option><option value="it">Italiano</option><option value="nl">Nederlands</option>
<option value="pt">Português</option><option value="pl">Polski</option>
</select>
</label>
<label>AI-Act-Rolle
<select id="profile-role">
<option value="">Aus Vorlage übernehmen</option>
<option value="deployer">Deployer</option>
<option value="provider">Provider</option>
<option value="both">Beides</option>
<option value="unsure">Unklar</option>
</select>
</label>
<label>Nutzungskontext
<select id="profile-use-context">
<option value="">Aus Vorlage übernehmen</option>
<option value="professional">Beruflich / organisatorisch</option>
<option value="personalNonProfessional">Rein persönlich / nicht beruflich</option>
<option value="unsure">Unklar</option>
</select>
</label>
<label>Nachweisgrundlage
<select id="profile-assurance">
<option value="">Aus Vorlage übernehmen</option>
<option value="selfDeclared">Selbstauskunft</option>
<option value="technicallyRecorded">Technisch erfasst</option>
<option value="signed">Signiert</option>
<option value="verified">Verifiziert</option>
</select>
</label>
<div class="subsection wide">
<h3>Autor / Byline <span>optional</span></h3>
<div class="profile-grid nested">
<label>Autor/in
<input id="profile-author" maxlength="200" placeholder="Name">
</label>
<label>Autoren-URL
<input id="profile-author-url" type="url" placeholder="https://example.org/autor">
</label>
</div>
</div>
<div class="subsection wide">
<h3>Redaktionelle Verantwortung <span>nur eintragen, wenn sie zur Vorlage passt</span></h3>
<div class="profile-grid nested">
<label>Rolle
<select id="profile-responsible-role">
<option value="">Aus Vorlage übernehmen / keine</option>
<option value="publisher">Publisher</option>
<option value="other">Andere verantwortliche Stelle</option>
</select>
</label>
<label>Name / Organisation
<input id="profile-responsible" maxlength="200" placeholder="Redaktion / Organisation">
</label>
<label class="wide">Impressum / Verantwortlichkeits-URL
<input id="profile-responsible-url" type="url" placeholder="https://example.org/impressum">
</label>
</div>
</div>
<div class="subsection wide">
<h3>Beschwerde- / Rückmeldestelle <span>Best Practice</span></h3>
<div class="profile-grid nested">
<label>Name
<input id="profile-complaint-name" maxlength="200" placeholder="Redaktion / Ombudsstelle">
</label>
<label>E-Mail
<input id="profile-complaint-email" type="email" placeholder="feedback@example.org">
</label>
<label class="wide">Kontakt-URL
<input id="profile-complaint-url" type="url" placeholder="https://example.org/kontakt">
</label>
</div>
</div>
</div>
<div class="toolbar wrap">
<button type="button" id="save-profile">Profil speichern</button>
<button type="button" class="button secondary" id="export-browser-data">Profile & Vorlagen exportieren</button>
<label class="button secondary file-button">Importieren<input id="import-browser-data" type="file" accept="application/json,.json"></label>
<button type="button" class="button subtle danger" id="clear-browser-data">Alle lokalen Daten löschen</button>
</div>
<p class="help">Gespeichert werden nur die von dir angelegten Profile und Vorlagen in <code>localStorage</code>. Eine Synchronisierung zum Server findet nicht statt.</p>
</section>
<section class="panel" aria-labelledby="urls-title">
<div class="section-heading">
<p class="eyebrow">3 · Inhalte</p>
<h2 id="urls-title">URLs oder Pfade einfügen</h2>
<p>Eine URL bzw. ein Pfad pro Zeile. Bei relativen Pfaden wird die optionale Basis-URL verwendet. Eine einspaltige CSV-Datei mit der Überschrift <code>url</code> kann ebenfalls direkt eingefügt werden.</p>
</div>
<div class="url-grid">
<label>Basis-URL für relative Pfade
<input id="base-url" type="url" placeholder="https://example.org">
</label>
<div class="counter-card" id="url-counter" aria-live="polite"><strong>0</strong><span>gültige URLs</span></div>
</div>
<label class="field wide">URL-Liste
<textarea id="url-input" rows="12" spellcheck="false" placeholder="/artikel/eins&#10;/artikel/zwei&#10;https://other.example/artikel/drei"></textarea>
</label>
<div id="url-messages" class="messages" aria-live="polite"></div>
<div class="toolbar wrap">
<button type="button" id="generate">Kennzeichnungen erzeugen</button>
<button type="button" class="button subtle" id="clear-urls">URL-Liste leeren</button>
</div>
</section>
<section class="panel results-panel" id="results-section" aria-labelledby="results-title" hidden>
<div class="section-heading">
<p class="eyebrow">4 · Ausgabe</p>
<h2 id="results-title">Ergebnisse</h2>
<p id="result-summary"></p>
</div>
<div class="toolbar wrap export-buttons">
<button type="button" data-copy="html">HTML kopieren</button>
<button type="button" class="button secondary" data-download="html">HTML herunterladen</button>
<button type="button" data-copy="markdown">Markdown kopieren</button>
<button type="button" class="button secondary" data-download="markdown">Markdown herunterladen</button>
<button type="button" data-copy="json">JSON-LD kopieren</button>
<button type="button" class="button secondary" data-download="json">JSON-LD herunterladen</button>
<button type="button" class="button secondary" data-download="jsonl">JSONL herunterladen</button>
<button type="button" class="button secondary" data-download="csv">CSV herunterladen</button>
</div>
<div class="result-table-wrap">
<table class="result-table">
<thead><tr><th>Inhalt</th><th>Status</th><th>Links</th></tr></thead>
<tbody id="result-rows"></tbody>
</table>
</div>
<div class="output-grid">
<label>HTML<textarea id="output-html" rows="12" readonly></textarea></label>
<label>Markdown<textarea id="output-markdown" rows="12" readonly></textarea></label>
<label>JSON-LD<textarea id="output-json" rows="12" readonly></textarea></label>
<label>CSV<textarea id="output-csv" rows="8" readonly></textarea></label>
</div>
</section>
<section class="panel compact-panel">
<h2>Was dieser Container bewusst nicht tut</h2>
<ul class="plain-list">
<li>keine Benutzerkonten und keine Datenbank</li>
<li>kein Crawling und kein Abruf der eingegebenen Inhalts-URLs</li>
<li>keine eigene rechtliche Entscheidungslogik</li>
<li>keine dauerhafte Speicherung von URL-Listen oder Ergebnissen</li>
</ul>
</section>
</main>
<footer>
<span>AI Disclosure Bulk · {{.Version}} · Core-Ausgabe über {{.DisclosureBaseURL}}</span>
<nav><a href="{{.DisclosureBaseURL}}/datenschutz">Datenschutz</a><a href="{{.DisclosureBaseURL}}/impressum">Impressum</a></nav>
</footer>
<script src="/static/bulk.js" defer></script>
</body>
</html>
{{end}}
-62
View File
@@ -1,62 +0,0 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/b1tsblog/ai-disclosure-standard/internal/bulk"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "--healthcheck" {
url := os.Getenv("BULK_HEALTHCHECK_URL")
if url == "" {
url = "http://127.0.0.1:8081/healthz"
}
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(url)
if err != nil || resp.StatusCode != http.StatusOK {
os.Exit(1)
}
_ = resp.Body.Close()
return
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
cfg := bulk.ConfigFromEnv()
handler, err := bulk.New(cfg, logger)
if err != nil {
logger.Error("bulk application initialization failed", "error", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
server := &http.Server{
Addr: cfg.ListenAddress, Handler: handler,
ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 20 * time.Second,
WriteTimeout: 90 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20,
}
go func() {
logger.Info("bulk server started", "address", cfg.ListenAddress, "core", cfg.CoreInternalURL)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("bulk server failed", "error", err)
os.Exit(1)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("bulk graceful shutdown failed", "error", err)
os.Exit(1)
}
}
+97
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.0"
image: ai-disclosure-standard:2.0.0
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.0"
image: ai-disclosure-standard-bulk:2.0.0
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:
+23 -36
View File
@@ -3,11 +3,30 @@ services:
build:
context: .
dockerfile: Dockerfile
image: ai-disclosure-standard:1.8.0-local
env_file:
- .env
args:
VERSION: "2.0.0"
image: ai-disclosure-standard:2.0.0-local
environment:
BULK_URL: ${BULK_URL:-http://localhost:8081}
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:
@@ -27,37 +46,5 @@ services:
retries: 3
start_period: 5s
bulk:
build:
context: .
dockerfile: Dockerfile.bulk
image: ai-disclosure-bulk:1.0.0-local
environment:
CORE_INTERNAL_URL: http://app:8080
DISCLOSURE_BASE_URL: ${BASE_URL:-http://localhost:8080}
GENERATOR_URL: ${BASE_URL:-http://localhost:8080}
BULK_PUBLIC_NAME: AI Disclosure Bulk
BULK_MAX_URLS: ${BULK_MAX_URLS:-500}
BULK_WORKERS: ${BULK_WORKERS:-4}
ports:
- "8081:8081"
read_only: true
tmpfs:
- /tmp:size=8m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
depends_on:
app:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "/ai-disclosure-bulk", "--healthcheck"]
interval: 15s
timeout: 3s
retries: 3
start_period: 5s
volumes:
license-cache:
+178
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.0"
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.0"
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.0
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
+28 -33
View File
@@ -4,6 +4,7 @@ metadata:
name: ai-disclosure
labels:
app.kubernetes.io/name: ai-disclosure
app.kubernetes.io/version: "2.0.0"
spec:
replicas: 3
strategy:
@@ -18,14 +19,20 @@ spec:
metadata:
labels:
app.kubernetes.io/name: ai-disclosure
app.kubernetes.io/version: "2.0.0"
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.8.0
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:2.0.0
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
-81
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
+61
View File
@@ -0,0 +1,61 @@
version: "3.9"
services:
bulk:
image: ghcr.io/REPLACE_ME/ai-disclosure-standard-bulk:2.0.0
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:
+8 -19
View File
@@ -1,43 +1,33 @@
version: "3.9"
services:
app:
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.8.0
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:2.0.0
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:
+52
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
+1 -1
View File
@@ -1,6 +1,6 @@
# Mehrsprachige Hintergrundseite
Version 1.8.0 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit.
Version 2.0.0 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit.
## Routen
+103
View File
@@ -0,0 +1,103 @@
# Bulk API
Der Bulk-Dienst ist für Publisher, CMS-Integrationen, Agenturen und andere Server-to-Server-Workflows gedacht.
## 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=
```
Damit enthält die Angriffsoberfläche keine Generator-, Produkt- oder Hintergrundseite.
## 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.
+88
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.
-229
View File
@@ -1,229 +0,0 @@
# EU AI Act: Compliance-Grenzen und Betreiber-Checkliste
**Rechtsstand: 22. 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.role`, `editorialResponsibility.name` und `editorialResponsibility.url` dienen nur der Dokumentation. Der Generator berücksichtigt eine mögliche Ausnahme nur, wenn mindestens Rolle und verantwortliche Person/Organisation ausdrücklich angegeben sind. Auch diese Dokumentation beweist nicht automatisch, dass die gesetzlichen Voraussetzungen tatsächlich vorliegen.
**Autor/Byline und redaktionelle Verantwortung sind getrennt.** `author` beschreibt die Urheberschaft bzw. Veröffentlichungs-Byline. `editorialResponsibility` beschreibt dagegen diejenige natürliche oder juristische Person, die letztlich die rechtliche redaktionelle Verantwortung für die Veröffentlichung trägt. Beides kann zusammenfallen, muss es aber nicht.
## 5. Besondere Ausnahme für gesetzlich autorisierte Strafverfolgungsnutzung
Artikel 50 Absatz 4 enthält für Deepfakes und Public-Interest-Texte eine besondere Ausnahme, soweit die konkrete Nutzung **gesetzlich zur Aufdeckung, Verhütung, Ermittlung oder Verfolgung von Straftaten autorisiert** ist.
Schema 1.3 kann diese Selbsteinordnung in `legalContext.lawEnforcementAuthorization` (`yes`, `no`, `unsure`) dokumentieren. Das Feld erscheint im Generator nur, wenn zuvor eine Deepfake- oder Public-Interest-Konstellation ausgewählt wurde. Ein bloßer Behördenstatus genügt nicht als Grundlage für „yes“; die konkrete gesetzliche Autorisierung des konkreten Einsatzes sollte intern nachvollziehbar dokumentiert sein. Bei `unsure` warnt der Generator davor, die Ausnahme ohne weitere Prüfung zugrunde zu legen.
Offizielle Quelle:
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
## 6. Beschwerde-/Rückmeldestelle: Best Practice, nicht Art.-50-Pflicht
Eine eigene interne Beschwerde- oder Rückmeldestelle für jeden gekennzeichneten Inhalt ist **keine allgemeine Pflicht aus Artikel 50**. Das Projekt bietet `complaintsContact` deshalb ausdrücklich als Best-Practice-Metadatum an und trennt es von rechtlich erforderlichen Feldern.
Daneben enthält der AI Act in Artikel 85 ein eigenständiges Recht, bei einer Marktüberwachungsbehörde Beschwerde über einen Verstoß gegen den AI Act einzulegen. Eine freiwillige interne Anlaufstelle des Publishers ersetzt dieses gesetzliche Beschwerderecht nicht. Zusätzlich können Medien-, Verbraucher-, Plattform-, Datenschutz- oder andere Fachregeln eigene Kontakt- oder Beschwerdepflichten begründen.
Offizielle Quellen:
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-85
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
## 7. Mögliche Konsequenzen bei Verstößen gegen Artikel 50
Artikel 99 Absatz 4 ordnet Verstöße gegen die Transparenzpflichten des Artikels 50 grundsätzlich einem Bußgeldrahmen von **bis zu 15 Mio. EUR** oder bei Unternehmen **bis zu 3 % des weltweiten Jahresgesamtumsatzes des vorangegangenen Geschäftsjahres** zu. Für KMU einschließlich Start-ups enthält Artikel 99 Absatz 6 eine besondere Deckelungsregel; außerdem müssen Art, Schwere, Dauer, Verantwortungsgrad und weitere Umstände des Einzelfalls berücksichtigt werden.
Der Generator zeigt diesen Rahmen als Risikohinweis, aber **nicht als Prognose einer konkreten Geldbuße**. Ob ein Verstoß vorliegt, welche Maßnahmen angemessen sind und ob bzw. in welcher Höhe eine Geldbuße verhängt wird, ist Sache der zuständigen Behörden bzw. Gerichte.
Offizielle Quelle:
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-99
## 8. 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.
Artikel 50 Absatz 2 nimmt Systeme aus, die lediglich Standard-Bearbeitungsfunktionen ausführen oder die vom Betreiber bereitgestellten Eingabedaten bzw. deren Semantik nicht wesentlich verändern. Ob diese Ausnahme tatsächlich greift, hängt von der konkreten Funktion und Veränderungswirkung ab; der sichtbare Generator kann diese technische Anbieterfrage nicht automatisch entscheiden.
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
## 9. 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
## 10. 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
## 11. 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
## 12. 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.
## 13. Strukturierter rechtlicher Kontext in Schema 1.3
Schema 1.3 erweitert die rechtliche Selbsteinordnung und trennt sie von allgemeinen Veröffentlichungsmetadaten.
`legalContext` unterstützt:
- `categories`: `deepfake`, `publicInterestText`, `artisticCreativeSatiricalFictional`, `otherVoluntary`;
- `actorRole`: `deployer`, `provider`, `both`, `unsure`;
- `useContext`: `professional`, `personalNonProfessional`, `unsure`;
- `outputDate`: Datum des betroffenen KI-Outputs für die zeitliche Einordnung;
- `deepfakeAssessment`: `yes`, `no`, `unsure` für die Deepfake-Einordnung, wenn KI-beteiligte Bild-/Audio-/Videoinhalte vorliegen;
- `publicInterestAssessment`: `yes`, `no`, `unsure` für die Public-Interest-Text-Einordnung, wenn KI-beteiligter Text vorliegt;
- `creativeWorkAssessment`: `yes`, `no`, `unsure` für die kreative/satirische/fiktionale Sonderform, wenn zuvor ein Deepfake bejaht wurde;
- `lawEnforcementAuthorization`: `yes`, `no`, `unsure` für die besondere Ausnahme des Art. 50 Abs. 4.
Daneben stehen getrennt:
- `author`: Autor/in bzw. Byline und optionale Profil-URL;
- `editorialResponsibility`: Rolle, verantwortliche Person/Organisation und optionale Nachweis-/Impressums-URL;
- `complaintsContact`: freiwillige Beschwerde-/Feedback-Anlaufstelle mit Name sowie E-Mail und/oder URL.
### Abhängigkeitslogik im Generator
Der Generator zeigt Felder nur in sachlich passenden Konstellationen:
1. **Keine KI-Beteiligung:** rechtlicher Art.-50-Kontext, Review- und Tätigkeitsfelder werden ausgeblendet bzw. nicht übertragen.
2. **Rolle und Nutzungskontext zuerst:** bei KI-Beteiligung werden zunächst AI-Act-Rolle und beruflicher/persönlicher Nutzungskontext verlangt. Bei rein persönlicher nicht-beruflicher Nutzung oder ausschließlich `provider`-seitiger Rolle werden die deployerspezifischen Inhaltsfragen nicht eingeblendet.
3. **KI-Bild/Audio/Video:** erst danach wird eine verpflichtende `yes`/`no`/`unsure`-Deepfake-Prüfung sichtbar; nach `yes`/`unsure` wird zunächst eine mögliche Strafverfolgungs-Ausnahme geprüft. Die kreative/satirische/fiktionale Sonderform erscheint bei bestätigtem Deepfake erst, wenn diese Ausnahme nicht als einschlägig angegeben wurde.
4. **KI-Text:** erst danach wird eine verpflichtende `yes`/`no`/`unsure`-Public-Interest-Prüfung sichtbar.
5. **Deepfake/Public-Interest = `yes` oder `unsure`:** die Frage nach gesetzlich autorisierter Strafverfolgungsnutzung wird sichtbar.
6. **Public-Interest + substanzielle Prüfung:** Felder zur redaktionellen Verantwortung werden eingeblendet, sofern nicht bereits die Strafverfolgungs-Ausnahme angegeben wurde und die Nutzung nicht rein persönlich bzw. ausschließlich providerseitig ist.
7. **Autor/Byline:** erscheint nur bei KI-Beteiligung in artikelförmiger bzw. textbezogener Nutzung; eine Profil-URL erst nach Eingabe eines Namens.
8. **Beschwerde-/Rückmeldestelle:** erscheint nur in rechtlich sensiblen Deepfake-/Public-Interest-Konstellationen und ist als Best Practice gekennzeichnet.
9. **Unvollständige Pflichtfragen:** solange eine aktuell erforderliche Selbsteinordnung leer ist, gibt die Vorschau bewusst noch kein „Pflicht / keine Pflicht“-Ergebnis aus.
### Plausibilitäts- und Rechtswarnungen
Der Generator warnt unter anderem bei:
- nur formaler (`basic`) Prüfung in einer Public-Interest-Konstellation;
- substantieller Prüfung ohne vollständig benannte redaktionelle Verantwortung;
- Provider-Rolle, weil ein Veröffentlichungs-Badge Art. 50 Abs. 2 nicht ersetzt;
- rein persönlicher, nicht beruflicher Nutzung;
- Output-Datum vor dem 2. August 2026;
- unklarer Strafverfolgungs-Autorisierung;
- widersprüchlicher Kombination von Deepfake/Public-Interest und den strukturierten Inhaltsbestandteilen;
- unvollständiger freiwilliger Beschwerde-/Rückmeldestelle.
Das System formuliert bewusst vorsichtig („spricht vieles dafür“, „kann in Betracht kommen“) und gibt keine verbindliche Rechtsentscheidung aus. Bei Grenzfällen sollte die tatsächliche Rechtslage des konkreten Einsatzes individuell geprüft werden.
-101
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.
-148
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
+1 -1
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.8.0",
ClientVersion: "2.0.0",
Token: cfg.LicenseToken,
TrustStore: trustStore,
BaseURL: cfg.BaseURL,
+21 -1
View File
@@ -119,7 +119,7 @@ Anfrage:
"baseUrl": "https://ai.example.org",
"host": "ai.example.org",
"instanceId": "production-eu-1",
"clientVersion": "1.8.0"
"clientVersion": "2.0.0"
}
```
@@ -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.
+40
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.
-35
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.
-85
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
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.
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/b1tsblog/ai-disclosure-standard
go 1.26
go 1.23
require github.com/b1tsblog/license-platform v1.0.0
+185
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[:])
}
+50 -228
View File
@@ -1,11 +1,6 @@
package app
import (
"errors"
"fmt"
"net/mail"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
@@ -15,45 +10,21 @@ import (
)
type Config struct {
EnvironmentErrors []string
ListenAddress string
BaseURL string
OutputBaseURL string
PublicName string
ContactURL string
SalesURL string
BulkURL string
DefaultLanguage string
TrustProxy bool
TrustedProxies []netip.Prefix
LogClientIP bool
EnableHSTS bool
MetricsEnabled bool
MetricsToken string
LegalStrict bool
LegalName string
LegalAddress string
LegalRepresentative string
LegalEmail string
LegalPhone string
LegalRegister string
LegalRegisterNumber string
LegalVATID string
EditorialResponsibleName string
EditorialResponsibleAddress string
DataProtectionContact string
HostingProvider string
HostingAddress string
LogRetention string
DataRecipients string
ThirdCountryTransfers string
SupervisoryAuthorityName string
SupervisoryAuthorityURL string
ConsumerDisputeStatus string
ConsumerDisputeBody string
ConsumerDisputeURL string
AccessibilityContact string
AccessibilityStatus string
ServiceMode string
BulkMaxItems int
BulkMaxBodyBytes int64
BulkRequireAPIKey bool
BulkAPIKey string
APIAllowedOrigin string
RequireLicense bool
WhiteLabel bool
LicenseToken string
LicenseMode licensekit.VerificationMode
@@ -62,67 +33,31 @@ 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"), "/"),
BaseURL: baseURL,
OutputBaseURL: strings.TrimRight(env("OUTPUT_BASE_URL", baseURL), "/"),
PublicName: env("PUBLIC_NAME", "AI Usage Disclosure"),
ContactURL: contactURL,
SalesURL: env("SALES_URL", contactURL),
BulkURL: strings.TrimRight(env("BULK_URL", ""), "/"),
DefaultLanguage: env("DEFAULT_LANGUAGE", "de"),
TrustProxy: trustProxy,
TrustedProxies: trustedProxies,
LogClientIP: logClientIP,
EnableHSTS: enableHSTS,
MetricsEnabled: metricsEnabled,
MetricsToken: secretEnv("METRICS_TOKEN"),
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", ""),
TrustProxy: strings.EqualFold(env("TRUST_PROXY", "false"), "true"),
ServiceMode: serviceModeEnv("SERVICE_MODE", "full"),
BulkMaxItems: intEnv("BULK_MAX_ITEMS", 500),
BulkMaxBodyBytes: int64(intEnv("BULK_MAX_BODY_BYTES", 4<<20)),
BulkRequireAPIKey: strings.EqualFold(env("BULK_REQUIRE_API_KEY", "false"), "true"),
BulkAPIKey: secretEnv("BULK_API_KEY"),
APIAllowedOrigin: envAllowEmpty("API_ALLOWED_ORIGIN", "*"),
RequireLicense: strings.EqualFold(env("REQUIRE_LICENSE", "false"), "true"),
WhiteLabel: strings.EqualFold(env("WHITE_LABEL", "false"), "true"),
LicenseToken: secretEnv("LICENSE_TOKEN"),
LicenseMode: mode,
@@ -131,124 +66,9 @@ 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, "; "))
}
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 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},
{"BULK_URL", cfg.BulkURL},
{"SUPERVISORY_AUTHORITY_URL", cfg.SupervisoryAuthorityURL},
{"CONSUMER_DISPUTE_URL", cfg.ConsumerDisputeURL},
} {
if err := validateOptionalHTTPURL(item.name, item.value); err != nil {
return err
}
}
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
}
}
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, ", "))
}
}
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 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
}
func env(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
@@ -256,17 +76,12 @@ func env(key, fallback string) string {
return fallback
}
func boolEnv(key string, fallback bool, environmentErrors *[]string) bool {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
func envAllowEmpty(key, fallback string) string {
value, ok := os.LookupEnv(key)
if !ok {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
*environmentErrors = append(*environmentErrors, key+" must be true or false")
return fallback
}
return parsed
return strings.TrimSpace(value)
}
func secretEnv(key string) string {
@@ -284,23 +99,6 @@ func secretEnv(key string) string {
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 == "" {
@@ -314,3 +112,27 @@ 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
}
}
-342
View File
@@ -1,342 +0,0 @@
package app
import (
"fmt"
"sort"
"strings"
)
type legalNav struct {
Imprint string
Privacy string
Accessibility string
}
type legalField struct {
Label string
Value string
URL string
}
type legalSection struct {
Title string
Paragraphs []string
Fields []legalField
Bullets []string
}
type legalPage struct {
Title string
MetaDescription string
Eyebrow string
Intro string
Warning string
Sections []legalSection
LastUpdated 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"}
}
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",
}
}
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."}},
}
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"}
}
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 Kern-Weboberfläche setzt keine Cookies, verwendet kein Webtracking und speichert keine Daten in Local Storage oder Session Storage. Der optionale Bulk-Container speichert vom Nutzer angelegte Website-Profile und Kennzeichnungsvorlagen ausschließlich lokal im Local Storage des jeweiligen Browsers; URL-Listen und erzeugte Ergebnisse werden dort nicht dauerhaft gespeichert und nicht zum Server synchronisiert. Lokale Bulk-Daten können exportiert, importiert oder vollständig gelöscht werden. 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"}
}
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 core interface sets no cookies, uses no web tracking and does not store data in Local Storage or Session Storage. The optional bulk container stores user-created site profiles and disclosure templates only in the respective browser's Local Storage; URL lists and generated results are not persistently stored there and are not synchronised to the server. Local bulk data can be exported, imported or deleted completely. 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"}
}
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}}}
default:
return legalSection{}
}
}
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)
}
}
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)
}
}
sort.Strings(missing)
return missing
}
func legalWarning(missing []string, german bool) string {
if len(missing) == 0 {
return ""
}
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 mailto(value string) string {
value = strings.TrimSpace(value)
if value == "" || !strings.Contains(value, "@") || strings.ContainsAny(value, "\r\n") {
return ""
}
return "mailto:" + value
}
func tel(value string) string {
value = strings.TrimSpace(value)
if value == "" || strings.ContainsAny(value, "\r\n") {
return ""
}
replacer := strings.NewReplacer(" ", "", "-", "", "(", "", ")", "", "/", "")
return "tel:" + replacer.Replace(value)
}
+7 -5
View File
@@ -8,11 +8,13 @@ import (
)
const (
ProductID = "ai-disclosure-standard"
ProductVersion = "1.8.0"
FeatureCustomText = "custom_text"
FeatureCustomBadge = "custom_badge"
FeatureWhiteLabel = "white_label"
ProductID = "ai-disclosure-standard"
ProductVersion = "2.0.0"
FeatureCustomText = "custom_text"
FeatureCustomBadge = "custom_badge"
FeatureWhiteLabel = "white_label"
FeatureBulkAPI = "bulk_api"
FeatureExportBundle = "export_bundle"
)
//go:embed trusted_keys.json
+12
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())
+272 -670
View File
File diff suppressed because it is too large Load Diff
+104 -418
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,397 +273,154 @@ 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&responsibleRole=publisher&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 TestGeneratorExposesDependencyDrivenLegalWorkflow(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/?lang=de", nil)
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())
}
body := w.Body.String()
for _, expected := range []string{
`id="legal-role"`,
`id="use-context"`,
`id="output-date"`,
`id="law-enforcement-authorization"`,
`id="legal-deepfake"`,
`id="legal-public-interest"`,
`id="legal-creative"`,
`id="author-fields"`,
`id="complaint-fields"`,
`id="responsibility-fields"`,
`id="article-audio"`,
`id="article-video"`,
"Für diese Einordnung erforderlich",
"Best Practice",
"keine allgemeine Pflicht",
"Mögliche rechtliche Konsequenzen",
"15 Mio. EUR",
} {
if !strings.Contains(body, expected) {
t.Fatalf("generator missing dependency-driven field %q", expected)
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 TestExtendedDeclarationShowsPublicationAndAccountabilityMetadata(t *testing.T) {
path := "/declaration?mode=article&textExtent=partial&textReview=expert&legalContext=publicInterestText&legalRole=deployer&useContext=professional&outputDate=2026-08-03&responsibleRole=publisher&responsible=Beispielredaktion&author=Ada%20Beispiel&authorUrl=https%3A%2F%2Fexample.org%2Fada&complaintName=Ombudsstelle&complaintEmail=feedback%40example.org&lang=de"
r := httptest.NewRequest(http.MethodGet, path, nil)
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())
}
body := w.Body.String()
for _, expected := range []string{
"Ada Beispiel",
"Ombudsstelle",
"feedback@example.org",
"AI-Act-Rolle (Selbsteinordnung)",
"Nutzungskontext (Selbsteinordnung)",
"2026-08-03",
"Schema 1.3",
} {
if !strings.Contains(body, expected) {
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 TestProviderOnlyRoleDoesNotTreatPublicationBadgeAsProviderCompliance(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/declaration?mode=article&imageExtent=full&imageReview=none&legalContext=deepfake&legalRole=provider&useContext=professional&lang=de", nil)
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.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "gesonderte Anbieterprüfung erforderlich") {
t.Fatalf("provider limitation missing: %s", w.Body.String())
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 TestPreApplicationOutputUsesNonRetroactivityGuidance(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/declaration?mode=article&imageExtent=full&imageReview=none&legalContext=deepfake&legalRole=deployer&useContext=professional&outputDate=2026-08-01&lang=de", nil)
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()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
h.ServeHTTP(w, r)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("bulk readiness status=%d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "nicht rückwirkend gekennzeichnet") {
t.Fatalf("pre-application guidance missing: %s", w.Body.String())
r = httptest.NewRequest(http.MethodGet, "/", nil)
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Fatalf("bulk mode should not expose UI, got %d", w.Code)
}
}
func TestLawEnforcementAuthorisationUsesArticle50ExceptionGuidance(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/declaration?mode=article&imageExtent=full&imageReview=none&legalContext=deepfake&legalRole=deployer&useContext=professional&lawEnforcementAuthorization=yes&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())
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")
}
body := w.Body.String()
for _, expected := range []string{"besondere Ausnahme", "Strafverfolgungs-Autorisierung (Selbsteinordnung)", "gesetzlich autorisiert"} {
if !strings.Contains(body, expected) {
t.Fatalf("law-enforcement guidance missing %q: %s", expected, body)
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)
}
}
}
func TestUnclearDeepfakeAssessmentUsesCautiousDisclosureGuidance(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/declaration?mode=article&imageExtent=full&imageReview=none&deepfakeAssessment=unsure&legalRole=deployer&useContext=professional&lawEnforcementAuthorization=no&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{"Deepfake-Einordnung", "Unklar / muss geprüft werden", "kann anhand der Angaben nicht ausgeschlossen werden"} {
if !strings.Contains(body, expected) {
t.Fatalf("unclear deepfake guidance missing %q: %s", expected, body)
}
}
}
func TestRenderEndpointReturnsReusableArtifacts(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/render?component=text&extent=partial&review=editorial&lang=de&subject=https%3A%2F%2Fcontent.example%2Farticle&theme=mono", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var payload struct {
Subject string `json:"subject"`
DeclarationURL string `json:"declarationUrl"`
BadgeURL string `json:"badgeUrl"`
ManifestURL string `json:"manifestUrl"`
HTML string `json:"html"`
Markdown string `json:"markdown"`
JSONLD map[string]any `json:"jsonLd"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.Subject != "https://content.example/article" {
t.Fatalf("subject %q", payload.Subject)
}
for name, value := range map[string]string{"declaration": payload.DeclarationURL, "badge": payload.BadgeURL, "manifest": payload.ManifestURL, "html": payload.HTML, "markdown": payload.Markdown} {
if value == "" {
t.Fatalf("%s is empty", name)
}
}
if got, _ := payload.JSONLD["subject"].(string); got != payload.Subject {
t.Fatalf("jsonLd subject %q", got)
}
if strings.Contains(payload.DeclarationURL, "theme=") {
t.Fatalf("theme leaked into declaration URL: %s", payload.DeclarationURL)
}
if !strings.Contains(payload.BadgeURL, "theme=mono") {
t.Fatalf("badge URL missing theme: %s", payload.BadgeURL)
}
}
func TestGeneratorExposesBulkHandoffWhenConfigured(t *testing.T) {
h := testHandlerConfig(t, Config{ListenAddress: ":0", BaseURL: "https://example.org", BulkURL: "https://bulk.example.org", PublicName: "Test", DefaultLanguage: "de"})
r := httptest.NewRequest(http.MethodGet, "/?lang=de", nil)
r := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
t.Fatalf("health status %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `id="bulk-template-link"`) || !strings.Contains(body, `"bulkURL":"https://bulk.example.org"`) {
t.Fatalf("bulk handoff missing: %s", body)
}
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)
}
}
}
+3 -7
View File
@@ -1,8 +1,4 @@
{
"licenseKeys": {
"YqRsIh8_hO5j2M652PGKLSrfmqJvC-tWJSRGJLFGTEI": "3mluItICZ4jNAipBpn3E5f27sksdgseqvWVMCSp3t0Q"
},
"leaseKeys": {
"fUt_sPkz_FdLBKL0KnPAydlflls-PyJaViEbNCRjRIJipGwiHz-E7mPYzrnY8YotKt-aom8L61YlJEYksUZMQg": "gM8FdzFWGCfffjbPj9_2Ao8n5agKdvbRkf8xMWXdk5c"
}
}
"licenseKeys": {},
"leaseKeys": {}
}
+11 -14
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{
+7 -100
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]) + `"`
}
-75
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)
}
})
}
}
-98
View File
@@ -1,98 +0,0 @@
package bulk
import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
ListenAddress string
CoreInternalURL string
DisclosureBaseURL string
GeneratorURL string
PublicName string
MaxURLs int
Workers int
RequestTimeout time.Duration
}
func ConfigFromEnv() Config {
return Config{
ListenAddress: env("BULK_LISTEN_ADDRESS", ":8081"),
CoreInternalURL: strings.TrimRight(env("CORE_INTERNAL_URL", "http://app:8080"), "/"),
DisclosureBaseURL: strings.TrimRight(env("DISCLOSURE_BASE_URL", "http://localhost:8080"), "/"),
GeneratorURL: strings.TrimRight(env("GENERATOR_URL", env("DISCLOSURE_BASE_URL", "http://localhost:8080")), "/"),
PublicName: env("BULK_PUBLIC_NAME", "AI Disclosure Bulk"),
MaxURLs: intEnv("BULK_MAX_URLS", 500),
Workers: intEnv("BULK_WORKERS", 4),
RequestTimeout: durationEnv("BULK_REQUEST_TIMEOUT", 8*time.Second),
}
}
func ValidateConfig(cfg Config) error {
for name, value := range map[string]string{
"CORE_INTERNAL_URL": cfg.CoreInternalURL,
"DISCLOSURE_BASE_URL": cfg.DisclosureBaseURL,
"GENERATOR_URL": cfg.GeneratorURL,
} {
if err := validateOrigin(name, value); err != nil {
return err
}
}
if strings.TrimSpace(cfg.PublicName) == "" {
return fmt.Errorf("BULK_PUBLIC_NAME must not be empty")
}
if cfg.MaxURLs < 1 || cfg.MaxURLs > 5000 {
return fmt.Errorf("BULK_MAX_URLS must be between 1 and 5000")
}
if cfg.Workers < 1 || cfg.Workers > 32 {
return fmt.Errorf("BULK_WORKERS must be between 1 and 32")
}
if cfg.RequestTimeout < time.Second || cfg.RequestTimeout > 60*time.Second {
return fmt.Errorf("BULK_REQUEST_TIMEOUT must be between 1s and 60s")
}
return nil
}
func validateOrigin(name, value string) error {
u, err := url.Parse(value)
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("%s must be an absolute http(s) origin without path, query, credentials or fragment", name)
}
return nil
}
func env(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func intEnv(key string, fallback int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func durationEnv(key string, fallback time.Duration) time.Duration {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return parsed
}
-279
View File
@@ -1,279 +0,0 @@
package bulk
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
bulkweb "github.com/b1tsblog/ai-disclosure-standard/bulkweb"
)
const Version = "1.0.0"
type Server struct {
cfg Config
logger *slog.Logger
templates *template.Template
client *http.Client
mux *http.ServeMux
}
type pageData struct {
Name string
Version string
DisclosureBaseURL string
GeneratorURL string
MaxURLs int
}
type publicConfig struct {
DisclosureBaseURL string `json:"disclosureBaseURL"`
GeneratorURL string `json:"generatorURL"`
MaxURLs int `json:"maxURLs"`
Version string `json:"version"`
}
type batchRequest struct {
Template string `json:"template"`
Subjects []string `json:"subjects"`
}
type batchResult struct {
Subject string `json:"subject"`
OK bool `json:"ok"`
Data json.RawMessage `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
type batchResponse struct {
Results []batchResult `json:"results"`
}
func New(cfg Config, logger *slog.Logger) (http.Handler, error) {
if err := ValidateConfig(cfg); err != nil {
return nil, err
}
tmpl, err := template.New("root").ParseFS(bulkweb.Files, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("parse bulk templates: %w", err)
}
s := &Server{
cfg: cfg,
logger: logger,
templates: tmpl,
client: &http.Client{
Timeout: cfg.RequestTimeout,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
},
mux: http.NewServeMux(),
}
s.routes()
return s.securityHeaders(s.mux), nil
}
func (s *Server) routes() {
staticFS, _ := fs.Sub(bulkweb.Files, "static")
s.mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
s.mux.HandleFunc("GET /", s.handleIndex)
s.mux.HandleFunc("GET /api/config", s.handleConfig)
s.mux.HandleFunc("POST /api/render-batch", s.handleRenderBatch)
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok\n")
})
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = s.templates.ExecuteTemplate(w, "index.html", pageData{
Name: s.cfg.PublicName, Version: Version, DisclosureBaseURL: s.cfg.DisclosureBaseURL,
GeneratorURL: s.cfg.GeneratorURL, MaxURLs: s.cfg.MaxURLs,
})
}
func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(publicConfig{
DisclosureBaseURL: s.cfg.DisclosureBaseURL,
GeneratorURL: s.cfg.GeneratorURL,
MaxURLs: s.cfg.MaxURLs,
Version: Version,
})
}
func (s *Server) handleRenderBatch(w http.ResponseWriter, r *http.Request) {
body := http.MaxBytesReader(w, r.Body, 1<<20)
defer body.Close()
dec := json.NewDecoder(body)
dec.DisallowUnknownFields()
var request batchRequest
if err := dec.Decode(&request); err != nil {
s.problem(w, http.StatusBadRequest, "invalid_json", "Request body must be valid JSON.")
return
}
if err := ensureEOF(dec); err != nil {
s.problem(w, http.StatusBadRequest, "invalid_json", "Request body must contain exactly one JSON object.")
return
}
if len(request.Subjects) == 0 {
s.problem(w, http.StatusBadRequest, "missing_subjects", "At least one subject URL is required.")
return
}
if len(request.Subjects) > s.cfg.MaxURLs {
s.problem(w, http.StatusRequestEntityTooLarge, "too_many_subjects", fmt.Sprintf("At most %d subject URLs are allowed per batch.", s.cfg.MaxURLs))
return
}
if len(request.Template) > 24<<10 {
s.problem(w, http.StatusRequestEntityTooLarge, "template_too_large", "Template query is too large.")
return
}
templateValues, err := url.ParseQuery(strings.TrimPrefix(strings.TrimSpace(request.Template), "?"))
if err != nil {
s.problem(w, http.StatusBadRequest, "invalid_template", "Template must be a valid URL query string.")
return
}
templateValues.Del("subject")
results := make([]batchResult, len(request.Subjects))
jobs := make(chan int)
var wg sync.WaitGroup
workers := min(s.cfg.Workers, len(request.Subjects))
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range jobs {
subject := strings.TrimSpace(request.Subjects[idx])
results[idx] = s.renderOne(r.Context(), templateValues, subject)
}
}()
}
for idx := range request.Subjects {
jobs <- idx
}
close(jobs)
wg.Wait()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(batchResponse{Results: results})
}
func (s *Server) renderOne(ctx context.Context, templateValues url.Values, subject string) batchResult {
if err := validateSubject(subject); err != nil {
return batchResult{Subject: subject, Error: err.Error()}
}
values := cloneValues(templateValues)
values.Set("subject", subject)
endpoint := s.cfg.CoreInternalURL + "/v1/render?" + values.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return batchResult{Subject: subject, Error: "could not build core request"}
}
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
s.logger.Warn("core render failed", "error", err)
return batchResult{Subject: subject, Error: "disclosure core is unavailable"}
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return batchResult{Subject: subject, Error: "could not read disclosure core response"}
}
if resp.StatusCode != http.StatusOK {
message := coreErrorMessage(payload)
if message == "" {
message = fmt.Sprintf("disclosure core returned HTTP %d", resp.StatusCode)
}
return batchResult{Subject: subject, Error: message}
}
if !json.Valid(payload) {
return batchResult{Subject: subject, Error: "disclosure core returned invalid JSON"}
}
return batchResult{Subject: subject, OK: true, Data: json.RawMessage(payload)}
}
func validateSubject(raw string) error {
u, err := url.ParseRequestURI(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
return errors.New("subject must be an absolute http(s) URL without credentials")
}
return nil
}
func coreErrorMessage(payload []byte) string {
var problem struct {
Detail string `json:"detail"`
Title string `json:"title"`
}
if json.Unmarshal(payload, &problem) != nil {
return ""
}
if strings.TrimSpace(problem.Detail) != "" {
return problem.Detail
}
return strings.TrimSpace(problem.Title)
}
func ensureEOF(dec *json.Decoder) error {
var extra any
err := dec.Decode(&extra)
if errors.Is(err, io.EOF) {
return nil
}
if err == nil {
return errors.New("unexpected second JSON value")
}
return err
}
func cloneValues(in url.Values) url.Values {
out := make(url.Values, len(in))
for key, values := range in {
out[key] = append([]string(nil), values...)
}
return out
}
func (s *Server) problem(w http.ResponseWriter, status int, code, detail string) {
w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{
"type": "about:blank", "title": code, "status": status, "detail": detail,
})
}
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("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
started := time.Now()
next.ServeHTTP(w, r)
s.logger.Debug("request", "method", r.Method, "path", r.URL.Path, "duration", time.Since(started))
})
}
-125
View File
@@ -1,125 +0,0 @@
package bulk
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestRenderBatchUsesFixedCoreAndReplacesSubject(t *testing.T) {
var mu sync.Mutex
var subjects []string
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/render" {
t.Fatalf("unexpected core path %q", r.URL.Path)
}
if got := r.URL.Query().Get("extent"); got != "partial" {
t.Fatalf("template extent = %q", got)
}
subject := r.URL.Query().Get("subject")
mu.Lock()
subjects = append(subjects, subject)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"subject": subject,
"declarationUrl": "https://public.example/declaration",
"badgeUrl": "https://public.example/badge.svg",
"manifestUrl": "https://public.example/manifest.json",
"html": "<a>ok</a>",
"markdown": "[ok]",
"jsonLd": map[string]any{"@type": "AIUsageDeclaration", "subject": subject},
})
}))
defer core.Close()
h, err := New(Config{
ListenAddress: ":0", CoreInternalURL: core.URL,
DisclosureBaseURL: "https://public.example", GeneratorURL: "https://public.example",
PublicName: "Bulk", MaxURLs: 10, Workers: 2, RequestTimeout: 2 * time.Second,
}, testLogger())
if err != nil {
t.Fatal(err)
}
body := `{"template":"extent=partial&subject=https%3A%2F%2Fold.example%2Fignored","subjects":["https://content.example/a","https://content.example/b"]}`
r := httptest.NewRequest(http.MethodPost, "/api/render-batch", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var response batchResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Results) != 2 || !response.Results[0].OK || !response.Results[1].OK {
t.Fatalf("unexpected results: %#v", response.Results)
}
mu.Lock()
defer mu.Unlock()
if len(subjects) != 2 {
t.Fatalf("core calls = %d", len(subjects))
}
seen := map[string]bool{}
for _, subject := range subjects {
seen[subject] = true
}
if !seen["https://content.example/a"] || !seen["https://content.example/b"] || seen["https://old.example/ignored"] {
t.Fatalf("subjects sent to core: %#v", subjects)
}
}
func TestRenderBatchRejectsNonHTTPSubjectWithoutCallingCore(t *testing.T) {
calls := 0
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
}))
defer core.Close()
h, err := New(Config{ListenAddress: ":0", CoreInternalURL: core.URL, DisclosureBaseURL: "https://public.example", GeneratorURL: "https://public.example", PublicName: "Bulk", MaxURLs: 10, Workers: 1, RequestTimeout: 2 * time.Second}, testLogger())
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodPost, "/api/render-batch", strings.NewReader(`{"template":"extent=partial","subjects":["file:///etc/passwd"]}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var response batchResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Results) != 1 || response.Results[0].OK || !strings.Contains(response.Results[0].Error, "http(s)") {
t.Fatalf("unexpected response: %#v", response.Results)
}
if calls != 0 {
t.Fatalf("core was called %d times for invalid subject", calls)
}
}
func TestRenderBatchHonoursConfiguredLimit(t *testing.T) {
h, err := New(Config{ListenAddress: ":0", CoreInternalURL: "http://127.0.0.1:9", DisclosureBaseURL: "https://public.example", GeneratorURL: "https://public.example", PublicName: "Bulk", MaxURLs: 1, Workers: 1, RequestTimeout: time.Second}, testLogger())
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodPost, "/api/render-batch", strings.NewReader(`{"template":"extent=partial","subjects":["https://example.org/a","https://example.org/b"]}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
}
+126 -213
View File
@@ -3,7 +3,6 @@ package declaration
import (
"errors"
"fmt"
"net/mail"
"net/url"
"regexp"
"sort"
@@ -14,15 +13,14 @@ import (
)
const (
SchemaVersion = "1.3"
PreviousSchemaVersion = "1.2"
OlderSchemaVersion = "1.1"
LegacySchemaVersion = "1.0"
SchemaVersion = "1.2"
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}$`)
)
@@ -34,32 +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"`
}
type LegalContext struct {
Categories []string `json:"categories,omitempty"`
Role string `json:"actorRole,omitempty"`
UseContext string `json:"useContext,omitempty"`
OutputDate string `json:"outputDate,omitempty"`
DeepfakeAssessment string `json:"deepfakeAssessment,omitempty"`
PublicInterestAssessment string `json:"publicInterestAssessment,omitempty"`
CreativeWorkAssessment string `json:"creativeWorkAssessment,omitempty"`
LawEnforcementAuthorization string `json:"lawEnforcementAuthorization,omitempty"`
}
type Author struct {
Name string `json:"name"`
URL string `json:"url,omitempty"`
}
type ContactPoint struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
URL string `json:"url,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 {
@@ -79,10 +82,8 @@ type Declaration struct {
DeclaredAt string `json:"declaredAt,omitempty"`
Language string `json:"language"`
Components map[string]Component `json:"components"`
Author *Author `json:"author,omitempty"`
EditorialResponsibility *Responsibility `json:"editorialResponsibility,omitempty"`
ComplaintsContact *ContactPoint `json:"complaintsContact,omitempty"`
LegalContext *LegalContext `json:"legalContext,omitempty"`
RegulatoryContext *RegulatoryContext `json:"regulatoryContext,omitempty"`
Assurance string `json:"assurance"`
Presentation *Presentation `json:"presentation,omitempty"`
}
@@ -101,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}
@@ -120,12 +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 validLegalRoles = map[string]bool{"deployer": true, "provider": true, "both": true, "unsure": true}
var validUseContexts = map[string]bool{"professional": true, "personalNonProfessional": true, "unsure": true}
var validLawEnforcementAuthorizations = map[string]bool{"yes": true, "no": true, "unsure": true}
var validLegalAssessments = map[string]bool{"yes": true, "no": true, "unsure": 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"})
@@ -174,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 == "" {
@@ -191,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 {
@@ -227,42 +220,24 @@ func NewFromQueryWithOptions(values url.Values, contextURL string, options Parse
Components: components,
Assurance: assurance,
}
authorName := clean(values.Get("author"), 200)
authorURL := clean(values.Get("authorUrl"), 2048)
if authorName != "" || authorURL != "" {
d.Author = &Author{Name: authorName, URL: authorURL}
}
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}
}
complaintName := clean(values.Get("complaintName"), 200)
complaintEmail := clean(values.Get("complaintEmail"), 320)
complaintURL := clean(values.Get("complaintUrl"), 2048)
if complaintName != "" || complaintEmail != "" || complaintURL != "" {
d.ComplaintsContact = &ContactPoint{Name: complaintName, Email: complaintEmail, URL: complaintURL}
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")),
}
legalCategories := splitCSV(values.Get("legalContext"))
legalRole := clean(values.Get("legalRole"), 24)
useContext := clean(values.Get("useContext"), 32)
outputDate := clean(values.Get("outputDate"), 10)
deepfakeAssessment := clean(values.Get("deepfakeAssessment"), 16)
publicInterestAssessment := clean(values.Get("publicInterestAssessment"), 16)
creativeWorkAssessment := clean(values.Get("creativeWorkAssessment"), 16)
lawEnforcementAuthorization := clean(values.Get("lawEnforcementAuthorization"), 16)
if deepfakeAssessment == "yes" {
legalCategories = appendUniqueSorted(legalCategories, "deepfake")
}
if publicInterestAssessment == "yes" {
legalCategories = appendUniqueSorted(legalCategories, "publicInterestText")
}
if creativeWorkAssessment == "yes" {
legalCategories = appendUniqueSorted(legalCategories, "artisticCreativeSatiricalFictional")
}
if len(legalCategories) > 0 || legalRole != "" || useContext != "" || outputDate != "" || deepfakeAssessment != "" || publicInterestAssessment != "" || creativeWorkAssessment != "" || lawEnforcementAuthorization != "" {
d.LegalContext = &LegalContext{Categories: legalCategories, Role: legalRole, UseContext: useContext, OutputDate: outputDate, DeepfakeAssessment: deepfakeAssessment, PublicInterestAssessment: publicInterestAssessment, CreativeWorkAssessment: creativeWorkAssessment, LawEnforcementAuthorization: lawEnforcementAuthorization}
if regulatory.PublicInterestText || regulatory.Deepfake || regulatory.ArtisticCreativeSatiricalFictional || regulatory.SubstantialHumanReview || regulatory.EditorialResponsibilityConfirmed || regulatory.FirstExposureDisclosure || regulatory.AccessibilityConsidered {
d.RegulatoryContext = regulatory
}
presentation := &Presentation{
@@ -290,26 +265,16 @@ func Validate(d Declaration) error {
var problems []string
switch d.SchemaVersion {
case SchemaVersion:
case PreviousSchemaVersion:
if d.Author != nil || d.ComplaintsContact != nil || (d.LegalContext != nil && (d.LegalContext.Role != "" || d.LegalContext.UseContext != "" || d.LegalContext.OutputDate != "" || d.LegalContext.DeepfakeAssessment != "" || d.LegalContext.PublicInterestAssessment != "" || d.LegalContext.CreativeWorkAssessment != "" || d.LegalContext.LawEnforcementAuthorization != "")) {
problems = append(problems, "author, complaintsContact and extended legalContext require schemaVersion 1.3")
}
case OlderSchemaVersion:
if d.Author != nil || d.ComplaintsContact != nil {
problems = append(problems, "author and complaintsContact require schemaVersion 1.3")
}
if d.LegalContext != nil {
problems = append(problems, "legalContext requires schemaVersion 1.2 or later")
}
case LegacySchemaVersion:
if d.RegulatoryContext != nil {
problems = append(problems, "regulatoryContext requires schemaVersion 1.2")
}
case LegacySchemaVersion10:
if d.Presentation != nil {
problems = append(problems, "presentation requires schemaVersion 1.1 or later")
problems = append(problems, "presentation requires schemaVersion 1.1 or newer")
}
if d.Author != nil || d.ComplaintsContact != nil {
problems = append(problems, "author and complaintsContact require schemaVersion 1.3")
}
if d.LegalContext != nil {
problems = append(problems, "legalContext requires schemaVersion 1.2 or later")
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")
@@ -339,112 +304,14 @@ func Validate(d Declaration) error {
problems = append(problems, "declaredAt must be RFC3339")
}
}
if d.Author != nil {
if strings.TrimSpace(d.Author.Name) == "" {
problems = append(problems, "author.name is required when author is present")
}
if d.Author.URL != "" {
if u, err := url.ParseRequestURI(d.Author.URL); err != nil || u.Scheme == "" || u.Host == "" {
problems = append(problems, "author.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.ComplaintsContact != nil {
if strings.TrimSpace(d.ComplaintsContact.Name) == "" {
problems = append(problems, "complaintsContact.name is required when complaintsContact is present")
}
if d.ComplaintsContact.Email == "" && d.ComplaintsContact.URL == "" {
problems = append(problems, "complaintsContact requires email or url")
}
if d.ComplaintsContact.Email != "" {
if addr, err := mail.ParseAddress(d.ComplaintsContact.Email); err != nil || addr.Address != d.ComplaintsContact.Email {
problems = append(problems, "complaintsContact.email must be a valid email address")
}
}
if d.ComplaintsContact.URL != "" {
if u, err := url.ParseRequestURI(d.ComplaintsContact.URL); err != nil || u.Scheme == "" || u.Host == "" {
problems = append(problems, "complaintsContact.url must be an absolute URL")
}
}
}
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.LegalContext != nil {
if d.LegalContext.Role != "" && !validLegalRoles[d.LegalContext.Role] {
problems = append(problems, "legalContext.role must be deployer, provider, both or unsure")
}
if d.LegalContext.UseContext != "" && !validUseContexts[d.LegalContext.UseContext] {
problems = append(problems, "legalContext.useContext must be professional, personalNonProfessional or unsure")
}
if d.LegalContext.OutputDate != "" {
if _, err := time.Parse("2006-01-02", d.LegalContext.OutputDate); err != nil {
problems = append(problems, "legalContext.outputDate must be YYYY-MM-DD")
}
}
for field, value := range map[string]string{
"deepfakeAssessment": d.LegalContext.DeepfakeAssessment,
"publicInterestAssessment": d.LegalContext.PublicInterestAssessment,
"creativeWorkAssessment": d.LegalContext.CreativeWorkAssessment,
} {
if value != "" && !validLegalAssessments[value] {
problems = append(problems, "legalContext."+field+" must be yes, no or unsure")
}
}
if d.LegalContext.LawEnforcementAuthorization != "" && !validLawEnforcementAuthorizations[d.LegalContext.LawEnforcementAuthorization] {
problems = append(problems, "legalContext.lawEnforcementAuthorization must be yes, no or unsure")
}
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 d.LegalContext.DeepfakeAssessment == "no" && seenLegal["deepfake"] {
problems = append(problems, "deepfakeAssessment=no conflicts with deepfake legalContext category")
}
if d.LegalContext.PublicInterestAssessment == "no" && seenLegal["publicInterestText"] {
problems = append(problems, "publicInterestAssessment=no conflicts with publicInterestText legalContext category")
}
if d.LegalContext.CreativeWorkAssessment == "no" && seenLegal["artisticCreativeSatiricalFictional"] {
problems = append(problems, "creativeWorkAssessment=no conflicts with artisticCreativeSatiricalFictional legalContext category")
}
if d.LegalContext.CreativeWorkAssessment != "" && d.LegalContext.DeepfakeAssessment != "yes" && !seenLegal["deepfake"] {
problems = append(problems, "creativeWorkAssessment is only applicable to a deepfake assessment")
}
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 {
@@ -504,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 {
@@ -514,17 +438,6 @@ func clean(s string, max int) string {
func runeLen(s string) int { return len([]rune(s)) }
func appendUniqueSorted(values []string, value string) []string {
for _, existing := range values {
if existing == value {
return values
}
}
values = append(values, value)
sort.Strings(values)
return values
}
func splitCSV(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
+52 -106
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,119 +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.3" || 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 TestExtendedPublicationAndLegalContextAreSerialized(t *testing.T) {
d, err := NewFromQuery(url.Values{
"preset": {"full"}, "lang": {"de"},
"author": {"Ada Beispiel"}, "authorUrl": {"https://example.org/ada"},
"complaintName": {"Ombudsstelle"}, "complaintEmail": {"feedback@example.org"},
"legalRole": {"deployer"}, "useContext": {"professional"}, "outputDate": {"2026-08-03"},
"publicInterestAssessment": {"yes"}, "lawEnforcementAuthorization": {"no"},
}, "https://example.org/context/v1")
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 d.Author == nil || d.Author.Name != "Ada Beispiel" || d.Author.URL == "" {
t.Fatalf("author not serialized: %#v", d.Author)
}
if d.ComplaintsContact == nil || d.ComplaintsContact.Email != "feedback@example.org" {
t.Fatalf("complaints contact not serialized: %#v", d.ComplaintsContact)
}
if d.LegalContext == nil || d.LegalContext.Role != "deployer" || d.LegalContext.UseContext != "professional" || d.LegalContext.OutputDate != "2026-08-03" || d.LegalContext.PublicInterestAssessment != "yes" || d.LegalContext.LawEnforcementAuthorization != "no" {
t.Fatalf("extended legal context not serialized: %#v", d.LegalContext)
}
if len(d.LegalContext.Categories) != 1 || d.LegalContext.Categories[0] != "publicInterestText" {
t.Fatalf("tri-state yes should derive legal category: %#v", d.LegalContext.Categories)
if got := d.Components["text"].HumanReview; got != "none" {
t.Fatalf("no-ai review = %q, want none", got)
}
}
func TestComplaintsContactRequiresContactMethod(t *testing.T) {
_, err := NewFromQuery(url.Values{
"preset": {"research"}, "complaintName": {"Ombudsstelle"},
}, "https://example.org/context/v1")
if err == nil || !strings.Contains(err.Error(), "complaintsContact requires email or url") {
t.Fatalf("expected complaints contact validation error, got %v", err)
}
}
func TestLegalContextValidatesRoleUseAndDate(t *testing.T) {
_, err := NewFromQuery(url.Values{
"preset": {"research"}, "legalRole": {"invalid"}, "useContext": {"professional"}, "outputDate": {"2026-08-03"},
}, "https://example.org/context/v1")
if err == nil || !strings.Contains(err.Error(), "legalContext.role") {
t.Fatalf("expected legal role validation error, got %v", err)
}
_, err = NewFromQuery(url.Values{
"preset": {"research"}, "legalRole": {"deployer"}, "useContext": {"professional"}, "outputDate": {"03.08.2026"},
}, "https://example.org/context/v1")
if err == nil || !strings.Contains(err.Error(), "legalContext.outputDate") {
t.Fatalf("expected legal output date validation error, got %v", err)
}
_, err = NewFromQuery(url.Values{
"preset": {"research"}, "legalRole": {"deployer"}, "useContext": {"professional"}, "lawEnforcementAuthorization": {"maybe"},
}, "https://example.org/context/v1")
if err == nil || !strings.Contains(err.Error(), "legalContext.lawEnforcementAuthorization") {
t.Fatalf("expected law-enforcement authorisation validation error, got %v", err)
}
}
func TestLegalAssessmentTriStateValidation(t *testing.T) {
_, err := NewFromQuery(url.Values{
"mode": {"article"}, "imageExtent": {"full"}, "deepfakeAssessment": {"maybe"},
}, "https://example.org/context/v1")
if err == nil || !strings.Contains(err.Error(), "deepfakeAssessment must be yes, no or unsure") {
t.Fatalf("expected tri-state validation error, got %v", err)
}
_, err = NewFromQuery(url.Values{
"mode": {"article"}, "imageExtent": {"full"}, "legalContext": {"deepfake"}, "deepfakeAssessment": {"no"},
}, "https://example.org/context/v1")
if err == nil || !strings.Contains(err.Error(), "conflicts with deepfake legalContext category") {
t.Fatalf("expected category/assessment conflict, got %v", err)
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)
}
}
+221 -271
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": "Für die angegebenen Inhalte wurde keine inhaltsspezifische Offenlegungskategorie aus Art. 50 Abs. 4 erkannt. Das ist keine Aussage dazu, ob andere Pflichten aus Art. 50 oder sonstigem Recht greifen; die Kennzeichnung wird hier 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", "responsible_placeholder": "Name oder Organisation", "field_responsible_url": "Nachweis/Impressum zur redaktionellen Verantwortung", "field_language": "Ausgabesprache", "field_theme": "Darstellung", "theme_mono": "Monochrom", "theme_color": "Farbig",
"preview": "Vorschau", "copy_html": "HTML kopieren", "copied": "Kopiert", "open_bulk": "Als Bulk-Vorlage öffnen",
"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.",
"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": "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", "open_bulk": "Open as bulk template", "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,240 +135,7 @@ func init() {
applyEuropeanTaxonomyTranslations()
applyArticleTranslations()
applyAssuranceTranslations()
applyThemeTranslations()
applyLegalGeneratorTranslations()
}
func applyLegalGeneratorTranslations() {
en := map[string]string{
"generator_step_content": "1. Content and AI use",
"generator_step_legal": "2. Article 50 scope check",
"generator_step_accountability": "3. Publication and accountability",
"generator_step_output": "4. Output",
"field_required": "Required for this assessment",
"field_optional": "Optional",
"field_best_practice": "Best practice",
"assessment_yes": "Yes",
"assessment_no": "No",
"assessment_unsure": "Unclear / needs assessment",
"field_legal_role": "Your role under the AI Act",
"legal_role_deployer": "Deployer I use an AI system under my authority",
"legal_role_provider": "Provider I provide / place the AI system on the market",
"legal_role_both": "Both provider and deployer",
"legal_role_unsure": "Unclear / needs assessment",
"legal_role_help": "Article 50 assigns different duties to providers and deployers. This generator mainly documents publication-side disclosures; provider-side machine marking is separate. Article 50(2) also contains a limited exception for standard editing assistance or outputs that do not substantially alter the deployer's input data or semantics.",
"field_use_context": "Use context",
"use_context_professional": "Professional / organisational use",
"use_context_personalNonProfessional": "Purely personal, non-professional use",
"use_context_unsure": "Unclear / mixed use",
"use_context_help": "A person using AI only for a personal, non-professional activity is generally not a deployer under the AI Act. Economic, business, occupational or freelance use can be professional.",
"field_output_date": "Date the affected AI output was generated or manipulated",
"output_date_help": "Article 50 applies from 2 August 2026. The Commission FAQ states that content generated before that date does not have to be labelled retroactively under Article 50.",
"legal_scope_intro": "The following questions are shown only where they fit the selected content. They support a cautious self-assessment and do not constitute legal advice.",
"legal_context_legend": "Legal context (self-assessment)",
"legal_context_help": "These details support a cautious Article 50 assessment and are not an automated legal decision.",
"legal_deepfake": "Deepfake / realistic AI manipulation",
"legal_deepfake_help": "Select only where image, audio or video resembles existing or plausible persons, objects, places, entities or events and could falsely appear authentic or truthful.",
"legal_public_interest": "AI-generated or manipulated text informing the public on a matter of public interest",
"legal_public_interest_help": "Examples can include politics, public administration, fundamental rights, safety, health, the environment, consumer safety and significant economic, scientific or cultural developments.",
"legal_creative": "Evidently artistic / creative / satirical / fictional",
"legal_creative_help": "For deepfakes in such works, disclosure may be made appropriately without hampering display or enjoyment of the work.",
"law_enforcement_title": "Legally authorised criminal-law use",
"field_law_enforcement_authorization": "Is this use authorised by law to detect, prevent, investigate or prosecute criminal offences?",
"law_enforcement_no": "No",
"law_enforcement_yes": "Yes legally authorised",
"law_enforcement_unsure": "Unclear / needs legal review",
"law_enforcement_help": "Article 50(4) contains a specific exception for qualifying uses authorised by law for criminal-law purposes. Do not select yes merely because the publisher is a public authority; an actual legal authorisation for the specific use is required.",
"legal_other_voluntary": "Other / voluntary transparency",
"legal_assessment_heading": "Cautious Article 50 assessment",
"legal_assessment_pending": "The Article 50 assessment is not complete yet. Answer the currently required scope questions; until then, the generator does not state that a disclosure duty exists or does not exist.",
"legal_assessment_none": "No content-specific disclosure category under Article 50(4) was identified from the supplied information. This does not determine whether other Article 50 or other legal duties apply; the label is treated here as voluntary transparency.",
"legal_assessment_voluntary": "The supplied information currently points to voluntary transparency; this does not mean that other AI Act duties are inapplicable.",
"legal_assessment_deepfake": "Based on the self-classification as a deepfake, there are strong indications that a clear and distinguishable disclosure is required no later than first exposure.",
"legal_assessment_deepfake_creative": "The content is classified as both a deepfake and evidently artistic, creative, satirical or fictional. Disclosure remains relevant but may be made appropriately without hampering display or enjoyment of the work.",
"legal_assessment_public_required": "Based on the supplied information, there are strong indications that the AI-generated or manipulated public-interest text should be clearly disclosed. Substantive human review/editorial control together with expressly assumed editorial responsibility is not fully documented.",
"legal_assessment_public_exemption": "The supplied information documents substantive human review/editorial control and expressly named editorial responsibility. The Article 50(4) exception for certain public-interest text may therefore be relevant. Voluntary transparency remains possible.",
"legal_assessment_inconsistent": "The selected legal category does not fully match the structured content data. Check the AI contribution and content components.",
"legal_assessment_deepfake_unsure": "It is unclear whether the AI-involved media qualifies as a deepfake. A content-specific Article 50(4) duty cannot be ruled out from the supplied facts. Until the classification is resolved, a clear disclosure is the more cautious publication setting.",
"legal_assessment_public_unsure": "It is unclear whether the AI-involved text is published to inform the public on a matter of public interest. A content-specific Article 50(4) duty cannot be ruled out from the supplied facts. Until the classification is resolved, a clear disclosure is the more cautious publication setting.",
"badge_legal_disclosure": "AI-generated / manipulated content",
"badge_public_interest": "AI-generated / edited content",
"fact_legal_context": "Legal context (self-assessment)",
"field_responsibility_role": "Type of editorial responsibility",
"responsibility_none": "Not specified",
"responsibility_publisher": "Publishing person/organisation assumes editorial responsibility",
"responsibility_other": "Another responsible party",
"field_responsible": "Editorially responsible person/organisation",
"responsible_placeholder": "Name or organisation",
"field_responsible_url": "Evidence / imprint for editorial responsibility",
"legal_deepfake_not_relevant": "Deepfake assessment is shown only for AI-involved image, audio or video content.",
"legal_public_not_relevant": "Public-interest text assessment is shown only where the text component contains AI-generated or manipulated content.",
"editorial_exception_title": "Possible public-interest text exception",
"editorial_exception_help": "For the Article 50(4) exception, both human review/editorial control and a natural or legal person holding editorial responsibility for publication must be present. Superficial or purely formal checks are not enough.",
"responsibility_required_help": "Shown because you selected public-interest text and documented substantive review. Name the person or legal entity that ultimately holds editorial legal responsibility for publication if you want the exception to be considered.",
"field_author": "Author / byline",
"field_author_url": "Author profile / reference URL",
"author_help": "The author is not automatically the same as the person or organisation holding editorial legal responsibility. This field is publication metadata, not a general Article 50 requirement.",
"accountability_intro": "These fields are separated from Article 50 requirements. They improve traceability without implying that every publication is legally required to provide them.",
"complaints_title": "Complaint / feedback contact",
"complaints_help": "Best practice for transparent publications. Article 50 does not create a general duty to name an internal complaint office for each labelled item. This voluntary contact also does not replace the separate right under Article 85 AI Act to lodge a complaint with a market surveillance authority; sectoral, media, consumer or platform rules may add further obligations.",
"field_complaint_name": "Contact point / organisation",
"field_complaint_email": "Complaint / feedback email",
"field_complaint_url": "Complaint / feedback URL",
"validation_heading": "Plausibility and legal-context notes",
"validation_formal_review": "Only a basic/formal review is documented. According to the Commission guidance, spelling, grammar or other superficial checks are not sufficient human review/editorial control for the public-interest text exception.",
"validation_missing_responsibility": "Substantive review is selected, but editorial responsibility is not fully named. The public-interest text exception should not be assumed on these facts.",
"validation_provider": "Provider role selected: a visible badge or JSON-LD declaration does not replace the provider-side machine-readable marking duty for in-scope generative AI outputs.",
"validation_role_unsure": "The AI Act actor role is unresolved. Deployer-side disclosure duties cannot safely be ruled out until it is clear whether the system is used under your authority in a professional/organisational context.",
"validation_use_context_unsure": "The use context is unresolved or mixed. Treat professional, business, occupational or freelance use separately from purely personal non-professional use before relying on the personal-use exclusion.",
"validation_personal": "Purely personal, non-professional use is generally outside the AI Act deployer definition. A voluntary transparency label may still be useful and other law can still apply.",
"validation_pre_application": "The entered output date is before 2 August 2026. The Commission FAQ says such content does not need to be labelled retroactively under Article 50; other legal duties remain unaffected.",
"validation_public_no_ai": "Public-interest text is selected, but no AI involvement in text is documented. Check the content data or remove this legal category.",
"validation_deepfake_no_media": "Deepfake is selected, but no AI-involved image, audio or video component is documented. Check the content data or remove this legal category.",
"validation_deepfake_unsure": "Deepfake classification is unresolved. Consider resemblance, whether the simulated subject exists or plausibly existed, false appearance of authenticity/truthfulness, deployment context and audience expectations before relying on a 'no' conclusion.",
"validation_public_unsure": "The public-interest classification is unresolved. Check whether the text is published, intended to inform the public, and concerns a matter of public interest before relying on a 'no' conclusion.",
"validation_creative_unsure": "The creative/satirical/fictional classification is unresolved. This affects the manner of deepfake disclosure, not whether the deepfake transparency obligation exists in principle.",
"validation_complaint_incomplete": "Complete the complaint / feedback contact with a name and at least one valid email address or URL.",
"consequence_disclosure": "Likely consequence: use a clear, distinguishable disclosure no later than first exposure to the affected content; accessibility requirements also apply.",
"consequence_provider": "Provider-side consequence: separately assess Article 50(2) machine-readable marking, including whether the limited standard-editing / non-substantial-alteration exception actually applies. This generator is supplementary documentation only.",
"consequence_voluntary": "No content-specific Article 50(4) duty is established by the supplied answers. Voluntary transparency remains possible and other AI Act or sector-specific duties may still apply.",
"legal_assessment_personal": "The supplied use context is purely personal and non-professional. Such use is generally outside the AI Act deployer definition, so this generator treats the label as voluntary for Article 50 purposes. Other law may still apply.",
"legal_assessment_pre_application": "The supplied output date is before 2 August 2026. The Commission FAQ states that content generated before that date does not need to be labelled retroactively under Article 50. Voluntary transparency and other legal duties remain possible.",
"legal_assessment_provider": "The supplied role is provider-only. This publication badge cannot establish compliance with provider-side Article 50 duties, especially machine-readable marking of in-scope generated or manipulated outputs. A separate provider assessment is required, including the limited Article 50(2) exception for standard editing assistance or non-substantial alteration of the deployer's input data or semantics.",
"legal_assessment_law_enforcement": "A qualifying legal authorisation for criminal-law purposes is stated. Article 50(4) provides a specific exception for that use, so this generator does not treat the content-specific deployer disclosure as required on that basis. The authorisation should be documented and other duties remain unaffected.",
"validation_law_enforcement_unsure": "The criminal-law authorisation is unclear. Do not rely on the Article 50(4) exception until the specific legal basis and scope have been verified.",
"legal_enforcement_title": "Possible legal consequences",
"legal_enforcement_help": "From 2 August 2026, infringements of Article 50 are within the Article 99(4) penalty framework: administrative fines may reach EUR 15 million or, for an undertaking, 3% of total worldwide annual turnover for the preceding financial year (subject to the AI Acts SME rule and the circumstances of the individual case). The competent authority determines whether an infringement exists and any measure or fine.",
"legal_disclaimer": "Decision aid, not legal advice: the result is based only on the supplied facts and the current Article 50 text / Commission guidance. Borderline cases require an individual legal assessment.",
"fact_author": "Author",
"fact_complaints_contact": "Complaint / feedback contact",
"fact_complaints_email": "Complaint / feedback email",
"fact_legal_role": "AI Act role (self-assessment)",
"fact_use_context": "Use context (self-assessment)",
"fact_output_date": "AI output date",
"fact_deepfake_assessment": "Deepfake assessment",
"fact_public_interest_assessment": "Public-interest text assessment",
"fact_creative_work_assessment": "Creative/satirical/fictional work assessment",
"fact_law_enforcement_authorization": "Criminal-law authorisation (self-assessment)",
}
de := map[string]string{
"generator_step_content": "1. Inhalt und KI-Nutzung",
"generator_step_legal": "2. Anwendungsbereich von Art. 50 prüfen",
"generator_step_accountability": "3. Veröffentlichung und Verantwortlichkeit",
"generator_step_output": "4. Ausgabe",
"field_required": "Für diese Einordnung erforderlich",
"select_please_choose": "Bitte auswählen",
"field_optional": "Optional",
"field_best_practice": "Best Practice",
"assessment_yes": "Ja",
"assessment_no": "Nein",
"assessment_unsure": "Unklar / muss geprüft werden",
"field_legal_role": "Deine Rolle nach dem AI Act",
"legal_role_deployer": "Deployer / Betreiber ich setze ein KI-System unter eigener Verantwortung ein",
"legal_role_provider": "Provider / Anbieter ich biete das KI-System an oder bringe es in Verkehr",
"legal_role_both": "Sowohl Provider als auch Deployer",
"legal_role_unsure": "Unklar / muss geprüft werden",
"legal_role_help": "Art. 50 weist Providern und Deployern unterschiedliche Pflichten zu. Dieser Generator dokumentiert vor allem veröffentlichungsbezogene Offenlegungen; die maschinenlesbare Anbieterkennzeichnung ist eigenständig. Art. 50 Abs. 2 enthält außerdem eine begrenzte Ausnahme für Standard-Bearbeitungsfunktionen bzw. Ausgaben, die Eingabedaten oder deren Semantik nicht wesentlich verändern.",
"field_use_context": "Nutzungskontext",
"use_context_professional": "Berufliche / organisatorische Nutzung",
"use_context_personalNonProfessional": "Rein persönliche, nicht berufliche Nutzung",
"use_context_unsure": "Unklar / gemischte Nutzung",
"use_context_help": "Wer KI ausschließlich persönlich und nicht beruflich nutzt, ist grundsätzlich kein Deployer im Sinne des AI Act. Regelmäßiger wirtschaftlicher, geschäftlicher, beruflicher oder freiberuflicher Einsatz kann dagegen beruflich sein.",
"field_output_date": "Datum der Erzeugung oder Manipulation des betroffenen KI-Outputs",
"output_date_help": "Art. 50 gilt ab 2. August 2026. Nach der FAQ der Kommission müssen vor diesem Datum erzeugte Inhalte nach Art. 50 nicht rückwirkend gekennzeichnet werden.",
"legal_scope_intro": "Die folgenden Fragen erscheinen nur, wenn sie zu den gewählten Inhaltsbestandteilen passen. Sie unterstützen eine vorsichtige Selbsteinordnung und sind keine Rechtsberatung.",
"legal_deepfake_not_relevant": "Die Deepfake-Prüfung wird nur bei KI-beteiligten Bild-, Audio- oder Videoinhalten eingeblendet.",
"legal_public_not_relevant": "Die Prüfung für Texte von öffentlichem Interesse wird nur eingeblendet, wenn beim Text eine KI-Beteiligung angegeben ist.",
"law_enforcement_title": "Gesetzlich autorisierte Strafverfolgungsnutzung",
"field_law_enforcement_authorization": "Ist diese Nutzung gesetzlich zur Aufdeckung, Verhütung, Ermittlung oder Verfolgung von Straftaten autorisiert?",
"law_enforcement_no": "Nein",
"law_enforcement_yes": "Ja gesetzlich autorisiert",
"law_enforcement_unsure": "Unklar / rechtlich zu prüfen",
"law_enforcement_help": "Art. 50 Abs. 4 enthält hierfür eine besondere Ausnahme. „Ja“ sollte nicht allein deshalb gewählt werden, weil eine Behörde veröffentlicht; erforderlich ist eine konkrete gesetzliche Autorisierung der betreffenden Nutzung.",
"editorial_exception_title": "Mögliche Ausnahme bei Texten von öffentlichem Interesse",
"editorial_exception_help": "Für die Ausnahme nach Art. 50 Abs. 4 müssen sowohl menschliche Prüfung/redaktionelle Kontrolle als auch die rechtliche redaktionelle Verantwortung einer natürlichen oder juristischen Person für die Veröffentlichung vorliegen. Oberflächliche oder rein formale Prüfungen genügen nicht.",
"responsibility_required_help": "Wird eingeblendet, weil ein Public-Interest-Text und eine substanzielle Prüfung angegeben wurden. Benenne die natürliche oder juristische Person, die letztlich die rechtliche redaktionelle Verantwortung für die Veröffentlichung trägt, wenn die Ausnahme berücksichtigt werden soll.",
"field_author": "Autor/in / Byline",
"field_author_url": "Autorprofil / Referenz-URL",
"author_help": "Autor/in und rechtlich redaktionell verantwortliche Person/Organisation sind nicht automatisch identisch. Das ist eine Veröffentlichungsangabe und keine allgemeine Pflicht aus Art. 50.",
"accountability_intro": "Diese Angaben sind bewusst von Art.-50-Pflichten getrennt. Sie verbessern die Nachvollziehbarkeit, ohne zu behaupten, dass jede Veröffentlichung sie zwingend enthalten muss.",
"complaints_title": "Beschwerde- / Rückmeldestelle",
"complaints_help": "Best Practice für transparente Veröffentlichungen. Art. 50 begründet keine allgemeine Pflicht, bei jedem gekennzeichneten Inhalt eine interne Beschwerdestelle zu benennen. Dieser freiwillige Kontakt ersetzt auch nicht das gesonderte Beschwerderecht nach Art. 85 AI Act gegenüber einer Marktüberwachungsbehörde; Medien-, Verbraucher-, Plattform- oder sonstiges Fachrecht kann zusätzliche Pflichten vorsehen.",
"field_complaint_name": "Anlaufstelle / Organisation",
"field_complaint_email": "Beschwerde- / Feedback-E-Mail",
"field_complaint_url": "Beschwerde- / Feedback-URL",
"validation_heading": "Plausibilitäts- und Rechtshinweise",
"validation_formal_review": "Es ist nur eine grundlegende/formale Prüfung dokumentiert. Nach den Kommissionsleitlinien reichen Rechtschreibung, Grammatik oder andere oberflächliche Prüfungen für die Public-Interest-Ausnahme nicht als menschliche Prüfung/redaktionelle Kontrolle aus.",
"validation_missing_responsibility": "Eine substanzielle Prüfung ist ausgewählt, die redaktionelle Verantwortung aber nicht vollständig benannt. Die Ausnahme für Public-Interest-Texte sollte auf dieser Grundlage nicht unterstellt werden.",
"validation_provider": "Provider-Rolle ausgewählt: Ein sichtbares Badge oder JSON-LD ersetzt nicht die anbieterseitige Pflicht zur maschinenlesbaren Markierung einschlägiger generativer KI-Ausgaben.",
"validation_role_unsure": "Die AI-Act-Rolle ist noch unklar. Deployer-seitige Offenlegungspflichten sollten nicht ausgeschlossen werden, bevor geklärt ist, ob das KI-System im beruflichen/organisatorischen Kontext unter eigener Verantwortung eingesetzt wird.",
"validation_use_context_unsure": "Der Nutzungskontext ist unklar oder gemischt. Berufliche, geschäftliche, freiberufliche oder sonst wirtschaftliche Nutzung sollte von rein persönlicher, nicht beruflicher Nutzung getrennt betrachtet werden, bevor auf die persönliche Nutzungsausnahme vertraut wird.",
"validation_personal": "Rein persönliche, nicht berufliche Nutzung fällt grundsätzlich nicht unter den Deployer-Begriff des AI Act. Eine freiwillige Transparenzkennzeichnung kann dennoch sinnvoll sein; anderes Recht kann weiterhin gelten.",
"validation_pre_application": "Das angegebene Output-Datum liegt vor dem 2. August 2026. Nach der Kommissions-FAQ müssen solche Inhalte nach Art. 50 nicht rückwirkend gekennzeichnet werden; andere Rechtspflichten bleiben unberührt.",
"validation_public_no_ai": "Public-Interest-Text ist ausgewählt, aber für den Text ist keine KI-Beteiligung dokumentiert. Bitte Inhaltsangaben prüfen oder die Kategorie entfernen.",
"validation_deepfake_no_media": "Deepfake ist ausgewählt, aber es ist kein KI-beteiligtes Bild, Audio oder Video dokumentiert. Bitte Inhaltsangaben prüfen oder die Kategorie entfernen.",
"validation_deepfake_unsure": "Die Deepfake-Einordnung ist offen. Vor einem „Nein“ sollten insbesondere Ähnlichkeit, reales oder plausibel reales Vorbild, der falsche Eindruck von Authentizität/Wahrheit sowie Veröffentlichungskontext und Publikumserwartung geprüft werden.",
"validation_public_unsure": "Die Public-Interest-Einordnung ist offen. Vor einem „Nein“ sollte geprüft werden, ob der Text veröffentlicht wird, die Öffentlichkeit informieren soll und eine Angelegenheit von öffentlichem Interesse betrifft.",
"validation_creative_unsure": "Die Einordnung als evident künstlerisch/kreativ/satirisch/fiktional ist offen. Sie betrifft bei Deepfakes die Art der Offenlegung, nicht die grundsätzliche Transparenzpflicht als solche.",
"validation_complaint_incomplete": "Bitte die Beschwerde- / Rückmeldestelle mit Name und mindestens einer gültigen E-Mail-Adresse oder URL vervollständigen.",
"consequence_disclosure": "Voraussichtliche Konsequenz: Einen klaren, unterscheidbaren Hinweis spätestens bei der ersten Exposition zum betroffenen Inhalt anzeigen; Barrierefreiheitsanforderungen sind ebenfalls zu beachten.",
"consequence_provider": "Anbieter-Konsequenz: Die eigenständigen Anforderungen aus Art. 50 Abs. 2 an maschinenlesbare Markierungen prüfen und dabei gesondert bewerten, ob die enge Ausnahme für Standard-Bearbeitung bzw. nicht wesentliche Änderungen tatsächlich greift. Dieser Generator ist nur ergänzende Dokumentation.",
"consequence_voluntary": "Aus den Angaben ergibt sich keine inhaltsspezifische Pflicht nach Art. 50 Abs. 4. Freiwillige Transparenz bleibt möglich; andere AI-Act- oder Fachpflichten können dennoch gelten.",
"legal_assessment_pending": "Die Art.-50-Einordnung ist noch nicht vollständig. Beantworte die aktuell als erforderlich markierten Fragen; bis dahin trifft der Generator keine Aussage dazu, ob eine Kennzeichnungspflicht besteht oder nicht besteht.",
"legal_assessment_personal": "Als Nutzungskontext wurde eine rein persönliche, nicht berufliche Nutzung angegeben. Diese fällt grundsätzlich nicht unter den Deployer-Begriff des AI Act; für Art. 50 behandelt der Generator die Kennzeichnung daher als freiwillig. Anderes Recht kann trotzdem gelten.",
"legal_assessment_pre_application": "Das angegebene Output-Datum liegt vor dem 2. August 2026. Nach der FAQ der Kommission müssen vor diesem Datum erzeugte Inhalte nach Art. 50 nicht rückwirkend gekennzeichnet werden. Freiwillige Transparenz und andere Rechtspflichten bleiben möglich.",
"legal_assessment_provider": "Es wurde ausschließlich die Provider-/Anbieterrolle angegeben. Dieses Veröffentlichungs-Badge kann die anbieterseitigen Art.-50-Pflichten, insbesondere die maschinenlesbare Markierung einschlägiger generierter oder manipulierter Outputs, nicht erfüllen. Dafür ist eine gesonderte Anbieterprüfung erforderlich, einschließlich der engen Ausnahme nach Art. 50 Abs. 2 für Standard-Bearbeitung bzw. nicht wesentliche Änderungen von Eingabedaten oder deren Semantik.",
"legal_assessment_law_enforcement": "Es wurde eine einschlägige gesetzliche Autorisierung für Strafverfolgungszwecke angegeben. Art. 50 Abs. 4 enthält dafür eine besondere Ausnahme; der Generator behandelt die inhaltsspezifische Deployer-Offenlegung auf dieser Grundlage daher nicht als erforderlich. Die Rechtsgrundlage sollte dokumentiert werden; andere Pflichten bleiben unberührt.",
"legal_assessment_deepfake_unsure": "Es ist unklar, ob der KI-beteiligte Medieninhalt als Deepfake einzuordnen ist. Eine inhaltsspezifische Pflicht nach Art. 50 Abs. 4 kann anhand der Angaben nicht ausgeschlossen werden. Bis zur Klärung ist eine klare Offenlegung die vorsichtigere Veröffentlichungseinstellung.",
"legal_assessment_public_unsure": "Es ist unklar, ob der KI-beteiligte Text veröffentlicht wird, um die Öffentlichkeit über eine Angelegenheit von öffentlichem Interesse zu informieren. Eine inhaltsspezifische Pflicht nach Art. 50 Abs. 4 kann anhand der Angaben nicht ausgeschlossen werden. Bis zur Klärung ist eine klare Offenlegung die vorsichtigere Veröffentlichungseinstellung.",
"validation_law_enforcement_unsure": "Die gesetzliche Autorisierung für Strafverfolgungszwecke ist unklar. Auf die Ausnahme nach Art. 50 Abs. 4 sollte erst vertraut werden, wenn konkrete Rechtsgrundlage und Anwendungsbereich geprüft sind.",
"legal_enforcement_title": "Mögliche rechtliche Konsequenzen",
"legal_enforcement_help": "Ab 2. August 2026 fallen Verstöße gegen Art. 50 grundsätzlich in den Bußgeldrahmen des Art. 99 Abs. 4: bis zu 15 Mio. EUR oder bei Unternehmen bis zu 3 % des weltweiten Jahresgesamtumsatzes des vorangegangenen Geschäftsjahres (unter Beachtung der besonderen KMU-Regel und der Umstände des Einzelfalls). Ob ein Verstoß vorliegt und welche Maßnahme oder Geldbuße verhängt wird, entscheidet die zuständige Behörde.",
"legal_disclaimer": "Entscheidungshilfe, keine Rechtsberatung: Die Einschätzung beruht ausschließlich auf den eingegebenen Tatsachen sowie dem aktuellen Art. 50 und den Kommissionsleitlinien. Grenzfälle benötigen eine individuelle rechtliche Prüfung.",
"fact_author": "Autor/in",
"fact_complaints_contact": "Beschwerde- / Rückmeldestelle",
"fact_complaints_email": "Beschwerde- / Feedback-E-Mail",
"fact_legal_role": "AI-Act-Rolle (Selbsteinordnung)",
"fact_use_context": "Nutzungskontext (Selbsteinordnung)",
"fact_output_date": "Datum des KI-Outputs",
"fact_deepfake_assessment": "Deepfake-Einordnung",
"fact_public_interest_assessment": "Public-Interest-Text-Einordnung",
"fact_creative_work_assessment": "Kreativ-/Satire-/Fiktions-Einordnung",
"fact_law_enforcement_authorization": "Strafverfolgungs-Autorisierung (Selbsteinordnung)",
}
for code := range catalogs {
if code == "de" {
continue
}
l := catalogs[code]
for k, v := range en {
l.Text[k] = v
}
catalogs[code] = l
}
l := catalogs["de"]
for k, v := range de {
l.Text[k] = v
}
catalogs["de"] = l
}
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()
}
func applyArticleTranslations() {
@@ -415,32 +175,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",
},
}
}
@@ -594,6 +354,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)
-247
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
}
-63
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())
}
}
}
+104 -159
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.8.0 .
{ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:2.0.0 .
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.8.0`},
{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.0`},
{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ń."}
+4 -14
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)
}
}
+257 -200
View File
@@ -1,8 +1,10 @@
openapi: 3.1.0
info:
title: AI Usage Disclosure API
version: 1.8.0
description: Stateless multilingual badge, declaration and validation API with optional licensed presentation capabilities.
version: 2.0.0
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,60 +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/legalRole'}
- {$ref: '#/components/parameters/useContext'}
- {$ref: '#/components/parameters/outputDate'}
- {$ref: '#/components/parameters/deepfakeAssessment'}
- {$ref: '#/components/parameters/publicInterestAssessment'}
- {$ref: '#/components/parameters/creativeWorkAssessment'}
- {$ref: '#/components/parameters/lawEnforcementAuthorization'}
- {$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'}
- {$ref: '#/components/parameters/legalRole'}
- {$ref: '#/components/parameters/useContext'}
- {$ref: '#/components/parameters/outputDate'}
- {$ref: '#/components/parameters/deepfakeAssessment'}
- {$ref: '#/components/parameters/publicInterestAssessment'}
- {$ref: '#/components/parameters/creativeWorkAssessment'}
- {$ref: '#/components/parameters/lawEnforcementAuthorization'}
- {$ref: '#/components/parameters/author'}
- {$ref: '#/components/parameters/authorUrl'}
- {$ref: '#/components/parameters/responsibleRole'}
- {$ref: '#/components/parameters/responsible'}
- {$ref: '#/components/parameters/responsibleUrl'}
- {$ref: '#/components/parameters/complaintName'}
- {$ref: '#/components/parameters/complaintEmail'}
- {$ref: '#/components/parameters/complaintUrl'}
- {$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/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'}
@@ -87,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'}
@@ -99,23 +98,16 @@ paths:
- {$ref: '#/components/parameters/activities'}
- {$ref: '#/components/parameters/review'}
- {name: subject, in: query, schema: {type: string, format: uri}}
- {$ref: '#/components/parameters/legalContext'}
- {$ref: '#/components/parameters/legalRole'}
- {$ref: '#/components/parameters/useContext'}
- {$ref: '#/components/parameters/outputDate'}
- {$ref: '#/components/parameters/deepfakeAssessment'}
- {$ref: '#/components/parameters/publicInterestAssessment'}
- {$ref: '#/components/parameters/creativeWorkAssessment'}
- {$ref: '#/components/parameters/lawEnforcementAuthorization'}
- {$ref: '#/components/parameters/author'}
- {$ref: '#/components/parameters/authorUrl'}
- {$ref: '#/components/parameters/responsibleRole'}
- {$ref: '#/components/parameters/responsible'}
- {$ref: '#/components/parameters/responsibleUrl'}
- {$ref: '#/components/parameters/complaintName'}
- {$ref: '#/components/parameters/complaintEmail'}
- {$ref: '#/components/parameters/complaintUrl'}
- {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'}
@@ -128,12 +120,15 @@ paths:
content:
application/ld+json:
schema: {$ref: './schema/declaration.schema.json'}
"403": {$ref: '#/components/responses/ProRequired'}
/v1/render:
"403": {$ref: '#/components/responses/LicensedCapabilityRequired'}
/v1/article50-assessment.json:
get:
summary: Render reusable embedding artifacts from declaration query parameters
description: Returns HTML, Markdown and the parsed JSON-LD declaration from the same core logic used by the normal generator. The optional bulk container calls this endpoint for each subject URL.
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'}
@@ -141,47 +136,110 @@ paths:
- {$ref: '#/components/parameters/activities'}
- {$ref: '#/components/parameters/review'}
- {name: subject, in: query, schema: {type: string, format: uri}}
- {$ref: '#/components/parameters/legalContext'}
- {$ref: '#/components/parameters/legalRole'}
- {$ref: '#/components/parameters/useContext'}
- {$ref: '#/components/parameters/outputDate'}
- {$ref: '#/components/parameters/deepfakeAssessment'}
- {$ref: '#/components/parameters/publicInterestAssessment'}
- {$ref: '#/components/parameters/creativeWorkAssessment'}
- {$ref: '#/components/parameters/lawEnforcementAuthorization'}
- {$ref: '#/components/parameters/author'}
- {$ref: '#/components/parameters/authorUrl'}
- {$ref: '#/components/parameters/responsibleRole'}
- {$ref: '#/components/parameters/responsible'}
- {$ref: '#/components/parameters/responsibleUrl'}
- {$ref: '#/components/parameters/complaintName'}
- {$ref: '#/components/parameters/complaintEmail'}
- {$ref: '#/components/parameters/complaintUrl'}
- {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'}
- {$ref: '#/components/parameters/theme'}
responses:
"200":
description: Rendered artifacts
description: Assessment with localized summary and warnings
content:
application/json:
schema:
type: object
required: [declarationUrl, badgeUrl, manifestUrl, html, markdown, jsonLd]
required: [assessment, title, summary, disclaimer]
properties:
subject: {type: string, format: uri}
declarationUrl: {type: string, format: uri}
badgeUrl: {type: string, format: uri}
manifestUrl: {type: string, format: uri}
html: {type: string}
markdown: {type: string}
jsonLd: {$ref: './schema/declaration.schema.json'}
"403": {$ref: '#/components/responses/ProRequired'}
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
@@ -195,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
@@ -204,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
@@ -228,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
@@ -246,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
@@ -254,90 +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}
legalRole:
name: legalRole
in: query
description: Self-assessed AI Act actor role. Provider duties and deployer duties are distinct.
schema: {type: string, enum: [deployer, provider, both, unsure]}
useContext:
name: useContext
in: query
description: Professional/organisational use or purely personal non-professional use for cautious scope assessment.
schema: {type: string, enum: [professional, personalNonProfessional, unsure]}
outputDate:
name: outputDate
in: query
description: Date the affected output was generated or manipulated; used only for application-date guidance.
schema: {type: string, format: date}
deepfakeAssessment:
name: deepfakeAssessment
in: query
description: Required-by-workflow yes/no/unsure self-assessment for potentially realistic AI-generated or manipulated image, audio or video content. A yes value is also reflected in legalContext.categories.
schema: {type: string, enum: [yes, no, unsure]}
publicInterestAssessment:
name: publicInterestAssessment
in: query
description: Required-by-workflow yes/no/unsure self-assessment whether AI-generated or manipulated text is published to inform the public on a matter of public interest. A yes value is also reflected in legalContext.categories.
schema: {type: string, enum: [yes, no, unsure]}
creativeWorkAssessment:
name: creativeWorkAssessment
in: query
description: Conditional yes/no/unsure self-assessment for the evidently artistic, creative, satirical or fictional deepfake modifier.
schema: {type: string, enum: [yes, no, unsure]}
lawEnforcementAuthorization:
name: lawEnforcementAuthorization
in: query
description: Self-assessment whether the Article 50(4) criminal-law authorisation exception applies to the specific use.
schema: {type: string, enum: [yes, no, unsure]}
author:
name: author
in: query
description: Optional author/byline metadata; distinct from editorial legal responsibility.
schema: {type: string, maxLength: 200}
authorUrl:
name: authorUrl
in: query
schema: {type: string, format: uri}
responsibleRole:
name: responsibleRole
in: query
schema: {type: string, enum: [publisher, other]}
responsible:
name: responsible
in: query
schema: {type: string, maxLength: 200}
responsibleUrl:
name: responsibleUrl
in: query
schema: {type: string, format: uri}
complaintName:
name: complaintName
in: query
description: Optional best-practice complaint/feedback contact; not presented as a general Article 50 requirement.
schema: {type: string, maxLength: 200}
complaintEmail:
name: complaintEmail
in: query
schema: {type: string, format: email}
complaintUrl:
name: complaintUrl
in: query
schema: {type: string, format: uri}
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
@@ -345,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}
-391
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
}
-81
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())
}
}
-4
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
-3
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
-472
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
}
-84
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)
}
}
-4
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
-271
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[:])
}
-91
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)
}
}
-130
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)
}
+26 -130
View File
@@ -20,7 +20,7 @@
"const": "AIUsageDeclaration"
},
"schemaVersion": {
"const": "1.3"
"const": "1.2"
},
"subject": {
"type": "string",
@@ -49,29 +49,9 @@
"$ref": "#/$defs/component"
}
},
"author": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"maxLength": 200
},
"url": {
"type": "string",
"format": "uri"
}
}
},
"editorialResponsibility": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
@@ -79,51 +59,40 @@
"url": {
"type": "string",
"format": "uri"
},
"assumed": {
"type": "boolean"
},
"role": {
"enum": [
"publisher",
"other"
]
}
}
},
"complaintsContact": {
"regulatoryContext": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"maxLength": 200
},
"email": {
"type": "string",
"format": "email",
"maxLength": 320
},
"url": {
"type": "string",
"format": "uri"
}
},
"anyOf": [
{
"required": [
"email"
"framework": {
"enum": [
"EU-AI-Act-Article-50"
]
},
{
"required": [
"url"
]
"publicInterestText": {
"type": "boolean"
},
"deepfake": {
"type": "boolean"
},
"artisticCreativeSatiricalFictional": {
"type": "boolean"
},
"substantialHumanReview": {
"type": "boolean"
},
"editorialResponsibilityConfirmed": {
"type": "boolean"
},
"firstExposureDisclosure": {
"type": "boolean"
},
"accessibilityConsidered": {
"type": "boolean"
}
]
}
},
"assurance": {
"enum": [
@@ -162,79 +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"
]
}
},
"actorRole": {
"enum": [
"deployer",
"provider",
"both",
"unsure"
]
},
"useContext": {
"enum": [
"professional",
"personalNonProfessional",
"unsure"
]
},
"outputDate": {
"type": "string",
"format": "date"
},
"lawEnforcementAuthorization": {
"type": "string",
"enum": [
"yes",
"no",
"unsure"
],
"description": "Self-declared assessment whether the use is authorised by law to detect, prevent, investigate or prosecute criminal offences for the Article 50(4) exception."
},
"deepfakeAssessment": {
"type": "string",
"enum": [
"yes",
"no",
"unsure"
],
"description": "Self-assessment whether the AI-involved image, audio or video qualifies as a deepfake for Article 50(4)."
},
"publicInterestAssessment": {
"type": "string",
"enum": [
"yes",
"no",
"unsure"
],
"description": "Self-assessment whether AI-involved text is published to inform the public on a matter of public interest for Article 50(4)."
},
"creativeWorkAssessment": {
"type": "string",
"enum": [
"yes",
"no",
"unsure"
],
"description": "Self-assessment whether a deepfake forms part of an evidently artistic, creative, satirical, fictional or analogous work or programme."
}
}
}
},
"$defs": {
-34
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"}}
}
}
-12
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
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`.
@@ -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)
}
@@ -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)
}
}
+51 -383
View File
@@ -5,13 +5,11 @@
const form = document.getElementById('generator-form');
if (!form) return;
const $ = id => document.getElementById(id);
const ARTICLE_50_START = '2026-08-02';
const presets = {
// Human/editorial review is a factual workflow statement and must never be inferred.
'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) {
@@ -19,20 +17,6 @@
return el && !el.disabled ? el.value.trim() : '';
}
function validValue(id) {
const el = $(id);
if (!el || el.disabled) return '';
const v = el.value.trim();
return v && (!el.validity || el.validity.valid) ? v : '';
}
function setHidden(id, hidden) {
const el = $(id);
if (!el) return;
el.hidden = hidden;
el.querySelectorAll?.('input,select,textarea').forEach(input => { input.disabled = hidden; });
}
function setArticleParams(params) {
params.set('mode', 'article');
const fields = {
@@ -46,315 +30,19 @@
code: 'article-code'
};
Object.entries(fields).forEach(([name, id]) => {
const extent = $(id).value;
params.set(name + 'Extent', extent);
params.set(name + 'Review', extent === 'none' ? 'none' : $(`${id}-review`).value);
params.set(name + 'Extent', $(id).value);
params.set(name + 'Review', $(`${id}-review`).value);
});
}
function legalCategories() {
const values = [];
if (value('legal-deepfake') === 'yes') values.push('deepfake');
if (value('legal-public-interest') === 'yes') values.push('publicInterestText');
if (value('legal-creative') === 'yes') values.push('artisticCreativeSatiricalFictional');
if ($('legal-other-voluntary')?.checked && !$('legal-other-voluntary').disabled) values.push('otherVoluntary');
return values;
}
function substantiveReview(mode) {
return mode === 'editorial' || mode === 'expert';
}
function articleMode() {
return $('declaration-mode').value === 'article';
}
function extentHasAI(extent) {
return Boolean(extent && extent !== 'none');
}
function singleHasAI(component) {
return $('component').value === component && extentHasAI($('extent').value);
}
function articleHasAI(id) {
return extentHasAI($(id)?.value || 'none');
}
function hasAIText() {
return articleMode() ? articleHasAI('article-text') : singleHasAI('text');
}
function hasAIMedia() {
if (articleMode()) {
return ['article-cover-image', 'article-image', 'article-audio', 'article-video'].some(articleHasAI);
}
return ['coverImage', 'image', 'audio', 'video'].includes($('component').value) && extentHasAI($('extent').value);
}
function hasAnyAI() {
if (!articleMode()) return extentHasAI($('extent').value);
return ['article-text','article-cover-image','article-image','article-research','article-translation','article-audio','article-video','article-code'].some(articleHasAI);
}
function currentTextReview() {
if (articleMode()) return $('article-text-review').value;
return $('component').value === 'text' && extentHasAI($('extent').value) ? $('review').value : 'none';
}
function selectedLegalContext() {
return {
role: value('legal-role'),
useContext: value('use-context'),
outputDate: value('output-date'),
deepfakeAssessment: value('legal-deepfake'),
publicInterestAssessment: value('legal-public-interest'),
creativeWorkAssessment: value('legal-creative'),
lawEnforcementAuthorization: value('law-enforcement-authorization'),
categories: legalCategories()
};
}
function legalAssessment(locale) {
const ctx = selectedLegalContext();
const deepfake = ctx.deepfakeAssessment === 'yes' || ctx.categories.includes('deepfake');
const publicInterest = ctx.publicInterestAssessment === 'yes' || ctx.categories.includes('publicInterestText');
const creative = ctx.creativeWorkAssessment === 'yes' || ctx.categories.includes('artisticCreativeSatiricalFictional');
const deepfakeUnsure = ctx.deepfakeAssessment === 'unsure';
const publicInterestUnsure = ctx.publicInterestAssessment === 'unsure';
if (!hasAnyAI()) {
return {text: locale.text.legal_assessment_none, legal: false, level: 'info', consequence: locale.text.consequence_voluntary, badge: locale.text.badge_article || 'Article transparency'};
}
if (!ctx.role || !ctx.useContext) {
return {text: locale.text.legal_assessment_pending, legal: false, level: 'attention', consequence: '', badge: locale.text.badge_article || 'Article transparency'};
}
if (ctx.useContext === 'personalNonProfessional') {
return {text: locale.text.legal_assessment_personal, legal: false, level: 'info', consequence: locale.text.consequence_voluntary, badge: locale.text.badge_article || 'Article transparency'};
}
if (ctx.role === 'provider') {
return {text: locale.text.legal_assessment_provider, legal: false, level: 'attention', consequence: locale.text.consequence_provider, badge: locale.text.badge_article || 'Article transparency'};
}
const mediaRelevant = hasAIMedia();
const textRelevant = hasAIText();
const lawEnforcementRelevant = [ctx.deepfakeAssessment, ctx.publicInterestAssessment].some(v => v === 'yes' || v === 'unsure');
const initialPending = (mediaRelevant && !ctx.deepfakeAssessment)
|| (textRelevant && !ctx.publicInterestAssessment)
|| (lawEnforcementRelevant && !ctx.lawEnforcementAuthorization);
if (initialPending) {
return {text: locale.text.legal_assessment_pending, legal: false, level: 'attention', consequence: '', badge: locale.text.badge_article || 'Article transparency'};
}
if (ctx.outputDate && ctx.outputDate < ARTICLE_50_START) {
return {text: locale.text.legal_assessment_pre_application, legal: false, level: 'info', consequence: locale.text.consequence_voluntary, badge: locale.text.badge_article || 'Article transparency'};
}
if ((deepfake || publicInterest || deepfakeUnsure || publicInterestUnsure) && ctx.lawEnforcementAuthorization === 'yes') {
return {text: locale.text.legal_assessment_law_enforcement, legal: false, level: 'info', consequence: locale.text.consequence_voluntary, badge: locale.text.badge_article || 'Article transparency'};
}
if (ctx.deepfakeAssessment === 'yes' && !ctx.creativeWorkAssessment) {
return {text: locale.text.legal_assessment_pending, legal: false, level: 'attention', consequence: '', badge: locale.text.badge_article || 'Article transparency'};
}
if (deepfakeUnsure) {
return {text: locale.text.legal_assessment_deepfake_unsure, legal: true, level: 'attention', consequence: locale.text.consequence_disclosure, badge: locale.text.badge_legal_disclosure || 'AI-generated / manipulated content'};
}
if (deepfake) {
return {
text: creative ? locale.text.legal_assessment_deepfake_creative : locale.text.legal_assessment_deepfake,
legal: true,
level: 'attention',
consequence: locale.text.consequence_disclosure,
badge: locale.text.badge_legal_disclosure || 'AI-generated / manipulated content'
};
}
if (publicInterestUnsure) {
return {text: locale.text.legal_assessment_public_unsure, legal: true, level: 'attention', consequence: locale.text.consequence_disclosure, badge: locale.text.badge_public_interest || 'AI-generated / edited content'};
}
if (publicInterest) {
const hasResponsibility = Boolean(value('responsible-role') && value('responsible'));
if (substantiveReview(currentTextReview()) && hasResponsibility) {
return {text: locale.text.legal_assessment_public_exemption, legal: false, level: 'info', consequence: locale.text.consequence_voluntary, badge: locale.text.badge_article || 'Article transparency'};
}
return {text: locale.text.legal_assessment_public_required, legal: true, level: 'attention', consequence: locale.text.consequence_disclosure, badge: locale.text.badge_public_interest || 'AI-generated / edited content'};
}
if (ctx.categories.includes('otherVoluntary')) {
return {text: locale.text.legal_assessment_voluntary, legal: false, level: 'info', consequence: locale.text.consequence_voluntary, badge: locale.text.badge_article || 'Article transparency'};
}
return {text: locale.text.legal_assessment_none, legal: false, level: 'info', consequence: locale.text.consequence_voluntary, badge: locale.text.badge_article || 'Article transparency'};
}
function renderAssessment(locale, assessment) {
const target = $('legal-assessment');
if (!target) return;
target.replaceChildren();
const heading = document.createElement('strong');
heading.textContent = locale.text.legal_assessment_heading || 'Article 50 assessment';
const text = document.createElement('span');
text.textContent = assessment.text || '';
const consequence = document.createElement('span');
consequence.className = 'assessment-consequence';
consequence.textContent = assessment.consequence || '';
target.append(heading, text, consequence);
target.dataset.level = assessment.level || (assessment.legal ? 'attention' : 'info');
}
function addValidationMessage(container, text, level = 'warning') {
if (!text) return;
const p = document.createElement('p');
p.className = `validation-message ${level}`;
p.textContent = text;
container.appendChild(p);
}
function renderValidationMessages(locale) {
const target = $('validation-messages');
if (!target) return;
target.replaceChildren();
if (!hasAnyAI()) return;
const ctx = selectedLegalContext();
const categories = ctx.categories;
const review = currentTextReview();
if (ctx.role === 'provider' || ctx.role === 'both') addValidationMessage(target, locale.text.validation_provider, 'info');
if (ctx.role === 'unsure') addValidationMessage(target, locale.text.validation_role_unsure);
if (ctx.useContext === 'personalNonProfessional') addValidationMessage(target, locale.text.validation_personal, 'info');
if (ctx.useContext === 'unsure') addValidationMessage(target, locale.text.validation_use_context_unsure);
if (ctx.outputDate && ctx.outputDate < ARTICLE_50_START) addValidationMessage(target, locale.text.validation_pre_application, 'info');
if (ctx.lawEnforcementAuthorization === 'unsure') addValidationMessage(target, locale.text.validation_law_enforcement_unsure);
if (ctx.deepfakeAssessment === 'unsure') addValidationMessage(target, locale.text.validation_deepfake_unsure);
if (ctx.publicInterestAssessment === 'unsure') addValidationMessage(target, locale.text.validation_public_unsure);
if (ctx.creativeWorkAssessment === 'unsure') addValidationMessage(target, locale.text.validation_creative_unsure, 'info');
if (categories.includes('publicInterestText') && !hasAIText()) addValidationMessage(target, locale.text.validation_public_no_ai);
if (categories.includes('deepfake') && !hasAIMedia()) addValidationMessage(target, locale.text.validation_deepfake_no_media);
if (categories.includes('publicInterestText') && review === 'basic') addValidationMessage(target, locale.text.validation_formal_review);
if (categories.includes('publicInterestText') && substantiveReview(review) && (!value('responsible-role') || !value('responsible'))) {
addValidationMessage(target, locale.text.validation_missing_responsibility);
}
const complaintStarted = value('complaint-name') || value('complaint-email') || value('complaint-url');
if (complaintStarted && (!value('complaint-name') || (!validValue('complaint-email') && !validValue('complaint-url')))) {
addValidationMessage(target, locale.text.validation_complaint_incomplete || 'Complete the complaint contact with a name and at least one valid contact method.');
}
}
function syncVisibility(locale) {
const isArticle = articleMode();
$('single-fields').hidden = isArticle;
$('article-fields').hidden = !isArticle;
const noSingleAI = !isArticle && !extentHasAI($('extent').value);
setHidden('single-review-field', noSingleAI);
setHidden('single-activities-field', noSingleAI);
if (noSingleAI) $('review').value = 'none';
document.querySelectorAll('#article-fields select[data-review]').forEach(extentSelect => {
const reviewID = extentSelect.dataset.review;
const wrap = document.querySelector(`[data-review-wrap="${reviewID}"]`);
const hidden = extentSelect.value === 'none';
if (wrap) {
wrap.hidden = hidden;
const reviewSelect = $(reviewID);
if (reviewSelect) {
reviewSelect.disabled = hidden;
if (hidden) reviewSelect.value = 'none';
}
}
});
const anyAI = hasAnyAI();
setHidden('legal-context-builder', !anyAI);
$('legal-role').required = anyAI;
$('use-context').required = anyAI;
$('legal-role').disabled = !anyAI;
$('use-context').disabled = !anyAI;
if (!anyAI) {
$('legal-role').value = '';
$('use-context').value = '';
['legal-deepfake','legal-public-interest','legal-creative','law-enforcement-authorization'].forEach(id => { if ($(id)) $(id).value = ''; });
if ($('legal-other-voluntary')) $('legal-other-voluntary').checked = false;
setHidden('legal-deepfake-row', true);
setHidden('legal-public-interest-row', true);
setHidden('legal-creative-row', true);
setHidden('law-enforcement-fields', true);
setHidden('legal-timing-fields', true);
setHidden('legal-other-voluntary-row', true);
}
if (anyAI) {
const prerequisitesAnswered = Boolean(value('legal-role') && value('use-context'));
const deployerScopeRelevant = prerequisitesAnswered && value('use-context') !== 'personalNonProfessional' && value('legal-role') !== 'provider';
const mediaRelevant = deployerScopeRelevant && hasAIMedia();
const textRelevant = deployerScopeRelevant && hasAIText();
setHidden('legal-deepfake-row', !mediaRelevant);
$('legal-deepfake').required = mediaRelevant;
$('legal-deepfake').disabled = !mediaRelevant;
if (!mediaRelevant) $('legal-deepfake').value = '';
setHidden('legal-public-interest-row', !textRelevant);
$('legal-public-interest').required = textRelevant;
$('legal-public-interest').disabled = !textRelevant;
if (!textRelevant) $('legal-public-interest').value = '';
const legalExceptionRelevant = deployerScopeRelevant && [value('legal-deepfake'), value('legal-public-interest')].some(v => v === 'yes' || v === 'unsure');
setHidden('law-enforcement-fields', !legalExceptionRelevant);
$('law-enforcement-authorization').required = legalExceptionRelevant;
$('law-enforcement-authorization').disabled = !legalExceptionRelevant;
if (!legalExceptionRelevant) $('law-enforcement-authorization').value = '';
const creativeRelevant = mediaRelevant && value('legal-deepfake') === 'yes' && ['no', 'unsure'].includes(value('law-enforcement-authorization'));
setHidden('legal-creative-row', !creativeRelevant);
$('legal-creative').required = creativeRelevant;
$('legal-creative').disabled = !creativeRelevant;
if (!creativeRelevant) $('legal-creative').value = '';
const allRelevantScopeQuestionsResolvedNo = deployerScopeRelevant && (!mediaRelevant || value('legal-deepfake') === 'no') && (!textRelevant || value('legal-public-interest') === 'no');
const noContentScopeQuestions = deployerScopeRelevant && !mediaRelevant && !textRelevant;
const showVoluntary = noContentScopeQuestions || allRelevantScopeQuestionsResolvedNo;
setHidden('legal-other-voluntary-row', !showVoluntary);
if (!showVoluntary) $('legal-other-voluntary').checked = false;
const legalQuestionAnswered = [value('legal-deepfake'), value('legal-public-interest')].some(Boolean);
setHidden('legal-timing-fields', !deployerScopeRelevant || !legalQuestionAnswered);
$('output-date').disabled = !deployerScopeRelevant || !legalQuestionAnswered;
}
const publicInterest = value('legal-public-interest') === 'yes';
const showResponsibility = anyAI && publicInterest && ['no', 'unsure'].includes(value('law-enforcement-authorization')) && substantiveReview(currentTextReview()) && value('use-context') !== 'personalNonProfessional' && value('legal-role') !== 'provider';
setHidden('responsibility-fields', !showResponsibility);
$('responsible-role').required = showResponsibility;
$('responsible').required = showResponsibility;
const showAuthor = anyAI && (isArticle || hasAIText());
setHidden('author-fields', !showAuthor);
setHidden('author-url-field', !showAuthor || !value('author'));
const accountabilityRelevant = anyAI && [value('legal-deepfake'), value('legal-public-interest')].some(v => v === 'yes' || v === 'unsure') && value('law-enforcement-authorization') !== 'yes' && value('use-context') !== 'personalNonProfessional';
setHidden('complaint-fields', !accountabilityRelevant);
renderValidationMessages(locale);
}
function appendMetadataParams(params) {
if (validValue('subject')) params.set('subject', validValue('subject'));
if (!$('author-fields')?.hidden && value('author')) {
params.set('author', value('author'));
if (validValue('author-url')) params.set('authorUrl', validValue('author-url'));
}
if (!$('responsibility-fields')?.hidden && value('responsible-role') && value('responsible')) {
params.set('responsibleRole', value('responsible-role'));
params.set('responsible', value('responsible'));
if (validValue('responsible-url')) params.set('responsibleUrl', validValue('responsible-url'));
}
if (!$('complaint-fields')?.hidden && value('complaint-name') && (validValue('complaint-email') || validValue('complaint-url'))) {
params.set('complaintName', value('complaint-name'));
if (validValue('complaint-email')) params.set('complaintEmail', validValue('complaint-email'));
if (validValue('complaint-url')) params.set('complaintUrl', validValue('complaint-url'));
}
}
function update(applyPreset = false) {
const isArticle = articleMode();
const mode = $('declaration-mode').value;
const articleMode = mode === 'article';
$('single-fields').hidden = articleMode;
$('article-fields').hidden = !articleMode;
const preset = $('preset').value;
if (!isArticle && applyPreset && presets[preset]) {
if (!articleMode && applyPreset && presets[preset]) {
$('extent').value = presets[preset].extent;
$('activities').value = presets[preset].activities;
$('review').value = presets[preset].review;
@@ -362,37 +50,35 @@
const lang = $('lang').value;
const locale = cfg.locales[lang] || cfg.locales.en;
syncVisibility(locale);
const assurance = $('assurance').value;
const assuranceDescription = locale.text[`assurance_${assurance}_description`];
if ($('assurance-description') && assuranceDescription) $('assurance-description').textContent = assuranceDescription;
if ($('assurance-description') && assuranceDescription) {
$('assurance-description').textContent = assuranceDescription;
}
const params = new URLSearchParams();
if (isArticle) {
if (articleMode) {
setArticleParams(params);
} else {
if (preset) params.set('preset', preset);
params.set('component', $('component').value);
params.set('extent', $('extent').value);
if (extentHasAI($('extent').value) && $('activities').value.trim()) params.set('activities', $('activities').value.trim());
params.set('review', extentHasAI($('extent').value) ? $('review').value : 'none');
if ($('activities').value.trim()) params.set('activities', $('activities').value.trim());
params.set('review', $('review').value);
}
params.set('lang', lang);
params.set('assurance', assurance);
if ($('subject').value.trim()) params.set('subject', $('subject').value.trim());
if (hasAnyAI() && !$('legal-context-builder').hidden) {
if (value('legal-role')) params.set('legalRole', value('legal-role'));
if (value('use-context')) params.set('useContext', value('use-context'));
if (value('output-date')) params.set('outputDate', value('output-date'));
if (value('legal-deepfake')) params.set('deepfakeAssessment', value('legal-deepfake'));
if (value('legal-public-interest')) params.set('publicInterestAssessment', value('legal-public-interest'));
if (value('legal-creative')) params.set('creativeWorkAssessment', value('legal-creative'));
if (!$('law-enforcement-fields')?.hidden && value('law-enforcement-authorization')) params.set('lawEnforcementAuthorization', value('law-enforcement-authorization'));
const legal = legalCategories();
if (legal.length) params.set('legalContext', legal.join(','));
}
appendMetadataParams(params);
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'));
if (cfg.capabilities.custom_text) {
if (value('custom-title')) params.set('customTitle', value('custom-title'));
@@ -411,53 +97,35 @@
badgeParams.set('link', declarationURL);
const badgeURL = `${base}/v1/badge.svg?${badgeParams}`;
const manifestURL = `${base}/v1/declaration.json?${params}`;
const assessment = legalAssessment(locale);
renderAssessment(locale, assessment);
const alt = assessment.legal
? assessment.badge
: (isArticle
? (locale.text.badge_article || 'Article transparency')
: ((locale.presets[preset] && locale.presets[preset].title) || locale.extents[$('extent').value] || 'AI usage disclosure'));
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;
const bulkLink = $('bulk-template-link');
if (bulkLink) {
bulkLink.hidden = !cfg.bulkURL;
if (cfg.bulkURL) {
const bulkParams = new URLSearchParams(params);
bulkParams.delete('subject');
bulkParams.delete('link');
const payload = {version: 1, query: bulkParams.toString(), theme: $('theme').value};
bulkLink.href = `${cfg.bulkURL}/#template=${encodeURIComponent(JSON.stringify(payload))}`;
if ($('assessment-code')) $('assessment-code').value = `${base}/v1/article50-assessment.json?${params}`;
}
document.querySelectorAll('#article-fields select[data-review]').forEach(extentSelect => {
extentSelect.addEventListener('change', () => {
const reviewSelect = $(extentSelect.dataset.review);
if (!reviewSelect) return;
if (extentSelect.value === 'none') {
reviewSelect.value = 'none';
} else if (reviewSelect.value === 'none') {
reviewSelect.value = 'editorial';
}
}
}
function enforceLegalExclusivity(changedId) {
if (['legal-deepfake','legal-public-interest'].includes(changedId) && ['yes','unsure'].includes(value(changedId)) && $('legal-other-voluntary')) {
$('legal-other-voluntary').checked = false;
}
}
form.addEventListener('input', event => {
enforceLegalExclusivity(event.target.id);
update(event.target.id === 'preset');
});
form.addEventListener('change', event => {
enforceLegalExclusivity(event.target.id);
update(event.target.id === 'preset');
update(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));
$('copy-html')?.addEventListener('click', async () => {
await navigator.clipboard.writeText($('html-code').value);
-7
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);
+25 -28
View File
@@ -1,5 +1,5 @@
:root{--bg:#f4f2ed;--surface:#fff;--ink:#171717;--muted:#646464;--line:#d7d3ca;--accent:#262626;--radius:18px;--shadow:0 16px 40px rgba(0,0,0,.08)}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.55}a{color:inherit}.site-header,footer{max-width:1180px;margin:auto;padding:24px 28px;display:flex;align-items:center;justify-content:space-between;gap:24px}.site-header nav{display:flex;gap:20px}.site-header nav a,.brand{text-decoration:none}.brand{font-weight:800;letter-spacing:-.02em}.site-header nav a{color:var(--muted)}main{max-width:1180px;margin:auto;padding:24px 28px 80px}.hero{padding:72px 0 88px;max-width:920px}.eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:.74rem;font-weight:800;color:var(--muted)}h1,h2{letter-spacing:-.045em;line-height:1.03;margin:.25em 0}h1{font-size:clamp(3rem,8vw,6.7rem)}h2{font-size:clamp(2rem,4vw,3.6rem)}.lead{font-size:clamp(1.15rem,2vw,1.45rem);max-width:760px;color:#404040}.badge-row{display:flex;flex-wrap:wrap;gap:10px;margin-top:34px}.badge-row img,.preview-card img{height:20px}.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:clamp(24px,5vw,58px);box-shadow:var(--shadow)}.generator-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;margin:34px 0}.generator-grid label,.code-tabs label{display:flex;flex-direction:column;gap:7px;font-weight:700}.generator-grid .wide{grid-column:1/-1}input,select,textarea,button{font:inherit}input,select,textarea{width:100%;border:1px solid var(--line);border-radius:10px;background:#fbfaf7;color:var(--ink);padding:12px 14px}small{font-weight:400;color:var(--muted)}.preview-card{border-top:1px solid var(--line);padding-top:28px}.code-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:22px 0}.code-tabs textarea{height:110px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.82rem}button{border:0;border-radius:999px;background:var(--accent);color:#fff;padding:12px 20px;font-weight:800;cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #5b9cff;outline-offset:3px}.docs-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin-top:24px}.docs-grid article{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:28px;overflow:hidden}.docs-grid h2{font-size:2rem}.docs-grid code{display:block;overflow:auto;padding:12px;background:#eeeae2;border-radius:8px;font-size:.82rem}.declaration-page{max-width:1180px}.back{text-decoration:none;color:var(--muted)}.declaration-card{margin-top:36px;background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:clamp(26px,6vw,70px);box-shadow:var(--shadow)}.declaration-card h1{font-size:clamp(2.8rem,7vw,5.5rem)}.facts{margin:42px 0}.facts div{display:grid;grid-template-columns:minmax(150px,.7fr) 1.5fr;gap:24px;padding:15px 0;border-top:1px solid var(--line)}.facts dt{font-weight:800}.facts dd{margin:0;overflow-wrap:anywhere}.notice{background:#eeeae2;border-radius:12px;padding:18px}footer{border-top:1px solid var(--line);color:var(--muted)}@media(max-width:760px){.site-header nav{display:none}.generator-grid,.code-tabs,.docs-grid{grid-template-columns:1fr}.generator-grid .wide{grid-column:auto}.facts div{grid-template-columns:1fr;gap:4px}.hero{padding-top:44px}h1{font-size:3.2rem}}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.55}a{color:inherit}.site-header,footer{max-width:1180px;margin:auto;padding:24px 28px;display:flex;align-items:center;justify-content:space-between;gap:24px}.site-header nav{display:flex;gap:20px}.site-header nav a,.brand{text-decoration:none}.brand{font-weight:800;letter-spacing:-.02em}.site-header nav a{color:var(--muted)}main{max-width:1180px;margin:auto;padding:24px 28px 80px}.hero{padding:72px 0 88px;max-width:920px}.eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:.74rem;font-weight:800;color:var(--muted)}h1,h2{letter-spacing:-.045em;line-height:1.03;margin:.25em 0}h1{font-size:clamp(3rem,8vw,6.7rem)}h2{font-size:clamp(2rem,4vw,3.6rem)}.lead{font-size:clamp(1.15rem,2vw,1.45rem);max-width:760px;color:#404040}.badge-row{display:flex;flex-wrap:wrap;gap:10px;margin-top:34px}.badge-row img,.preview-card img{height:20px}.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:clamp(24px,5vw,58px);box-shadow:var(--shadow)}.generator-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;margin:34px 0}.generator-grid label,.code-tabs label{display:flex;flex-direction:column;gap:7px;font-weight:700}.generator-grid .wide{grid-column:1/-1}input,select,textarea,button{font:inherit}input,select,textarea{width:100%;border:1px solid var(--line);border-radius:10px;background:#fbfaf7;color:var(--ink);padding:12px 14px}small{font-weight:400;color:var(--muted)}.preview-card{border-top:1px solid var(--line);padding-top:28px}.code-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:22px 0}.code-tabs textarea{height:110px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.82rem}button{border:0;border-radius:999px;background:var(--accent);color:#fff;padding:12px 20px;font-weight:800;cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #5b9cff;outline-offset:3px}.docs-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin-top:24px}.docs-grid article{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:28px;overflow:hidden}.docs-grid h2{font-size:2rem}.docs-grid code{display:block;overflow:auto;padding:12px;background:#eeeae2;border-radius:8px;font-size:.82rem}.declaration-page{max-width:920px}.back{text-decoration:none;color:var(--muted)}.declaration-card{margin-top:36px;background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:clamp(26px,6vw,70px);box-shadow:var(--shadow)}.declaration-card h1{font-size:clamp(2.8rem,7vw,5.5rem)}.facts{margin:42px 0}.facts div{display:grid;grid-template-columns:minmax(150px,.7fr) 1.5fr;gap:24px;padding:15px 0;border-top:1px solid var(--line)}.facts dt{font-weight:800}.facts dd{margin:0;overflow-wrap:anywhere}.notice{background:#eeeae2;border-radius:12px;padding:18px}footer{border-top:1px solid var(--line);color:var(--muted)}@media(max-width:760px){.site-header nav{display:none}.generator-grid,.code-tabs,.docs-grid{grid-template-columns:1fr}.generator-grid .wide{grid-column:auto}.facts div{grid-template-columns:1fr;gap:4px}.hero{padding-top:44px}h1{font-size:3.2rem}}
.language-switch select{width:auto;min-width:130px;padding:8px 34px 8px 10px;background:#fff}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.pro-panel{margin:28px 0;border:1px solid var(--line);border-radius:14px;padding:24px;background:#f8f6f1}.pro-panel.enabled{background:#f2f7f3}.pro-panel.locked{background:#f3f1ed}.pro-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px}.pro-heading h2{font-size:clamp(1.7rem,3vw,2.5rem)}.edition-pill{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:6px 12px;font-size:.78rem;font-weight:800;text-transform:uppercase;background:#fff}.generator-grid.compact{margin-bottom:0}.pro-panel input:disabled{cursor:not-allowed;opacity:.68}.declaration-card img{max-width:100%;height:auto}
@media(max-width:760px){.language-switch{margin-left:auto}.site-header{gap:12px}.pro-heading{align-items:center}}
@@ -43,33 +43,30 @@
@media(max-width:900px){.background-hero{grid-template-columns:1fr;align-items:start}.legal-status{max-width:720px}.background-toc{grid-template-columns:1fr}.background-toc ol{grid-template-columns:repeat(2,minmax(0,1fr))}.background-section{grid-template-columns:56px minmax(0,1fr);gap:16px}.background-disclaimer{margin-left:56px;margin-right:0}.background-cta{align-items:flex-start;flex-direction:column}}
@media(max-width:650px){.background-main{width:min(100% - 28px,1180px);padding:0}.background-hero{padding-top:46px}.background-hero-copy h1{font-size:clamp(3rem,16vw,5rem)}.background-toc ol{grid-template-columns:1fr}.background-section{grid-template-columns:1fr;padding:62px 0}.section-number{margin:0}.legal-card-grid,.principle-grid,.project-scope-list{grid-template-columns:1fr}.scenario-row{grid-template-columns:1fr;gap:14px;padding:22px}.background-disclaimer{margin:42px 0;padding:24px}.background-cta{margin-bottom:72px}}
/* Legal pages and footer navigation */
.footer-legal{display:flex;flex-wrap:wrap;gap:12px 20px;align-items:center}.footer-legal a{color:inherit;text-underline-offset:4px}.legal-page{background:var(--bg)}.legal-main{width:min(960px,calc(100% - 40px));margin:0 auto;padding:0 0 96px}.legal-hero{padding:clamp(54px,9vw,108px) 0 42px;border-bottom:1px solid var(--line)}.legal-hero h1{font-size:clamp(3rem,8vw,6.5rem);line-height:.93;letter-spacing:-.065em;margin:.15em 0}.legal-hero .lead{max-width:800px}.legal-updated{color:var(--muted);font-size:.92rem;margin-top:26px}.legal-language{display:flex;gap:8px}.legal-language a{display:inline-flex;align-items:center;justify-content:center;min-width:38px;min-height:38px;border:1px solid var(--line);border-radius:999px;text-decoration:none;font-weight:800}.configuration-warning{margin:34px 0;padding:22px 24px;border:2px solid #9b2c2c;border-radius:14px;background:#fff5f5;color:#651b1b}.configuration-warning strong{display:block;font-size:1.05rem}.configuration-warning p{margin:.5rem 0 0;line-height:1.6}.legal-content{display:grid;gap:0}.legal-section{padding:52px 0;border-bottom:1px solid var(--line)}.legal-section h2{font-size:clamp(1.65rem,4vw,2.8rem);letter-spacing:-.045em;margin:0 0 22px}.legal-section p,.legal-section li{max-width:82ch;line-height:1.75}.legal-section ul{padding-left:1.4rem}.legal-fields{margin:26px 0 0;border:1px solid var(--line);border-radius:14px;overflow:hidden;background:#fff}.legal-fields div{display:grid;grid-template-columns:minmax(190px,.7fr) minmax(0,1.4fr);border-top:1px solid var(--line)}.legal-fields div:first-child{border-top:0}.legal-fields dt,.legal-fields dd{padding:15px 17px;margin:0;white-space:pre-line;overflow-wrap:anywhere}.legal-fields dt{font-weight:800;background:#f3f1ec;border-right:1px solid var(--line)}.legal-fields a{overflow-wrap:anywhere}
@media(max-width:700px){.legal-main{width:min(100% - 28px,960px)}.legal-fields div{grid-template-columns:1fr}.legal-fields dt{border-right:0;border-bottom:1px solid var(--line)}.site-header .legal-language{margin-left:auto}footer{align-items:flex-start;gap:18px}.footer-legal{justify-content:flex-start}}
/* Article 50 decision-support fields */
.legal-context-builder{margin:8px 0 0;padding:22px;border:1px solid var(--line);border-radius:16px;background:#fbfaf7}
.legal-context-builder legend{padding:0 8px;font-size:1.05rem;font-weight:850;letter-spacing:-.02em}
.fieldset-intro{max-width:82ch;margin:0 0 18px;color:var(--muted);line-height:1.65}
.legal-check-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.check-card{display:flex!important;flex-direction:row!important;align-items:flex-start;gap:12px!important;padding:15px;border:1px solid var(--line);border-radius:12px;background:#fff;font-weight:400!important;cursor:pointer}
.check-card input{width:18px;height:18px;margin:2px 0 0;flex:0 0 auto;accent-color:#6d3fd1}
.check-card span{display:grid;gap:5px}.check-card strong{font-size:.95rem;line-height:1.35}.check-card small{font-weight:400;color:var(--muted);line-height:1.45}
.legal-responsibility-fields{margin-top:16px}
/* Square emoji badge preview */
.preview-card img[src*="theme=emoji"]{width:64px;height:64px}
.declaration-card img[src*="theme=emoji"]{width:72px;height:72px}
/* Regulatory assessment on declaration pages */
.regulatory-section{margin-top:44px;padding-top:2px}
.section-heading-row{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:16px}.section-heading-row h2{margin:.2rem 0 0;font-size:clamp(1.55rem,3vw,2.2rem);letter-spacing:-.03em}
.assessment-status{display:inline-flex;align-items:center;max-width:420px;padding:8px 12px;border-radius:999px;font-size:.78rem;font-weight:850;line-height:1.25;text-align:center}
.assessment-neutral{background:#f0efec;color:#484848;border:1px solid #dbd9d3}.assessment-caution{background:#f5f0ff;color:#5d35a8;border:1px solid #ddd0fa}.assessment-attention{background:#fff5e8;color:#8a4604;border:1px solid #f0d0a5}
.assessment-card{padding:22px 24px;border-radius:14px;border:1px solid var(--line);background:#fff}.assessment-card.assessment-caution{border-left:5px solid #7c3aed}.assessment-card.assessment-attention{border-left:5px solid #b45309}
.assessment-summary{margin:0;font-size:1.04rem;line-height:1.65}.assessment-warnings{margin:16px 0 0;padding-left:1.3rem;color:#71400d}.assessment-warnings li{margin:.35rem 0}.assessment-disclaimer{margin:17px 0 0;padding-top:15px;border-top:1px solid var(--line);color:var(--muted);font-size:.88rem;line-height:1.55}
.regulatory-section h3{margin:28px 0 12px;font-size:1.05rem}.regulatory-facts{margin-top:0;border:1px solid var(--line);border-radius:14px;overflow:hidden;background:#fff}.regulatory-facts div{grid-template-columns:minmax(260px,1.2fr) minmax(100px,.45fr);gap:0;padding:0;border-top:1px solid var(--line)}.regulatory-facts div:first-child{border-top:0}.regulatory-facts dt,.regulatory-facts dd{padding:13px 15px}.regulatory-facts dt{background:#f7f5f0;border-right:1px solid var(--line)}
@media(max-width:760px){.legal-check-grid{grid-template-columns:1fr}.section-heading-row{align-items:flex-start;flex-direction:column}.assessment-status{max-width:none}.regulatory-facts div{grid-template-columns:1fr}.regulatory-facts dt{border-right:0;border-bottom:1px solid var(--line)}}
/* Compliance guidance */
.form-warning {
grid-column: 1 / -1;
margin: 0;
font-size: .9rem;
line-height: 1.5;
.assessment-findings {
margin: 0.9rem 0 0;
padding-left: 1.25rem;
display: grid;
gap: 0.45rem;
}
.compliance-notice { margin-top: 14px; }
/* Article 50 context and cautious assessment */
.legal-context-builder{border:1px solid var(--line);border-radius:14px;padding:22px;background:#fbfaf7}.legal-context-builder legend{padding:0 8px;font-weight:800}.legal-context-builder>p{margin-top:0;color:var(--muted)}.generator-grid .check-row{display:grid;grid-template-columns:22px 1fr;gap:10px;align-items:start;padding:10px 0;font-weight:400}.check-row input{width:18px;height:18px;margin-top:3px}.check-row span{display:flex;flex-direction:column;gap:3px}.check-row strong{font-weight:800}.legal-assessment{margin-top:14px;border-radius:12px;padding:16px 18px;background:#eeeae2;display:flex;flex-direction:column;gap:6px}.legal-assessment[data-level="attention"]{border-left:4px solid #8f2d1f}.legal-assessment[data-level="info"]{border-left:4px solid #5d5a50}.legal-assessment-section{margin-top:32px}.legal-assessment-card{border:1px solid var(--line);border-radius:14px;padding:20px;background:#fbfaf7}.legal-assessment-card.attention{border-left:5px solid #8f2d1f}.legal-assessment-card.info{border-left:5px solid #5d5a50}
/* Conditional generator workflow */
[hidden]{display:none!important}
.form-step{border:1px solid var(--line);border-radius:16px;padding:22px;background:#fff}.form-step>legend{padding:0 10px;font-weight:900;font-size:1.05rem}.form-step>p{margin-top:0;color:var(--muted)}
.field-badge{display:inline-flex;width:max-content;align-items:center;border-radius:999px;padding:2px 8px;margin-left:6px;font-size:.68rem;line-height:1.5;font-weight:800;vertical-align:middle;background:#eeeae2;color:#4a473f}.field-badge.required{background:#fff0ed;color:#7a261c}.field-badge.best-practice{background:#eef4ff;color:#294f85}.field-badge.optional{background:#f2f1ed;color:#5d5a50}
.field-help{font-weight:400;margin-top:2px}.field-help summary{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:1px solid var(--line);border-radius:999px;cursor:pointer;font-weight:900;color:var(--muted);list-style:none}.field-help summary::-webkit-details-marker{display:none}.field-help[open] summary{margin-bottom:7px}.field-help span{display:block;padding:10px 12px;border-radius:10px;background:#f3f1ec;color:var(--muted);line-height:1.5}
.legal-category-list{display:grid;gap:10px;margin-top:18px}.legal-question{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:#fff}.legal-question label{display:block}.legal-question small{display:block;margin-top:6px;color:var(--muted);font-weight:400;line-height:1.45}.legal-question select{margin-top:10px}.conditional-block{margin-top:18px}.conditional-panel{border:1px solid var(--line);border-radius:14px;padding:18px;background:#fbfaf7}.conditional-panel h3{margin:0 0 6px;font-size:1rem}.conditional-panel>p{margin:.3rem 0 1rem;color:var(--muted);font-size:.92rem}
.validation-messages{display:grid;gap:8px;margin-top:12px}.validation-message{margin:0;padding:12px 14px;border-radius:10px;background:#fff6ed;border-left:4px solid #9a5d14;font-size:.9rem}.validation-message.info{background:#eef4ff;border-left-color:#4f6f9f}.assessment-consequence{margin-top:5px;padding-top:8px;border-top:1px solid rgba(0,0,0,.09);font-weight:650}
input:required,select:required{border-left-width:3px}.article-component label[hidden]{display:none!important}
@media(max-width:760px){.form-step{padding:17px}.field-badge{margin-left:0;margin-top:3px}.conditional-panel{padding:15px}}
.legal-consequences{margin-top:16px;border:1px solid var(--line);border-radius:12px;background:#f7f4ee;padding:14px 16px}.legal-consequences summary{cursor:pointer;font-weight:850}.legal-consequences p{margin:.8rem 0 0;color:var(--muted);font-size:.9rem;line-height:1.55}.legal-consequences .form-warning{color:#5b3029}
.assessment-findings li { line-height: 1.55; }
+1 -1
View File
@@ -158,7 +158,7 @@
</section>
</main>
<footer><span>{{.Background.Footer}} · App 1.8.0 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<footer><span>{{if not .WhiteLabel}}{{.Background.Footer}}{{end}}</span><span>App 2.0.0 · Schema 1.2 · {{.License.Edition}}</span></footer>
<script src="/static/marketing.js" defer></script>
</body>
</html>
+25 -13
View File
@@ -11,7 +11,7 @@
{{range .LanguageLinks}}<link rel="alternate" hreflang="{{.Code}}" href="{{.AbsoluteURL}}">{{end}}
<link rel="alternate" hreflang="x-default" href="{{.DefaultLanguageURL}}">
<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>
@@ -22,7 +22,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 +37,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 +70,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="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a></span></footer>
</body>
</html>
{{end}}
+67 -203
View File
@@ -33,209 +33,75 @@
<p>{{index .Text "generator_intro"}}</p>
</div>
<form id="generator-form" class="generator-grid">
<fieldset class="form-step wide" aria-labelledby="step-content-title">
<legend id="step-content-title">{{index .Text "generator_step_content"}}</legend>
<div class="form-subgrid">
<label class="wide">{{index .Text "field_mode"}}
<select id="declaration-mode" name="mode"><option value="single">{{index .Text "mode_single"}}</option><option value="article">{{index .Text "mode_article"}}</option></select>
</label>
<div id="single-fields" class="form-subgrid wide">
<label>{{index .Text "field_preset"}}
<select id="preset" name="preset">
{{range .Presets}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
<option value="">{{index .Text "option_custom"}}</option>
</select>
</label>
<label>{{index .Text "field_component"}}
<select id="component" name="component">{{range .Components}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
</label>
<label>{{index .Text "field_extent"}}
<select id="extent" name="extent">{{range .Extents}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
</label>
<label id="single-review-field">{{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 id="single-activities-field" class="wide">{{index .Text "field_activities"}}
<input id="activities" name="activities" value="research" placeholder="research,summarisation">
<small>{{index .Text "activities_help"}}</small>
</label>
</div>
<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 data-review-wrap="article-text-review">{{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 "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label data-review-wrap="article-research-review">{{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>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 data-review-wrap="article-cover-image-review">{{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 "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label data-review-wrap="article-image-review">{{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>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 data-review-wrap="article-audio-review">{{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 data-review-wrap="article-video-review">{{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>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 data-review-wrap="article-translation-review">{{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>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 data-review-wrap="article-code-review">{{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>
<label class="wide">{{index .Text "field_mode"}}
<select id="declaration-mode" name="mode"><option value="single">{{index .Text "mode_single"}}</option><option value="article">{{index .Text "mode_article"}}</option></select>
</label>
<div id="single-fields" class="form-subgrid wide">
<label>{{index .Text "field_preset"}}
<select id="preset" name="preset">
{{range .Presets}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
<option value="">{{index .Text "option_custom"}}</option>
</select>
</label>
<label>{{index .Text "field_component"}}
<select id="component" name="component">{{range .Components}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
</label>
<label>{{index .Text "field_extent"}}
<select id="extent" name="extent">{{range .Extents}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
</label>
<label>{{index .Text "field_review"}}
<select id="review" name="review">{{range .Reviews}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
</label>
<label class="wide">{{index .Text "field_activities"}}
<input id="activities" name="activities" value="research" placeholder="research,summarisation">
<small>{{index .Text "activities_help"}}</small>
</label>
</div>
<fieldset id="article-fields" class="article-builder wide" hidden>
<legend>{{index .Text "article_fields"}}</legend>
<p>{{index .Text "article_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 "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 "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>
<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></select>
</label>
<label class="wide">{{index .Text "field_assurance"}}
<select id="assurance" name="assurance">{{range .Assurances}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
<small id="assurance-description">{{index .Text "assurance_selfDeclared_description"}}</small>
</label>
<label class="wide">{{index .Text "field_subject"}}
<input id="subject" name="subject" type="url" placeholder="https://example.org/article">
</label>
<fieldset id="legal-context-builder" class="legal-context-builder form-step wide">
<legend>{{index .Text "generator_step_legal"}}</legend>
<p>{{index .Text "legal_scope_intro"}}</p>
<div class="form-subgrid">
<label>{{index .Text "field_legal_role"}} <span class="field-badge required">{{index .Text "field_required"}}</span>
<select id="legal-role" name="legalRole" required>
<option value="">{{index .Text "select_please_choose"}}</option>
<option value="deployer">{{index .Text "legal_role_deployer"}}</option>
<option value="provider">{{index .Text "legal_role_provider"}}</option>
<option value="both">{{index .Text "legal_role_both"}}</option>
<option value="unsure">{{index .Text "legal_role_unsure"}}</option>
</select>
<details class="field-help"><summary>?</summary><span>{{index .Text "legal_role_help"}}</span></details>
</label>
<label>{{index .Text "field_use_context"}} <span class="field-badge required">{{index .Text "field_required"}}</span>
<select id="use-context" name="useContext" required>
<option value="">{{index .Text "select_please_choose"}}</option>
<option value="professional">{{index .Text "use_context_professional"}}</option>
<option value="personalNonProfessional">{{index .Text "use_context_personalNonProfessional"}}</option>
<option value="unsure">{{index .Text "use_context_unsure"}}</option>
</select>
<details class="field-help"><summary>?</summary><span>{{index .Text "use_context_help"}}</span></details>
</label>
<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="legal-category-list">
<div id="legal-deepfake-row" class="legal-question" hidden>
<label><strong>{{index .Text "legal_deepfake"}}</strong> <span class="field-badge required">{{index .Text "field_required"}}</span>
<small>{{index .Text "legal_deepfake_help"}}</small>
<select id="legal-deepfake" name="deepfakeAssessment">
<option value="">{{index .Text "select_please_choose"}}</option>
<option value="yes">{{index .Text "assessment_yes"}}</option>
<option value="no">{{index .Text "assessment_no"}}</option>
<option value="unsure">{{index .Text "assessment_unsure"}}</option>
</select>
</label>
</div>
<div id="legal-public-interest-row" class="legal-question" hidden>
<label><strong>{{index .Text "legal_public_interest"}}</strong> <span class="field-badge required">{{index .Text "field_required"}}</span>
<small>{{index .Text "legal_public_interest_help"}}</small>
<select id="legal-public-interest" name="publicInterestAssessment">
<option value="">{{index .Text "select_please_choose"}}</option>
<option value="yes">{{index .Text "assessment_yes"}}</option>
<option value="no">{{index .Text "assessment_no"}}</option>
<option value="unsure">{{index .Text "assessment_unsure"}}</option>
</select>
</label>
</div>
<label id="legal-other-voluntary-row" class="check-row" hidden><input id="legal-other-voluntary" type="checkbox" value="otherVoluntary"><span><strong>{{index .Text "legal_other_voluntary"}}</strong></span></label>
</div>
<div id="law-enforcement-fields" class="conditional-panel wide" hidden>
<h3>{{index .Text "law_enforcement_title"}}</h3>
<label>{{index .Text "field_law_enforcement_authorization"}} <span class="field-badge required">{{index .Text "field_required"}}</span>
<select id="law-enforcement-authorization" name="lawEnforcementAuthorization">
<option value="">{{index .Text "select_please_choose"}}</option>
<option value="no">{{index .Text "law_enforcement_no"}}</option>
<option value="yes">{{index .Text "law_enforcement_yes"}}</option>
<option value="unsure">{{index .Text "law_enforcement_unsure"}}</option>
</select>
<details class="field-help"><summary>?</summary><span>{{index .Text "law_enforcement_help"}}</span></details>
</label>
</div>
<div id="legal-creative-row" class="legal-question conditional-block" hidden>
<label><strong>{{index .Text "legal_creative"}}</strong> <span class="field-badge required">{{index .Text "field_required"}}</span>
<small>{{index .Text "legal_creative_help"}}</small>
<select id="legal-creative" name="creativeWorkAssessment">
<option value="">{{index .Text "select_please_choose"}}</option>
<option value="yes">{{index .Text "assessment_yes"}}</option>
<option value="no">{{index .Text "assessment_no"}}</option>
<option value="unsure">{{index .Text "assessment_unsure"}}</option>
</select>
</label>
</div>
<div id="legal-timing-fields" class="form-subgrid conditional-block" hidden>
<label class="wide">{{index .Text "field_output_date"}} <span class="field-badge optional">{{index .Text "field_optional"}}</span>
<input id="output-date" name="outputDate" type="date">
<details class="field-help"><summary>?</summary><span>{{index .Text "output_date_help"}}</span></details>
</label>
</div>
<div id="legal-assessment" class="legal-assessment" role="status" aria-live="polite"></div>
<div id="validation-messages" class="validation-messages" aria-live="polite"></div>
<details class="legal-consequences">
<summary>{{index .Text "legal_enforcement_title"}}</summary>
<p>{{index .Text "legal_enforcement_help"}}</p>
<p class="form-warning">{{index .Text "legal_disclaimer"}}</p>
</details>
</fieldset>
<fieldset id="accountability-fields" class="form-step wide">
<legend>{{index .Text "generator_step_accountability"}}</legend>
<p>{{index .Text "accountability_intro"}}</p>
<div class="form-subgrid">
<label class="wide">{{index .Text "field_subject"}} <span class="field-badge optional">{{index .Text "field_optional"}}</span>
<input id="subject" name="subject" type="url" placeholder="https://example.org/article">
</label>
<div id="author-fields" class="form-subgrid wide" hidden>
<label>{{index .Text "field_author"}} <span class="field-badge optional">{{index .Text "field_optional"}}</span>
<input id="author" name="author" maxlength="200" placeholder="Name">
<small>{{index .Text "author_help"}}</small>
</label>
<label id="author-url-field" hidden>{{index .Text "field_author_url"}}
<input id="author-url" name="authorUrl" type="url" placeholder="https://example.org/autor">
</label>
</div>
<div id="responsibility-fields" class="conditional-panel wide" hidden>
<h3>{{index .Text "editorial_exception_title"}}</h3>
<p>{{index .Text "editorial_exception_help"}}</p>
<p class="form-warning">{{index .Text "responsibility_required_help"}}</p>
<div class="form-subgrid">
<label class="wide">{{index .Text "field_responsibility_role"}} <span class="field-badge required">{{index .Text "field_required"}}</span>
<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"}} <span class="field-badge required">{{index .Text "field_required"}}</span>
<input id="responsible" name="responsible" maxlength="200" placeholder="{{index .Text "responsible_placeholder"}}">
</label>
<label>{{index .Text "field_responsible_url"}} <span class="field-badge optional">{{index .Text "field_optional"}}</span>
<input id="responsible-url" name="responsibleUrl" type="url" placeholder="https://example.org/impressum">
</label>
</div>
</div>
<div id="complaint-fields" class="conditional-panel wide" hidden>
<h3>{{index .Text "complaints_title"}} <span class="field-badge best-practice">{{index .Text "field_best_practice"}}</span></h3>
<p>{{index .Text "complaints_help"}}</p>
<div class="form-subgrid">
<label>{{index .Text "field_complaint_name"}}
<input id="complaint-name" name="complaintName" maxlength="200" placeholder="Redaktion / Ombudsstelle">
</label>
<label>{{index .Text "field_complaint_email"}}
<input id="complaint-email" name="complaintEmail" type="email" placeholder="feedback@example.org">
</label>
<label class="wide">{{index .Text "field_complaint_url"}}
<input id="complaint-url" name="complaintUrl" type="url" placeholder="https://example.org/beschwerde">
</label>
</div>
</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>
<fieldset class="form-step wide">
<legend>{{index .Text "generator_step_output"}}</legend>
<div class="form-subgrid">
<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>
</label>
<label class="wide">{{index .Text "field_assurance"}}
<select id="assurance" name="assurance">{{range .Assurances}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
<small id="assurance-description">{{index .Text "assurance_selfDeclared_description"}}</small>
</label>
</div>
</fieldset>
<p class="form-warning wide">{{index .Text "review_legal_text"}}</p>
</form>
{{if or .CustomText .CustomBadge}}
@@ -275,11 +141,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>
<div class="toolbar wrap"><button id="copy-html" type="button">{{index .Text "copy_html"}}</button><a id="bulk-template-link" class="button secondary" href="#" target="_blank" rel="noopener" hidden>{{or (index .Text "open_bulk") "Bulk"}}</a></div>
<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>
<button id="copy-html" type="button">{{index .Text "copy_html"}}</button>
</div>
</section>
@@ -289,8 +153,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.8.0 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<script nonce="{{.CSPNonce}}">window.APP_CONFIG={{.AppConfig}};</script>
<footer><span>{{if not .WhiteLabel}}AI Usage Disclosure · {{end}}App 2.0.0 · Schema 1.2 · {{.License.Edition}}</span><span>{{index .Text "footer_no_legal"}}</span></footer>
<script>window.APP_CONFIG={{.AppConfig}};</script>
<script src="/static/app.js" defer></script>
</body>
</html>
-69
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}}
+2 -2
View File
@@ -46,7 +46,7 @@
<img src="/badge/full.svg?lang={{.Lang}}&amp;theme=color" alt="Full generation badge">
<pre><code>{
"@type": "AIUsageDeclaration",
"schemaVersion": "1.3",
"schemaVersion": "1.2",
"assurance": "selfDeclared"
}</code></pre>
</div>
@@ -119,7 +119,7 @@
</section>
</main>
<footer><span>{{.Marketing.Footer}} · App 1.8.0 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<footer><span>{{if not .WhiteLabel}}{{.Marketing.Footer}}{{end}}</span><span>App 2.0.0 · Schema 1.2 · {{.License.Edition}}</span></footer>
<script src="/static/marketing.js" defer></script>
</body>
</html>