Sprint 1 : scaffolding complet de Glibredecision

Plateforme de decisions collectives pour Duniter/G1.
Backend FastAPI async + PostgreSQL (14 tables, 8 routers, 6 services,
moteur de vote avec formule d'inertie WoT/Smith/TechComm).
Frontend Nuxt 4 + Nuxt UI v3 + Pinia (9 pages, 5 stores).
Infrastructure Docker + Woodpecker CI + Traefik.
Documentation technique et utilisateur (15 fichiers).
Seed : Licence G1, Engagement Forgeron v2.0.0, 4 protocoles de vote.
30 tests unitaires (formules, mode params, vote nuance) -- tous verts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 12:46:11 +01:00
commit 25437f24e3
100 changed files with 10236 additions and 0 deletions

180
frontend/app/stores/auth.ts Normal file
View File

@@ -0,0 +1,180 @@
/**
* Auth store: manages Duniter Ed25519 challenge-response authentication.
*
* Persists the session token in localStorage for SPA rehydration.
* The identity object mirrors the backend IdentityOut schema.
*/
export interface DuniterIdentity {
id: string
address: string
display_name: string | null
wot_status: string
is_smith: boolean
is_techcomm: boolean
}
interface AuthState {
token: string | null
identity: DuniterIdentity | null
loading: boolean
error: string | null
}
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
token: null,
identity: null,
loading: false,
error: null,
}),
getters: {
isAuthenticated: (state): boolean => !!state.token && !!state.identity,
isSmith: (state): boolean => state.identity?.is_smith ?? false,
isTechComm: (state): boolean => state.identity?.is_techcomm ?? false,
displayName: (state): string => {
if (!state.identity) return ''
return state.identity.display_name || state.identity.address.slice(0, 12) + '...'
},
},
actions: {
/**
* Initiate the challenge-response login flow.
*
* Steps:
* 1. POST /auth/challenge with the Duniter SS58 address
* 2. Client signs the challenge with Ed25519 private key
* 3. POST /auth/verify with address + signature + challenge
* 4. Store the returned token and identity
*/
async login(address: string, signFn?: (challenge: string) => Promise<string>) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
// Step 1: Request challenge
const challengeRes = await $api<{ challenge: string; expires_at: string }>(
'/auth/challenge',
{
method: 'POST',
body: { address },
},
)
// Step 2: Sign the challenge
// In production, signFn would use the Duniter keypair to produce an Ed25519 signature.
// For development, we use a placeholder signature.
let signature: string
if (signFn) {
signature = await signFn(challengeRes.challenge)
} else {
// Development placeholder -- backend currently accepts any signature
signature = 'dev_signature_placeholder'
}
// Step 3: Verify and get token
const verifyRes = await $api<{ token: string; identity: DuniterIdentity }>(
'/auth/verify',
{
method: 'POST',
body: {
address,
signature,
challenge: challengeRes.challenge,
},
},
)
// Step 4: Store credentials
this.token = verifyRes.token
this.identity = verifyRes.identity
this._persistToken()
return verifyRes
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur de connexion'
throw err
} finally {
this.loading = false
}
},
/**
* Fetch the currently authenticated identity from the backend.
* Used on app init to validate a persisted token.
*/
async fetchMe() {
if (!this.token) return
this.loading = true
this.error = null
try {
const { $api } = useApi()
const identity = await $api<DuniterIdentity>('/auth/me')
this.identity = identity
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Session invalide'
this.token = null
this.identity = null
this._clearToken()
throw err
} finally {
this.loading = false
}
},
/**
* Log out: invalidate session on server and clear local state.
*/
async logout() {
try {
if (this.token) {
const { $api } = useApi()
await $api('/auth/logout', { method: 'POST' })
}
} catch {
// Ignore errors during logout -- clear local state regardless
} finally {
this.token = null
this.identity = null
this.error = null
this._clearToken()
navigateTo('/login')
}
},
/**
* Hydrate the token from localStorage on app init.
*/
hydrateFromStorage() {
if (import.meta.client) {
const stored = localStorage.getItem('glibredecision_token')
if (stored) {
this.token = stored
}
}
},
/** @internal Persist token to localStorage */
_persistToken() {
if (import.meta.client && this.token) {
localStorage.setItem('glibredecision_token', this.token)
}
},
/** @internal Clear token from localStorage */
_clearToken() {
if (import.meta.client) {
localStorage.removeItem('glibredecision_token')
}
},
},
})
// Note: hydration from localStorage happens in app.vue onMounted
// via auth.hydrateFromStorage() before calling auth.fetchMe().

View File

@@ -0,0 +1,138 @@
/**
* Decisions store: decision processes and their steps.
*
* Maps to the backend /api/v1/decisions endpoints.
*/
export interface DecisionStep {
id: string
decision_id: string
step_order: number
step_type: string
title: string | null
description: string | null
status: string
vote_session_id: string | null
outcome: string | null
created_at: string
}
export interface Decision {
id: string
title: string
description: string | null
context: string | null
decision_type: string
status: string
voting_protocol_id: string | null
created_by_id: string | null
created_at: string
updated_at: string
steps: DecisionStep[]
}
export interface DecisionCreate {
title: string
description?: string | null
context?: string | null
decision_type: string
voting_protocol_id?: string | null
}
interface DecisionsState {
list: Decision[]
current: Decision | null
loading: boolean
error: string | null
}
export const useDecisionsStore = defineStore('decisions', {
state: (): DecisionsState => ({
list: [],
current: null,
loading: false,
error: null,
}),
getters: {
byStatus: (state) => {
return (status: string) => state.list.filter(d => d.status === status)
},
activeDecisions: (state): Decision[] => {
return state.list.filter(d => d.status === 'active' || d.status === 'in_progress')
},
completedDecisions: (state): Decision[] => {
return state.list.filter(d => d.status === 'completed' || d.status === 'closed')
},
},
actions: {
/**
* Fetch all decisions with optional filters.
*/
async fetchAll(params?: { decision_type?: string; status?: string }) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const query: Record<string, string> = {}
if (params?.decision_type) query.decision_type = params.decision_type
if (params?.status) query.status = params.status
this.list = await $api<Decision[]>('/decisions/', { query })
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des decisions'
} finally {
this.loading = false
}
},
/**
* Fetch a single decision by ID with all its steps.
*/
async fetchById(id: string) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
this.current = await $api<Decision>(`/decisions/${id}`)
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Decision introuvable'
} finally {
this.loading = false
}
},
/**
* Create a new decision.
*/
async create(payload: DecisionCreate) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const decision = await $api<Decision>('/decisions/', {
method: 'POST',
body: payload,
})
this.list.unshift(decision)
return decision
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation de la decision'
throw err
} finally {
this.loading = false
}
},
/**
* Clear the current decision.
*/
clearCurrent() {
this.current = null
},
},
})

View File

@@ -0,0 +1,149 @@
/**
* Documents store: reference documents, their items, and item versions.
*
* Maps to the backend /api/v1/documents endpoints.
*/
export interface DocumentItem {
id: string
document_id: string
position: string
item_type: string
title: string | null
current_text: string
voting_protocol_id: string | null
sort_order: number
created_at: string
updated_at: string
}
export interface Document {
id: string
slug: string
title: string
doc_type: string
version: string
status: string
description: string | null
ipfs_cid: string | null
chain_anchor: string | null
created_at: string
updated_at: string
items_count: number
}
export interface DocumentCreate {
slug: string
title: string
doc_type: string
description?: string | null
version?: string
}
interface DocumentsState {
list: Document[]
current: Document | null
items: DocumentItem[]
loading: boolean
error: string | null
}
export const useDocumentsStore = defineStore('documents', {
state: (): DocumentsState => ({
list: [],
current: null,
items: [],
loading: false,
error: null,
}),
getters: {
byType: (state) => {
return (docType: string) => state.list.filter(d => d.doc_type === docType)
},
activeDocuments: (state): Document[] => {
return state.list.filter(d => d.status === 'active')
},
draftDocuments: (state): Document[] => {
return state.list.filter(d => d.status === 'draft')
},
},
actions: {
/**
* Fetch all documents with optional filters.
*/
async fetchAll(params?: { doc_type?: string; status?: string }) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const query: Record<string, string> = {}
if (params?.doc_type) query.doc_type = params.doc_type
if (params?.status) query.status = params.status
this.list = await $api<Document[]>('/documents/', { query })
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des documents'
} finally {
this.loading = false
}
},
/**
* Fetch a single document by slug and its items.
*/
async fetchBySlug(slug: string) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const [doc, items] = await Promise.all([
$api<Document>(`/documents/${slug}`),
$api<DocumentItem[]>(`/documents/${slug}/items`),
])
this.current = doc
this.items = items
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Document introuvable'
} finally {
this.loading = false
}
},
/**
* Create a new reference document.
*/
async createDocument(payload: DocumentCreate) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const doc = await $api<Document>('/documents/', {
method: 'POST',
body: payload,
})
this.list.unshift(doc)
return doc
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation du document'
throw err
} finally {
this.loading = false
}
},
/**
* Clear the current document and items.
*/
clearCurrent() {
this.current = null
this.items = []
},
},
})

View File

@@ -0,0 +1,100 @@
/**
* Protocols store: voting protocols and formula configurations.
*
* Maps to the backend /api/v1/protocols endpoints.
*/
export interface FormulaConfig {
id: string
name: string
description: string | null
duration_days: number
majority_pct: number
base_exponent: number
gradient_exponent: number
constant_base: number
smith_exponent: number | null
techcomm_exponent: number | null
nuanced_min_participants: number | null
nuanced_threshold_pct: number | null
created_at: string
}
export interface VotingProtocol {
id: string
name: string
description: string | null
vote_type: string
formula_config_id: string
mode_params: string | null
is_meta_governed: boolean
created_at: string
formula_config: FormulaConfig
}
interface ProtocolsState {
protocols: VotingProtocol[]
formulas: FormulaConfig[]
loading: boolean
error: string | null
}
export const useProtocolsStore = defineStore('protocols', {
state: (): ProtocolsState => ({
protocols: [],
formulas: [],
loading: false,
error: null,
}),
getters: {
binaryProtocols: (state): VotingProtocol[] => {
return state.protocols.filter(p => p.vote_type === 'binary')
},
nuancedProtocols: (state): VotingProtocol[] => {
return state.protocols.filter(p => p.vote_type === 'nuanced')
},
metaGovernedProtocols: (state): VotingProtocol[] => {
return state.protocols.filter(p => p.is_meta_governed)
},
},
actions: {
/**
* Fetch all voting protocols with their formula configurations.
*/
async fetchProtocols(params?: { vote_type?: string }) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const query: Record<string, string> = {}
if (params?.vote_type) query.vote_type = params.vote_type
this.protocols = await $api<VotingProtocol[]>('/protocols/', { query })
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des protocoles'
} finally {
this.loading = false
}
},
/**
* Fetch all formula configurations.
*/
async fetchFormulas() {
this.loading = true
this.error = null
try {
const { $api } = useApi()
this.formulas = await $api<FormulaConfig[]>('/protocols/formulas')
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des formules'
} finally {
this.loading = false
}
},
},
})

View File

@@ -0,0 +1,178 @@
/**
* Votes store: vote sessions, individual votes, and result computation.
*
* Maps to the backend /api/v1/votes endpoints.
*/
export interface Vote {
id: string
session_id: string
voter_id: string
vote_value: string
nuanced_level: number | null
comment: string | null
signature: string
signed_payload: string
voter_wot_status: string
voter_is_smith: boolean
voter_is_techcomm: boolean
is_active: boolean
created_at: string
}
export interface VoteSession {
id: string
decision_id: string | null
item_version_id: string | null
voting_protocol_id: string
wot_size: number
smith_size: number
techcomm_size: number
starts_at: string
ends_at: string
status: string
votes_for: number
votes_against: number
votes_total: number
smith_votes_for: number
techcomm_votes_for: number
threshold_required: number
result: string | null
chain_recorded: boolean
chain_tx_hash: string | null
created_at: string
}
export interface VoteResult {
session_id: string
status: string
votes_for: number
votes_against: number
votes_total: number
wot_size: number
smith_size: number
techcomm_size: number
smith_votes_for: number
techcomm_votes_for: number
threshold_required: number
result: string
smith_threshold: number | null
smith_pass: boolean
techcomm_threshold: number | null
techcomm_pass: boolean
}
export interface VoteCreate {
session_id: string
vote_value: string
nuanced_level?: number | null
comment?: string | null
signature: string
signed_payload: string
}
interface VotesState {
currentSession: VoteSession | null
votes: Vote[]
result: VoteResult | null
loading: boolean
error: string | null
}
export const useVotesStore = defineStore('votes', {
state: (): VotesState => ({
currentSession: null,
votes: [],
result: null,
loading: false,
error: null,
}),
getters: {
isSessionOpen: (state): boolean => {
if (!state.currentSession) return false
return state.currentSession.status === 'open' && new Date(state.currentSession.ends_at) > new Date()
},
participationRate: (state): number => {
if (!state.currentSession || state.currentSession.wot_size === 0) return 0
return (state.currentSession.votes_total / state.currentSession.wot_size) * 100
},
forPercentage: (state): number => {
if (!state.currentSession || state.currentSession.votes_total === 0) return 0
return (state.currentSession.votes_for / state.currentSession.votes_total) * 100
},
},
actions: {
/**
* Fetch a vote session by ID with its votes and result.
*/
async fetchSession(sessionId: string) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const [session, votes, result] = await Promise.all([
$api<VoteSession>(`/votes/sessions/${sessionId}`),
$api<Vote[]>(`/votes/sessions/${sessionId}/votes`),
$api<VoteResult>(`/votes/sessions/${sessionId}/result`),
])
this.currentSession = session
this.votes = votes
this.result = result
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Session de vote introuvable'
} finally {
this.loading = false
}
},
/**
* Submit a vote to the current session.
*/
async submitVote(payload: VoteCreate) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const vote = await $api<Vote>(`/votes/sessions/${payload.session_id}/vote`, {
method: 'POST',
body: payload,
})
// Update local state
this.votes.push(vote)
// Refresh session tallies and result
if (this.currentSession) {
const [session, result] = await Promise.all([
$api<VoteSession>(`/votes/sessions/${payload.session_id}`),
$api<VoteResult>(`/votes/sessions/${payload.session_id}/result`),
])
this.currentSession = session
this.result = result
}
return vote
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du vote'
throw err
} finally {
this.loading = false
}
},
/**
* Clear the current session state.
*/
clearSession() {
this.currentSession = null
this.votes = []
this.result = null
},
},
})