A self-hosted web application for onboarding members into Renewable Energy Communities (REC). Built for Italian CERs (Comunita Energetiche Rinnovabili), designed to work across EU regions.
Joining a Renewable Energy Community involves collecting personal data, verifying utility contracts, and obtaining legal consents. Most RECs do this via paper forms, email exchanges, and manual data entry — error-prone and hard to scale.
This platform automates the process: a public-facing wizard collects data from applicants, extracts details from utility bills using AI, validates fields, checks geographic eligibility, and delivers a complete submission to the operator — with a full GDPR audit trail.
- Accept consents — GDPR privacy policy and community rules, with links to the actual documents. This step creates the submission and records the IP address, timestamp, and document versions.
- Upload utility bill (optional) — photos or PDFs of the electricity bill. The system uses AI vision to extract the holder's name, fiscal code, POD code, address, and provider. Multiple pages can be uploaded; each one refines the extracted data.
- Confirm personal data — a form pre-filled with extracted data. The applicant reviews and corrects. Fiscal code and POD are validated against their official formats. Optional ID card upload provides cross-validation against bill data.
- Eligibility check (if configured) — the applicant's address is geocoded and checked against the community's coverage area (municipalities, postal codes, or regions).
- Accept statute — the community's founding document, presented separately from the data-collection consents. If the community enables it, this step also offers an optional data-sharing consent: the applicant can authorise sharing specific offers into the dataspace. It is never required and does not block submission (GDPR Art. 7(4)).
- Review and submit — summary of all entered data. On submit, the applicant receives a PDF summary and the operator is notified by email.
The entire process has a 10-minute inactivity window. After that, the session token expires and the public API rejects further requests. This limits the exposure window for personal data.
Operators work in the console at /admin, signing in with their Keycloak identity; what they may do is decided by their organization and group (see Authorization). They can work the queue, open a submission in full — consents, documents, extracted data, enablement — change status, repair a failed enablement step, and export to CSV. The same flow is available from the terminal with onboarding-cli admin; see Operator console. Naming a recipient on the export (--recipient) records the offline disclosure as a DataDisclosed provenance event — codes, DIDs and hashes only, never PII. All admin operations are audit-logged.
Approval enables a participant in three steps, in order: a Keycloak user, a member in the REC registry, then a dataspace identity. Registry registration fails closed — a participant missing from it is enabled in name only, invisible to every pipeline and dashboard that joins on user_id, POD and sensor ids. Which community they join, and their area, are per-community settings in the template manifest's rec_registry: block; without one, registration is skipped and the wizard still works.
When dataspace provisioning is enabled (DATASPACE_ENABLED=true), changing a submission to approved provisions a dataspace identity via the identity-registry HTTP API: a user DID, a Verifiable Credential, a membership in the REC organization, and a dataspace_did attribute on the Keycloak user. Onboarding keeps only the subject ID, DID, credential ID, and issuance timestamp. If DS_CONNECTOR_URL is set and the applicant gave data-sharing consent, the consented offers are then provisioned to the dataspace connector as a final, non-fatal step; a failed share leaves share_provisioned=false and can be retried from the console or via POST /api/admin/{rec}/submissions/{id}/enablement/retry. See Dataspace Integration and Data Sharing for details.
Each REC gets a template folder that customizes the platform without code changes:
- Branding — name, logo, primary color (applied as CSS variables site-wide)
- Consent documents — local PDFs or links to external URLs, with versioning
- Coverage area — municipalities, postal codes, or regions for eligibility checks
- Wizard steps — reorderable via the manifest (skip eligibility if no coverage restriction)
- Content — markdown files for the welcome page, consent intro, and success message
- Notifications — sender address, operator email list, optional storage backend (S3/Google Drive), optional webhook
Templates are imported into the database with task import-templates, and served per community at /{rec} — one deployment hosts several.
Backend: Python 3.12, FastAPI (async), SQLAlchemy 2 (async), PostgreSQL, Alembic migrations. Rate limiting via slowapi. PDF generation with fpdf2. Email via SMTP.
Frontend: SvelteKit 5, CSS custom properties for theming, sveltekit-i18n (Italian + English), marked for markdown rendering with DOMPurify sanitization. No CSS framework — design tokens from a shared design system.
Extraction pipeline: uploaded files are classified by magic bytes. Images are compressed to JPEG (max 1600px, quality 75) and sent to the OpenAI Vision API. PDFs are converted to text via markitdown. Both go into a single LLM call that returns structured JSON. The model is configurable via env var.
Eligibility: addresses are geocoded via Nominatim (OpenStreetMap). The reverse-geocoded municipality/postal code is checked against rules defined in the template manifest. The checker is a protocol — swap in a different implementation for polygon checks, external APIs, etc.
All PII is encrypted using Fernet symmetric encryption (ENCRYPTION_KEY). This covers:
- Uploaded documents (utility bills, ID cards) encrypted on disk
- Database columns:
first_name,last_name,email,phone,fiscal_code,pod_code,consent_ip - JSON fields:
extracted_data,id_extracted_data(OCR results),raw_response(LLM responses)
Encryption is mandatory by default. The app refuses to start without ENCRYPTION_KEY unless REQUIRE_ENCRYPTION=false (dev-only). Legacy unencrypted data is read gracefully during migration.
- Applicant sessions: 32-byte random tokens with 10-minute inactivity TTL. All data-mutating endpoints (including extraction) require a valid session token via
X-Session-Tokenheader. - Admin endpoints (
/api/admin/**): a Keycloak identity, verified against the issuer's JWKS (signature, issuer, audience, expiry). Authorised by the caller's organization + group for operators (admins/managers/editors/viewers, at realm or organization level) and by scope for service accounts, decided by OPA policies inpolicies/. Every action is audit-logged against the actor. - Download links: Fernet-encrypted tokens with configurable TTL (default 24 hours).
Security headers are enabled by default (SECURITY_HEADERS=true): X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy. CORS is configurable with restricted methods/headers. Rate limiting on extraction (10/hr), submission creation (20/hr), PDF download (5/min).
- Consent-first: data collection only after explicit GDPR and policy consent, with IP, timestamp, and document version recorded
- Right to erasure:
DELETE /api/admin/{rec}/submissions/{id}removes files from disk and all DB records - Audit trail: all admin operations logged with action, entity, IP, detail and the operator who performed them
- DPA enforcement: app refuses to start with LLM extraction steps unless
DPA_SIGNED=yes(and SMS providers unlessDPA_SMS_SIGNED=yes) - CER field coverage vs GSE registration: see docs/regulatory-compliance.md
- Data minimization:
consent_ipexcluded from public API responses, only visible to admins - Markdown content sanitized with DOMPurify to prevent XSS
- PostgreSQL (external, already running)
- Python 3.12+ with uv
- Node.js 22+ with pnpm
- Task (optional, for task runner)
# Clone and configure
cp .env.example .env
# Edit .env — required: DATABASE_URL, OPENAI_API_KEY, ENCRYPTION_KEY
# For dev without encryption: set REQUIRE_ENCRYPTION=false
# Backend
cd src && uv sync && cd ..
# Frontend
cd ui && pnpm install --ignore-scripts && cd ..
# Database
task migrate # or: uv run --project src alembic upgrade head
# Run
task run:api # FastAPI on :8000
task run:ui # SvelteKit on :5173 (proxies /api to backend)docker compose upThis creates the database, runs migrations, and starts backend + frontend. Requires an external PostgreSQL instance (configured via DB_HOST, DB_PORT, etc.).
task import-templates -- --filter my-communitySee templates/example/ for the manifest format.
| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL async connection string (e.g. postgresql+asyncpg://user:pass@host:5432/db) |
OPENAI_API_KEY |
OpenAI API key for bill/ID extraction |
ENCRYPTION_KEY |
Fernet key for PII encryption. Generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" |
DPA_SIGNED |
Set to yes after signing a DPA with your LLM provider (required when using extraction steps) |
OIDC_BASE_URL |
Keycloak realm issuer for the admin console and outbound M2M (e.g. http://keycloak.celine.localhost/realms/celine). Startup refuses without it. |
| Variable | Default | Description |
|---|---|---|
REQUIRE_ENCRYPTION |
true |
App refuses to start without ENCRYPTION_KEY. Set false for local dev only. |
SECURITY_HEADERS |
true |
Adds security headers to all responses. Disable if your reverse proxy handles them. |
CORS_ORIGINS |
http://localhost:3000,http://localhost:5173 |
Comma-separated allowed origins |
DOWNLOAD_TOKEN_TTL |
86400 |
Download link expiry in seconds (default: 24 hours) |
| Variable | Default | Description |
|---|---|---|
TEMPLATES_DIR |
./templates |
Root directory templates are imported from |
DATA_DIR |
./data |
Upload and export storage path |
MAX_UPLOAD_SIZE_MB |
10 |
Maximum file upload size |
EXTRACTION_BASE_URL |
https://api.openai.com/v1 |
Base URL for OpenAI-compatible API |
EXTRACTION_MODEL |
gpt-5.4 |
Model for OCR extraction |
All optional. If SMTP_HOST is unset, email notifications are silently skipped.
| Variable | Default | Description |
|---|---|---|
SMTP_HOST |
(none) | SMTP server hostname |
SMTP_PORT |
587 |
SMTP port |
SMTP_USER |
(none) | SMTP username |
SMTP_PASSWORD |
(none) | SMTP password |
SMTP_FROM |
(none) | Sender address (overridden by manifest notifications.from) |
SMTP_TLS |
true |
STARTTLS with certificate verification |
SMTP_NOTIFY |
(none) | Fallback operator emails (overridden by manifest notifications.notify) |
Optional. When a REC manifest's steps includes phone_verify, participants verify their phone via an SMS one-time code, and approval is gated on successful verification. Defaults to a log provider (prints the code) for local dev; any real provider requires a signed DPA. See docs/phone-verification.md.
| Variable | Default | Description |
|---|---|---|
SMS_PROVIDER |
log |
log (dev) or brevo |
BREVO_API_KEY |
(none) | Required for brevo |
BREVO_SMS_SENDER |
(none) | Alphanumeric sender id or E.164; required for brevo |
SMS_OTP_TEMPLATE |
Il tuo codice di verifica e' {code} |
Message body (must contain {code}) |
DPA_SMS_SIGNED |
false |
Must be yes for any non-log provider (GDPR Art. 28) |
OTP_CODE_LENGTH |
6 |
Digits in the code |
OTP_TTL_SECONDS |
600 |
Code validity |
OTP_MAX_ATTEMPTS |
3 |
Wrong guesses before lockout |
OTP_MAX_SENDS_PER_HOUR |
3 |
Per phone number |
OTP_LOCKOUT_SECONDS |
3600 |
Lockout duration |
Optional. Set DATASPACE_ENABLED=true to provision a dataspace identity (DID + Verifiable Credential + REC organization membership + Keycloak DID attribute) when an admin approves a submission. Requires the identity-registry service and celine-sdk>=1.13.0 for M2M authentication.
Which organization a community's members join is set per community, in that template's manifest.yaml under dataspace: — not as a deployment-wide variable. Omit the block and the community simply is not in the dataspace. The organization must already exist and be promoted in the registry; onboarding never creates one. See docs/dataspace-integration.md for the full integration guide.
After approval a participant manages and withdraws their sharing decisions in the dataspace portal (/my-data), authenticated by their own credential — not here.
| Variable | Default | Description |
|---|---|---|
DATASPACE_ENABLED |
false |
Deployment-wide gate for dataspace identity provisioning. A community also needs a dataspace: block in its manifest |
IDENTITY_REGISTRY_URL |
(none) | Base URL of the identity-registry service |
OIDC_BASE_URL |
(none) | OIDC issuer URL for M2M token acquisition |
DS_ONBOARDING_CLIENT_ID |
svc-ds-onboarding |
Keycloak client ID for M2M auth |
DS_ONBOARDING_CLIENT_SECRET |
(none) | Keycloak client secret for M2M auth |
DATASPACE_USER_ROLE |
(none) | Role assigned in the credential |
DATASPACE_ALLOWED_ACTIONS |
(none) | Comma-separated authorized actions |
DATASPACE_VC_TTL_DAYS |
(none) | Credential validity period in days |
DATASPACE_SUBJECT_SOURCE |
email_hash |
Subject ID source (email_hash delegates derivation to the identity-registry's GET /users/resolve?derive=true) |
Which organization a community's members join, its DID and the linked participant are per community, in that template's manifest.yaml under dataspace: — there is no deployment-wide equivalent, because one would file every community's members into a single organization.
| DS_CONNECTOR_URL | (none) | Connector base URL for provisioning data-sharing consent on approval (POST /consent/admin/shares). Empty disables share provisioning |
| DS_NS_URL | (none) | Public vocabulary base (GET /ns/sharing-offers) the wizard renders offers from; empty falls back to the connector's /ns path |
| DS_PROVENANCE_URL | (none) | Provenance base URL for recording a named-recipient CSV export as a DataDisclosed event (POST /prov/events, scope provenance.write); empty disables the emission |
templates/my-rec/
manifest.yaml # community config
assets/logo.svg # branding
consent/ # local consent docs (optional if using URLs)
policy.pdf
policy.pdf.json # metadata sidecar: slug, title, version, mime_type
content/
welcome.md # landing page body
consent_intro.md # shown above consent checkboxes
success.md # shown after submission
The manifest declares everything the platform needs to customize for this community: name, branding, consent document versions and locations, coverage rules, wizard step order, notification recipients, optional storage backend, and optional webhook. See AGENTS.md for the full manifest schema.
task run:api # backend with hot reload
task run:ui # frontend with hot reload + API proxy
task migrate # apply migrations
task migration -- "msg" # create new migration
task test # backend + frontend tests
task lint # ruff + svelte-check
task sdk:local # dev-only: celine-sdk from ../celine-sdk (unreleased wrappers)
task export-csv # export submissions to data/exports/
task export-pod-list # export consented supply points for a distributor- Add the column to
src/celine/onboarding/models/submission.py— useEncryptedStringfor PII fields - Add to
SubmissionUpdateandSubmissionReadinmodels/schemas.py— add toSubmissionAdminReadif it should be admin-only - Run
task migration -- "add_field_name"thentask migrate - Add the form field in
ui/src/routes/onboarding/+page.svelte - Add i18n keys in
ui/src/lib/i18n/{it,en}/onboarding.json
- Add the step name to the template's
manifest.yamlstepslist - Add a label mapping in
STEP_LABELSin the wizard page - Add a
{:else if currentStepName === 'mystep'}block in the template - Add
canProceedlogic for the step - Add any
advanceStepsave logic
- Add the field name to
RULE_FIELD_MAPinservices/eligibility.py - Parse the field from Nominatim's address response in
_parse_address - Use it in the manifest:
{ type: "my_field", values: [...] }
Copyright 2026 Spindox Labs
Apache-2.0 see LICENSE