This commit is contained in:
2026-07-20 21:03:05 +02:00
parent dc60551e07
commit c3c85eef21
70 changed files with 8049 additions and 1 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.github
*.zip
*.tar.gz
coverage.out
bin
data
.env
*.key
*.private

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
BASE_URL=http://localhost:8080
PUBLIC_NAME=AI Usage Disclosure
CONTACT_URL=https://b1tsblog.org/page/ai
DEFAULT_LANGUAGE=de
TRUST_PROXY=true
# Runtime license issued by the standalone Universal License Platform.
# No private keys or configurable public keys belong in this application.
LICENSE_TOKEN=
LICENSE_MODE=offline
LICENSE_SERVER_URL=
LICENSE_INSTANCE_ID=
LICENSE_CACHE_FILE=/data/license-lease.json
LICENSE_REFRESH_INTERVAL=15m
LICENSE_REQUEST_TIMEOUT=5s

View File

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

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/bin/
/coverage.out
/data/
*.log
.env
*.key
*.private
.DS_Store

105
CHANGELOG.md Normal file
View File

@@ -0,0 +1,105 @@
# Changelog
## 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.
## 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.

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.23-alpine 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 web ./web
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ai-disclosure ./cmd/server
FROM scratch
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
ENTRYPOINT ["/ai-disclosure"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 AI Usage Disclosure contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

24
Makefile Normal file
View File

@@ -0,0 +1,24 @@
.PHONY: run test test-license-client check build docker-build
run:
go run ./cmd/server
test:
go test ./...
cd third_party/license-platform-client && go test ./...
test-license-client:
cd third_party/license-platform-client && go test ./...
check:
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 ./...
build:
mkdir -p bin
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/ai-disclosure ./cmd/server
docker-build:
docker build -t ai-disclosure-standard:1.6.1-local .

219
README.md
View File

@@ -1,2 +1,219 @@
# ai-disclosure-standard
# AI Disclosure Standard 1.6.1
Ein zustandsloser Go-Dienst für sichtbare und maschinenlesbare Erklärungen zur KI-Nutzung in Artikeln, Webseiten und einzelnen Inhaltsbestandteilen.
## Funktionen
- einzelne Deklarationen und zusammengefasste Artikel-/Webseiten-Deklarationen;
- SVG-Badges, HTML-Erklärung und JSON-LD unter einem gemeinsamen Link;
- 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;
- Deutsch, Englisch, Französisch, Spanisch, Italienisch, Niederländisch, Portugiesisch und Polnisch;
- Sprachumschalter direkt auf der Ergebnisseite;
- optionale lizenzierte Funktionen für eigene Texte und Badge-Darstellung;
- Offline-, Hybrid- und Online-Prüfung über die eigenständige Universal License Platform;
- Health-, Readiness- und Prometheus-Endpunkte;
- Docker-, Kubernetes- und Docker-Swarm-Deployment.
## Start unter Windows
```powershell
go run .\cmd\server
```
Danach:
```text
Generator: http://localhost:8080/
Produktseite: http://localhost:8080/product
Healthcheck: http://localhost:8080/healthz
Funktionen: http://localhost:8080/v1/capabilities
```
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
```powershell
Copy-Item .env.example .env
docker compose --env-file .env up -d --build
```
## 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**.
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:
```text
ai-disclosure-standard
```
Für die vorhandenen Funktionen verwendet die Plattform diese Feature-IDs:
```text
custom_text
custom_badge
white_label
```
### Vertrauensschlüssel einbetten
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:
```text
internal/app/trusted_keys.json
```
PowerShell-Beispiel:
```powershell
Invoke-WebRequest `
"https://licenses.example.org/api/v1/trust-store" `
-OutFile ".\internal\app\trusted_keys.json"
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.
### Prüfmodi
**Offline** prüft den signierten Lizenz-Token ausschließlich lokal.
```env
LICENSE_MODE=offline
LICENSE_TOKEN=...
```
**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.
```env
LICENSE_MODE=hybrid
LICENSE_TOKEN=...
LICENSE_CACHE_FILE=/data/license-lease.json
```
**Online** benötigt eine erfolgreiche aktuelle Prüfung durch die Plattform.
```env
LICENSE_MODE=online
LICENSE_TOKEN=...
```
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.
Der Client verwendet die Plattform-API:
```text
POST /api/v1/licenses/validate
```
und unterstützt für bestehende Installationen weiterhin:
```text
POST /v1/introspect
```
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).
## Konfiguration
| Variable | Standard | Bedeutung |
|---|---|---|
| `LISTEN_ADDRESS` | `:8080` | HTTP-Adresse |
| `BASE_URL` | `http://localhost:8080` | öffentliche kanonische URL und Domainprüfung |
| `PUBLIC_NAME` | `AI Usage Disclosure` | sichtbarer Produktname |
| `CONTACT_URL` | Projektseite | Kontakt-/Informationsseite |
| `DEFAULT_LANGUAGE` | `de` | Standardsprache |
| `TRUST_PROXY` | `false` | Proxy-Header für Client-IP berücksichtigen |
| `LICENSE_TOKEN` | leer | von der Universal License Platform ausgestellter Token |
| `LICENSE_MODE` | `offline` | Mindestmodus `offline`, `hybrid` oder `online` |
| `LICENSE_SERVER_URL` | leer | optionaler Prüfserver-Override |
| `LICENSE_INSTANCE_ID` | leer | optionale Instanzbindung |
| `LICENSE_CACHE_FILE` | `./data/license-lease.json` | signierter Hybrid-Lease-Cache |
| `LICENSE_REFRESH_INTERVAL` | `15m` | Hintergrundaktualisierung |
| `LICENSE_REQUEST_TIMEOUT` | `5s` | Timeout der Onlineprüfung |
## API
```text
GET /badge/{preset}.svg
GET /v1/badge.svg
GET /declaration
GET /v1/declaration.json
POST /v1/validate
GET /v1/capabilities
GET /healthz
GET /readyz
GET /metrics
```
Beispiel für eine Artikelerklärung:
```text
/declaration?mode=article&textExtent=none&textReview=none&imageExtent=full&imageReview=editorial&researchExtent=assisted&researchReview=expert&assurance=technicallyRecorded&lang=de
```
### 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:
```bash
make check
```
## Projektgrenze
Im Hauptprojekt verbleiben ausschließlich:
- ein eingebetteter öffentlicher Trust Store;
- ein verifikationsfähiger Client;
- Feature- und Limit-Abfragen;
- optionaler signierter Lease-Cache.
Nicht enthalten sind:
- 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.

16
SECURITY.md Normal file
View File

@@ -0,0 +1,16 @@
# Security policy
Version 1.6.x is the supported line in this project archive.
## 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 only 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.
Report suspected vulnerabilities privately to the project operator before public disclosure.

70
cmd/server/main.go Normal file
View File

@@ -0,0 +1,70 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/b1tsblog/ai-disclosure-standard/internal/app"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "--healthcheck" {
url := os.Getenv("HEALTHCHECK_URL")
if url == "" {
url = "http://127.0.0.1:8080/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 := app.ConfigFromEnv()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
handler, err := app.New(ctx, cfg, logger)
if err != nil {
logger.Error("application initialization failed", "error", err)
os.Exit(1)
}
server := &http.Server{
Addr: cfg.ListenAddress,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
go func() {
logger.Info("server started", "address", cfg.ListenAddress, "base_url", cfg.BaseURL)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}()
<-ctx.Done()
logger.Info("shutdown requested")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed", "error", err)
os.Exit(1)
}
logger.Info("server stopped")
}

38
compose.yaml Normal file
View File

@@ -0,0 +1,38 @@
services:
app:
build: .
image: ai-disclosure-standard:1.6.1-local
environment:
BASE_URL: "${BASE_URL:-http://localhost:8080}"
PUBLIC_NAME: "${PUBLIC_NAME:-AI Usage Disclosure}"
CONTACT_URL: "${CONTACT_URL:-https://b1tsblog.org/page/ai}"
DEFAULT_LANGUAGE: "${DEFAULT_LANGUAGE:-de}"
TRUST_PROXY: "${TRUST_PROXY:-true}"
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}"
ports:
- "8080:8080"
volumes:
- license-cache:/data
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
volumes:
license-cache:

175
deploy/kubernetes.yaml Normal file
View File

@@ -0,0 +1,175 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-disclosure
labels:
app.kubernetes.io/name: ai-disclosure
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: ai-disclosure
template:
metadata:
labels:
app.kubernetes.io/name: ai-disclosure
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: /metrics
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.6.1
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
env:
- name: BASE_URL
value: https://ai.example.org
- name: PUBLIC_NAME
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: "true"
- name: LICENSE_TOKEN
valueFrom:
secretKeyRef:
name: ai-disclosure-license
key: token
optional: true
- name: LICENSE_MODE
value: offline
- name: LICENSE_SERVER_URL
value: ""
- name: LICENSE_INSTANCE_ID
valueFrom:
fieldRef:
fieldPath: metadata.uid
- name: LICENSE_CACHE_FILE
value: /data/license-lease.json
volumeMounts:
- name: license-cache
mountPath: /data
resources:
requests:
cpu: 25m
memory: 24Mi
limits:
cpu: 500m
memory: 128Mi
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: {}
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: ai-disclosure
---
apiVersion: v1
kind: Service
metadata:
name: ai-disclosure
spec:
selector:
app.kubernetes.io/name: ai-disclosure
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: ai-disclosure
spec:
minAvailable: 2
selector:
matchLabels:
app.kubernetes.io/name: ai-disclosure
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: ai-disclosure
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: ai-disclosure
minReplicas: 3
maxReplicas: 12
behavior:
scaleDown:
stabilizationWindowSeconds: 300
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 65
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ai-disclosure
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "15"
nginx.ingress.kubernetes.io/proxy-send-timeout: "15"
nginx.ingress.kubernetes.io/limit-rps: "50"
spec:
ingressClassName: nginx
tls:
- hosts: [ai.example.org]
secretName: ai-disclosure-tls
rules:
- host: ai.example.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ai-disclosure
port:
name: http

View File

@@ -0,0 +1,81 @@
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

70
deploy/swarm-stack.yaml Normal file
View File

@@ -0,0 +1,70 @@
version: "3.9"
services:
app:
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.6.1
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: "true"
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
ports:
- target: 8080
published: 8080
protocol: tcp
mode: ingress
networks:
- public
volumes:
- 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:
mode: replicated
replicas: 3
endpoint_mode: vip
update_config:
parallelism: 1
delay: 5s
order: start-first
failure_action: rollback
rollback_config:
parallelism: 1
order: stop-first
restart_policy:
condition: on-failure
delay: 3s
max_attempts: 5
window: 30s
placement:
preferences:
- spread: node.labels.zone
resources:
reservations:
cpus: "0.05"
memory: 24M
limits:
cpus: "0.50"
memory: 128M
networks:
public:
driver: overlay
attachable: true
volumes:
license-cache:

View File

@@ -0,0 +1,9 @@
# Commercial boundary
The declaration renderer and standard remain usable without a commercial entitlement. Licensed capabilities are enabled only by tokens issued through the separately operated Universal License Platform.
Official binaries embed a public trust store at build time. Customers can configure only a signed `LICENSE_TOKEN` and optional runtime verification settings. They cannot replace the trusted issuer or lease keys through environment variables.
The AI Disclosure Standard repository does not contain private keys, key generation, license issuance, administration, revocation storage or lease signing. Those responsibilities belong to the standalone platform.
A technically capable user can still modify open source and compile an unofficial binary. Legal licence terms, trademark protection, official signed releases, support, updates and centrally verified services remain the enforceable commercial boundary.

40
docs/LICENSE-CLIENT.md Normal file
View File

@@ -0,0 +1,40 @@
# Runtime license client
The application uses the client protocol from Universal License Platform v1.0.0.
Imports:
```go
import (
"github.com/b1tsblog/license-platform/pkg/licensekit"
"github.com/b1tsblog/license-platform/sdk/go/licenseclient"
)
```
Initialization is performed in `internal/app/server.go`. The product ID and embedded trust store are not customer-configurable.
```go
licenses := licenseclient.New(ctx, licenseclient.Config{
Product: "ai-disclosure-standard",
ClientVersion: "1.6.1",
Token: cfg.LicenseToken,
TrustStore: trustStore,
BaseURL: cfg.BaseURL,
InstanceID: cfg.LicenseInstanceID,
Mode: cfg.LicenseMode,
ServerURL: cfg.LicenseServerURL,
CacheFile: cfg.LicenseCacheFile,
RefreshEvery: cfg.LicenseRefreshEvery,
RequestTimeout: cfg.LicenseTimeout,
})
```
Feature checks remain server-side:
```go
if licenses.Has("custom_badge") {
// accept custom badge presentation
}
```
The runtime client contains no license-signing or private-key functionality. See [`LICENSE-INTEGRATION.md`](LICENSE-INTEGRATION.md) for deployment details.

172
docs/LICENSE-INTEGRATION.md Normal file
View File

@@ -0,0 +1,172 @@
# Integration mit der Universal License Platform
## Verantwortungsgrenze
Die AI-Disclosure-Anwendung ist ausschließlich ein Lizenz-Client. Die Universal License Platform ist die einzige Autorität für:
- Schlüsselverwaltung;
- Produkterfassung;
- Lizenzausstellung;
- Token-Registry;
- Widerruf und Reaktivierung;
- Onlineprüfung;
- Signierung kurzlebiger Leases;
- Audit- und Benutzerverwaltung.
Das Hauptprojekt enthält keine dieser Funktionen.
## Produkt in der Plattform anlegen
Verwende als unveränderliche Produkt-ID:
```text
ai-disclosure-standard
```
Die aktuell ausgewerteten Feature-IDs sind:
| Feature | Wirkung |
|---|---|
| `custom_text` | eigener Titel und eigener Erklärungstext |
| `custom_badge` | eigene Badge-Beschriftungen und Farben |
| `white_label` | reserviert für markenneutrale Ausgaben |
Unbekannte Features werden vom Produkt ignoriert und können für andere Produkte weiterverwendet werden.
## Trust Store installieren
Die Plattform liefert unter folgendem Endpunkt ausschließlich öffentliche Schlüssel:
```text
GET /api/v1/trust-store
```
Die Antwort muss vor dem Produkt-Build als folgende Datei gespeichert werden:
```text
internal/app/trusted_keys.json
```
Erwartetes Format:
```json
{
"licenseKeys": {
"issuer-2026": "PUBLIC_KEY_BASE64URL"
},
"leaseKeys": {
"lease-2026": "PUBLIC_KEY_BASE64URL"
}
}
```
Anschließend muss das Produkt neu kompiliert beziehungsweise das Container-Image neu gebaut werden. Der Trust Store ist absichtlich nicht per Umgebungsvariable konfigurierbar.
## Runtime-Konfiguration
Minimal für Offline-Lizenzen:
```env
LICENSE_TOKEN=...
LICENSE_MODE=offline
```
Hybrid:
```env
LICENSE_TOKEN=...
LICENSE_MODE=hybrid
LICENSE_CACHE_FILE=/data/license-lease.json
LICENSE_REFRESH_INTERVAL=15m
LICENSE_REQUEST_TIMEOUT=5s
```
Online:
```env
LICENSE_TOKEN=...
LICENSE_MODE=online
LICENSE_REQUEST_TIMEOUT=5s
```
Optional:
```env
LICENSE_SERVER_URL=https://licenses.example.org
LICENSE_INSTANCE_ID=production-eu-1
```
Die in der Lizenz signierte `verification.serverUrl` wird automatisch erkannt. Die Auflösungsreihenfolge ist:
1. `licenseclient.Config.ServerURL` beziehungsweise `LICENSE_SERVER_URL`;
2. signierte `verification.serverUrl` aus dem Lizenz-Token;
3. `/.well-known/license-server` auf der Produkt-Basis-URL.
## Onlineprotokoll
Der Client sendet an:
```text
POST /api/v1/licenses/validate
```
Anfrage:
```json
{
"token": "...",
"product": "ai-disclosure-standard",
"baseUrl": "https://ai.example.org",
"host": "ai.example.org",
"instanceId": "production-eu-1",
"clientVersion": "1.6.1"
}
```
Erwartete Antwort:
```json
{
"valid": true,
"leaseToken": "..."
}
```
Der Lease-Token wird erneut lokal gegen die eingebetteten Lease-Public-Keys geprüft. Eine bloße positive JSON-Antwort ohne gültige Signatur schaltet keine Funktionen frei.
## Domain- und Instanzbindung
Die Plattform kann Lizenzen binden an:
```text
example.org
*.example.org
*
```
`*` erlaubt alle Domains. `*.example.org` erlaubt nur echte Subdomains und nicht automatisch `example.org` selbst.
Instanz-IDs funktionieren entsprechend. Ist eine Lizenz an Instanzen gebunden, muss `LICENSE_INSTANCE_ID` gesetzt sein.
## Schlüsselrotation
1. In der Universal License Platform einen neuen Issuer- oder Lease-Key aktivieren.
2. Einen Trust Store herunterladen, der alten und neuen Public Key enthält.
3. Das Produkt mit beiden Schlüsseln neu bauen und ausrollen.
4. Neue Lizenzen beziehungsweise Leases mit dem neuen Key ausstellen.
5. Den alten Public Key erst entfernen, wenn alle damit signierten Tokens abgelaufen oder ersetzt sind.
## Client-Snapshot
Unter `third_party/license-platform-client` liegt eine client-only, protokollkompatible Momentaufnahme des Go-SDK aus Universal License Platform v1.0.0. Sie enthält nur:
- öffentliche Protokolltypen;
- Trust-Store-Parsing;
- Ed25519-Verifikation;
- Kontext-, Domain- und Instanzprüfung;
- Offline-/Hybrid-/Online-Client;
- signierten Lease-Cache.
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.

35
docs/MARKETING-PAGE.md Normal file
View File

@@ -0,0 +1,35 @@
# 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.

View File

@@ -0,0 +1,40 @@
# Migration von 1.5 auf 1.6
## Entfernte Bestandteile
Aus dem AI-Disclosure-Projekt wurden vollständig entfernt:
- lokale Schlüsselgenerierung und Token-Signierung;
- Lizenz-Webinterface;
- eingebauter Lizenz- und Widerrufsserver;
- Admin-API und lokale Lizenz-Registry;
- Lease-Signierung;
- zugehörige Docker-, Kubernetes-, OpenAPI- und Schema-Dateien.
Diese Aufgaben übernimmt ausschließlich die eigenständige Universal License Platform.
## Umstellung
1. Universal License Platform v1.0.0 separat starten.
2. Vorhandene Issuer- und Lease-Schlüssel entsprechend deren Migrationsanleitung importieren.
3. In der Plattform das Produkt `ai-disclosure-standard` und die benötigten Features verwenden.
4. Den öffentlichen Trust Store aus `GET /api/v1/trust-store` herunterladen.
5. Die Datei als `internal/app/trusted_keys.json` in dieses Projekt kopieren.
6. Produkt-Binary oder Container neu bauen.
7. Beim Produkt nur noch `LICENSE_TOKEN` und gegebenenfalls Client-Einstellungen setzen.
## Nicht mehr unterstützte Konfiguration
Folgende Werte gehören nicht mehr zum Produktprojekt:
```text
LICENSE_PUBLIC_KEY
LICENSE_PRIVATE_KEY
LEASE_SIGNING_PRIVATE_KEY
LEASE_SIGNING_KEY_ID
LICENSE_SERVER_ADMIN_TOKEN
LICENSE_SERVER_DATA
LICENSE_TRUST_STORE_FILE
```
Eine vom alten eingebauten Server verwendete `LICENSE_SERVER_URL` muss auf die neue Universal License Platform zeigen. Neue Hybrid-/Online-Lizenzen können deren öffentliche URL bereits signiert im Token enthalten.

10
docs/PRODUCT-PAGE.md Normal file
View File

@@ -0,0 +1,10 @@
# Product and installation page
The public `/product` page documents features and installation paths without prices or upgrade advertising.
Routes:
- `/product` — feature and installation overview;
- `/install` — redirects to `/product#install`.
The public page does not expose license administration. Runtime capabilities are available through `/v1/capabilities`; all license creation and management is handled by the separately deployed Universal License Platform.

7
go.mod Normal file
View File

@@ -0,0 +1,7 @@
module github.com/b1tsblog/ai-disclosure-standard
go 1.26
require github.com/b1tsblog/license-platform v1.0.0
replace github.com/b1tsblog/license-platform => ./third_party/license-platform-client

99
internal/app/config.go Normal file
View File

@@ -0,0 +1,99 @@
package app
import (
"os"
"strconv"
"strings"
"time"
"github.com/b1tsblog/license-platform/pkg/licensekit"
)
type Config struct {
ListenAddress string
BaseURL string
PublicName string
ContactURL string
SalesURL string
DefaultLanguage string
TrustProxy bool
LicenseToken string
LicenseMode licensekit.VerificationMode
LicenseServerURL string
LicenseInstanceID string
LicenseCacheFile string
LicenseRefreshEvery time.Duration
LicenseTimeout time.Duration
CommunityPrice string
ProPrice string
PublisherPrice string
AgencyPrice string
}
func ConfigFromEnv() Config {
contactURL := env("CONTACT_URL", "https://b1tsblog.org/page/ai")
mode, err := licensekit.ParseMode(env("LICENSE_MODE", "offline"))
if err != nil {
mode = licensekit.ModeOffline
}
return Config{
ListenAddress: env("LISTEN_ADDRESS", ":8080"),
BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"),
PublicName: env("PUBLIC_NAME", "AI Usage Disclosure"),
ContactURL: contactURL,
SalesURL: env("SALES_URL", contactURL),
DefaultLanguage: env("DEFAULT_LANGUAGE", "de"),
TrustProxy: strings.EqualFold(env("TRUST_PROXY", "false"), "true"),
LicenseToken: secretEnv("LICENSE_TOKEN"),
LicenseMode: mode,
LicenseServerURL: strings.TrimRight(env("LICENSE_SERVER_URL", ""), "/"),
LicenseInstanceID: env("LICENSE_INSTANCE_ID", ""),
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 env(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func secretEnv(key string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
path := strings.TrimSpace(os.Getenv(key + "_FILE"))
if path == "" {
return ""
}
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func durationEnv(key string, fallback time.Duration) time.Duration {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
if duration, err := time.ParseDuration(value); err == nil {
return duration
}
if seconds, err := strconv.ParseInt(value, 10, 64); err == nil {
return time.Duration(seconds) * time.Second
}
return fallback
}

27
internal/app/licensing.go Normal file
View File

@@ -0,0 +1,27 @@
package app
import (
_ "embed"
"fmt"
"github.com/b1tsblog/license-platform/pkg/licensekit"
)
const (
ProductID = "ai-disclosure-standard"
ProductVersion = "1.6.1"
FeatureCustomText = "custom_text"
FeatureCustomBadge = "custom_badge"
FeatureWhiteLabel = "white_label"
)
//go:embed trusted_keys.json
var embeddedTrustStore []byte
func productTrustStore() (licensekit.TrustStore, error) {
store, err := licensekit.ParseTrustStore(embeddedTrustStore)
if err != nil {
return licensekit.TrustStore{}, fmt.Errorf("embedded license trust store: %w", err)
}
return store, nil
}

42
internal/app/metrics.go Normal file
View File

@@ -0,0 +1,42 @@
package app
import (
"fmt"
"net/http"
"sync/atomic"
"time"
)
type metrics struct {
startedAt time.Time
requests atomic.Uint64
badgeRenders atomic.Uint64
validationRequests atomic.Uint64
validationFailures atomic.Uint64
panics atomic.Uint64
}
func newMetrics() *metrics { return &metrics{startedAt: time.Now()} }
func (m *metrics) serveHTTP(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprintf(w, "# HELP ai_disclosure_uptime_seconds Process uptime in seconds.\n")
fmt.Fprintf(w, "# TYPE ai_disclosure_uptime_seconds gauge\n")
fmt.Fprintf(w, "ai_disclosure_uptime_seconds %.0f\n", time.Since(m.startedAt).Seconds())
fmt.Fprintf(w, "# HELP ai_disclosure_http_requests_total Total HTTP requests.\n")
fmt.Fprintf(w, "# TYPE ai_disclosure_http_requests_total counter\n")
fmt.Fprintf(w, "ai_disclosure_http_requests_total %d\n", m.requests.Load())
fmt.Fprintf(w, "# HELP ai_disclosure_badge_renders_total Total rendered SVG badges.\n")
fmt.Fprintf(w, "# TYPE ai_disclosure_badge_renders_total counter\n")
fmt.Fprintf(w, "ai_disclosure_badge_renders_total %d\n", m.badgeRenders.Load())
fmt.Fprintf(w, "# HELP ai_disclosure_validation_requests_total Total validation requests.\n")
fmt.Fprintf(w, "# TYPE ai_disclosure_validation_requests_total counter\n")
fmt.Fprintf(w, "ai_disclosure_validation_requests_total %d\n", m.validationRequests.Load())
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_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())
}

988
internal/app/server.go Normal file
View File

@@ -0,0 +1,988 @@
package app
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http"
"net/url"
"runtime/debug"
"sort"
"strings"
"time"
"github.com/b1tsblog/ai-disclosure-standard/internal/badge"
"github.com/b1tsblog/ai-disclosure-standard/internal/declaration"
"github.com/b1tsblog/ai-disclosure-standard/internal/i18n"
"github.com/b1tsblog/ai-disclosure-standard/internal/marketing"
webassets "github.com/b1tsblog/ai-disclosure-standard/web"
"github.com/b1tsblog/license-platform/sdk/go/licenseclient"
)
type Server struct {
cfg Config
logger *slog.Logger
templates *template.Template
metrics *metrics
licenses *licenseclient.Client
mux *http.ServeMux
}
type option struct{ Value, Label string }
type fact struct{ Label, Value, Link string }
type componentRow struct{ Name, Extent, Activities, Review, Note string }
type languageLink struct {
Code, Name, URL, AbsoluteURL string
Current bool
}
type pageData struct {
Name string
BaseURL string
ContactURL string
Lang string
Text map[string]string
Languages []i18n.LanguageOption
Presets []option
Components []option
Extents []option
Reviews []option
Assurances []option
Declaration declaration.Declaration
Title string
Description string
BadgeURL string
ManifestURL string
CanonicalURL string
JSONLD template.JS
AppConfig template.JS
Facts []fact
ComponentRows []componentRow
Summary []string
IsArticle bool
License licenseclient.Status
CustomText bool
CustomBadge bool
Marketing marketing.Page
LanguageLinks []languageLink
DefaultLanguageURL string
}
type clientConfig struct {
BaseURL string `json:"baseURL"`
SelectedLanguage string `json:"selectedLanguage"`
Locales map[string]i18n.Locale `json:"locales"`
Capabilities map[string]bool `json:"capabilities"`
}
func New(ctx context.Context, cfg Config, logger *slog.Logger) (http.Handler, error) {
tmpl, err := template.New("root").ParseFS(webassets.Files, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
trustStore, err := productTrustStore()
if err != nil {
return nil, err
}
licenses := licenseclient.New(ctx, licenseclient.Config{
Product: ProductID, Token: cfg.LicenseToken, TrustStore: trustStore, BaseURL: cfg.BaseURL,
InstanceID: cfg.LicenseInstanceID, Mode: cfg.LicenseMode, ServerURL: cfg.LicenseServerURL,
CacheFile: cfg.LicenseCacheFile, RefreshEvery: cfg.LicenseRefreshEvery,
RequestTimeout: cfg.LicenseTimeout, ClientVersion: ProductVersion,
})
licenses.Start(ctx)
s := &Server{cfg: cfg, logger: logger, templates: tmpl, metrics: newMetrics(), licenses: licenses, mux: http.NewServeMux()}
s.routes()
return s.middleware(s.mux), nil
}
func (s *Server) routes() {
staticFS, _ := fs.Sub(webassets.Files, "static")
fileServer := http.FileServer(http.FS(staticFS))
s.mux.HandleFunc("GET /", s.handleIndex)
s.mux.HandleFunc("GET /product", s.handleMarketing)
s.mux.HandleFunc("GET /install", s.handleMarketingAlias)
s.mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(fileServer)))
s.mux.HandleFunc("GET /badge/{file}", s.handlePresetBadge)
s.mux.HandleFunc("GET /v1/badge.svg", s.handleBadge)
s.mux.HandleFunc("GET /declaration", s.handleDeclaration)
s.mux.HandleFunc("GET /v1/declaration.json", s.handleManifest)
s.mux.HandleFunc("POST /v1/validate", s.handleValidate)
s.mux.HandleFunc("GET /v1/capabilities", s.handleCapabilities)
s.mux.HandleFunc("GET /schema/v1/declaration.schema.json", s.handleSchema)
s.mux.HandleFunc("GET /context/v1", s.handleContext)
s.mux.HandleFunc("GET /healthz", s.handleHealth)
s.mux.HandleFunc("GET /readyz", s.handleReady)
s.mux.HandleFunc("GET /metrics", s.metrics.serveHTTP)
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
lang := s.language(r)
locale := i18n.Get(lang)
cfg := clientConfig{
BaseURL: s.cfg.BaseURL, SelectedLanguage: lang, Locales: i18n.ClientCatalogs(),
Capabilities: map[string]bool{
FeatureCustomText: s.licenses.Has(FeatureCustomText),
FeatureCustomBadge: s.licenses.Has(FeatureCustomBadge),
},
}
appJSON, _ := json.Marshal(cfg)
data := pageData{
Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: lang, Text: locale.Text,
Languages: i18n.Languages(), Presets: presetOptions(locale), Components: orderedOptions(locale.Components, []string{"text", "coverImage", "image", "audio", "video", "code", "other"}),
Extents: orderedOptions(locale.Extents, []string{"assisted", "none", "partial", "mostly", "full"}), Reviews: orderedOptions(locale.Reviews, []string{"editorial", "expert", "basic", "none"}),
Assurances: orderedOptions(locale.Assurances, []string{"selfDeclared", "technicallyRecorded", "signed", "verified"}),
AppConfig: template.JS(appJSON), License: s.licenses.Status(), CustomText: s.licenses.Has(FeatureCustomText), CustomBadge: s.licenses.Has(FeatureCustomBadge),
}
s.renderHTML(w, "index.html", data)
}
func (s *Server) handleMarketing(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/product" {
http.NotFound(w, r)
return
}
lang := s.language(r)
page := marketing.Build(lang, s.cfg.PublicName, s.cfg.BaseURL, s.cfg.SalesURL, s.cfg.ContactURL, marketing.Prices{
Community: s.cfg.CommunityPrice,
Pro: s.cfg.ProPrice,
Publisher: s.cfg.PublisherPrice,
Agency: s.cfg.AgencyPrice,
})
data := pageData{
Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL,
Lang: lang, Languages: i18n.Languages(), Marketing: page, License: s.licenses.Status(),
}
s.renderHTML(w, "marketing.html", data)
}
func (s *Server) handleMarketingAlias(w http.ResponseWriter, r *http.Request) {
target := "/product"
if raw := r.URL.Query().Encode(); raw != "" {
target += "?" + raw
}
if r.URL.Path == "/install" {
target += "#install"
}
http.Redirect(w, r, target, http.StatusTemporaryRedirect)
}
func (s *Server) handlePresetBadge(w http.ResponseWriter, r *http.Request) {
file := r.PathValue("file")
if !strings.HasSuffix(file, ".svg") {
http.NotFound(w, r)
return
}
presetID := strings.TrimSuffix(file, ".svg")
if _, ok := declaration.Presets[presetID]; !ok {
s.problem(w, http.StatusNotFound, "unknown_preset", "Unknown badge preset.")
return
}
q := cloneValues(r.URL.Query())
q.Set("preset", presetID)
q.Set("lang", s.languageFromValues(r, q))
s.renderBadge(w, r, q)
}
func (s *Server) handleBadge(w http.ResponseWriter, r *http.Request) {
q := cloneValues(r.URL.Query())
q.Set("lang", s.languageFromValues(r, q))
s.renderBadge(w, r, q)
}
func (s *Server) renderBadge(w http.ResponseWriter, r *http.Request, q url.Values) {
if customTextRequested(q, "label", "badgeLabel", "message", "badgeMessage") && !s.licenses.Has(FeatureCustomBadge) {
s.problem(w, http.StatusForbidden, "pro_feature_required", "Custom badge labels require the Pro feature custom_badge.")
return
}
if customTextRequested(q, "leftColor", "rightColor") && !s.licenses.Has(FeatureCustomBadge) {
s.problem(w, http.StatusForbidden, "pro_feature_required", "Custom badge colours require the Pro feature custom_badge.")
return
}
leftColor, rightColor := strings.TrimSpace(q.Get("leftColor")), strings.TrimSpace(q.Get("rightColor"))
if (leftColor != "" && !validHexColor(leftColor)) || (rightColor != "" && !validHexColor(rightColor)) {
s.problem(w, http.StatusBadRequest, "invalid_colour", "Badge colours must use the form #RRGGBB.")
return
}
extent := strings.TrimSpace(q.Get("extent"))
presetID := strings.TrimSpace(q.Get("preset"))
isArticle := q.Get("mode") == "article"
if isArticle {
if d, err := s.declarationFromQuery(q); err == nil {
extent = overallExtent(d)
}
}
if p, ok := declaration.Presets[presetID]; ok && extent == "" {
extent = p.Extent
}
if extent == "" {
extent = "assisted"
}
if !declaration.IsValidExtent(extent) {
s.problem(w, http.StatusBadRequest, "invalid_extent", "extent must be one of none, assisted, partial, mostly or full")
return
}
locale := i18n.Get(q.Get("lang"))
label := locale.Text["ai_label"]
message := locale.Extents[extent]
if preset, ok := locale.Presets[presetID]; ok {
message = preset.Title
}
if isArticle {
message = locale.Text["badge_article"]
// Article-level declarations combine several AI-use states. Use a calm
// violet as the neutral default instead of inheriting the strongest
// component colour (which may be red). Explicit Pro colours still win.
if rightColor == "" {
rightColor = "#7c3aed"
}
}
if v := firstNonEmpty(q.Get("badgeLabel"), q.Get("label")); v != "" {
label = v
}
if v := firstNonEmpty(q.Get("badgeMessage"), q.Get("message")); v != "" {
message = v
}
link := strings.TrimSpace(q.Get("link"))
if link == "auto" {
copy := cloneValues(q)
for _, key := range []string{"link", "label", "message", "style", "theme", "leftColor", "rightColor"} {
copy.Del(key)
}
link = s.cfg.BaseURL + "/declaration?" + copy.Encode()
}
data, etag := badge.Render(badge.Options{Label: label, Message: message, Extent: extent, Style: q.Get("style"), Theme: q.Get("theme"), Link: link, LeftColor: leftColor, RightColor: rightColor})
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
s.metrics.badgeRenders.Add(1)
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
w.Header().Set("ETag", etag)
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
_, _ = w.Write(data)
}
func (s *Server) handleDeclaration(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)
presetText := locale.Presets[q.Get("preset")]
title, description := presetText.Title, presetText.Description
if title == "" {
for _, c := range d.Components {
title = locale.Extents[c.AIExtent]
break
}
description = locale.Text["transparency_text"]
}
if d.Presentation != nil {
if d.Presentation.Title != "" {
title = d.Presentation.Title
}
if d.Presentation.Description != "" {
description = d.Presentation.Description
}
}
canonicalURL := s.cfg.BaseURL + "/declaration?" + q.Encode()
manifestURL := s.cfg.BaseURL + "/v1/declaration.json?" + q.Encode()
badgeQ := cloneValues(q)
badgeQ.Set("link", canonicalURL)
badgeURL := s.cfg.BaseURL + "/v1/badge.svg?" + badgeQ.Encode()
jsonLD, _ := json.Marshal(d)
isArticle := q.Get("mode") == "article" || len(d.Components) > 1
if isArticle && (d.Presentation == nil || d.Presentation.Title == "") {
title = locale.Text["article_title"]
description = articleShortDescription(d, locale)
}
languageLinks, defaultLanguageURL := s.declarationLanguageLinks(q, d.Language)
data := pageData{
Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: d.Language, Text: locale.Text, Languages: i18n.Languages(),
Declaration: d, Title: title, Description: description, BadgeURL: badgeURL, ManifestURL: manifestURL, CanonicalURL: canonicalURL, JSONLD: template.JS(jsonLD),
Facts: declarationFacts(d, locale), ComponentRows: declarationComponentRows(d, locale), Summary: declarationSummary(d, locale), IsArticle: isArticle, License: s.licenses.Status(),
LanguageLinks: languageLinks, DefaultLanguageURL: defaultLanguageURL,
}
w.Header().Set("Link", "<"+manifestURL+">; rel=describedby; type=application/ld+json")
s.renderHTML(w, "declaration.html", data)
}
func (s *Server) declarationLanguageLinks(q url.Values, current string) ([]languageLink, string) {
links := make([]languageLink, 0, len(i18n.Languages()))
for _, language := range i18n.Languages() {
values := cloneValues(q)
values.Set("lang", language.Code)
relative := "/declaration?" + values.Encode()
links = append(links, languageLink{
Code: language.Code, Name: language.Name, URL: relative,
AbsoluteURL: s.cfg.BaseURL + relative, Current: language.Code == current,
})
}
defaultValues := cloneValues(q)
defaultValues.Del("lang")
defaultURL := s.cfg.BaseURL + "/declaration"
if encoded := defaultValues.Encode(); encoded != "" {
defaultURL += "?" + encoded
}
return links, defaultURL
}
func (s *Server) handleManifest(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
}
w.Header().Set("Content-Type", "application/ld+json; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
w.Header().Set("Access-Control-Allow-Origin", "*")
_ = json.NewEncoder(w).Encode(d)
}
func (s *Server) declarationFromQuery(q url.Values) (declaration.Declaration, error) {
return declaration.NewFromQueryWithOptions(q, s.cfg.BaseURL+"/context/v1", declaration.ParseOptions{
AllowCustomText: s.licenses.Has(FeatureCustomText), AllowCustomBadge: s.licenses.Has(FeatureCustomBadge), DefaultLanguage: s.cfg.DefaultLanguage,
})
}
func (s *Server) declarationError(w http.ResponseWriter, err error) {
if errors.Is(err, declaration.ErrCustomTextRequiresPro) || errors.Is(err, declaration.ErrCustomBadgeRequiresPro) {
s.problem(w, http.StatusForbidden, "pro_feature_required", err.Error())
return
}
s.problem(w, http.StatusBadRequest, "invalid_declaration", err.Error())
}
func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {
s.metrics.validationRequests.Add(1)
body := http.MaxBytesReader(w, r.Body, 1<<20)
defer body.Close()
dec := json.NewDecoder(body)
dec.DisallowUnknownFields()
var d declaration.Declaration
if err := dec.Decode(&d); err != nil {
s.metrics.validationFailures.Add(1)
s.problem(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if err := ensureEOF(dec); err != nil {
s.metrics.validationFailures.Add(1)
s.problem(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if err := declaration.Validate(d); err != nil {
s.metrics.validationFailures.Add(1)
s.problem(w, http.StatusUnprocessableEntity, "validation_failed", err.Error())
return
}
s.writeJSON(w, http.StatusOK, map[string]any{"valid": true, "schemaVersion": d.SchemaVersion})
}
func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
s.writeJSON(w, http.StatusOK, map[string]any{"license": s.licenses.Status(), "supportedLanguages": languageCodes()})
}
func (s *Server) handleSchema(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/schema+json; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("Access-Control-Allow-Origin", "*")
_, _ = w.Write([]byte(strings.ReplaceAll(declarationSchema, "__BASE_URL__", s.cfg.BaseURL)))
}
func (s *Server) handleContext(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/ld+json; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("Access-Control-Allow-Origin", "*")
_, _ = w.Write([]byte(strings.ReplaceAll(jsonLDContext, "__BASE_URL__", s.cfg.BaseURL)))
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = io.WriteString(w, "ok\n")
}
func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = io.WriteString(w, "ready\n")
}
func (s *Server) renderHTML(w http.ResponseWriter, name string, data pageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
s.logger.Error("template render failed", "template", name, "error", err)
}
}
func (s *Server) language(r *http.Request) string {
return i18n.Resolve(r.URL.Query().Get("lang"), r.Header.Get("Accept-Language"), s.cfg.DefaultLanguage)
}
func (s *Server) languageFromValues(r *http.Request, values url.Values) string {
return i18n.Resolve(values.Get("lang"), r.Header.Get("Accept-Language"), s.cfg.DefaultLanguage)
}
func (s *Server) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
s.metrics.requests.Add(1)
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = randomID()
}
w.Header().Set("X-Request-ID", requestID)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
if strings.HasPrefix(r.URL.Path, "/v1/") || strings.HasPrefix(r.URL.Path, "/schema/") || strings.HasPrefix(r.URL.Path, "/context/") {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
}
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
defer func() {
if recovered := recover(); recovered != nil {
s.metrics.panics.Add(1)
s.logger.Error("handler panic", "request_id", requestID, "panic", recovered, "stack", string(debug.Stack()))
http.Error(rw, "internal server error", http.StatusInternalServerError)
}
s.logger.Info("request", "request_id", requestID, "method", r.Method, "path", r.URL.Path, "status", rw.status, "bytes", rw.bytes, "duration_ms", time.Since(started).Milliseconds(), "remote", clientIP(r, s.cfg.TrustProxy))
}()
next.ServeHTTP(rw, r)
})
}
func (s *Server) problem(w http.ResponseWriter, status int, code, detail string) {
w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"type": s.cfg.BaseURL + "/problems/" + code, "title": http.StatusText(status), "status": status, "code": code, "detail": detail})
}
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)
}
type responseWriter struct {
http.ResponseWriter
status, bytes int
}
func (w *responseWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (w *responseWriter) Write(b []byte) (int, error) {
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
func cacheStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=3600")
next.ServeHTTP(w, r)
})
}
func presetOptions(locale i18n.Locale) []option {
ids := []string{"research", "no-ai", "summary", "full"}
out := make([]option, 0, len(ids))
for _, id := range ids {
out = append(out, option{Value: id, Label: locale.Presets[id].Title})
}
return out
}
func orderedOptions(values map[string]string, order []string) []option {
out := make([]option, 0, len(order))
for _, key := range order {
out = append(out, option{Value: key, Label: values[key]})
}
return out
}
func declarationFacts(d declaration.Declaration, locale i18n.Locale) []fact {
facts := []fact{{Label: locale.Text["fact_assurance"], Value: locale.Assurances[d.Assurance]}}
if d.Subject != "" {
facts = append(facts, fact{Label: locale.Text["fact_subject"], Value: d.Subject, Link: d.Subject})
}
if d.EditorialResponsibility != nil && d.EditorialResponsibility.Name != "" {
facts = append(facts, fact{Label: locale.Text["fact_responsibility"], Value: d.EditorialResponsibility.Name, Link: d.EditorialResponsibility.URL})
}
if d.DeclaredAt != "" {
facts = append(facts, fact{Label: locale.Text["fact_declared_at"], Value: d.DeclaredAt})
}
return facts
}
func declarationComponentRows(d declaration.Declaration, locale i18n.Locale) []componentRow {
order := []string{"text", "coverImage", "image", "research", "translation", "audio", "video", "code", "other"}
rows := make([]componentRow, 0, len(d.Components))
for _, name := range order {
component, ok := d.Components[name]
if !ok {
continue
}
activities := make([]string, 0, len(component.Activities))
for _, activity := range component.Activities {
if translated := locale.Activities[activity]; translated != "" {
activities = append(activities, translated)
} else {
activities = append(activities, activity)
}
}
activityText := locale.Text["not_specified"]
if component.AIExtent == "none" {
activityText = locale.Text["not_applicable"]
}
if activityText == "" {
activityText = locale.Text["none_value"]
}
if len(activities) > 0 {
activityText = strings.Join(activities, ", ")
}
rows = append(rows, componentRow{Name: locale.Components[name], Extent: locale.Extents[component.AIExtent], Activities: activityText, Review: locale.Reviews[component.HumanReview], Note: component.Note})
}
return rows
}
func overallExtent(d declaration.Declaration) string {
rank := map[string]int{"none": 0, "assisted": 1, "partial": 2, "mostly": 3, "full": 4}
best, bestRank := "none", 0
for _, c := range d.Components {
if rank[c.AIExtent] > bestRank {
best, bestRank = c.AIExtent, rank[c.AIExtent]
}
}
return best
}
func articleShortDescription(d declaration.Declaration, locale i18n.Locale) string {
rows := declarationComponentRows(d, locale)
parts := make([]string, 0, len(rows))
for _, row := range rows {
parts = append(parts, row.Name+": "+row.Extent)
}
return strings.Join(parts, " · ")
}
func declarationSummary(d declaration.Declaration, locale i18n.Locale) []string {
if len(d.Components) == 0 {
return nil
}
intro := map[string]string{
"de": "Diese Erklärung dokumentiert nachvollziehbar, in welchen Bereichen künstliche Intelligenz bei der Erstellung und Veröffentlichung des gekennzeichneten Inhalts eingesetzt wurde.",
"en": "This declaration documents, in a transparent and traceable form, where artificial intelligence was used in creating and publishing the labelled content.",
"fr": "Cette déclaration documente de manière transparente et traçable les domaines dans lesquels lintelligence artificielle a été utilisée pour créer et publier le contenu identifié.",
"es": "Esta declaración documenta de forma transparente y trazable en qué áreas se utilizó inteligencia artificial para crear y publicar el contenido identificado.",
"it": "La presente dichiarazione documenta in modo trasparente e tracciabile gli ambiti in cui lintelligenza artificiale è stata utilizzata per creare e pubblicare il contenuto indicato.",
"nl": "Deze verklaring documenteert op transparante en navolgbare wijze op welke onderdelen kunstmatige intelligentie is gebruikt bij het maken en publiceren van de aangeduide inhoud.",
"pt": "Esta declaração documenta, de forma transparente e rastreável, as áreas em que a inteligência artificial foi utilizada na criação e publicação do conteúdo identificado.",
"pl": "Niniejsza deklaracja w przejrzysty i możliwy do prześledzenia sposób dokumentuje obszary, w których wykorzystano sztuczną inteligencję podczas tworzenia i publikacji oznaczonej treści.",
}
lang := locale.Code
if intro[lang] == "" {
lang = "en"
}
statements := []string{intro[lang]}
for _, name := range []string{"text", "coverImage", "image", "research", "translation", "audio", "video", "code", "other"} {
component, ok := d.Components[name]
if !ok {
continue
}
statements = append(statements, componentSummaryStatement(locale, name, component))
}
closing := map[string][]string{
"de": {
"Sämtliche Angaben werden unter einem gemeinsamen Link veröffentlicht und stehen zusätzlich als maschinenlesbares JSON-LD zur Verfügung.",
"Die inhaltliche und redaktionelle Verantwortung verbleibt unabhängig vom ausgewiesenen KI-Anteil bei der veröffentlichenden Person oder Organisation.",
},
"en": {
"All information is published under a single link and is also available as machine-readable JSON-LD.",
"Responsibility for the content and its editorial publication remains with the publishing person or organisation, irrespective of the stated level of AI involvement.",
},
"fr": {
"Toutes les informations sont publiées sous un lien unique et sont également disponibles au format JSON-LD lisible par machine.",
"La responsabilité du contenu et de sa publication éditoriale demeure celle de la personne ou de lorganisation éditrice, indépendamment du niveau dintervention de lIA indiqué.",
},
"es": {
"Toda la información se publica mediante un único enlace y también está disponible como JSON-LD legible por máquina.",
"La responsabilidad sobre el contenido y su publicación editorial permanece en la persona u organización editora, con independencia del nivel de intervención de la IA indicado.",
},
"it": {
"Tutte le informazioni sono pubblicate tramite un unico collegamento e sono disponibili anche come JSON-LD leggibile automaticamente.",
"La responsabilità del contenuto e della sua pubblicazione editoriale resta in capo alla persona o allorganizzazione che lo pubblica, indipendentemente dal livello dichiarato di intervento dellIA.",
},
"nl": {
"Alle gegevens worden via één gezamenlijke link gepubliceerd en zijn daarnaast beschikbaar als machineleesbare JSON-LD.",
"De inhoudelijke en redactionele verantwoordelijkheid blijft, ongeacht de vermelde mate van AI-gebruik, bij de publicerende persoon of organisatie.",
},
"pt": {
"Todas as informações são publicadas através de uma única ligação e estão igualmente disponíveis como JSON-LD legível por máquina.",
"A responsabilidade pelo conteúdo e pela sua publicação editorial permanece com a pessoa ou organização responsável pela publicação, independentemente do grau de utilização de IA indicado.",
},
"pl": {
"Wszystkie informacje są publikowane pod jednym wspólnym odsyłaczem i są również dostępne jako maszynowo czytelny JSON-LD.",
"Odpowiedzialność za treść i jej redakcyjną publikację pozostaje po stronie publikującej osoby lub organizacji, niezależnie od wskazanego zakresu użycia AI.",
},
}
end := closing[lang]
end = append(end, assuranceStatement(d.Assurance, locale))
return []string{strings.Join(statements, " "), strings.Join(end, " ")}
}
func componentSummaryStatement(locale i18n.Locale, name string, component declaration.Component) string {
componentName := locale.Components[name]
if componentName == "" {
componentName = name
}
extent := locale.Extents[component.AIExtent]
activities := make([]string, 0, len(component.Activities))
for _, activity := range component.Activities {
if value := locale.Activities[activity]; value != "" {
activities = append(activities, value)
} else {
activities = append(activities, activity)
}
}
activityText := strings.Join(activities, ", ")
review := locale.Reviews[component.HumanReview]
switch locale.Code {
case "de":
if component.AIExtent == "none" {
if component.HumanReview == "none" {
return fmt.Sprintf("Für den Bereich „%s“ wurde nach den vorliegenden Angaben keine KI eingesetzt.", componentName)
}
return fmt.Sprintf("Für den Bereich „%s“ wurde nach den vorliegenden Angaben keine KI eingesetzt; ergänzend ist eine %s menschliche Prüfung dokumentiert.", componentName, germanReviewAdjective(component.HumanReview))
}
purpose := ""
if activityText != "" {
purpose = " für " + activityText
}
if component.HumanReview == "none" {
return fmt.Sprintf("Für den Bereich „%s“ ist %s%s dokumentiert; eine menschliche Prüfung der KI-bezogenen Ergebnisse ist nicht angegeben.", componentName, germanExtentPhrase(component.AIExtent), purpose)
}
return fmt.Sprintf("Für den Bereich „%s“ ist %s%s dokumentiert; die KI-bezogenen Ergebnisse wurden %s geprüft.", componentName, germanExtentPhrase(component.AIExtent), purpose, germanReviewAdverb(component.HumanReview))
case "en":
if component.AIExtent == "none" {
if component.HumanReview == "none" {
return fmt.Sprintf("According to the information provided, no AI was used for “%s”.", componentName)
}
return fmt.Sprintf("According to the information provided, no AI was used for “%s”; a %s human review is nevertheless recorded.", componentName, strings.ToLower(review))
}
purpose := ""
if activityText != "" {
purpose = " for " + activityText
}
if component.HumanReview == "none" {
return fmt.Sprintf("For “%s”, %s%s is recorded; no human review of the AI-related output is specified.", componentName, strings.ToLower(extent), purpose)
}
return fmt.Sprintf("For “%s”, %s%s is recorded; the AI-related output underwent %s human review.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review))
default:
if component.AIExtent == "none" {
return genericNoAIStatement(locale.Code, componentName, component.HumanReview, review)
}
return genericAIStatement(locale.Code, componentName, extent, activityText, component.HumanReview, review)
}
}
func germanExtentPhrase(extent string) string {
switch extent {
case "assisted":
return "eine KI-unterstützte Nutzung"
case "partial":
return "eine teilweise KI-generierte Erstellung"
case "mostly":
return "eine überwiegend KI-generierte Erstellung"
case "full":
return "eine vollständig KI-generierte Erstellung"
default:
return "eine KI-Nutzung"
}
}
func germanReviewAdverb(review string) string {
switch review {
case "basic":
return "grundlegend"
case "editorial":
return "redaktionell"
case "expert":
return "fachlich"
default:
return "menschlich"
}
}
func germanReviewAdjective(review string) string {
switch review {
case "basic":
return "grundlegende"
case "editorial":
return "redaktionelle"
case "expert":
return "fachliche"
default:
return "menschliche"
}
}
func genericNoAIStatement(lang, componentName, reviewCode, review string) string {
withReview := reviewCode != "none"
switch lang {
case "fr":
if withReview {
return fmt.Sprintf("Selon les informations fournies, aucune IA na été utilisée pour « %s » ; une vérification humaine %s est néanmoins documentée.", componentName, strings.ToLower(review))
}
return fmt.Sprintf("Selon les informations fournies, aucune IA na été utilisée pour « %s ».", componentName)
case "es":
if withReview {
return fmt.Sprintf("Según la información proporcionada, no se utilizó IA en «%s»; no obstante, se documenta una revisión humana %s.", componentName, strings.ToLower(review))
}
return fmt.Sprintf("Según la información proporcionada, no se utilizó IA en «%s».", componentName)
case "it":
if withReview {
return fmt.Sprintf("Secondo le informazioni fornite, per «%s» non è stata utilizzata lIA; è comunque documentata una revisione umana %s.", componentName, strings.ToLower(review))
}
return fmt.Sprintf("Secondo le informazioni fornite, per «%s» non è stata utilizzata lIA.", componentName)
case "nl":
if withReview {
return fmt.Sprintf("Volgens de verstrekte informatie is voor %s geen AI gebruikt; er is wel een %s menselijke controle vastgelegd.", componentName, strings.ToLower(review))
}
return fmt.Sprintf("Volgens de verstrekte informatie is voor %s geen AI gebruikt.", componentName)
case "pt":
if withReview {
return fmt.Sprintf("De acordo com as informações fornecidas, não foi utilizada IA em «%s»; está, ainda assim, documentada uma revisão humana %s.", componentName, strings.ToLower(review))
}
return fmt.Sprintf("De acordo com as informações fornecidas, não foi utilizada IA em «%s».", componentName)
case "pl":
if withReview {
return fmt.Sprintf("Zgodnie z podanymi informacjami w obszarze „%s” nie użyto AI; udokumentowano jednak weryfikację człowieka na poziomie %s.", componentName, review)
}
return fmt.Sprintf("Zgodnie z podanymi informacjami w obszarze „%s” nie użyto AI.", componentName)
default:
return fmt.Sprintf("According to the information provided, no AI was used for “%s”.", componentName)
}
}
func genericAIStatement(lang, componentName, extent, activities string, reviewCode, review string) string {
purpose := activities
if purpose == "" {
purpose = "—"
}
switch lang {
case "fr":
if reviewCode == "none" {
return fmt.Sprintf("Pour « %s », lutilisation déclarée est %s, avec pour finalité %s ; aucune vérification humaine des résultats liés à lIA nest indiquée.", componentName, strings.ToLower(extent), purpose)
}
return fmt.Sprintf("Pour « %s », lutilisation déclarée est %s, avec pour finalité %s ; les résultats liés à lIA ont fait lobjet dune vérification humaine %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review))
case "es":
if reviewCode == "none" {
return fmt.Sprintf("Para «%s» se declara %s, con la finalidad %s; no se indica una revisión humana de los resultados relacionados con la IA.", componentName, strings.ToLower(extent), purpose)
}
return fmt.Sprintf("Para «%s» se declara %s, con la finalidad %s; los resultados relacionados con la IA fueron objeto de una revisión humana %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review))
case "it":
if reviewCode == "none" {
return fmt.Sprintf("Per «%s» è dichiarato %s, con finalità %s; non è indicata una revisione umana dei risultati legati allIA.", componentName, strings.ToLower(extent), purpose)
}
return fmt.Sprintf("Per «%s» è dichiarato %s, con finalità %s; i risultati legati allIA sono stati sottoposti a revisione umana %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review))
case "nl":
if reviewCode == "none" {
return fmt.Sprintf("Voor %s is %s vastgelegd, met als doel %s; er is geen menselijke controle van de AI-gerelateerde resultaten aangegeven.", componentName, strings.ToLower(extent), purpose)
}
return fmt.Sprintf("Voor %s is %s vastgelegd, met als doel %s; de AI-gerelateerde resultaten hebben een %s menselijke controle ondergaan.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review))
case "pt":
if reviewCode == "none" {
return fmt.Sprintf("Para «%s» está documentado %s, com a finalidade %s; não é indicada uma revisão humana dos resultados relacionados com IA.", componentName, strings.ToLower(extent), purpose)
}
return fmt.Sprintf("Para «%s» está documentado %s, com a finalidade %s; os resultados relacionados com IA foram sujeitos a uma revisão humana %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review))
case "pl":
if reviewCode == "none" {
return fmt.Sprintf("Dla obszaru „%s” udokumentowano %s w celu %s; nie wskazano weryfikacji człowieka dla wyników związanych z AI.", componentName, extent, purpose)
}
return fmt.Sprintf("Dla obszaru „%s” udokumentowano %s w celu %s; wyniki związane z AI poddano weryfikacji człowieka na poziomie %s.", componentName, extent, purpose, review)
default:
return fmt.Sprintf("For “%s”, %s is recorded.", componentName, strings.ToLower(extent))
}
}
func assuranceStatement(assurance string, locale i18n.Locale) string {
key := "assurance_" + assurance + "_statement"
if value := locale.Text[key]; value != "" {
if assurance == "signed" {
switch locale.Code {
case "de":
return value + " Die Signatur bestätigt nicht automatisch die inhaltliche Richtigkeit der Angaben."
case "en":
return value + " The signature does not by itself confirm the substantive accuracy of the information."
}
}
return value
}
if value := locale.Text["assurance_"+assurance+"_description"]; value != "" {
return value
}
return locale.Assurances[assurance]
}
func customTextRequested(q url.Values, keys ...string) bool {
for _, key := range keys {
if strings.TrimSpace(q.Get(key)) != "" {
return true
}
}
return false
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func validHexColor(value string) bool {
if len(value) != 7 || value[0] != '#' {
return false
}
for _, r := range value[1:] {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
return true
}
func languageCodes() []string {
languages := i18n.Languages()
out := make([]string, 0, len(languages))
for _, l := range languages {
out = append(out, l.Code)
}
sort.Strings(out)
return out
}
func cloneValues(in url.Values) url.Values {
out := url.Values{}
for k, v := range in {
out[k] = append([]string(nil), v...)
}
return out
}
func randomID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func clientIP(r *http.Request, trustProxy bool) string {
if trustProxy {
if x := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); x != "" {
return x
}
}
return r.RemoteAddr
}
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("request body must contain exactly one JSON value")
}
return err
}
const declarationSchema = `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "__BASE_URL__/schema/v1/declaration.schema.json",
"title": "AI Usage Declaration",
"type": "object",
"additionalProperties": false,
"required": ["@context", "@type", "schemaVersion", "language", "components", "assurance"],
"properties": {
"@context": {"type": "string"},
"@type": {"const": "AIUsageDeclaration"},
"schemaVersion": {"const": "1.1"},
"subject": {"type": "string", "format": "uri"},
"declaredAt": {"type": "string", "format": "date-time"},
"language": {"enum": ["de", "en", "fr", "es", "it", "nl", "pt", "pl"]},
"assurance": {"enum": ["selfDeclared", "technicallyRecorded", "signed", "verified"]},
"editorialResponsibility": {
"type": "object", "additionalProperties": false,
"properties": {"name": {"type": "string"}, "url": {"type": "string", "format": "uri"}}
},
"presentation": {
"type": "object", "additionalProperties": false,
"properties": {
"title": {"type": "string", "maxLength": 120},
"description": {"type": "string", "maxLength": 500},
"badgeLabel": {"type": "string", "maxLength": 40},
"badgeMessage": {"type": "string", "maxLength": 80},
"leftColor": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"},
"rightColor": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"}
}
},
"components": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/$defs/component"}}
},
"$defs": {
"component": {
"type": "object", "additionalProperties": false,
"required": ["aiExtent", "humanReview"],
"properties": {
"aiExtent": {"enum": ["none", "assisted", "partial", "mostly", "full"]},
"activities": {"type": "array", "uniqueItems": true, "items": {"enum": ["research", "summarisation", "drafting", "generation", "translation", "editing", "imageGeneration", "codeGeneration", "transcription", "classification"]}},
"humanReview": {"enum": ["none", "basic", "editorial", "expert"]},
"note": {"type": "string", "maxLength": 500}
}
}
}
}`
const jsonLDContext = `{
"@context": {
"@version": 1.1,
"AIUsageDeclaration": "__BASE_URL__/vocab/AIUsageDeclaration",
"schemaVersion": "__BASE_URL__/vocab/schemaVersion",
"subject": {"@id": "https://schema.org/about", "@type": "@id"},
"declaredAt": {"@id": "https://schema.org/dateCreated", "@type": "https://www.w3.org/2001/XMLSchema#dateTime"},
"language": "https://schema.org/inLanguage",
"components": "__BASE_URL__/vocab/components",
"aiExtent": "__BASE_URL__/vocab/aiExtent",
"activities": "__BASE_URL__/vocab/activities",
"humanReview": "__BASE_URL__/vocab/humanReview",
"assurance": "__BASE_URL__/vocab/assurance",
"presentation": "__BASE_URL__/vocab/presentation",
"editorialResponsibility": "https://schema.org/accountablePerson"
}
}`

223
internal/app/server_test.go Normal file
View File

@@ -0,0 +1,223 @@
package app
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func testHandler(t *testing.T) http.Handler {
t.Helper()
return testHandlerConfig(t, Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de"})
}
func testHandlerConfig(t *testing.T, cfg Config) http.Handler {
t.Helper()
h, err := New(context.Background(), cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
return h
}
func TestPresetBadgeFrench(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/badge/research.svg?lang=fr", 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 ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "image/svg+xml") {
t.Fatalf("content type %q", ct)
}
if !strings.Contains(w.Body.String(), "Aide à la recherche") {
t.Fatal("missing localized preset")
}
}
func TestValidate(t *testing.T) {
body := `{"@context":"https://example.org/context/v1","@type":"AIUsageDeclaration","schemaVersion":"1.1","language":"de","components":{"text":{"aiExtent":"assisted","activities":["research"],"humanReview":"editorial"}},"assurance":"selfDeclared"}`
r := httptest.NewRequest(http.MethodPost, "/v1/validate", strings.NewReader(body))
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result["valid"] != true {
t.Fatalf("unexpected response: %#v", result)
}
}
func TestCommunityRejectsCustomBadge(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/badge.svg?lang=en&badgeMessage=Custom", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "pro_feature_required") {
t.Fatalf("unexpected response: %s", w.Body.String())
}
}
func TestCapabilities(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/capabilities", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
if !strings.Contains(w.Body.String(), `"edition":"community"`) {
t.Fatalf("unexpected response: %s", w.Body.String())
}
}
func TestMarketingPageGerman(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/product?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{"KI-Nutzung transparent kennzeichnen", "Docker Compose", "JSON-LD"} {
if !strings.Contains(body, expected) {
t.Fatalf("marketing page missing %q", expected)
}
}
}
func TestMarketingPageLanguageNegotiation(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/product", nil)
r.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8")
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(), "Déclarez lusage de lIA") {
t.Fatal("missing French marketing content")
}
}
func TestInstallAlias(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/install?lang=en", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusTemporaryRedirect {
t.Fatalf("status %d", w.Code)
}
if location := w.Header().Get("Location"); location != "/product?lang=en#install" {
t.Fatalf("unexpected redirect %q", location)
}
}
func TestPricingRouteRemoved(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/pricing", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Fatalf("status %d", w.Code)
}
}
func TestDeclarationLanguageSwitcherPreservesQuery(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/declaration?mode=article&textExtent=partial&textReview=expert&coverImageExtent=partial&coverImageReview=editorial&lang=de&assurance=selfDeclared", 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="declaration-language"`,
`lang=en`,
`textExtent=partial`,
`textReview=expert`,
`coverImageExtent=partial`,
`hreflang="en"`,
`hreflang="x-default"`,
} {
if !strings.Contains(body, expected) {
t.Fatalf("language switcher missing %q", expected)
}
}
if !strings.Contains(body, `<option value="/declaration?`) || !strings.Contains(body, `lang=de`) || !strings.Contains(body, ` selected`) {
t.Fatal("current language is not selected")
}
}
func TestArticleBadgeUsesCalmVioletDefault(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/badge.svg?mode=article&textExtent=none&textReview=none&imageExtent=full&imageReview=editorial&lang=de", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `fill="#7c3aed"`) {
t.Fatalf("article badge does not use violet default: %s", w.Body.String())
}
}
func TestGeneratorExposesAssuranceSelection(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="assurance"`,
`value="selfDeclared"`,
`value="technicallyRecorded"`,
`value="signed"`,
`value="verified"`,
`Nachweisgrundlage`,
} {
if !strings.Contains(body, expected) {
t.Fatalf("generator missing %q", expected)
}
}
}
func TestDeclarationUsesSelectedAssurance(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/declaration?preset=research&lang=de&assurance=technicallyRecorded", 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{
`"assurance":"technicallyRecorded"`,
`Als Nachweisgrundlage ist eine technische Protokollierung im Erstellungs- oder Veröffentlichungsprozess angegeben.`,
`<colgroup>`,
`scope="col"`,
} {
if !strings.Contains(body, expected) {
t.Fatalf("declaration missing %q", expected)
}
}
}
func TestSignedAssuranceExplainsScope(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/declaration?preset=summary&lang=de&assurance=signed", 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(), "Die Signatur bestätigt nicht automatisch die inhaltliche Richtigkeit der Angaben.") {
t.Fatal("signed assurance scope is not explained")
}
}

View File

@@ -0,0 +1 @@
{"licenseKeys":{"issuer-2026":"8NpJ01Ew-wGyLbZnBsT2lrMFRKERFS-_eqREUJVHLYk"},"leaseKeys":{"lease-2026":"QhFaAPcgT9ZVxQF9jwQ-aYFptu9mpoROlUdW5t6aTeM"}}

108
internal/badge/render.go Normal file
View File

@@ -0,0 +1,108 @@
package badge
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"html"
"strings"
"unicode/utf8"
)
type Options struct {
Label string
Message string
Extent string
Style string
Theme string
Link string
LeftColor string
RightColor string
}
var extentColors = map[string]string{
"none": "#2f855a", "assisted": "#2b6cb0", "partial": "#b7791f", "mostly": "#c05621", "full": "#c53030",
}
func Render(o Options) ([]byte, string) {
label := truncate(strings.TrimSpace(o.Label), 40)
if label == "" {
label = "AI use"
}
message := truncate(strings.TrimSpace(o.Message), 80)
if message == "" {
message = o.Extent
}
style := o.Style
if style != "flat-square" {
style = "flat"
}
leftWidth := textWidth(label)
rightWidth := textWidth(message)
total := leftWidth + rightWidth
radius := 3
if style == "flat-square" {
radius = 0
}
leftColor, rightColor := "#555555", extentColors[o.Extent]
if rightColor == "" {
rightColor = "#4a5568"
}
if o.Theme == "mono" {
leftColor, rightColor = "#262626", "#666666"
}
if isHexColor(o.LeftColor) {
leftColor = o.LeftColor
}
if isHexColor(o.RightColor) {
rightColor = o.RightColor
}
title := html.EscapeString(label + ": " + message)
labelEsc := html.EscapeString(label)
messageEsc := html.EscapeString(message)
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)
data := []byte(svg)
hash := sha256.Sum256(data)
return data, `"` + hex.EncodeToString(hash[:12]) + `"`
}
func textWidth(s string) int {
runes := utf8.RuneCountInString(s)
width := runes*7 + 12
if width < 40 {
width = 40
}
return width
}
func truncate(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max-1]) + "…"
}
func isHexColor(s string) bool {
if len(s) != 7 || s[0] != '#' {
return false
}
for _, r := range s[1:] {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
return true
}

View File

@@ -0,0 +1,19 @@
package badge
import (
"bytes"
"testing"
)
func TestRenderEscapesText(t *testing.T) {
data, etag := Render(Options{Label: `<script>`, Message: `A&B`, Extent: "assisted"})
if bytes.Contains(data, []byte(`<script>`)) {
t.Fatal("unescaped label")
}
if !bytes.Contains(data, []byte(`A&amp;B`)) {
t.Fatal("message not escaped")
}
if etag == "" {
t.Fatal("missing etag")
}
}

View File

@@ -0,0 +1,343 @@
package declaration
import (
"errors"
"fmt"
"net/url"
"regexp"
"sort"
"strings"
"time"
"github.com/b1tsblog/ai-disclosure-standard/internal/i18n"
)
const (
SchemaVersion = "1.1"
LegacySchemaVersion = "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")
hexColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
)
type Component struct {
AIExtent string `json:"aiExtent"`
Activities []string `json:"activities,omitempty"`
HumanReview string `json:"humanReview"`
Note string `json:"note,omitempty"`
}
type Responsibility struct {
Name string `json:"name"`
URL string `json:"url,omitempty"`
}
type Presentation struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
BadgeLabel string `json:"badgeLabel,omitempty"`
BadgeMessage string `json:"badgeMessage,omitempty"`
LeftColor string `json:"leftColor,omitempty"`
RightColor string `json:"rightColor,omitempty"`
}
type Declaration struct {
Context string `json:"@context"`
Type string `json:"@type"`
SchemaVersion string `json:"schemaVersion"`
Subject string `json:"subject,omitempty"`
DeclaredAt string `json:"declaredAt,omitempty"`
Language string `json:"language"`
Components map[string]Component `json:"components"`
EditorialResponsibility *Responsibility `json:"editorialResponsibility,omitempty"`
Assurance string `json:"assurance"`
Presentation *Presentation `json:"presentation,omitempty"`
}
type Preset struct {
ID string
Extent string
Activities []string
Review string
}
type ParseOptions struct {
AllowCustomText bool
AllowCustomBadge bool
DefaultLanguage string
}
var Presets = map[string]Preset{
"no-ai": {ID: "no-ai", Extent: "none", Review: "editorial"},
"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}
var validReviews = map[string]bool{"none": true, "basic": true, "editorial": true, "expert": true}
var validAssurance = map[string]bool{"selfDeclared": true, "technicallyRecorded": true, "signed": true, "verified": true}
var validActivities = map[string]bool{
"research": true, "summarisation": true, "drafting": true, "generation": true,
"translation": true, "editing": true, "imageGeneration": true, "codeGeneration": true,
"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}
func NewFromQuery(values url.Values, contextURL string) (Declaration, error) {
return NewFromQueryWithOptions(values, contextURL, ParseOptions{DefaultLanguage: "de"})
}
func NewFromQueryWithOptions(values url.Values, contextURL string, options ParseOptions) (Declaration, error) {
lang := i18n.Normalize(clean(values.Get("lang"), 16))
if lang == "" {
lang = i18n.Normalize(options.DefaultLanguage)
}
if lang == "" {
lang = "en"
}
if !i18n.Supported(lang) {
return Declaration{}, fmt.Errorf("unsupported language %q", lang)
}
componentName := clean(values.Get("component"), 32)
if componentName == "" {
componentName = "text"
}
if !validComponents[componentName] {
return Declaration{}, fmt.Errorf("unknown component %q", componentName)
}
extent := clean(values.Get("extent"), 24)
activities := splitCSV(values.Get("activities"))
review := clean(values.Get("review"), 24)
if presetID := clean(values.Get("preset"), 24); presetID != "" {
preset, ok := Presets[presetID]
if !ok {
return Declaration{}, fmt.Errorf("unknown preset %q", presetID)
}
if extent == "" {
extent = preset.Extent
}
if len(activities) == 0 {
activities = append([]string(nil), preset.Activities...)
}
if review == "" {
review = preset.Review
}
}
if extent == "" {
extent = "assisted"
}
if review == "" {
review = "editorial"
}
assurance := clean(values.Get("assurance"), 32)
if assurance == "" {
assurance = "selfDeclared"
}
components := map[string]Component{}
if clean(values.Get("mode"), 16) == "article" {
for _, name := range []string{"text", "coverImage", "image", "research", "translation", "audio", "video", "code"} {
componentExtent := clean(values.Get(name+"Extent"), 24)
if componentExtent == "" {
continue
}
componentReview := clean(values.Get(name+"Review"), 24)
if componentReview == "" {
if componentExtent == "none" {
componentReview = "none"
} else {
componentReview = "editorial"
}
}
componentActivities := splitCSV(values.Get(name + "Activities"))
if len(componentActivities) == 0 {
switch name {
case "research":
if componentExtent != "none" {
componentActivities = []string{"research"}
}
case "translation":
if componentExtent != "none" {
componentActivities = []string{"translation"}
}
case "coverImage", "image":
if componentExtent != "none" {
componentActivities = []string{"imageGeneration"}
}
case "code":
if componentExtent != "none" {
componentActivities = []string{"codeGeneration"}
}
}
}
components[name] = Component{AIExtent: componentExtent, Activities: componentActivities, HumanReview: componentReview, Note: clean(values.Get(name+"Note"), 500)}
}
}
if len(components) == 0 {
components[componentName] = Component{AIExtent: extent, Activities: activities, HumanReview: review, Note: clean(values.Get("note"), 500)}
}
d := Declaration{
Context: contextURL, Type: "AIUsageDeclaration", SchemaVersion: SchemaVersion,
Subject: clean(values.Get("subject"), 2048), DeclaredAt: clean(values.Get("declaredAt"), 64), Language: lang,
Components: components,
Assurance: assurance,
}
responsibleName := clean(values.Get("responsible"), 200)
responsibleURL := clean(values.Get("responsibleUrl"), 2048)
if responsibleName != "" || responsibleURL != "" {
d.EditorialResponsibility = &Responsibility{Name: responsibleName, URL: responsibleURL}
}
presentation := &Presentation{
Title: clean(values.Get("customTitle"), 120), Description: clean(values.Get("customDescription"), 500),
BadgeLabel: clean(values.Get("badgeLabel"), 40), BadgeMessage: clean(values.Get("badgeMessage"), 80),
LeftColor: clean(values.Get("leftColor"), 7), RightColor: clean(values.Get("rightColor"), 7),
}
if presentation.Title != "" || presentation.Description != "" {
if !options.AllowCustomText {
return Declaration{}, ErrCustomTextRequiresPro
}
}
if presentation.BadgeLabel != "" || presentation.BadgeMessage != "" || presentation.LeftColor != "" || presentation.RightColor != "" {
if !options.AllowCustomBadge {
return Declaration{}, ErrCustomBadgeRequiresPro
}
}
if !presentation.empty() {
d.Presentation = presentation
}
return d, Validate(d)
}
func Validate(d Declaration) error {
var problems []string
switch d.SchemaVersion {
case SchemaVersion:
case LegacySchemaVersion:
if d.Presentation != nil {
problems = append(problems, "presentation requires schemaVersion 1.1")
}
if d.Language != "de" && d.Language != "en" {
problems = append(problems, "schemaVersion 1.0 supports only de and en")
}
default:
problems = append(problems, "unsupported schemaVersion")
}
if d.Type != "AIUsageDeclaration" {
problems = append(problems, "@type must be AIUsageDeclaration")
}
if !i18n.Supported(d.Language) {
problems = append(problems, "unsupported language")
}
if !validAssurance[d.Assurance] {
problems = append(problems, "invalid assurance")
}
if len(d.Components) == 0 {
problems = append(problems, "at least one component is required")
}
if d.Subject != "" {
if u, err := url.ParseRequestURI(d.Subject); err != nil || u.Scheme == "" || u.Host == "" {
problems = append(problems, "subject must be an absolute URL")
}
}
if d.DeclaredAt != "" {
if _, err := time.Parse(time.RFC3339, d.DeclaredAt); err != nil {
problems = append(problems, "declaredAt must be RFC3339")
}
}
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")
}
}
for name, c := range d.Components {
if !validComponents[name] {
problems = append(problems, "invalid component: "+name)
}
if !validExtents[c.AIExtent] {
problems = append(problems, "invalid aiExtent for "+name)
}
if !validReviews[c.HumanReview] {
problems = append(problems, "invalid humanReview for "+name)
}
seen := map[string]bool{}
for _, activity := range c.Activities {
if !validActivities[activity] {
problems = append(problems, "invalid activity for "+name+": "+activity)
}
if seen[activity] {
problems = append(problems, "duplicate activity for "+name+": "+activity)
}
seen[activity] = true
}
if c.AIExtent == "none" && len(c.Activities) > 0 {
problems = append(problems, "activities must be empty when aiExtent is none")
}
}
if p := d.Presentation; p != nil {
if runeLen(p.Title) > 120 {
problems = append(problems, "presentation.title is too long")
}
if runeLen(p.Description) > 500 {
problems = append(problems, "presentation.description is too long")
}
if runeLen(p.BadgeLabel) > 40 {
problems = append(problems, "presentation.badgeLabel is too long")
}
if runeLen(p.BadgeMessage) > 80 {
problems = append(problems, "presentation.badgeMessage is too long")
}
if p.LeftColor != "" && !hexColorPattern.MatchString(p.LeftColor) {
problems = append(problems, "presentation.leftColor must be a six-digit hex colour")
}
if p.RightColor != "" && !hexColorPattern.MatchString(p.RightColor) {
problems = append(problems, "presentation.rightColor must be a six-digit hex colour")
}
}
if len(problems) > 0 {
sort.Strings(problems)
return fmt.Errorf("%s", strings.Join(problems, "; "))
}
return nil
}
func IsValidExtent(value string) bool { return validExtents[value] }
func (p *Presentation) empty() bool {
return p == nil || (p.Title == "" && p.Description == "" && p.BadgeLabel == "" && p.BadgeMessage == "" && p.LeftColor == "" && p.RightColor == "")
}
func clean(s string, max int) string {
r := []rune(strings.TrimSpace(s))
if len(r) > max {
r = r[:max]
}
return string(r)
}
func runeLen(s string) int { return len([]rune(s)) }
func splitCSV(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, p := range parts {
p = clean(p, 32)
if p != "" && !seen[p] {
out = append(out, p)
seen[p] = true
}
}
sort.Strings(out)
return out
}

View File

@@ -0,0 +1,114 @@
package declaration
import (
"errors"
"net/url"
"testing"
)
func TestPresetQuery(t *testing.T) {
v := url.Values{"preset": {"research"}, "subject": {"https://example.org/post"}, "lang": {"fr"}}
d, err := NewFromQuery(v, "https://example.org/context/v1")
if err != nil {
t.Fatal(err)
}
c := d.Components["text"]
if c.AIExtent != "assisted" || len(c.Activities) != 1 || c.Activities[0] != "research" {
t.Fatalf("unexpected component: %#v", c)
}
if d.Language != "fr" {
t.Fatalf("unexpected language: %s", d.Language)
}
}
func TestNoAIRejectsActivities(t *testing.T) {
d := Declaration{Context: "https://example.org/context/v1", Type: "AIUsageDeclaration", SchemaVersion: SchemaVersion, Language: "de", Assurance: "selfDeclared", Components: map[string]Component{"text": {AIExtent: "none", Activities: []string{"research"}, HumanReview: "editorial"}}}
if Validate(d) == nil {
t.Fatal("expected validation error")
}
}
func TestCustomRequiresPro(t *testing.T) {
v := url.Values{"preset": {"research"}, "lang": {"en"}, "customTitle": {"Custom"}}
_, err := NewFromQuery(v, "https://example.org/context/v1")
if !errors.Is(err, ErrCustomTextRequiresPro) {
t.Fatalf("unexpected error: %v", err)
}
d, err := NewFromQueryWithOptions(v, "https://example.org/context/v1", ParseOptions{AllowCustomText: true, DefaultLanguage: "en"})
if err != nil {
t.Fatal(err)
}
if d.Presentation == nil || d.Presentation.Title != "Custom" {
t.Fatalf("missing presentation: %#v", d.Presentation)
}
}
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"}}}
if err := Validate(d); err != nil {
t.Fatalf("legacy declaration should remain valid: %v", err)
}
d.Presentation = &Presentation{Title: "Not allowed in 1.0"}
if Validate(d) == nil {
t.Fatal("expected presentation to require schema 1.1")
}
}
func TestArticleDeclarationFromQuery(t *testing.T) {
q := url.Values{
"mode": {"article"}, "lang": {"de"},
"textExtent": {"none"}, "imageExtent": {"full"}, "researchExtent": {"assisted"},
}
d, err := NewFromQuery(q, "https://example.org/context/v1")
if err != nil {
t.Fatal(err)
}
if len(d.Components) != 3 {
t.Fatalf("components=%d", len(d.Components))
}
if d.Components["text"].AIExtent != "none" {
t.Fatalf("unexpected text extent")
}
if d.Components["text"].HumanReview != "none" {
t.Fatalf("no-AI component review=%q, want none", d.Components["text"].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)
}
if got := d.Components["image"].Activities; len(got) != 1 || got[0] != "imageGeneration" {
t.Fatalf("image activities=%v", got)
}
}
func TestArticleDeclarationKeepsPerComponentReviews(t *testing.T) {
q := url.Values{
"mode": {"article"}, "lang": {"de"},
"textExtent": {"assisted"}, "textReview": {"basic"},
"imageExtent": {"full"}, "imageReview": {"expert"},
}
d, err := NewFromQuery(q, "https://example.org/context/v1")
if err != nil {
t.Fatal(err)
}
if got := d.Components["text"].HumanReview; got != "basic" {
t.Fatalf("text review=%q", got)
}
if got := d.Components["image"].HumanReview; got != "expert" {
t.Fatalf("image review=%q", got)
}
}
func TestAssuranceFromQuery(t *testing.T) {
for _, assurance := range []string{"selfDeclared", "technicallyRecorded", "signed", "verified"} {
d, err := NewFromQuery(url.Values{"preset": {"research"}, "lang": {"de"}, "assurance": {assurance}}, "https://example.org/context/v1")
if err != nil {
t.Fatalf("assurance %s: %v", assurance, err)
}
if d.Assurance != assurance {
t.Fatalf("assurance=%q, want %q", d.Assurance, assurance)
}
}
}

430
internal/i18n/catalog.go Normal file
View File

@@ -0,0 +1,430 @@
package i18n
import (
"sort"
"strings"
)
type PresetText struct {
Title string `json:"title"`
Description string `json:"description"`
}
type Locale struct {
Code string `json:"code"`
Name string `json:"name"`
Text map[string]string `json:"text"`
Presets map[string]PresetText `json:"presets"`
Extents map[string]string `json:"extents"`
Components map[string]string `json:"components"`
Reviews map[string]string `json:"reviews"`
Activities map[string]string `json:"activities"`
Assurances map[string]string `json:"assurances"`
}
type LanguageOption struct {
Code string
Name string
}
var catalogs = map[string]Locale{
"de": locale("de", "Deutsch", map[string]string{
"meta_description": "Offene, maschinenlesbare und selbst hostbare Kennzeichnung von KI-Nutzung.",
"nav_generator": "Generator", "nav_api": "API", "nav_background": "Hintergrund",
"standard_eyebrow": "Offener Kennzeichnungsstandard", "hero_title": "Offenlegen, wie KI an einem Inhalt beteiligt war.",
"hero_lead": "Sichtbares SVG-Badge, verständliche Erklärungsseite und maschinenlesbares JSON-LD ohne Cookies, Tracking oder Datenbankzwang.",
"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.",
"field_subject": "URL des gekennzeichneten Inhalts", "field_language": "Ausgabesprache", "field_theme": "Darstellung", "theme_mono": "Monochrom", "theme_color": "Farbig",
"preview": "Vorschau", "copy_html": "HTML kopieren", "copied": "Kopiert",
"pro_eyebrow": "Pro-Anpassung", "pro_title": "Eigene Texte und Badge-Designs", "pro_enabled": "Diese Instanz besitzt eine gültige Pro-Lizenz. Individuelle Texte und Farben können verwendet werden.",
"pro_locked": "Individuelle Titel, Beschreibungstexte, Badge-Beschriftungen und Farben sind in der Pro-Ausgabe verfügbar.",
"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",
"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_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",
"ai_label": "KI-Nutzung", "capabilities": "Funktionen",
},
map[string]PresetText{
"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", "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{
"meta_description": "Open, machine-readable and self-hostable AI usage disclosure.", "nav_generator": "Generator", "nav_api": "API", "nav_background": "Background",
"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.", "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": "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.",
"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_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", "ai_label": "AI use", "capabilities": "Capabilities",
},
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", "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",
map[string]PresetText{"no-ai": {"Aucune utilisation de lIA", "Le contenu identifié a été créé sans IA générative et vérifié par une personne."}, "research": {"Aide à la recherche", "LIA a été utilisée pour la recherche ou lidentification de sources. Le contenu a été rédigé et vérifié éditorialement."}, "summary": {"Résumé de contenu", "LIA a été utilisée pour résumer des sources. La sélection, linterprétation et la version finale ont été vérifiées par une personne."}, "full": {"Génération complète du contenu", "Le contenu a été majoritairement ou entièrement généré par lIA puis vérifié éditorialement."}}),
"es": europeanLocale("es", "Español", "Indica cómo ha contribuido la IA a un contenido.", "Uso de IA", "Sin uso de IA", "Asistido por IA", "Generado parcialmente por IA", "Generado mayoritariamente por IA", "Generado completamente por IA",
map[string]PresetText{"no-ai": {"Sin uso de IA", "El contenido identificado se creó sin IA generativa y fue revisado por una persona."}, "research": {"Apoyo a la investigación", "La IA se utilizó para investigar o localizar fuentes relevantes. El contenido se redactó y revisó editorialmente."}, "summary": {"Resumen de contenido", "La IA se utilizó para resumir fuentes. La selección, interpretación y versión final fueron revisadas por una persona."}, "full": {"Generación completa del contenido", "El contenido fue generado mayoritaria o completamente por IA y posteriormente revisado editorialmente."}}),
"it": europeanLocale("it", "Italiano", "Dichiara come lIA ha contribuito a un contenuto.", "Uso dellIA", "Nessun uso dellIA", "Assistito dallIA", "Generato parzialmente dallIA", "Generato prevalentemente dallIA", "Generato interamente dallIA",
map[string]PresetText{"no-ai": {"Nessun uso dellIA", "Il contenuto indicato è stato creato senza IA generativa e verificato da una persona."}, "research": {"Supporto alla ricerca", "LIA è stata usata per la ricerca o per individuare fonti rilevanti. Il contenuto è stato creato e verificato editorialmente."}, "summary": {"Sintesi del contenuto", "LIA è stata usata per riassumere le fonti. Selezione, interpretazione e versione finale sono state verificate da una persona."}, "full": {"Generazione completa del contenuto", "Il contenuto è stato generato prevalentemente o interamente dallIA e poi verificato editorialmente."}}),
"nl": europeanLocale("nl", "Nederlands", "Maak zichtbaar hoe AI aan inhoud heeft bijgedragen.", "AI-gebruik", "Geen AI gebruikt", "AI-ondersteund", "Gedeeltelijk door AI gegenereerd", "Grotendeels door AI gegenereerd", "Volledig door AI gegenereerd",
map[string]PresetText{"no-ai": {"Geen AI gebruikt", "De gemarkeerde inhoud is zonder generatieve AI gemaakt en door een mens gecontroleerd."}, "research": {"Onderzoeksondersteuning", "AI is gebruikt voor onderzoek of het vinden van relevante bronnen. De inhoud is redactioneel gemaakt en gecontroleerd."}, "summary": {"Samenvatting van inhoud", "AI is gebruikt om bronnen samen te vatten. Selectie, duiding en eindversie zijn door een mens gecontroleerd."}, "full": {"Volledige inhoudsgeneratie", "De inhoud is grotendeels of volledig door AI gegenereerd en daarna redactioneel gecontroleerd."}}),
"pt": europeanLocale("pt", "Português", "Declare como a IA contribuiu para um conteúdo.", "Uso de IA", "Sem uso de IA", "Assistido por IA", "Parcialmente gerado por IA", "Maioritariamente gerado por IA", "Totalmente gerado por IA",
map[string]PresetText{"no-ai": {"Sem uso de IA", "O conteúdo identificado foi criado sem IA generativa e revisto por uma pessoa."}, "research": {"Apoio à pesquisa", "A IA foi usada para pesquisa ou identificação de fontes relevantes. O conteúdo foi criado e revisto editorialmente."}, "summary": {"Resumo de conteúdo", "A IA foi usada para resumir fontes. A seleção, interpretação e versão final foram revistas por uma pessoa."}, "full": {"Geração completa do conteúdo", "O conteúdo foi maioritariamente ou totalmente gerado por IA e depois revisto editorialmente."}}),
"pl": europeanLocale("pl", "Polski", "Pokaż, w jaki sposób AI uczestniczyła w tworzeniu treści.", "Użycie AI", "Bez użycia AI", "Wspomagane przez AI", "Częściowo wygenerowane przez AI", "W większości wygenerowane przez AI", "W pełni wygenerowane przez AI",
map[string]PresetText{"no-ai": {"Bez użycia AI", "Oznaczona treść została utworzona bez generatywnej AI i sprawdzona przez człowieka."}, "research": {"Wsparcie badań", "AI wykorzystano do wyszukiwania informacji lub odpowiednich źródeł. Treść została opracowana i sprawdzona redakcyjnie."}, "summary": {"Streszczenie treści", "AI wykorzystano do streszczania źródeł. Wybór, interpretacja i wersja końcowa zostały sprawdzone przez człowieka."}, "full": {"Pełne generowanie treści", "Treść została w większości lub w całości wygenerowana przez AI, a następnie sprawdzona redakcyjnie."}}),
}
func locale(code, name string, text map[string]string, presets map[string]PresetText, extents, components, reviews, activities, assurances map[string]string) Locale {
return Locale{Code: code, Name: name, Text: text, Presets: presets, Extents: extents, Components: components, Reviews: reviews, Activities: activities, Assurances: assurances}
}
// europeanLocale starts with the locale-specific public wording. init fills missing
// administrative UI labels from English so every supported locale is complete.
func europeanLocale(code, name, hero, aiLabel, none, assisted, partial, mostly, full string, presets map[string]PresetText) Locale {
return Locale{
Code: code, Name: name,
Text: map[string]string{"hero_title": hero, "ai_label": aiLabel},
Presets: presets,
Extents: map[string]string{"none": none, "assisted": assisted, "partial": partial, "mostly": mostly, "full": full},
}
}
func init() {
base := catalogs["en"]
for _, code := range []string{"fr", "es", "it", "nl", "pt", "pl"} {
l := catalogs[code]
for k, v := range base.Text {
if l.Text[k] == "" {
l.Text[k] = v
}
}
l.Components = cloneMap(base.Components)
l.Reviews = cloneMap(base.Reviews)
l.Activities = cloneMap(base.Activities)
l.Assurances = cloneMap(base.Assurances)
catalogs[code] = l
}
for code, overrides := range europeanTextOverrides() {
l := catalogs[code]
for k, v := range overrides {
l.Text[k] = v
}
catalogs[code] = l
}
applyEuropeanTaxonomyTranslations()
applyArticleTranslations()
applyAssuranceTranslations()
}
func applyArticleTranslations() {
texts := map[string]map[string]string{
"de": {"field_mode": "Art der Erklärung", "mode_single": "Einzelne Nutzung", "mode_article": "Artikel oder Webseite zusammenfassen", "article_fields": "Bestandteile des Artikels", "article_help": "Lege für jeden typischen Bestandteil fest, ob und wie KI eingesetzt wurde.", "article_title": "Dokumentation der KI-Nutzung", "article_summary_heading": "Zusammenfassende Einordnung", "article_table_heading": "Strukturierte Angaben nach Inhaltsbestandteil", "table_component": "Bereich", "table_extent": "KI-Nutzung", "table_activities": "Einsatzgebiet", "table_review": "Menschliche Prüfung", "badge_article": "Artikeltransparenz"},
"en": {"field_mode": "Declaration type", "mode_single": "Single use", "mode_article": "Summarise an article or website", "article_fields": "Article components", "article_help": "Specify whether and how AI was used for each typical component.", "article_title": "Documentation of AI use", "article_summary_heading": "Summary assessment", "article_table_heading": "Structured details by content component", "table_component": "Area", "table_extent": "AI use", "table_activities": "Purpose", "table_review": "Human review", "badge_article": "Article transparency"},
"fr": {"field_mode": "Type de déclaration", "mode_single": "Utilisation individuelle", "mode_article": "Résumer un article ou un site", "article_fields": "Composants de larticle", "article_help": "Indiquez si et comment lIA a été utilisée pour chaque composant.", "article_title": "Utilisation de lIA en un coup dœil", "article_summary_heading": "Résumé", "article_table_heading": "Détails par composant", "table_component": "Élément", "table_extent": "Utilisation de lIA", "table_activities": "Usage", "table_review": "Vérification humaine", "badge_article": "Transparence de larticle"},
"es": {"field_mode": "Tipo de declaración", "mode_single": "Uso individual", "mode_article": "Resumir un artículo o sitio", "article_fields": "Componentes del artículo", "article_help": "Indica si se utilizó IA y de qué manera en cada componente.", "article_title": "Uso de IA de un vistazo", "article_summary_heading": "Resumen", "article_table_heading": "Detalles por componente", "table_component": "Área", "table_extent": "Uso de IA", "table_activities": "Finalidad", "table_review": "Revisión humana", "badge_article": "Transparencia del artículo"},
"it": {"field_mode": "Tipo di dichiarazione", "mode_single": "Uso singolo", "mode_article": "Riassumi un articolo o sito", "article_fields": "Componenti dellarticolo", "article_help": "Indica se e come lIA è stata usata per ogni componente.", "article_title": "Uso dellIA in sintesi", "article_summary_heading": "Riepilogo", "article_table_heading": "Dettagli per componente", "table_component": "Area", "table_extent": "Uso dellIA", "table_activities": "Finalità", "table_review": "Revisione umana", "badge_article": "Trasparenza dellarticolo"},
"nl": {"field_mode": "Type verklaring", "mode_single": "Afzonderlijk gebruik", "mode_article": "Artikel of website samenvatten", "article_fields": "Onderdelen van het artikel", "article_help": "Geef per onderdeel aan of en hoe AI is gebruikt.", "article_title": "AI-gebruik in één oogopslag", "article_summary_heading": "Samenvatting", "article_table_heading": "Details per onderdeel", "table_component": "Onderdeel", "table_extent": "AI-gebruik", "table_activities": "Doel", "table_review": "Menselijke controle", "badge_article": "Artikeltransparantie"},
"pt": {"field_mode": "Tipo de declaração", "mode_single": "Uso individual", "mode_article": "Resumir um artigo ou site", "article_fields": "Componentes do artigo", "article_help": "Indique se e como a IA foi usada em cada componente.", "article_title": "Utilização de IA em resumo", "article_summary_heading": "Resumo", "article_table_heading": "Detalhes por componente", "table_component": "Área", "table_extent": "Utilização de IA", "table_activities": "Finalidade", "table_review": "Revisão humana", "badge_article": "Transparência do artigo"},
"pl": {"field_mode": "Typ deklaracji", "mode_single": "Pojedyncze użycie", "mode_article": "Podsumuj artykuł lub stronę", "article_fields": "Elementy artykułu", "article_help": "Określ, czy i jak użyto AI w każdym elemencie.", "article_title": "Użycie AI w skrócie", "article_summary_heading": "Podsumowanie", "article_table_heading": "Szczegóły według elementu", "table_component": "Obszar", "table_extent": "Użycie AI", "table_activities": "Cel", "table_review": "Weryfikacja człowieka", "badge_article": "Przejrzystość artykułu"},
}
componentNames := map[string]map[string]string{
"de": {"research": "Recherche", "translation": "Übersetzung"}, "en": {"research": "Research", "translation": "Translation"},
"fr": {"research": "Recherche", "translation": "Traduction"}, "es": {"research": "Investigación", "translation": "Traducción"},
"it": {"research": "Ricerca", "translation": "Traduzione"}, "nl": {"research": "Onderzoek", "translation": "Vertaling"},
"pt": {"research": "Pesquisa", "translation": "Tradução"}, "pl": {"research": "Badania", "translation": "Tłumaczenie"},
}
for code, values := range texts {
l := catalogs[code]
for k, v := range values {
l.Text[k] = v
}
for k, v := range componentNames[code] {
l.Components[k] = v
}
catalogs[code] = l
}
}
func europeanTextOverrides() map[string]map[string]string {
return map[string]map[string]string{
"fr": {
"meta_description": "Déclaration ouverte, lisible par machine et auto-hébergeable de lutilisation de lIA.", "nav_generator": "Générateur", "nav_background": "Contexte",
"standard_eyebrow": "Norme ouverte de transparence", "hero_lead": "Badge SVG visible, page explicative et JSON-LD lisible par machine sans cookies, suivi ni base de données obligatoire.",
"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",
},
"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",
},
"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",
},
"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",
},
"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",
},
"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",
},
}
}
func applyEuropeanTaxonomyTranslations() {
translations := map[string]struct {
components, reviews, activities, assurances map[string]string
}{
"fr": {
components: map[string]string{"text": "Texte", "coverImage": "Image de couverture", "image": "Image", "audio": "Audio", "video": "Vidéo", "code": "Code", "other": "Autre"},
reviews: map[string]string{"none": "Aucune", "basic": "Basique", "editorial": "Éditoriale", "expert": "Experte"},
activities: map[string]string{"research": "Recherche", "summarisation": "Résumé", "drafting": "Rédaction initiale", "generation": "Génération", "translation": "Traduction", "editing": "Révision", "imageGeneration": "Génération dimage", "codeGeneration": "Génération de code", "transcription": "Transcription", "classification": "Classification"},
assurances: map[string]string{"selfDeclared": "Auto-déclaré", "technicallyRecorded": "Enregistré techniquement", "signed": "Signé", "verified": "Vérifié"},
},
"es": {components: map[string]string{"text": "Texto", "coverImage": "Imagen de portada", "image": "Imagen", "audio": "Audio", "video": "Vídeo", "code": "Código", "other": "Otro"}, reviews: map[string]string{"none": "Ninguna", "basic": "Básica", "editorial": "Editorial", "expert": "Experta"}, activities: map[string]string{"research": "Investigación", "summarisation": "Resumen", "drafting": "Borrador", "generation": "Generación", "translation": "Traducción", "editing": "Edición", "imageGeneration": "Generación de imágenes", "codeGeneration": "Generación de código", "transcription": "Transcripción", "classification": "Clasificación"}, assurances: map[string]string{"selfDeclared": "Autodeclarado", "technicallyRecorded": "Registrado técnicamente", "signed": "Firmado", "verified": "Verificado"}},
"it": {components: map[string]string{"text": "Testo", "coverImage": "Immagine di copertina", "image": "Immagine", "audio": "Audio", "video": "Video", "code": "Codice", "other": "Altro"}, reviews: map[string]string{"none": "Nessuna", "basic": "Di base", "editorial": "Editoriale", "expert": "Esperta"}, activities: map[string]string{"research": "Ricerca", "summarisation": "Sintesi", "drafting": "Bozza", "generation": "Generazione", "translation": "Traduzione", "editing": "Revisione", "imageGeneration": "Generazione di immagini", "codeGeneration": "Generazione di codice", "transcription": "Trascrizione", "classification": "Classificazione"}, assurances: map[string]string{"selfDeclared": "Autodichiarato", "technicallyRecorded": "Registrato tecnicamente", "signed": "Firmato", "verified": "Verificato"}},
"nl": {components: map[string]string{"text": "Tekst", "coverImage": "Omslagafbeelding", "image": "Afbeelding", "audio": "Audio", "video": "Video", "code": "Code", "other": "Overig"}, reviews: map[string]string{"none": "Geen", "basic": "Basis", "editorial": "Redactioneel", "expert": "Deskundig"}, activities: map[string]string{"research": "Onderzoek", "summarisation": "Samenvatting", "drafting": "Concept", "generation": "Generatie", "translation": "Vertaling", "editing": "Bewerking", "imageGeneration": "Afbeeldingsgeneratie", "codeGeneration": "Codegeneratie", "transcription": "Transcriptie", "classification": "Classificatie"}, assurances: map[string]string{"selfDeclared": "Zelf verklaard", "technicallyRecorded": "Technisch vastgelegd", "signed": "Ondertekend", "verified": "Geverifieerd"}},
"pt": {components: map[string]string{"text": "Texto", "coverImage": "Imagem de capa", "image": "Imagem", "audio": "Áudio", "video": "Vídeo", "code": "Código", "other": "Outro"}, reviews: map[string]string{"none": "Nenhuma", "basic": "Básica", "editorial": "Editorial", "expert": "Especializada"}, activities: map[string]string{"research": "Pesquisa", "summarisation": "Resumo", "drafting": "Rascunho", "generation": "Geração", "translation": "Tradução", "editing": "Edição", "imageGeneration": "Geração de imagens", "codeGeneration": "Geração de código", "transcription": "Transcrição", "classification": "Classificação"}, assurances: map[string]string{"selfDeclared": "Autodeclarado", "technicallyRecorded": "Registado tecnicamente", "signed": "Assinado", "verified": "Verificado"}},
"pl": {components: map[string]string{"text": "Tekst", "coverImage": "Obraz okładkowy", "image": "Obraz", "audio": "Audio", "video": "Wideo", "code": "Kod", "other": "Inne"}, reviews: map[string]string{"none": "Brak", "basic": "Podstawowa", "editorial": "Redakcyjna", "expert": "Ekspercka"}, activities: map[string]string{"research": "Badania", "summarisation": "Streszczenie", "drafting": "Szkic", "generation": "Generowanie", "translation": "Tłumaczenie", "editing": "Edycja", "imageGeneration": "Generowanie obrazów", "codeGeneration": "Generowanie kodu", "transcription": "Transkrypcja", "classification": "Klasyfikacja"}, assurances: map[string]string{"selfDeclared": "Samodeklaracja", "technicallyRecorded": "Zapisane technicznie", "signed": "Podpisane", "verified": "Zweryfikowane"}},
}
for code, tr := range translations {
l := catalogs[code]
l.Components, l.Reviews, l.Activities, l.Assurances = tr.components, tr.reviews, tr.activities, tr.assurances
catalogs[code] = l
}
}
func applyAssuranceTranslations() {
texts := map[string]map[string]string{
"de": {
"field_assurance": "Nachweisgrundlage",
"fact_assurance": "Nachweisgrundlage",
"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.",
"not_applicable": "Nicht anwendbar",
"not_specified": "Nicht angegeben",
"assurance_selfDeclared_description": "Die Angaben beruhen auf einer Selbsterklärung der veröffentlichenden Person oder Organisation.",
"assurance_technicallyRecorded_description": "Nur auswählen, wenn die Angaben im Erstellungs- oder Veröffentlichungsprozess technisch protokolliert werden.",
"assurance_signed_description": "Nur auswählen, wenn die Erklärung tatsächlich digital signiert wird und Herkunft sowie Unverändertheit prüfbar sind.",
"assurance_verified_description": "Nur auswählen, wenn die Angaben nach einem dokumentierten Prüfverfahren zusätzlich verifiziert wurden.",
"assurance_selfDeclared_statement": "Die Angaben zur KI-Nutzung beruhen auf einer Selbsterklärung der veröffentlichenden Person oder Organisation.",
"assurance_technicallyRecorded_statement": "Als Nachweisgrundlage ist eine technische Protokollierung im Erstellungs- oder Veröffentlichungsprozess angegeben.",
"assurance_signed_statement": "Als Nachweisgrundlage ist eine digitale Signatur der Erklärung angegeben; damit können Herkunft und Unverändertheit geprüft werden.",
"assurance_verified_statement": "Als Nachweisgrundlage ist eine zusätzliche Verifikation nach einem dokumentierten Prüfverfahren angegeben.",
},
"en": {
"field_assurance": "Evidence basis",
"fact_assurance": "Evidence basis",
"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.",
"not_applicable": "Not applicable",
"not_specified": "Not specified",
"assurance_selfDeclared_description": "The information is based on a declaration made by the publishing person or organisation.",
"assurance_technicallyRecorded_description": "Select only when the information is technically recorded during the creation or publication workflow.",
"assurance_signed_description": "Select only when the declaration is actually digitally signed and its origin and integrity can be checked.",
"assurance_verified_description": "Select only when the information has been additionally verified under a documented review procedure.",
"assurance_selfDeclared_statement": "The information on AI use is based on a declaration made by the publishing person or organisation.",
"assurance_technicallyRecorded_statement": "Technical recording during the creation or publication workflow is stated as the evidence basis.",
"assurance_signed_statement": "A digital signature of the declaration is stated as the evidence basis, allowing its origin and integrity to be checked.",
"assurance_verified_statement": "Additional verification under a documented review procedure is stated as the evidence basis.",
},
"fr": {
"field_assurance": "Base de preuve",
"not_applicable": "Non applicable",
"not_specified": "Non indiqué",
"fact_assurance": "Base de preuve",
"assurance_selfDeclared_description": "Les informations sont fournies par la personne ou lorganisation éditrice elle-même.",
"assurance_technicallyRecorded_description": "Les informations ont été enregistrées techniquement pendant le processus de création ou de publication.",
"assurance_signed_description": "La déclaration a été signée numériquement afin de permettre le contrôle de son origine et de son intégrité.",
"assurance_verified_description": "Les informations ont en outre été vérifiées selon une procédure documentée.",
"assurance_selfDeclared_statement": "Les informations sur lutilisation de lIA reposent sur une déclaration de la personne ou de lorganisation éditrice.",
"assurance_technicallyRecorded_statement": "Un enregistrement technique pendant le processus de création ou de publication est indiqué comme base de preuve.",
"assurance_signed_statement": "Une signature numérique de la déclaration est indiquée comme base de preuve, ce qui permet den contrôler lorigine et lintégrité.",
"assurance_verified_statement": "Une vérification supplémentaire selon une procédure documentée est indiquée comme base de preuve.",
},
"es": {
"field_assurance": "Base de evidencia",
"not_applicable": "No aplicable",
"not_specified": "No especificado",
"fact_assurance": "Base de evidencia",
"assurance_selfDeclared_description": "La información es proporcionada por la propia persona u organización que publica el contenido.",
"assurance_technicallyRecorded_description": "La información se registró técnicamente durante el proceso de creación o publicación.",
"assurance_signed_description": "La declaración se firmó digitalmente para permitir la comprobación de su origen e integridad.",
"assurance_verified_description": "La información fue verificada adicionalmente mediante un procedimiento documentado.",
"assurance_selfDeclared_statement": "La información sobre el uso de IA se basa en una declaración de la persona u organización editora.",
"assurance_technicallyRecorded_statement": "Se indica como base de evidencia un registro técnico durante el proceso de creación o publicación.",
"assurance_signed_statement": "Se indica como base de evidencia una firma digital de la declaración, que permite comprobar su origen e integridad.",
"assurance_verified_statement": "Se indica como base de evidencia una verificación adicional conforme a un procedimiento documentado.",
},
"it": {
"field_assurance": "Base probatoria",
"not_applicable": "Non applicabile",
"not_specified": "Non specificato",
"fact_assurance": "Base probatoria",
"assurance_selfDeclared_description": "Le informazioni sono fornite dalla persona o dallorganizzazione che pubblica il contenuto.",
"assurance_technicallyRecorded_description": "Le informazioni sono state registrate tecnicamente durante il processo di creazione o pubblicazione.",
"assurance_signed_description": "La dichiarazione è stata firmata digitalmente per consentire la verifica dellorigine e dellintegrità.",
"assurance_verified_description": "Le informazioni sono state inoltre verificate secondo una procedura documentata.",
"assurance_selfDeclared_statement": "Le informazioni sulluso dellIA si basano su una dichiarazione della persona o dellorganizzazione che pubblica il contenuto.",
"assurance_technicallyRecorded_statement": "Come base probatoria è indicata una registrazione tecnica durante il processo di creazione o pubblicazione.",
"assurance_signed_statement": "Come base probatoria è indicata una firma digitale della dichiarazione, che consente di verificarne origine e integrità.",
"assurance_verified_statement": "Come base probatoria è indicata una verifica aggiuntiva secondo una procedura documentata.",
},
"nl": {
"field_assurance": "Bewijsgrondslag",
"not_applicable": "Niet van toepassing",
"not_specified": "Niet vermeld",
"fact_assurance": "Bewijsgrondslag",
"assurance_selfDeclared_description": "De informatie is verstrekt door de publicerende persoon of organisatie zelf.",
"assurance_technicallyRecorded_description": "De informatie is technisch vastgelegd tijdens het creatie- of publicatieproces.",
"assurance_signed_description": "De verklaring is digitaal ondertekend, zodat herkomst en integriteit kunnen worden gecontroleerd.",
"assurance_verified_description": "De informatie is aanvullend geverifieerd volgens een gedocumenteerde procedure.",
"assurance_selfDeclared_statement": "De informatie over AI-gebruik is gebaseerd op een verklaring van de publicerende persoon of organisatie.",
"assurance_technicallyRecorded_statement": "Technische vastlegging tijdens het creatie- of publicatieproces is als bewijsgrondslag aangegeven.",
"assurance_signed_statement": "Een digitale ondertekening van de verklaring is als bewijsgrondslag aangegeven, zodat herkomst en integriteit kunnen worden gecontroleerd.",
"assurance_verified_statement": "Aanvullende verificatie volgens een gedocumenteerde procedure is als bewijsgrondslag aangegeven.",
},
"pt": {
"field_assurance": "Base de evidência",
"not_applicable": "Não aplicável",
"not_specified": "Não indicado",
"fact_assurance": "Base de evidência",
"assurance_selfDeclared_description": "As informações são fornecidas pela própria pessoa ou organização responsável pela publicação.",
"assurance_technicallyRecorded_description": "As informações foram registadas tecnicamente durante o processo de criação ou publicação.",
"assurance_signed_description": "A declaração foi assinada digitalmente para permitir a verificação da origem e integridade.",
"assurance_verified_description": "As informações foram adicionalmente verificadas segundo um procedimento documentado.",
"assurance_selfDeclared_statement": "As informações sobre a utilização de IA baseiam-se numa declaração da pessoa ou organização responsável pela publicação.",
"assurance_technicallyRecorded_statement": "É indicado como base de evidência um registo técnico durante o processo de criação ou publicação.",
"assurance_signed_statement": "É indicada como base de evidência uma assinatura digital da declaração, permitindo verificar a origem e a integridade.",
"assurance_verified_statement": "É indicada como base de evidência uma verificação adicional segundo um procedimento documentado.",
},
"pl": {
"field_assurance": "Podstawa dowodowa",
"not_applicable": "Nie dotyczy",
"not_specified": "Nie wskazano",
"fact_assurance": "Podstawa dowodowa",
"assurance_selfDeclared_description": "Informacje zostały podane przez osobę lub organizację publikującą treść.",
"assurance_technicallyRecorded_description": "Informacje zostały technicznie zarejestrowane w procesie tworzenia lub publikacji.",
"assurance_signed_description": "Deklaracja została podpisana cyfrowo, co umożliwia sprawdzenie jej pochodzenia i integralności.",
"assurance_verified_description": "Informacje zostały dodatkowo zweryfikowane zgodnie z udokumentowaną procedurą.",
"assurance_selfDeclared_statement": "Informacje o użyciu AI opierają się na deklaracji osoby lub organizacji publikującej treść.",
"assurance_technicallyRecorded_statement": "Jako podstawę dowodową wskazano techniczną rejestrację w procesie tworzenia lub publikacji.",
"assurance_signed_statement": "Jako podstawę dowodową wskazano cyfrowy podpis deklaracji, umożliwiający sprawdzenie jej pochodzenia i integralności.",
"assurance_verified_statement": "Jako podstawę dowodową wskazano dodatkową weryfikację zgodnie z udokumentowaną procedurą.",
},
}
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)
out.Presets = make(map[string]PresetText, len(in.Presets))
for k, v := range in.Presets {
out.Presets[k] = v
}
out.Extents = cloneMap(in.Extents)
out.Components = cloneMap(in.Components)
out.Reviews = cloneMap(in.Reviews)
out.Activities = cloneMap(in.Activities)
out.Assurances = cloneMap(in.Assurances)
return out
}
func cloneMap(in map[string]string) map[string]string {
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func Supported(code string) bool {
_, ok := catalogs[Normalize(code)]
return ok
}
func Normalize(code string) string {
code = strings.ToLower(strings.TrimSpace(code))
if i := strings.IndexAny(code, "-_"); i >= 0 {
code = code[:i]
}
return code
}
func Resolve(explicit, acceptLanguage, fallback string) string {
if code := Normalize(explicit); Supported(code) {
return code
}
for _, part := range strings.Split(acceptLanguage, ",") {
code := Normalize(strings.SplitN(part, ";", 2)[0])
if Supported(code) {
return code
}
}
if code := Normalize(fallback); Supported(code) {
return code
}
return "en"
}
func Get(code string) Locale {
if l, ok := catalogs[Normalize(code)]; ok {
return cloneLocale(l)
}
return cloneLocale(catalogs["en"])
}
func Languages() []LanguageOption {
out := make([]LanguageOption, 0, len(catalogs))
for code, l := range catalogs {
out = append(out, LanguageOption{Code: code, Name: l.Name})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func ClientCatalogs() map[string]Locale {
out := make(map[string]Locale, len(catalogs))
for code, l := range catalogs {
out[code] = cloneLocale(l)
}
return out
}

View File

@@ -0,0 +1,22 @@
package i18n
func init() {
labels := map[string]string{
"de": "Produkt & Installation",
"en": "Product & installation",
"fr": "Produit & installation",
"es": "Producto e instalación",
"it": "Prodotto e installazione",
"nl": "Product & installatie",
"pt": "Produto e instalação",
"pl": "Produkt i instalacja",
}
for code, label := range labels {
locale := catalogs[code]
if locale.Text == nil {
locale.Text = map[string]string{}
}
locale.Text["nav_product"] = label
catalogs[code] = locale
}
}

View File

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

View File

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

View File

@@ -0,0 +1,601 @@
package marketing
import "strings"
type Feature struct {
Kicker string
Title string
Description string
}
type ComparisonRow struct {
Feature string
Community string
Pro string
}
type Plan struct {
Name string
Price string
Period string
Description string
Features []string
CTA string
URL string
Featured bool
}
type InstallMethod struct {
ID string
Title string
Summary string
Code string
}
type ConfigRow struct {
Name string
Default string
Description string
}
type FAQ struct {
Question string
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
}
type copySet struct {
MetaDescription string
NavFeatures, NavPricing, NavInstall, NavGenerator, NavBackground, LanguageLabel string
HeroEyebrow, HeroTitle, HeroLead, PrimaryCTA, SecondaryCTA string
Proof []string
FeaturesEyebrow, FeaturesTitle, FeaturesLead string
Features []Feature
CompareEyebrow, CompareTitle, CompareLead, CompareFeature, CompareCommunity, ComparePro string
Comparison []ComparisonRow
PricingEyebrow, PricingTitle, PricingLead, PricePeriod, PriceNote string
PlanDescriptions [5]string
PlanFeatures [5][]string
PlanCTA [5]string
InstallEyebrow, InstallTitle, InstallLead, CopyLabel, CopiedLabel string
InstallTitles [5]string
InstallSummaries [5]string
ConfigTitle, ConfigVariable, ConfigDefault, ConfigMeaning string
ConfigDescriptions [8]string
FAQEyebrow, FAQTitle string
FAQs []FAQ
FinalTitle, FinalLead, FinalPrimary, FinalSecondary, Footer string
EnterprisePrice string
}
func Build(lang, productName, baseURL, salesURL, contactURL string, prices Prices) Page {
lang = normalize(lang)
copy := copies()[lang]
if copy.HeroTitle == "" {
lang = "en"
copy = copies()[lang]
}
copy = neutralize(copy, lang)
baseURL = strings.TrimRight(baseURL, "/")
if salesURL == "" {
salesURL = contactURL
}
if salesURL == "" {
salesURL = baseURL + "/?lang=" + lang
}
if contactURL == "" {
contactURL = baseURL + "/?lang=" + lang
}
if prices.Community == "" {
prices.Community = "0 €"
}
if prices.Pro == "" {
prices.Pro = "19 €"
}
if prices.Publisher == "" {
prices.Publisher = "79 €"
}
if prices.Agency == "" {
prices.Agency = "199 €"
}
generatorURL := baseURL + "/?lang=" + lang + "#generator"
planNames := []string{"Community", "Pro", "Publisher", "Agency", "Enterprise"}
planPrices := []string{prices.Community, prices.Pro, prices.Publisher, prices.Agency, copy.EnterprisePrice}
plans := make([]Plan, 0, len(planNames))
for i := range planNames {
url := salesURL
if i == 0 {
url = generatorURL
}
period := copy.PricePeriod
if i == 4 {
period = ""
}
plans = append(plans, Plan{
Name: planNames[i], Price: planPrices[i], Period: period,
Description: copy.PlanDescriptions[i], Features: copy.PlanFeatures[i], CTA: copy.PlanCTA[i], URL: url, Featured: i == 1,
})
}
install := []InstallMethod{
{ID: "compose", Title: copy.InstallTitles[0], Summary: copy.InstallSummaries[0], Code: `cp .env.example .env
# Adjust BASE_URL and PUBLIC_NAME in .env
docker compose up -d --build
curl -fsS http://localhost:8080/readyz`},
{ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:1.6.1 .
docker run -d --name ai-disclosure \
-p 8080:8080 \
-e BASE_URL=https://ai.example.org \
-e PUBLIC_NAME="AI Usage Disclosure" \
--read-only --tmpfs /tmp \
ai-disclosure-standard:1.6.1`},
{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='...'
kubectl apply -f deploy/kubernetes.yaml
kubectl rollout status deployment/ai-disclosure`},
{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`},
{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 \
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", "-"}
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]})
}
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,
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,
FinalTitle: copy.FinalTitle, FinalLead: copy.FinalLead, FinalPrimary: copy.FinalPrimary, FinalSecondary: copy.FinalSecondary, Footer: copy.Footer,
}
}
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.", "Link zur Hintergrund- oder Kontaktseite.", "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.", "Background or contact 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 de contexte ou de contact.", "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 de contexto o contacto.", "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 di contesto o contatto.", "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.", "Achtergrond- of contactpagina.", "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 de contexto ou contacto.", "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.", "Strona informacji lub kontaktu.", "Język zapasowy.", "Token licencji podpisany przez dostawcę.", "Minimalny tryb: offline, hybrid lub online.", "Opcjonalny centralny serwer weryfikacji dla trybu hybrydowego i online."},
}
if descriptions, ok := values[normalize(lang)]; ok {
return descriptions
}
return values["en"]
}
func neutralize(c copySet, lang string) copySet {
// The public product page documents the open software only. Commercial
// pricing, edition comparisons and sales messaging intentionally stay out
// of the application UI.
if len(c.Features) > 4 {
c.Features = append(append([]Feature{}, c.Features[:4]...), c.Features[5:]...)
}
if len(c.FAQs) > 2 {
c.FAQs = c.FAQs[2:]
}
c.SecondaryCTA, c.FinalSecondary = "", ""
switch lang {
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.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.FinalTitle = "Direkt mit einer eigenen Erklärung starten."
c.FinalLead = "Der Generator funktioniert ohne Registrierung, Cookies oder externe Ressourcen."
case "fr":
c.HeroEyebrow = "Open source · Auto-hébergeable"
c.FeaturesLead = "Badges visibles, résumés compréhensibles et déclarations lisibles par machine dans une application légère."
c.InstallLead = "Toutes les variantes utilisent le même binaire Go et fonctionnent sans service externe."
case "es":
c.HeroEyebrow = "Código abierto · Autoalojable"
c.FeaturesLead = "Avisos visibles, resúmenes comprensibles y declaraciones legibles por máquina en una aplicación ligera."
c.InstallLead = "Todas las variantes usan el mismo binario Go y funcionan sin servicios externos."
case "it":
c.HeroEyebrow = "Open source · Self-hosted"
c.FeaturesLead = "Avvisi visibili, riepiloghi comprensibili e dichiarazioni leggibili dalle macchine in un'applicazione leggera."
c.InstallLead = "Tutte le varianti usano lo stesso binario Go e funzionano senza servizi esterni."
case "nl":
c.HeroEyebrow = "Open source · Zelf te hosten"
c.FeaturesLead = "Zichtbare meldingen, begrijpelijke samenvattingen en machineleesbare verklaringen in één lichte toepassing."
c.InstallLead = "Alle varianten gebruiken dezelfde Go-binary en werken zonder externe diensten."
case "pt":
c.HeroEyebrow = "Código aberto · Autoalojado"
c.FeaturesLead = "Avisos visíveis, resumos compreensíveis e declarações legíveis por máquina numa aplicação leve."
c.InstallLead = "Todas as variantes usam o mesmo binário Go e funcionam sem serviços externos."
case "pl":
c.HeroEyebrow = "Open source · Samodzielny hosting"
c.FeaturesLead = "Widoczne oznaczenia, zrozumiałe podsumowania i deklaracje maszynowe w lekkiej aplikacji."
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.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."
}
return c
}
func normalize(lang string) string {
lang = strings.ToLower(strings.TrimSpace(lang))
if i := strings.IndexAny(lang, "-_"); i >= 0 {
lang = lang[:i]
}
return lang
}
func copies() map[string]copySet {
return map[string]copySet{
"de": german(), "en": english(), "fr": french(), "es": spanish(),
"it": italian(), "nl": dutch(), "pt": portuguese(), "pl": polish(),
}
}
func commonComparison(lang, yes, no, included, proOnly string) []ComparisonRow {
features := map[string][]string{
"de": {"SVG-Badges", "HTML-Erklärungseiten", "JSON-LD und Schema-Validierung", "8 Sprachen", "Docker, Kubernetes und Swarm", "Eigene Erklärungstexte", "Eigene Badge-Texte und Farben", "Signierte Offline-Lizenz", "Domaingebundene Aktivierung", "Kommerzieller Support"},
"en": {"SVG badges", "HTML declaration pages", "JSON-LD & schema validation", "8 languages", "Docker, Kubernetes & Swarm", "Custom declaration copy", "Custom badge labels & colours", "Offline signed licence", "Domain-bound activation", "Commercial support"},
"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"},
"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"},
}
labels := features[normalize(lang)]
if len(labels) == 0 {
labels = features["en"]
}
community := []string{included, included, included, included, included, no, no, no, no, no}
pro := []string{included, included, included, included, included, yes, yes, yes, yes, proOnly}
rows := make([]ComparisonRow, 0, len(labels))
for i, feature := range labels {
rows = append(rows, ComparisonRow{Feature: feature, Community: community[i], Pro: pro[i]})
}
return rows
}
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.",
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.",
Features: []Feature{
{"Sichtbar", "SVG-Badges", "Deterministische, cachefähige Badges aus Presets oder strukturierten Parametern."},
{"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."},
{"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"),
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{
{"Standard-Presets", "Alle 8 Sprachen", "SVG, HTML und JSON-LD", "Self-Hosting"},
{"Bis zu 3 Domains", "Eigene Texte", "Eigene Badge-Texte und Farben", "Signierte Offline-Lizenz"},
{"Bis zu 20 Domains", "Alle Pro-Funktionen", "Priorisierter Support", "Migrationshilfe"},
{"Bis zu 100 Domains", "Kundendomains", "Kommerzielle Agenturnutzung", "Technisches Onboarding"},
{"Individuelle Domainzahl", "SLA und Supportfenster", "Private Distribution", "Individuelle Lizenzbedingungen"},
},
PlanCTA: [5]string{"Kostenlos starten", "Pro anfragen", "Publisher anfragen", "Agency anfragen", "Kontakt aufnehmen"}, EnterprisePrice: "Individuell",
InstallEyebrow: "Deployment", InstallTitle: "In wenigen Befehlen produktiv.", InstallLead: "Alle Varianten verwenden dasselbe Go-Binary. Community und Pro unterscheiden sich nur durch optionale Lizenz-Secrets.", CopyLabel: "Kopieren", CopiedLabel: "Kopiert",
InstallTitles: [5]string{"Docker Compose", "Docker CLI", "Kubernetes", "Docker Swarm", "Direkt mit Go"},
InstallSummaries: [5]string{"Lokaler oder einzelner Server mit deklarativer Konfiguration.", "Image selbst bauen und als gehärteten Container starten.", "Drei Replikate, Probes, Rolling Updates, HPA und PodDisruptionBudget.", "Mehrere Replikate mit Docker-nativem Orchestrator betreiben.", "Für Entwicklung, Tests oder ein natives Systemd-Deployment."},
ConfigTitle: "Wichtige Umgebungsvariablen", ConfigVariable: "Variable", ConfigDefault: "Standard", ConfigMeaning: "Bedeutung",
ConfigDescriptions: [8]string{"Bind-Adresse des HTTP-Servers.", "Öffentliche Basis-URL ohne abschließenden Slash.", "Produkt- oder Seitentitel.", "Link zur Hintergrund- oder Kontaktseite.", "Ziel für Pro- und Vertriebsanfragen.", "Rückfallsprache der Ausgabe.", "Öffentlicher Ed25519-Schlüssel für Pro.", "Signierter und optional domaingebundener Pro-Token."},
FAQEyebrow: "FAQ", FAQTitle: "Häufige Fragen",
FAQs: []FAQ{
{"Ist die Community-Ausgabe eingeschränkt?", "Die Standard-Presets, acht Sprachen, SVG, Erklärungseiten, JSON-LD, Validator und alle Deployment-Dateien sind vollständig nutzbar. Nur eigene Texte, Farben und kommerzielle Zusatzleistungen benötigen Pro."},
{"Benötigt Pro eine Verbindung zu einem Lizenzserver?", "Nein. Die Ed25519-Signatur wird lokal in jeder Replik geprüft. Dadurch bleibt das Deployment hochverfügbar und funktioniert auch in abgeschotteten Netzen."},
{"Kann ich das System hinter einem CDN betreiben?", "Ja. Badge- und Manifest-Antworten sind deterministisch, senden ETags und geeignete Cache-Control-Header."},
{"Ist die Kennzeichnung automatisch rechtskonform?", "Nein. Der Dienst stellt technische Deklarationen und Integrationen bereit, ersetzt aber keine rechtliche Prüfung oder redaktionelle Verantwortung."},
},
FinalTitle: "Starte offen. Erweitere nur dort, wo deine Marke es braucht.", FinalLead: "Der Generator ist ohne Registrierung nutzbar. Für eigene Texte, Farben und Domains steht die Pro-Lizenz bereit.", FinalPrimary: "Generator öffnen", FinalSecondary: "Pro besprechen", Footer: "Offener KI-Nutzungsstandard · Keine Rechtsberatung.",
}
}
func english() copySet {
return copySet{
MetaDescription: "Product, pricing and installation for the open AI usage disclosure standard.",
NavFeatures: "Features", NavPricing: "Pricing", NavInstall: "Installation", NavGenerator: "Generator", NavBackground: "Background", LanguageLabel: "Language",
HeroEyebrow: "Open source core · Pro when needed", HeroTitle: "Disclose AI use without locking into a platform.", HeroLead: "A stateless Go service for SVG badges, human-readable declaration pages and JSON-LD. Self-hostable, internationalised and built for highly available deployments.",
PrimaryCTA: "Create a badge", SecondaryCTA: "Talk about Pro", Proof: []string{"8 languages", "No cookies", "No database", "Docker & Kubernetes"},
FeaturesEyebrow: "Feature set", FeaturesTitle: "From a visible notice to machine-readable evidence.", FeaturesLead: "Community covers the open integration. Pro adds branded presentation and commercial licensing.",
Features: []Feature{
{"Visible", "SVG badges", "Deterministic, cacheable badges generated from presets or structured parameters."},
{"Readable", "Declaration pages", "Human-readable pages explain AI contribution, activities, review and responsibility."},
{"Machine-readable", "JSON-LD and schema", "Link, validate or include manifests in build and CMS workflows."},
{"International", "Eight languages", "German, English, French, Spanish, Italian, Dutch, Portuguese and Polish."},
{"Pro", "Custom copy and design", "Custom titles, descriptions, badge labels and colours with server-side enforcement."},
{"Offline", "Signed licensing", "Ed25519 licences are verified locally without an external licensing service."},
{"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"),
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"}},
PlanCTA: [5]string{"Start free", "Contact Pro", "Contact Publisher", "Contact Agency", "Contact sales"}, EnterprisePrice: "Custom",
InstallEyebrow: "Deployment", InstallTitle: "Production-ready in a few commands.", InstallLead: "Every option runs the same Go binary. Community and Pro differ only through optional licence secrets.", CopyLabel: "Copy", CopiedLabel: "Copied",
InstallTitles: [5]string{"Docker Compose", "Docker CLI", "Kubernetes", "Docker Swarm", "Run with Go"}, InstallSummaries: [5]string{"Declarative setup for a local or single-server deployment.", "Build the image and run a hardened container directly.", "Three replicas, probes, rolling updates, HPA and a PodDisruptionBudget.", "Operate several replicas with Docker's native orchestrator.", "For development, tests or a native systemd deployment."},
ConfigTitle: "Important environment variables", ConfigVariable: "Variable", ConfigDefault: "Default", ConfigMeaning: "Purpose", ConfigDescriptions: [8]string{"HTTP server bind address.", "Public base URL without a trailing slash.", "Product or site title.", "Background or contact page.", "Destination for Pro and sales enquiries.", "Fallback output language.", "Public Ed25519 key for Pro verification.", "Signed and optionally domain-bound Pro token."},
FAQEyebrow: "FAQ", FAQTitle: "Common questions", FAQs: []FAQ{{"Is Community artificially limited?", "No. Standard presets, eight languages, SVG, declaration pages, JSON-LD, validation and deployment files are fully usable. Only custom presentation and commercial services require Pro."}, {"Does Pro depend on a licensing server?", "No. Every replica verifies the Ed25519 signature locally, preserving availability and supporting isolated networks."}, {"Can I place the service behind a CDN?", "Yes. Badge and manifest responses are deterministic and include ETag and cache-control headers."}, {"Does this automatically make a site legally compliant?", "No. The service provides technical declarations and integrations; it does not replace legal review or editorial responsibility."}},
FinalTitle: "Start open. Add branding only where you need it.", FinalLead: "The generator works without registration. Pro is available for custom copy, colours and licensed domains.", FinalPrimary: "Open generator", FinalSecondary: "Discuss Pro", Footer: "Open AI usage disclosure standard · Not legal advice.",
}
}
func french() copySet {
c := english()
c.MetaDescription = "Produit, tarifs et installation de la norme ouverte de déclaration d'utilisation de l'IA."
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 l'usage de l'IA sans dépendre d'une 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.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.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.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 de contexte ou de contact.", "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.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."
return c
}
func spanish() copySet {
c := english()
c.MetaDescription = "Producto, precios e instalación del estándar abierto de declaración de uso de IA."
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Funciones", "Precios", "Instalación", "Generador", "Contexto", "Idioma"
c.HeroEyebrow, c.HeroTitle = "Núcleo open source · Pro cuando haga falta", "Declara el uso de IA sin depender de una plataforma."
c.HeroLead = "Un servicio Go sin estado para insignias SVG, páginas explicativas y JSON-LD. Autoalojable, internacional y preparado para alta disponibilidad."
c.PrimaryCTA, c.SecondaryCTA = "Crear una insignia", "Consultar Pro"
c.Proof = []string{"8 idiomas", "Sin cookies", "Sin base de datos", "Docker y Kubernetes"}
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Funciones", "Desde el aviso visible hasta la declaración legible por máquina.", "Community cubre la integración abierta. Pro añade personalización y licencia comercial."
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.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."}
c.PlanFeatures = [5][]string{{"Preajustes estándar", "8 idiomas", "SVG, HTML y JSON-LD", "Autoalojamiento"}, {"Hasta 3 dominios", "Textos propios", "Etiquetas y colores propios", "Licencia offline firmada"}, {"Hasta 20 dominios", "Todas las funciones Pro", "Soporte prioritario", "Ayuda de migración"}, {"Hasta 100 dominios", "Dominios de clientes", "Uso comercial de agencia", "Onboarding técnico"}, {"Dominios a medida", "SLA y ventanas de soporte", "Distribución privada", "Condiciones de licencia propias"}}
c.PlanCTA = [5]string{"Empezar gratis", "Consultar Pro", "Consultar Publisher", "Consultar Agency", "Contactar ventas"}
c.EnterprisePrice = "A medida"
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Despliegue", "Listo para producción en pocos comandos.", "Todas las opciones ejecutan el mismo binario Go. Community y Pro solo se diferencian por secretos de licencia opcionales."
c.CopyLabel, c.CopiedLabel = "Copiar", "Copiado"
c.InstallSummaries = [5]string{"Configuración declarativa local o de un servidor.", "Construye la imagen y ejecuta un contenedor endurecido.", "Tres réplicas, probes, rolling updates, HPA y PodDisruptionBudget.", "Varias réplicas con el orquestador nativo de Docker.", "Para desarrollo, pruebas o un despliegue nativo con systemd."}
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Variables de entorno importantes", "Variable", "Valor por defecto", "Uso"
c.ConfigDescriptions = [8]string{"Dirección de escucha HTTP.", "URL pública sin barra final.", "Título del producto o sitio.", "Página de contexto o contacto.", "Destino de consultas Pro y comerciales.", "Idioma de respaldo.", "Clave pública Ed25519 para Pro.", "Token Pro firmado y opcionalmente ligado a dominios."}
c.FAQEyebrow, c.FAQTitle = "FAQ", "Preguntas frecuentes"
c.FAQs = []FAQ{{"¿Community está limitada artificialmente?", "No. Los preajustes, ocho idiomas, SVG, páginas, JSON-LD, validación y archivos de despliegue son completos. Solo la personalización comercial requiere Pro."}, {"¿Pro depende de un servidor de licencias?", "No. Cada réplica verifica localmente la firma Ed25519."}, {"¿Puedo usar un CDN?", "Sí. Las respuestas son deterministas e incluyen ETag y Cache-Control."}, {"¿Garantiza cumplimiento legal?", "No. Es infraestructura técnica y no sustituye revisión jurídica ni responsabilidad editorial."}}
c.FinalTitle, c.FinalLead, c.FinalPrimary, c.FinalSecondary = "Empieza abierto. Añade marca solo cuando la necesites.", "El generador funciona sin registro. Pro añade textos, colores y dominios personalizados.", "Abrir generador", "Hablar de Pro"
c.Footer = "Estándar abierto de declaración de uso de IA · No es asesoramiento jurídico."
return c
}
func italian() copySet {
c := english()
c.MetaDescription = "Prodotto, prezzi e installazione dello standard aperto per dichiarare l'uso dell'IA."
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.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.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.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"}}
c.PlanCTA = [5]string{"Inizia gratis", "Contatta Pro", "Contatta Publisher", "Contatta Agency", "Contatta vendite"}
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.ConfigDescriptions = [8]string{"Indirizzo di ascolto HTTP.", "URL pubblico senza slash finale.", "Titolo del prodotto o sito.", "Pagina di contesto o contatto.", "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."
return c
}
func dutch() copySet {
c := english()
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.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.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.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.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.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.", "Achtergrond- of contactpagina.", "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.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
}
func portuguese() copySet {
c := english()
c.MetaDescription = "Produto, preços e instalação do padrão aberto de declaração de uso de IA."
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Funcionalidades", "Preços", "Instalação", "Gerador", "Contexto", "Idioma"
c.HeroEyebrow, c.HeroTitle = "Núcleo open source · Pro quando necessário", "Declare o uso de IA sem dependência de plataforma."
c.HeroLead = "Um serviço Go sem estado para badges SVG, páginas explicativas e JSON-LD. Autoalojável, internacional e preparado para alta disponibilidade."
c.PrimaryCTA, c.SecondaryCTA = "Criar badge", "Falar sobre Pro"
c.Proof = []string{"8 idiomas", "Sem cookies", "Sem base de dados", "Docker e Kubernetes"}
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Funcionalidades", "Do aviso visível à declaração legível por máquina.", "Community cobre a integração aberta. Pro adiciona personalização e licenciamento comercial."
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.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."}
c.PlanFeatures = [5][]string{{"Presets padrão", "8 idiomas", "SVG, HTML e JSON-LD", "Autoalojamento"}, {"Até 3 domínios", "Textos próprios", "Etiquetas e cores próprias", "Licença offline assinada"}, {"Até 20 domínios", "Todas as funções Pro", "Suporte prioritário", "Ajuda de migração"}, {"Até 100 domínios", "Domínios de clientes", "Uso comercial por agência", "Onboarding técnico"}, {"Domínios personalizados", "SLA e janelas de suporte", "Distribuição privada", "Condições personalizadas"}}
c.PlanCTA = [5]string{"Começar grátis", "Contactar Pro", "Contactar Publisher", "Contactar Agency", "Contactar vendas"}
c.EnterprisePrice = "Personalizado"
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Deployment", "Pronto para produção em poucos comandos.", "Todas as opções executam o mesmo binário Go. Community e Pro diferem apenas por secrets de licença opcionais."
c.CopyLabel, c.CopiedLabel = "Copiar", "Copiado"
c.InstallSummaries = [5]string{"Configuração declarativa local ou num único servidor.", "Construa a imagem e execute um contentor hardened.", "Três réplicas, probes, rolling updates, HPA e PodDisruptionBudget.", "Várias réplicas com Docker Swarm.", "Para desenvolvimento, testes ou deployment nativo com systemd."}
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Variáveis de ambiente importantes", "Variável", "Padrão", "Objetivo"
c.ConfigDescriptions = [8]string{"Endereço de escuta HTTP.", "URL pública sem barra final.", "Título do produto ou site.", "Página de contexto ou contacto.", "Destino para pedidos Pro e comerciais.", "Idioma de fallback.", "Chave pública Ed25519 para Pro.", "Token Pro assinado e opcionalmente ligado a domínios."}
c.FAQEyebrow, c.FAQTitle = "FAQ", "Perguntas frequentes"
c.FAQs = []FAQ{{"Community é limitada artificialmente?", "Não. Presets, oito idiomas, SVG, páginas, JSON-LD, validação e ficheiros de deployment estão completos. Apenas a personalização comercial exige Pro."}, {"Pro depende de um servidor de licenças?", "Não. Cada réplica valida localmente a assinatura Ed25519."}, {"Posso usar um CDN?", "Sim. Badges e manifestos são determinísticos e incluem ETag e Cache-Control."}, {"Isto garante conformidade legal?", "Não. É infraestrutura técnica e não substitui análise jurídica ou responsabilidade editorial."}}
c.FinalTitle, c.FinalLead, c.FinalPrimary, c.FinalSecondary = "Comece aberto. Adicione a marca apenas quando necessário.", "O gerador funciona sem registo. Pro adiciona textos, cores e domínios personalizados.", "Abrir gerador", "Falar sobre Pro"
c.Footer = "Padrão aberto de declaração de uso de IA · Não é aconselhamento jurídico."
return c
}
func polish() copySet {
c := english()
c.MetaDescription = "Produkt, ceny i instalacja otwartego standardu deklarowania użycia AI."
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Funkcje", "Cennik", "Instalacja", "Generator", "Informacje", "Język"
c.HeroEyebrow, c.HeroTitle = "Rdzeń open source · Pro w razie potrzeby", "Deklaruj użycie AI bez uzależnienia od platformy."
c.HeroLead = "Bezstanowa usługa Go dla plakietek SVG, zrozumiałych stron deklaracji i JSON-LD. Samodzielny hosting, wiele języków i wysoka dostępność."
c.PrimaryCTA, c.SecondaryCTA = "Utwórz plakietkę", "Zapytaj o Pro"
c.Proof = []string{"8 języków", "Bez cookies", "Bez bazy danych", "Docker i Kubernetes"}
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Funkcje", "Od widocznej informacji do deklaracji czytelnej maszynowo.", "Community zapewnia otwartą integrację. Pro dodaje personalizację i licencję komercyjną."
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.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ń."}
c.PlanFeatures = [5][]string{{"Standardowe presety", "8 języków", "SVG, HTML i JSON-LD", "Self-hosting"}, {"Do 3 domen", "Własne teksty", "Własne etykiety i kolory", "Podpisana licencja offline"}, {"Do 20 domen", "Wszystkie funkcje Pro", "Priorytetowe wsparcie", "Pomoc w migracji"}, {"Do 100 domen", "Domeny klientów", "Komercyjne użycie agencyjne", "Onboarding techniczny"}, {"Indywidualna liczba domen", "SLA i okna wsparcia", "Prywatna dystrybucja", "Indywidualne warunki"}}
c.PlanCTA = [5]string{"Zacznij bezpłatnie", "Zapytaj o Pro", "Zapytaj o Publisher", "Zapytaj o Agency", "Kontakt ze sprzedażą"}
c.EnterprisePrice = "Indywidualnie"
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Wdrożenie", "Gotowe do produkcji w kilku poleceniach.", "Każda opcja uruchamia ten sam plik Go. Community i Pro różnią się tylko opcjonalnymi sekretami licencji."
c.CopyLabel, c.CopiedLabel = "Kopiuj", "Skopiowano"
c.InstallSummaries = [5]string{"Deklaratywna konfiguracja lokalna lub na jednym serwerze.", "Zbuduj obraz i uruchom utwardzony kontener.", "Trzy repliki, probes, rolling updates, HPA i PodDisruptionBudget.", "Wiele replik z Docker Swarm.", "Do developmentu, testów lub natywnego wdrożenia systemd."}
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Ważne zmienne środowiskowe", "Zmienna", "Domyślnie", "Znaczenie"
c.ConfigDescriptions = [8]string{"Adres nasłuchiwania HTTP.", "Publiczny URL bazowy bez końcowego ukośnika.", "Nazwa produktu lub strony.", "Strona informacji lub kontaktu.", "Cel zapytań Pro i sprzedażowych.", "Język zapasowy.", "Publiczny klucz Ed25519 dla Pro.", "Podpisany i opcjonalnie związany z domeną token Pro."}
c.FAQEyebrow, c.FAQTitle = "FAQ", "Częste pytania"
c.FAQs = []FAQ{{"Czy Community jest sztucznie ograniczona?", "Nie. Presety, osiem języków, SVG, strony, JSON-LD, walidacja i pliki wdrożeniowe są kompletne. Tylko personalizacja komercyjna wymaga Pro."}, {"Czy Pro wymaga serwera licencji?", "Nie. Każda replika lokalnie weryfikuje podpis Ed25519."}, {"Czy mogę użyć CDN?", "Tak. Plakietki i manifesty są deterministyczne i używają ETag oraz Cache-Control."}, {"Czy to gwarantuje zgodność prawną?", "Nie. To infrastruktura techniczna, która nie zastępuje analizy prawnej ani odpowiedzialności redakcyjnej."}}
c.FinalTitle, c.FinalLead, c.FinalPrimary, c.FinalSecondary = "Zacznij otwarcie. Dodaj markę tylko tam, gdzie jej potrzebujesz.", "Generator działa bez rejestracji. Pro dodaje własne teksty, kolory i domeny.", "Otwórz generator", "Porozmawiaj o Pro"
c.Footer = "Otwarty standard deklarowania użycia AI · To nie jest porada prawna."
return c
}

View File

@@ -0,0 +1,25 @@
package marketing
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 == "" {
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)
}
}
}
func TestBuildPriceOverrides(t *testing.T) {
page := Build("en", "Test", "https://example.org", "https://example.org/sales", "", Prices{Pro: "$29", Publisher: "$99", Agency: "$249"})
if page.Plans[1].Price != "$29" || page.Plans[2].Price != "$99" || page.Plans[3].Price != "$249" {
t.Fatalf("price overrides not applied: %#v", page.Plans)
}
}

View File

@@ -0,0 +1,74 @@
openapi: 3.1.0
info:
title: Universal Licence Server API
version: 1.5.0
description: Optional introspection, registration, revocation and signed lease API.
paths:
/v1/introspect:
post:
summary: Validate a registered licence and issue a signed short-lived lease
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token, product, baseUrl]
properties:
token: {type: string}
product: {type: string}
baseUrl: {type: string, format: uri}
instanceId: {type: string}
clientVersion: {type: string}
responses:
"200":
description: Valid licence and signed lease
content:
application/json:
schema:
type: object
properties:
valid: {const: true}
leaseToken: {type: string}
expiresAt: {type: string, format: date-time}
"403": {description: Invalid, unregistered or revoked licence}
/v1/admin/licenses:
get:
summary: List registered licences
security: [{bearerAuth: []}]
responses: {"200": {description: Registry records}}
post:
summary: Register or replace a signed licence
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token]
properties: {token: {type: string}}
responses: {"201": {description: Registered}}
/v1/admin/licenses/{licenseId}/revoke:
post:
summary: Revoke a registered licence
security: [{bearerAuth: []}]
parameters:
- {name: licenseId, in: path, required: true, schema: {type: string}}
responses: {"200": {description: Revoked}}
/v1/admin/licenses/{licenseId}/restore:
post:
summary: Restore a registered licence
security: [{bearerAuth: []}]
parameters:
- {name: licenseId, in: path, required: true, schema: {type: string}}
responses: {"200": {description: Restored}}
/healthz:
get:
summary: Health check
responses: {"200": {description: Healthy}}
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer

207
openapi.yaml Normal file
View File

@@ -0,0 +1,207 @@
openapi: 3.1.0
info:
title: AI Usage Disclosure API
version: 1.6.1
description: Stateless multilingual badge, declaration and validation API with optional licensed presentation capabilities.
servers:
- url: https://ai.example.org
paths:
/badge/{preset}.svg:
get:
summary: Render a standard preset badge
parameters:
- name: preset
in: path
required: true
schema: {type: string, enum: [no-ai, research, summary, full]}
- {$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.}}
responses:
"200":
description: SVG badge
content:
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/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
parameters:
- {$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/assurance'}
- {$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: Human-readable HTML declaration}
"403": {$ref: '#/components/responses/ProRequired'}
/v1/declaration.json:
get:
summary: Generate a JSON-LD declaration from query parameters
parameters:
- {$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/customTitle'}
- {$ref: '#/components/parameters/customDescription'}
- {$ref: '#/components/parameters/badgeLabel'}
- {$ref: '#/components/parameters/badgeMessage'}
- {$ref: '#/components/parameters/leftColor'}
- {$ref: '#/components/parameters/rightColor'}
responses:
"200":
description: JSON-LD declaration
content:
application/ld+json:
schema: {$ref: './schema/declaration.schema.json'}
"403": {$ref: '#/components/responses/ProRequired'}
/v1/validate:
post:
summary: Validate an AI usage declaration
requestBody:
required: true
content:
application/json:
schema: {$ref: './schema/declaration.schema.json'}
responses:
"200": {description: Valid declaration}
"422": {description: Validation failed}
/v1/capabilities:
get:
summary: Return edition, licensed features and supported languages
responses:
"200":
description: Runtime capabilities
content:
application/json:
schema:
type: object
properties:
license: {type: object}
supportedLanguages:
type: array
items: {type: string}
/healthz:
get:
summary: Liveness endpoint
responses: {"200": {description: Healthy}}
/readyz:
get:
summary: Readiness endpoint
responses: {"200": {description: Ready}}
components:
parameters:
preset:
name: preset
in: query
schema: {type: string, enum: [no-ai, research, summary, full]}
extent:
name: extent
in: query
schema: {type: string, enum: [none, assisted, partial, mostly, full], default: assisted}
language:
name: lang
in: query
schema: {type: string, enum: [de, en, fr, es, it, nl, pt, pl]}
component:
name: component
in: query
schema: {type: string, enum: [text, coverImage, image, audio, video, code, other], default: text}
activities:
name: activities
in: query
schema: {type: string, description: Comma-separated activity identifiers.}
review:
name: review
in: query
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}
theme:
name: theme
in: query
schema: {type: string, enum: [mono, color], default: color}
style:
name: style
in: query
schema: {type: string, enum: [flat, flat-square], default: flat}
customTitle:
name: customTitle
in: query
description: Licensed feature custom_text
schema: {type: string, maxLength: 120}
customDescription:
name: customDescription
in: query
description: Licensed feature custom_text
schema: {type: string, maxLength: 500}
badgeLabel:
name: badgeLabel
in: query
description: Licensed feature custom_badge
schema: {type: string, maxLength: 40}
badgeMessage:
name: badgeMessage
in: query
description: Licensed feature custom_badge
schema: {type: string, maxLength: 80}
leftColor:
name: leftColor
in: query
description: Licensed feature custom_badge
schema: {type: string, pattern: '^#[0-9A-Fa-f]{6}$'}
rightColor:
name: rightColor
in: query
description: Licensed feature custom_badge
schema: {type: string, pattern: '^#[0-9A-Fa-f]{6}$'}
responses:
ProRequired:
description: A valid licensed capability is required
content:
application/problem+json:
schema:
type: object
properties:
code: {const: pro_feature_required}
status: {const: 403}
detail: {type: string}

391
pkg/licenseclient/client.go Normal file
View File

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

View File

@@ -0,0 +1,81 @@
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
pkg/licenseclient/doc.go Normal file
View File

@@ -0,0 +1,4 @@
// 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
pkg/licensekit/doc.go Normal file
View File

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

View File

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

View File

@@ -0,0 +1,84 @@
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
pkg/licenseserver/doc.go Normal file
View File

@@ -0,0 +1,4 @@
// 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
pkg/licenseserver/server.go Normal file
View File

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

View File

@@ -0,0 +1,91 @@
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
pkg/licenseserver/store.go Normal file
View File

@@ -0,0 +1,130 @@
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)
}

35
run.ps1 Normal file
View File

@@ -0,0 +1,35 @@
$ErrorActionPreference = "Stop"
$envFile = Join-Path $PSScriptRoot ".env"
if (-not (Test-Path $envFile)) {
throw "Keine .env-Datei gefunden: $envFile"
}
Get-Content $envFile | ForEach-Object {
$line = $_.Trim()
if ($line -and -not $line.StartsWith("#")) {
$parts = $line -split "=", 2
if ($parts.Count -eq 2) {
$name = $parts[0].Trim()
$value = $parts[1].Trim()
if (
($value.StartsWith('"') -and $value.EndsWith('"')) -or
($value.StartsWith("'") -and $value.EndsWith("'"))
) {
$value = $value.Substring(1, $value.Length - 2)
}
[Environment]::SetEnvironmentVariable(
$name,
$value,
"Process"
)
}
}
}
go run .\cmd\server

View File

@@ -0,0 +1,55 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.org/schema/v1/declaration.schema.json",
"title": "AI Usage Declaration",
"type": "object",
"additionalProperties": false,
"required": ["@context", "@type", "schemaVersion", "language", "components", "assurance"],
"properties": {
"@context": {"type": "string"},
"@type": {"const": "AIUsageDeclaration"},
"schemaVersion": {"const": "1.1"},
"subject": {"type": "string", "format": "uri"},
"declaredAt": {"type": "string", "format": "date-time"},
"language": {"enum": ["de", "en", "fr", "es", "it", "nl", "pt", "pl"]},
"components": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/$defs/component"}},
"editorialResponsibility": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {"type": "string"},
"url": {"type": "string", "format": "uri"}
}
},
"assurance": {"enum": ["selfDeclared", "technicallyRecorded", "signed", "verified"]},
"presentation": {
"type": "object",
"additionalProperties": false,
"properties": {
"title": {"type": "string", "maxLength": 120},
"description": {"type": "string", "maxLength": 500},
"badgeLabel": {"type": "string", "maxLength": 40},
"badgeMessage": {"type": "string", "maxLength": 80},
"leftColor": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"},
"rightColor": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"}
}
}
},
"$defs": {
"component": {
"type": "object",
"additionalProperties": false,
"required": ["aiExtent", "humanReview"],
"properties": {
"aiExtent": {"enum": ["none", "assisted", "partial", "mostly", "full"]},
"activities": {
"type": "array",
"uniqueItems": true,
"items": {"enum": ["research", "summarisation", "drafting", "generation", "translation", "editing", "imageGeneration", "codeGeneration", "transcription", "classification"]}
},
"humanReview": {"enum": ["none", "basic", "editorial", "expert"]},
"note": {"type": "string", "maxLength": 500}
}
}
}
}

View File

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

View File

@@ -0,0 +1,12 @@
{
"$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}}
}
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 AI Usage Disclosure contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,16 @@
# Universal License Platform — client snapshot
This directory contains only the runtime verification subset required by the
AI Disclosure Standard. It is compatible with Universal License Platform
v1.0.0 and intentionally excludes key generation, private-key decoding,
license issuance, administrative APIs, storage and server code.
Source protocol: `github.com/b1tsblog/license-platform`
The product imports:
- `pkg/licensekit`: public-key trust-store parsing and token verification only;
- `sdk/go/licenseclient`: offline, hybrid and online runtime checks.
The standalone Universal License Platform remains the sole authority for key
management, license issuance, revocation and lease signing.

View File

@@ -0,0 +1,3 @@
module github.com/b1tsblog/license-platform
go 1.23

View File

@@ -0,0 +1,3 @@
// Package licensekit implements the verification-only token protocol consumed
// by Universal License Platform client applications.
package licensekit

View File

@@ -0,0 +1,408 @@
// Package licensekit contains the verification-only protocol types and
// cryptographic checks used by Universal License Platform clients.
//
// This client snapshot intentionally contains no private-key handling, token
// signing, key generation, license issuance or administrative functionality.
package licensekit
import (
"crypto/ed25519"
"encoding/base64"
"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"`
ServerURL string `json:"serverUrl,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 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); 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); 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 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 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) 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 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")
}
if raw := strings.TrimSpace(c.Verification.ServerURL); raw != "" {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
return errors.New("verification server URL must be an absolute HTTP(S) URL")
}
}
return nil
}
func validateLeaseClaims(c LeaseClaims, now time.Time) 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")
}
return nil
}
func decodeStrict(data []byte, target any) error {
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
if err := dec.Decode(target); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,35 @@
package licensekit
import (
"strings"
"testing"
"time"
)
const (
fixtureIssuerPublic = "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ"
fixtureOfflineToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxJQ0VOU0UiLCJraWQiOiJpc3N1ZXItZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsaWNlbnNlSWQiOiJsaWNfb2ZmbGluZV9maXh0dXJlIiwiaXNzdWVyIjoidW5pdmVyc2FsLWxpY2Vuc2UtcGxhdGZvcm0iLCJjdXN0b21lciI6IkZpeHR1cmUgQ3VzdG9tZXIiLCJwcm9kdWN0IjoiYWktZGlzY2xvc3VyZS1zdGFuZGFyZCIsImVkaXRpb24iOiJwcm8iLCJmZWF0dXJlcyI6WyJjdXN0b21fdGV4dCIsImN1c3RvbV9iYWRnZSJdLCJsaW1pdHMiOnsic2l0ZXMiOjEwfSwiZG9tYWlucyI6WyIqIl0sImlzc3VlZEF0IjoxNzY3MjI1NjAwLCJleHBpcmVzQXQiOjI1MjQ2MDgwMDAsInZlcmlmaWNhdGlvbiI6eyJtb2RlIjoib2ZmbGluZSJ9fQ.xrJiWqCjUQuiu9DWQrmyx-f96rkz3wvbau6IN-z2EyxZwqXVtuICGpEcUN4JEpU4uDMagQpr89boeGMvGs38Cg"
)
func TestVerifyUniversalPlatformToken(t *testing.T) {
store := NewTrustStore()
store.LicenseKeys["issuer-fixture-1"] = fixtureIssuerPublic
verified, err := VerifyLicense(store, fixtureOfflineToken, time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if verified.Claims.Product != "ai-disclosure-standard" || verified.Claims.Edition != "pro" {
t.Fatalf("unexpected claims: %#v", verified.Claims)
}
if err := ValidateLicenseContext(verified.Claims, "ai-disclosure-standard", "https://any-domain.example", ""); err != nil {
t.Fatalf("global domain wildcard should be accepted: %v", err)
}
}
func TestUnknownIssuerKeyIsRejected(t *testing.T) {
_, err := VerifyLicense(NewTrustStore(), fixtureOfflineToken, time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC))
if err == nil || !strings.Contains(err.Error(), "unknown key") {
t.Fatalf("expected unknown-key error, got %v", err)
}
}

View File

@@ -0,0 +1,439 @@
package licenseclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/b1tsblog/license-platform/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"`
ServerURL string `json:"serverUrl,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 {
cfg.ServerURL = strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/")
if cfg.ServerURL == "" {
cfg.ServerURL = strings.TrimRight(strings.TrimSpace(os.Getenv("LICENSE_SERVER_URL")), "/")
}
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
}
serverURL := c.resolveServerURL(ctx, claims)
base.ServerURL = serverURL
if serverURL == "" {
base.Edition = "community"
base.Reason = "online verification is required but no license server URL could be resolved"
c.apply(base, claims)
return
}
lease, source, err := c.obtainLease(ctx, claims, mode, now, serverURL)
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, serverURL string) (licensekit.LeaseClaims, string, error) {
leaseToken, err := c.requestLease(ctx, serverURL)
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) resolveServerURL(ctx context.Context, claims licensekit.Claims) string {
if value := strings.TrimRight(strings.TrimSpace(c.cfg.ServerURL), "/"); value != "" {
return value
}
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("LICENSE_SERVER_URL")), "/"); value != "" {
return value
}
if value := strings.TrimRight(strings.TrimSpace(claims.Verification.ServerURL), "/"); value != "" {
return value
}
base := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
if base == "" {
return ""
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/.well-known/license-server", nil)
if err != nil {
return ""
}
req.Header.Set("Accept", "application/json")
resp, err := c.cfg.HTTPClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
var discovery struct {
ServerURL string `json:"serverUrl"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&discovery); err != nil {
return ""
}
return strings.TrimRight(strings.TrimSpace(discovery.ServerURL), "/")
}
func (c *Client) requestLease(ctx context.Context, serverURL string) (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
}
paths := []string{"/api/v1/licenses/validate", "/v1/introspect"}
var lastErr error
for index, path := range paths {
token, status, err := c.requestLeaseAt(ctx, strings.TrimRight(serverURL, "/")+path, body)
if err == nil {
return token, nil
}
lastErr = err
if index == 0 && status == http.StatusNotFound {
continue
}
break
}
return "", lastErr
}
func (c *Client) requestLeaseAt(ctx context.Context, endpoint string, body []byte) (string, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", 0, 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 "", 0, err
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", resp.StatusCode, err
}
var result introspectResponse
if err := json.Unmarshal(payload, &result); err != nil {
return "", resp.StatusCode, fmt.Errorf("decode verification response: %w", err)
}
if resp.StatusCode != http.StatusOK || !result.Valid || result.LeaseToken == "" {
if result.Reason == "" {
result.Reason = resp.Status
}
return "", resp.StatusCode, errors.New(result.Reason)
}
return result.LeaseToken, resp.StatusCode, 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
}

View File

@@ -0,0 +1,64 @@
package licenseclient
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/b1tsblog/license-platform/pkg/licensekit"
)
const (
fixtureIssuerPublic = "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ"
fixtureLeasePublic = "5_FioQvsVZr-oZXk3OhLaVaNXSywlj60RsBoXisX8vA"
fixtureOfflineToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxJQ0VOU0UiLCJraWQiOiJpc3N1ZXItZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsaWNlbnNlSWQiOiJsaWNfb2ZmbGluZV9maXh0dXJlIiwiaXNzdWVyIjoidW5pdmVyc2FsLWxpY2Vuc2UtcGxhdGZvcm0iLCJjdXN0b21lciI6IkZpeHR1cmUgQ3VzdG9tZXIiLCJwcm9kdWN0IjoiYWktZGlzY2xvc3VyZS1zdGFuZGFyZCIsImVkaXRpb24iOiJwcm8iLCJmZWF0dXJlcyI6WyJjdXN0b21fdGV4dCIsImN1c3RvbV9iYWRnZSJdLCJsaW1pdHMiOnsic2l0ZXMiOjEwfSwiZG9tYWlucyI6WyIqIl0sImlzc3VlZEF0IjoxNzY3MjI1NjAwLCJleHBpcmVzQXQiOjI1MjQ2MDgwMDAsInZlcmlmaWNhdGlvbiI6eyJtb2RlIjoib2ZmbGluZSJ9fQ.xrJiWqCjUQuiu9DWQrmyx-f96rkz3wvbau6IN-z2EyxZwqXVtuICGpEcUN4JEpU4uDMagQpr89boeGMvGs38Cg"
fixtureHybridToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxJQ0VOU0UiLCJraWQiOiJpc3N1ZXItZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsaWNlbnNlSWQiOiJsaWNfcGxhdGZvcm1fZml4dHVyZSIsImlzc3VlciI6InVuaXZlcnNhbC1saWNlbnNlLXBsYXRmb3JtIiwiY3VzdG9tZXIiOiJGaXh0dXJlIEN1c3RvbWVyIiwicHJvZHVjdCI6ImFpLWRpc2Nsb3N1cmUtc3RhbmRhcmQiLCJlZGl0aW9uIjoicHJvIiwiZmVhdHVyZXMiOlsiY3VzdG9tX3RleHQiLCJjdXN0b21fYmFkZ2UiXSwibGltaXRzIjp7InNpdGVzIjoxMH0sImRvbWFpbnMiOlsiKiJdLCJpc3N1ZWRBdCI6MTc2NzIyNTYwMCwiZXhwaXJlc0F0IjoyNTI0NjA4MDAwLCJ2ZXJpZmljYXRpb24iOnsibW9kZSI6Imh5YnJpZCIsImxlYXNlVHRsU2Vjb25kcyI6MzYwMCwib2ZmbGluZUdyYWNlU2Vjb25kcyI6NzIwMCwic2VydmVyVXJsIjoiaHR0cHM6Ly9saWNlbnNlcy5leGFtcGxlLnRlc3QifX0.Jvb6NPFnZZgH0KxpEsjsVyn6kjBkk3cce1kOWrNPZmD5qA35_9Prgt1f_2RmtOtaXTp7j6ISNeYGAetpsHIQDw"
fixtureLeaseToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxFQVNFIiwia2lkIjoibGVhc2UtZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsZWFzZUlkIjoibGVhc2VfcGxhdGZvcm1fZml4dHVyZSIsImxpY2Vuc2VJZCI6ImxpY19wbGF0Zm9ybV9maXh0dXJlIiwicHJvZHVjdCI6ImFpLWRpc2Nsb3N1cmUtc3RhbmRhcmQiLCJjdXN0b21lciI6IkZpeHR1cmUgQ3VzdG9tZXIiLCJlZGl0aW9uIjoicHJvIiwiZmVhdHVyZXMiOlsiY3VzdG9tX3RleHQiLCJjdXN0b21fYmFkZ2UiXSwiaG9zdCI6ImV4YW1wbGUub3JnIiwiaXNzdWVkQXQiOjE3NjcyMjU2MDAsImV4cGlyZXNBdCI6MjUyNDYwODAwMH0.NwWSLoJVwY1X9WGobZu2edPAhmUhjHq8QZkCwpN2CTCqxaqWQjs1suC3mFxsjd5EG2wODr-PQh2duZ4qN4mdCA"
)
func fixtureTrustStore() licensekit.TrustStore {
store := licensekit.NewTrustStore()
store.LicenseKeys["issuer-fixture-1"] = fixtureIssuerPublic
store.LeaseKeys["lease-fixture-1"] = fixtureLeasePublic
return store
}
func TestOfflinePlatformLicense(t *testing.T) {
client := New(context.Background(), Config{
Product: "ai-disclosure-standard",
Token: fixtureOfflineToken,
TrustStore: fixtureTrustStore(),
BaseURL: "https://example.org",
Mode: licensekit.ModeOffline,
})
if !client.Status().Licensed || !client.Has("custom_text") {
t.Fatalf("unexpected status: %#v", client.Status())
}
}
func TestHybridUsesPlatformValidateEndpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"valid": true, "leaseToken": fixtureLeaseToken})
}))
defer server.Close()
client := New(context.Background(), Config{
Product: "ai-disclosure-standard",
Token: fixtureHybridToken,
TrustStore: fixtureTrustStore(),
BaseURL: "https://example.org",
Mode: licensekit.ModeHybrid,
ServerURL: server.URL,
})
if !client.Status().Licensed || client.Status().Source != "online" {
t.Fatalf("unexpected status: %#v", client.Status())
}
if requestedPath != "/api/v1/licenses/validate" {
t.Fatalf("unexpected endpoint %q", requestedPath)
}
}

View File

@@ -0,0 +1,4 @@
// 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

8
web/embed.go Normal file
View File

@@ -0,0 +1,8 @@
package web
import "embed"
// Files contains the self-contained web UI and public schemas.
//
//go:embed templates/*.html static/*
var Files embed.FS

126
web/static/app.js Normal file
View File

@@ -0,0 +1,126 @@
(() => {
const cfg = window.APP_CONFIG;
if (!cfg) return;
const base = cfg.baseURL.replace(/\/$/, '');
const form = document.getElementById('generator-form');
if (!form) return;
const $ = id => document.getElementById(id);
const presets = {
'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) {
const el = $(id);
return el && !el.disabled ? el.value.trim() : '';
}
function setArticleParams(params) {
params.set('mode', 'article');
const fields = {
text: 'article-text',
coverImage: 'article-cover-image',
image: 'article-image',
research: 'article-research',
translation: 'article-translation',
code: 'article-code'
};
Object.entries(fields).forEach(([name, id]) => {
params.set(name + 'Extent', $(id).value);
params.set(name + 'Review', $(`${id}-review`).value);
});
}
function update(applyPreset = false) {
const mode = $('declaration-mode').value;
const articleMode = mode === 'article';
$('single-fields').hidden = articleMode;
$('article-fields').hidden = !articleMode;
const preset = $('preset').value;
if (!articleMode && applyPreset && presets[preset]) {
$('extent').value = presets[preset].extent;
$('activities').value = presets[preset].activities;
$('review').value = presets[preset].review;
}
const lang = $('lang').value;
const locale = cfg.locales[lang] || cfg.locales.en;
const assurance = $('assurance').value;
const assuranceDescription = locale.text[`assurance_${assurance}_description`];
if ($('assurance-description') && assuranceDescription) {
$('assurance-description').textContent = assuranceDescription;
}
const params = new URLSearchParams();
if (articleMode) {
setArticleParams(params);
} else {
if (preset) params.set('preset', preset);
params.set('component', $('component').value);
params.set('extent', $('extent').value);
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 (cfg.capabilities.custom_text) {
if (value('custom-title')) params.set('customTitle', value('custom-title'));
if (value('custom-description')) params.set('customDescription', value('custom-description'));
}
if (cfg.capabilities.custom_badge) {
if (value('badge-label')) params.set('badgeLabel', value('badge-label'));
if (value('badge-message')) params.set('badgeMessage', value('badge-message'));
if (value('left-color')) params.set('leftColor', value('left-color'));
if (value('right-color')) params.set('rightColor', value('right-color'));
}
const declarationURL = `${base}/declaration?${params}`;
const badgeParams = new URLSearchParams(params);
badgeParams.set('theme', $('theme').value);
badgeParams.set('link', declarationURL);
const badgeURL = `${base}/v1/badge.svg?${badgeParams}`;
const manifestURL = `${base}/v1/declaration.json?${params}`;
const alt = articleMode ? ((cfg.locales[lang] || cfg.locales.en).text.badge_article || 'Article transparency') : 'AI usage disclosure';
$('preview-badge').src = badgeURL;
$('preview-badge').alt = alt;
$('preview-link').href = declarationURL;
$('html-code').value = `<a href="${declarationURL}"><img src="${badgeURL}" alt="${alt}"></a>`;
$('markdown-code').value = `[![${alt}](${badgeURL})](${declarationURL})`;
$('json-code').value = manifestURL;
}
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';
}
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);
const locale = cfg.locales[$('lang').value] || cfg.locales.en;
$('copy-html').textContent = locale.text.copied || 'Copied';
setTimeout(() => { $('copy-html').textContent = locale.text.copy_html || 'Copy HTML'; }, 1600);
});
$('page-language')?.addEventListener('change', event => {
const url = new URL(window.location.href);
url.searchParams.set('lang', event.target.value);
window.location.href = url.toString();
});
update(true);
})();

22
web/static/marketing.js Normal file
View File

@@ -0,0 +1,22 @@
(() => {
document.getElementById('marketing-language')?.addEventListener('change', event => {
const next = new URL(window.location.href);
next.searchParams.set('lang', event.target.value);
window.location.assign(next.toString());
});
document.querySelectorAll('.copy-code').forEach(button => {
button.addEventListener('click', async () => {
const target = document.getElementById(button.dataset.copyTarget);
if (!target) return;
const original = button.dataset.label || button.textContent;
try {
await navigator.clipboard.writeText(target.textContent);
button.textContent = button.dataset.copied || original;
window.setTimeout(() => { button.textContent = original; }, 1200);
} catch (_) {
button.textContent = original;
}
});
});
})();

39
web/static/style.css Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,60 @@
{{define "declaration.html"}}
<!doctype html>
<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Title}} · {{.Name}}</title>
<meta name="description" content="{{.Description}}">
<link rel="canonical" href="{{.CanonicalURL}}">
<link rel="describedby" type="application/ld+json" href="{{.ManifestURL}}">
{{range .LanguageLinks}}<link rel="alternate" hreflang="{{.Code}}" href="{{.AbsoluteURL}}">{{end}}
<link rel="alternate" hreflang="x-default" href="{{.DefaultLanguageURL}}">
<link rel="stylesheet" href="/static/style.css">
<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="{{.ManifestURL}}">JSON-LD</a></nav></header>
<main class="declaration-page">
<a class="back" href="/?lang={{.Lang}}">← {{index .Text "back"}}</a>
<article class="declaration-card">
<div class="declaration-toolbar">
<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"}}" 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>
</form>
</div>
<h1>{{.Title}}</h1>
<p class="lead">{{.Description}}</p>
<p class="declaration-badge"><img src="{{.BadgeURL}}" alt="{{.Title}}"></p>
<section class="summary-section" aria-labelledby="summary-title">
<h2 id="summary-title">{{index .Text "article_summary_heading"}}</h2>
<div class="summary-copy">{{range .Summary}}<p>{{.}}</p>{{end}}</div>
</section>
<section class="component-section" aria-labelledby="component-title">
<h2 id="component-title">{{index .Text "article_table_heading"}}</h2>
<div class="component-table-wrap">
<table class="component-table">
<colgroup><col class="component-col"><col class="extent-col"><col class="activity-col"><col class="review-col"></colgroup>
<thead><tr><th scope="col">{{index .Text "table_component"}}</th><th scope="col">{{index .Text "table_extent"}}</th><th scope="col">{{index .Text "table_activities"}}</th><th scope="col">{{index .Text "table_review"}}</th></tr></thead>
<tbody>{{range .ComponentRows}}<tr><th scope="row">{{.Name}}</th><td>{{.Extent}}</td><td>{{.Activities}}</td><td>{{.Review}}</td></tr>{{if .Note}}<tr class="component-note"><td colspan="4">{{.Note}}</td></tr>{{end}}{{end}}</tbody>
</table>
</div>
</section>
<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>
</article>
</main>
<footer><span><a href="{{.ManifestURL}}">{{index .Text "manifest"}}</a></span><span><a href="{{.ContactURL}}">{{index .Text "nav_background"}}</a></span></footer>
</body>
</html>
{{end}}

140
web/templates/index.html Normal file
View File

@@ -0,0 +1,140 @@
{{define "index.html"}}
<!doctype html>
<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Name}}</title>
<meta name="description" content="{{index .Text "meta_description"}}">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="/?lang={{.Lang}}">{{.Name}}</a>
<nav><a href="/product?lang={{.Lang}}">{{index .Text "nav_product"}}</a><a href="#generator">{{index .Text "nav_generator"}}</a><a href="#api">{{index .Text "nav_api"}}</a><a href="{{.ContactURL}}">{{index .Text "nav_background"}}</a></nav>
<label class="language-switch"><span class="sr-only">{{index .Text "field_language"}}</span><select id="page-language">
{{range .Languages}}<option value="{{.Code}}" {{if eq $.Lang .Code}}selected{{end}}>{{.Name}}</option>{{end}}
</select></label>
</header>
<main>
<section class="hero">
<p class="eyebrow">{{index .Text "standard_eyebrow"}}</p>
<h1>{{index .Text "hero_title"}}</h1>
<p class="lead">{{index .Text "hero_lead"}}</p>
<div class="badge-row">
{{range .Presets}}<a href="/declaration?preset={{.Value}}&amp;lang={{$.Lang}}"><img src="/badge/{{.Value}}.svg?theme=mono&amp;link=auto&amp;lang={{$.Lang}}" alt="{{.Label}}"></a>{{end}}
</div>
</section>
<section id="generator" class="panel">
<div>
<p class="eyebrow">{{index .Text "generator_eyebrow"}}</p>
<h2>{{index .Text "generator_title"}}</h2>
<p>{{index .Text "generator_intro"}}</p>
</div>
<form id="generator-form" class="generator-grid">
<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>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>
</form>
{{if or .CustomText .CustomBadge}}
<section class="pro-panel enabled" aria-labelledby="pro-title">
<div class="pro-heading">
<div><p class="eyebrow">{{index .Text "pro_eyebrow"}}</p><h2 id="pro-title">{{index .Text "pro_title"}}</h2></div>
<span class="edition-pill">{{.License.Edition}}</span>
</div>
<p>{{index .Text "pro_enabled"}}</p>
<div class="generator-grid compact">
<label>{{index .Text "custom_title"}}
<input id="custom-title" maxlength="120" {{if not .CustomText}}disabled placeholder="{{index .Text "pro_required"}}"{{end}}>
</label>
<label>{{index .Text "custom_description"}}
<input id="custom-description" maxlength="500" {{if not .CustomText}}disabled placeholder="{{index .Text "pro_required"}}"{{end}}>
</label>
<label>{{index .Text "custom_badge_label"}}
<input id="badge-label" maxlength="40" {{if not .CustomBadge}}disabled placeholder="{{index .Text "pro_required"}}"{{end}}>
</label>
<label>{{index .Text "custom_badge_message"}}
<input id="badge-message" maxlength="80" {{if not .CustomBadge}}disabled placeholder="{{index .Text "pro_required"}}"{{end}}>
</label>
<label>{{index .Text "custom_left_color"}}
<input id="left-color" maxlength="7" pattern="#[0-9A-Fa-f]{6}" placeholder="#262626" {{if not .CustomBadge}}disabled{{end}}>
</label>
<label>{{index .Text "custom_right_color"}}
<input id="right-color" maxlength="7" pattern="#[0-9A-Fa-f]{6}" placeholder="#2b6cb0" {{if not .CustomBadge}}disabled{{end}}>
</label>
</div>
</section>
{{end}}
<div class="preview-card">
<p class="eyebrow">{{index .Text "preview"}}</p>
<a id="preview-link" href="#"><img id="preview-badge" alt="{{index .Text "preview"}}"></a>
<div class="code-tabs">
<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>
</div>
<button id="copy-html" type="button">{{index .Text "copy_html"}}</button>
</div>
</section>
<section id="api" class="docs-grid">
<article><p class="eyebrow">SVG</p><h2>{{index .Text "api_badge_title"}}</h2><code>{{.BaseURL}}/badge/research.svg?lang={{.Lang}}</code><p>{{index .Text "api_badge_desc"}}</p></article>
<article><p class="eyebrow">JSON-LD</p><h2>{{index .Text "api_manifest_title"}}</h2><code>{{.BaseURL}}/v1/declaration.json?preset=research&amp;lang={{.Lang}}</code><p>{{index .Text "api_manifest_desc"}}</p></article>
<article><p class="eyebrow">HA</p><h2>{{index .Text "api_stateless_title"}}</h2><p>{{index .Text "api_stateless_desc"}}</p></article>
</section>
</main>
<footer><span>AI Usage Disclosure · App 1.6.1 · Schema 1.1 · {{.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>
{{end}}

View File

@@ -0,0 +1,126 @@
{{define "marketing.html"}}
<!doctype html>
<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Name}} · {{.Marketing.NavFeatures}}</title>
<meta name="description" content="{{.Marketing.MetaDescription}}">
<link rel="canonical" href="{{.BaseURL}}/product?lang={{.Lang}}">
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="marketing-page">
<header class="site-header marketing-header">
<a class="brand" href="/product?lang={{.Lang}}">{{.Name}}</a>
<nav>
<a href="#features">{{.Marketing.NavFeatures}}</a>
<a href="#install">{{.Marketing.NavInstall}}</a>
<a href="/?lang={{.Lang}}#generator">{{.Marketing.NavGenerator}}</a>
<a href="{{.ContactURL}}">{{.Marketing.NavBackground}}</a>
</nav>
<label class="language-switch"><span class="sr-only">{{.Marketing.LanguageLabel}}</span><select id="marketing-language">
{{range .Languages}}<option value="{{.Code}}" {{if eq $.Lang .Code}}selected{{end}}>{{.Name}}</option>{{end}}
</select></label>
</header>
<main class="marketing-main">
<section class="marketing-hero">
<div class="marketing-hero-copy">
<p class="eyebrow">{{.Marketing.HeroEyebrow}}</p>
<h1>{{.Marketing.HeroTitle}}</h1>
<p class="lead">{{.Marketing.HeroLead}}</p>
<div class="cta-row">
<a class="button primary" href="/?lang={{.Lang}}#generator">{{.Marketing.PrimaryCTA}}</a>
</div>
<ul class="proof-list" aria-label="Product highlights">
{{range .Marketing.Proof}}<li>{{.}}</li>{{end}}
</ul>
</div>
<div class="product-demo" aria-label="Badge examples">
<div class="demo-window">
<div class="demo-bar"><span></span><span></span><span></span></div>
<div class="demo-content">
<p class="demo-url">{{.BaseURL}}/badge/research.svg</p>
<img src="/badge/research.svg?lang={{.Lang}}&amp;theme=color" alt="Research badge">
<img src="/badge/summary.svg?lang={{.Lang}}&amp;theme=mono" alt="Summary badge">
<img src="/badge/full.svg?lang={{.Lang}}&amp;theme=color" alt="Full generation badge">
<pre><code>{
"@type": "AIUsageDeclaration",
"schemaVersion": "1.1",
"assurance": "selfDeclared"
}</code></pre>
</div>
</div>
</div>
</section>
<section id="features" class="marketing-section">
<div class="section-heading">
<p class="eyebrow">{{.Marketing.FeaturesEyebrow}}</p>
<h2>{{.Marketing.FeaturesTitle}}</h2>
<p>{{.Marketing.FeaturesLead}}</p>
</div>
<div class="feature-grid">
{{range .Marketing.Features}}
<article class="feature-card">
<p class="feature-kicker">{{.Kicker}}</p>
<h3>{{.Title}}</h3>
<p>{{.Description}}</p>
</article>
{{end}}
</div>
</section>
<section id="install" class="marketing-section install-section">
<div class="section-heading">
<p class="eyebrow">{{.Marketing.InstallEyebrow}}</p>
<h2>{{.Marketing.InstallTitle}}</h2>
<p>{{.Marketing.InstallLead}}</p>
</div>
<div class="install-list">
{{range $index, $method := .Marketing.InstallMethods}}
<details class="install-method" {{if eq $index 0}}open{{end}}>
<summary><span><strong>{{$method.Title}}</strong><small>{{$method.Summary}}</small></span><span aria-hidden="true">+</span></summary>
<div class="code-block">
<button type="button" class="copy-code" data-copy-target="install-{{$method.ID}}" data-label="{{$.Marketing.CopyLabel}}" data-copied="{{$.Marketing.CopiedLabel}}">{{$.Marketing.CopyLabel}}</button>
<pre id="install-{{$method.ID}}"><code>{{$method.Code}}</code></pre>
</div>
</details>
{{end}}
</div>
<div class="config-panel">
<h3>{{.Marketing.ConfigTitle}}</h3>
<div class="comparison-wrap">
<table class="config-table">
<thead><tr><th>{{.Marketing.ConfigVariable}}</th><th>{{.Marketing.ConfigDefault}}</th><th>{{.Marketing.ConfigMeaning}}</th></tr></thead>
<tbody>{{range .Marketing.Config}}<tr><td><code>{{.Name}}</code></td><td><code>{{.Default}}</code></td><td>{{.Description}}</td></tr>{{end}}</tbody>
</table>
</div>
</div>
</section>
<section class="marketing-section faq-section">
<div class="section-heading">
<p class="eyebrow">{{.Marketing.FAQEyebrow}}</p>
<h2>{{.Marketing.FAQTitle}}</h2>
</div>
<div class="faq-grid">
{{range .Marketing.FAQs}}<details><summary>{{.Question}}</summary><p>{{.Answer}}</p></details>{{end}}
</div>
</section>
<section class="final-cta">
<h2>{{.Marketing.FinalTitle}}</h2>
<p>{{.Marketing.FinalLead}}</p>
<div class="cta-row">
<a class="button primary" href="/?lang={{.Lang}}#generator">{{.Marketing.FinalPrimary}}</a>
</div>
</section>
</main>
<footer><span>{{.Marketing.Footer}}</span><span>App 1.6 · Schema 1.1 · {{.License.Edition}}</span></footer>
<script src="/static/marketing.js" defer></script>
</body>
</html>
{{end}}