From d0fa75a3d3a7c96486da82b6f41a4b2b5e1e6c23 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sun, 5 Jul 2026 21:37:53 +0200 Subject: [PATCH] docs: piano implementazione ruoli/login/GUID/modifica inline --- .../2026-07-05-utenti-ruoli-login-inline.md | 1825 +++++++++++++++++ 1 file changed, 1825 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-utenti-ruoli-login-inline.md diff --git a/docs/superpowers/plans/2026-07-05-utenti-ruoli-login-inline.md b/docs/superpowers/plans/2026-07-05-utenti-ruoli-login-inline.md new file mode 100644 index 0000000..70e0d79 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-utenti-ruoli-login-inline.md @@ -0,0 +1,1825 @@ +# Utenti, ruoli, login pubblico, GUID e modifica inline — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Portare il sito da due ruoli (`admin`|`editor`) a tre ruoli (`admin`|`superuser`|`user`) con login pubblico, identità nell'header, blog con ownership per autore, un GUID stabile per ogni contenuto taggato e modifica dei contenuti in pagina tramite overlay attivabile con toggle. + +**Architecture:** Estensione incrementale vanilla dell'esistente. Migrazioni SQLite additive e idempotenti in `createDb()`. Autorizzazione per matrice di prefissi in `auth.ts`, applicata dal middleware. Login pubblico che riusa la logica di `/admin/login`. Overlay tag esistente evoluto: attivazione via cookie `showtags`, badge con GUID e modal di modifica in pagina che riusa le API contenuti già deployate (unica aggiunta backend: `GET /api/admin/content/[tag]`). Zero dipendenze frontend nuove. + +**Tech Stack:** Astro 7 SSR (adapter node standalone, `prerender = false`), better-sqlite3, bcryptjs, vitest, TipTap (già presente per il blog), CSS/JS vanilla. + +## Global Constraints + +- Tutte le pagine e API SSR: `export const prerender = false` in ogni nuovo file di pagina/endpoint. +- Migrazioni DB SOLO additive e idempotenti, in `createDb()` (`src/lib/db.ts`), PRIMA del seed `syncSeed`; verificare l'esistenza di colonne con `PRAGMA table_info` prima di ogni `ALTER`. +- Ruoli ammessi: esattamente `'admin' | 'superuser' | 'user'`. Il vecchio `'editor'` non esiste più a runtime dopo la migrazione. +- Il tag semantico (`about.mission.title`) resta chiave primaria e identificatore usato nel codice e nel pannello. Il GUID si **affianca**, non sostituisce: nessun tag cambia nome. +- I POST in dev richiedono l'header `Origin` (checkOrigin CSRF di Astro); i `fetch` same-origin del browser lo inviano automaticamente. +- Nomi file upload già gestiti da `validateUploadFile`/`TAG_RE` esistenti: non reintrodurre nomi prevedibili. +- Prosa pubblica in italiano corretto con accenti; chat/log/commit in stile breve. +- Commit frequenti, uno per task. Messaggi commit brevi in italiano. +- Test runner: `npm test` (vitest). Build di verifica: `npm run build`. Type check: `npm run astro -- check` (o `npx astro check`). + +--- + +### Task 1: Migrazioni DB — ruoli, GUID contenuti, autore articoli + +**Files:** +- Modify: `src/lib/db.ts` +- Modify: `src/lib/content.ts` (funzione `syncSeed` duplicata — deve inserire il guid) +- Test: `tests/content-db.test.ts` + +**Interfaces:** +- Consumes: `contentSeed` da `src/data/content-seed.ts`, tabelle `users`/`posts`/`content_blocks`. +- Produces: dopo `createDb()`, ogni riga `content_blocks` ha `guid` non nullo e univoco; nessun utente con `role='editor'` (migrato a `'superuser'`); colonna `posts.author_id INTEGER` presente. + +- [ ] **Step 1: Scrivi i test di migrazione** + +In `tests/content-db.test.ts` aggiungi (in coda, dentro il file esistente; se il file importa già `createDb` riusa l'import): + +```typescript +import { describe, it, expect } from 'vitest'; +import { createDb } from '../src/lib/db'; +import { createUser } from '../src/lib/auth'; + +describe('migrazioni ruoli/guid/autore', () => { + it('ogni content_block ha un guid non vuoto e univoco', () => { + const db = createDb(':memory:'); + const rows = db.prepare('SELECT guid FROM content_blocks').all() as { guid: string | null }[]; + expect(rows.length).toBeGreaterThan(100); + expect(rows.every((r) => typeof r.guid === 'string' && r.guid!.length >= 10)).toBe(true); + const guids = new Set(rows.map((r) => r.guid)); + expect(guids.size).toBe(rows.length); + }); + + it('migra il ruolo editor a superuser', () => { + const db = createDb(':memory:'); + db.prepare("INSERT INTO users (username, password_hash, role) VALUES ('vecchio', 'x', 'editor')").run(); + // Rilancia la migrazione: nella stessa istanza simuliamo un secondo boot ricreando su file? No: + // la migrazione gira in createDb, quindi inseriamo PRIMA e ricreiamo su un file condiviso. + const path = ':memory:'; + // Verifica idempotenza logica: la UPDATE è esposta anche come effetto di createDb; qui + // testiamo direttamente l'invariante col DB già migrato + insert manuale + re-run UPDATE. + db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run(); + const row = db.prepare("SELECT role FROM users WHERE username = 'vecchio'").get() as { role: string }; + expect(row.role).toBe('superuser'); + expect(path).toBe(':memory:'); + }); + + it('posts ha la colonna author_id', () => { + const db = createDb(':memory:'); + const cols = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[]; + expect(cols.some((c) => c.name === 'author_id')).toBe(true); + }); +}); +``` + +Nota: `:memory:` non persiste tra due `createDb`, quindi la migrazione `editor→superuser` su DB pre-esistente si verifica end-to-end nello smoke in produzione (Task 14); qui verifichiamo l'invariante SQL e la presenza colonna. + +- [ ] **Step 2: Esegui i test e verifica che falliscano** + +Run: `npm test -- tests/content-db.test.ts` +Expected: FAIL — `no such column: guid` / `author_id` assente. + +- [ ] **Step 3: Aggiungi le migrazioni in `db.ts`** + +In `src/lib/db.ts`, aggiungi in cima l'import: + +```typescript +import { randomUUID } from 'node:crypto'; +``` + +Dopo il blocco che aggiunge `users.role` (dopo la riga `db.exec(\`ALTER TABLE users ADD COLUMN role ...\`)` — cioè dopo la chiusura di quell'`if`, riga ~52), inserisci: + +```typescript + // Ruolo editor unificato in superuser (3 ruoli: admin, superuser, user). Idempotente. + db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run(); + + // GUID stabile per ogni contenuto taggato: colonna additiva + backfill + indice univoco. + const contentCols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[]; + if (!contentCols.some((c) => c.name === 'guid')) { + db.exec(`ALTER TABLE content_blocks ADD COLUMN guid TEXT`); + } + + // Autore articoli: colonna nullable (post storici restano senza autore). + const postCols = db.prepare(`PRAGMA table_info(posts)`).all() as { name: string }[]; + if (!postCols.some((c) => c.name === 'author_id')) { + db.exec(`ALTER TABLE posts ADD COLUMN author_id INTEGER`); + } +``` + +Poi sostituisci il blocco seed finale (righe ~99-101, `const ins = ...; syncTx();`) con: + +```typescript + const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)'); + const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); }); + syncTx(); + // Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano + // ignorate dall'INSERT OR IGNORE e hanno guid nullo → assegna un UUID a ciascuna. + const missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[]; + const backfill = db.prepare('UPDATE content_blocks SET guid = ? WHERE tag = ?'); + const bfTx = db.transaction(() => { for (const m of missing) backfill.run(randomUUID(), m.tag); }); + bfTx(); + // Indice univoco creato dopo il backfill, così non fallisce su righe a guid nullo. + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)`); +``` + +- [ ] **Step 4: Aggiorna `syncSeed` in `content.ts`** + +In `src/lib/content.ts`, aggiungi l'import `import { randomUUID } from 'node:crypto';` e sostituisci il corpo di `syncSeed`: + +```typescript +export function syncSeed(db: Database.Database): void { + const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)'); + const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); }); + tx(); +} +``` + +- [ ] **Step 5: Esegui i test e verifica che passino** + +Run: `npm test -- tests/content-db.test.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/db.ts src/lib/content.ts tests/content-db.test.ts +git commit -m "feat(db): migrazioni ruoli editor->superuser, guid contenuti, posts.author_id" +``` + +--- + +### Task 2: Autorizzazione a matrice in `auth.ts` + +**Files:** +- Modify: `src/lib/auth.ts` +- Test: `tests/roles.test.ts` (riscrittura) + +**Interfaces:** +- Produces: + - `type Role = 'admin' | 'superuser' | 'user'` + - `canAccessAdminPath(role: string, pathname: string): boolean` + - `landingFor(role: string): string` + - `createUser(db, username, password, role?: Role): number` (default invariato `'admin'`) + +- [ ] **Step 1: Riscrivi il test dei ruoli** + +Sostituisci l'intero `tests/roles.test.ts` con: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { createDb } from '../src/lib/db'; +import { createUser, login, getSessionUser, canAccessAdminPath, landingFor } from '../src/lib/auth'; + +let db: Database.Database; +beforeEach(() => { db = createDb(':memory:'); }); + +describe('ruoli', () => { + it('createUser accetta i tre ruoli e getSessionUser li ritorna', () => { + createUser(db, 'u', 'segreta123', 'user'); + createUser(db, 's', 'segreta123', 'superuser'); + expect(getSessionUser(db, login(db, 'u', 'segreta123')!)).toMatchObject({ role: 'user' }); + expect(getSessionUser(db, login(db, 's', 'segreta123')!)).toMatchObject({ role: 'superuser' }); + }); + + it('createUser senza ruolo crea admin', () => { + createUser(db, 'adriano', 'segreta123'); + expect(getSessionUser(db, login(db, 'adriano', 'segreta123')!)).toMatchObject({ role: 'admin' }); + }); +}); + +describe('canAccessAdminPath', () => { + it('admin accede a tutto', () => { + for (const p of ['/admin', '/admin/users', '/admin/content', '/api/admin/posts', '/api/admin/users/1']) + expect(canAccessAdminPath('admin', p)).toBe(true); + }); + + it('superuser: contenuti + blog sì, utenti no', () => { + expect(canAccessAdminPath('superuser', '/admin/content')).toBe(true); + expect(canAccessAdminPath('superuser', '/admin/content/')).toBe(true); + expect(canAccessAdminPath('superuser', '/api/admin/content/home.hero.title')).toBe(true); + expect(canAccessAdminPath('superuser', '/admin')).toBe(true); + expect(canAccessAdminPath('superuser', '/admin/new')).toBe(true); + expect(canAccessAdminPath('superuser', '/api/admin/posts')).toBe(true); + expect(canAccessAdminPath('superuser', '/admin/users')).toBe(false); + expect(canAccessAdminPath('superuser', '/api/admin/users/1')).toBe(false); + }); + + it('user: blog sì, contenuti e utenti no', () => { + expect(canAccessAdminPath('user', '/admin')).toBe(true); + expect(canAccessAdminPath('user', '/admin/new')).toBe(true); + expect(canAccessAdminPath('user', '/api/admin/posts')).toBe(true); + expect(canAccessAdminPath('user', '/api/admin/upload')).toBe(true); + expect(canAccessAdminPath('user', '/admin/logout')).toBe(true); + expect(canAccessAdminPath('user', '/admin/content')).toBe(false); + expect(canAccessAdminPath('user', '/api/admin/content/x')).toBe(false); + expect(canAccessAdminPath('user', '/admin/users')).toBe(false); + }); + + it('la matrice non confonde prefissi simili', () => { + expect(canAccessAdminPath('user', '/admin/contentious')).toBe(true); // NON è /admin/content + expect(canAccessAdminPath('superuser', '/admin/users-x')).toBe(true); // NON è /admin/users + }); +}); + +describe('landingFor', () => { + it('instrada ogni ruolo alla sua home', () => { + expect(landingFor('superuser')).toBe('/admin/content'); + expect(landingFor('user')).toBe('/admin'); + expect(landingFor('admin')).toBe('/admin'); + }); +}); +``` + +- [ ] **Step 2: Esegui e verifica il fallimento** + +Run: `npm test -- tests/roles.test.ts` +Expected: FAIL — `landingFor` non esportata, matrice non aggiornata. + +- [ ] **Step 3: Aggiorna `auth.ts`** + +In `src/lib/auth.ts`: + +1. Cambia il tipo: `export type Role = 'admin' | 'superuser' | 'user';` +2. Sostituisci il blocco finale (da `// Percorsi admin consentiti anche al ruolo editor.` fino a fine file) con: + +```typescript +// Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola +// che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a +// qualsiasi loggato (il middleware protegge solo /admin e /api/admin, quindi qui arrivano solo +// utenti già autenticati). +const RULES: [RegExp, Role[]][] = [ + [/^\/admin\/users(\/|$)/, ['admin']], + [/^\/api\/admin\/users(\/|$)/, ['admin']], + [/^\/admin\/content(\/|$)/, ['admin', 'superuser']], + [/^\/api\/admin\/content(\/|$)/, ['admin', 'superuser']], +]; + +export function canAccessAdminPath(role: string, pathname: string): boolean { + if (role === 'admin') return true; + for (const [re, roles] of RULES) { + if (re.test(pathname)) return roles.includes(role as Role); + } + return true; // blog, upload, logout, showtags: tutti i loggati +} + +export function landingFor(role: string): string { + if (role === 'superuser') return '/admin/content'; + return '/admin'; +} +``` + +- [ ] **Step 4: Esegui e verifica il successo** + +Run: `npm test -- tests/roles.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/auth.ts tests/roles.test.ts +git commit -m "feat(auth): matrice permessi 3 ruoli + landingFor" +``` + +--- + +### Task 3: Middleware con matrice e redirect per ruolo + +**Files:** +- Modify: `src/middleware.ts` + +**Interfaces:** +- Consumes: `canAccessAdminPath`, `landingFor` (Task 2). + +- [ ] **Step 1: Aggiorna il middleware** + +In `src/middleware.ts` cambia l'import e il ramo autorizzazione. Import: + +```typescript +import { getSessionUser, SESSION_COOKIE, canAccessAdminPath, landingFor } from './lib/auth'; +``` + +Sostituisci il blocco `if (!canAccessAdminPath(...))` con: + +```typescript + if (!canAccessAdminPath(user.role, pathname)) { + if (pathname.startsWith('/api/')) { + return new Response(JSON.stringify({ error: 'Permessi insufficienti' }), { + status: 403, headers: { 'Content-Type': 'application/json' }, + }); + } + return context.redirect(landingFor(user.role)); + } +``` + +- [ ] **Step 2: Verifica build + type check** + +Run: `npm run build` +Expected: build OK, nessun errore di tipo. + +- [ ] **Step 3: Commit** + +```bash +git add src/middleware.ts +git commit -m "feat(middleware): autorizzazione a matrice + redirect per ruolo" +``` + +--- + +### Task 4: Repository posts — autore e liste per autore + +**Files:** +- Modify: `src/lib/posts.ts` +- Modify: `src/lib/auth.ts` (helper utenti riusati anche dal blog e dalla gestione utenti) +- Test: `tests/posts.test.ts` + +**Interfaces:** +- Produces: + - `interface Post` con `author_id: number | null` + - `interface PostInput` con `author_id?: number | null` + - `createPost(db, input)` scrive `author_id` + - `listByAuthor(db, authorId: number): Post[]` + - in `auth.ts`: `getUsernameById(db, id: number): string | null`, `listUsers(db): { id: number; username: string; role: string }[]` + +- [ ] **Step 1: Scrivi i test del repository** + +Aggiungi in `tests/posts.test.ts` (in coda, riusando `db`/`base`): + +```typescript +import { listByAuthor } from '../src/lib/posts'; +import { createUser, getUsernameById, listUsers } from '../src/lib/auth'; + +describe('autore articoli', () => { + it('createPost salva author_id e listByAuthor filtra', () => { + const aliceId = createUser(db, 'alice', 'segreta123', 'user'); + const bobId = createUser(db, 'bob', 'segreta123', 'user'); + createPost(db, { ...base, slug: 'a1', author_id: aliceId }); + createPost(db, { ...base, slug: 'a2', author_id: aliceId }); + createPost(db, { ...base, slug: 'b1', author_id: bobId }); + expect(listByAuthor(db, aliceId).length).toBe(2); + expect(listByAuthor(db, bobId).length).toBe(1); + }); + + it('post senza author_id ha author_id null', () => { + const id = createPost(db, { ...base, slug: 'orfano' }); + expect(getPostById(db, id)!.author_id).toBeNull(); + }); + + it('getUsernameById e listUsers', () => { + const id = createUser(db, 'carla', 'segreta123', 'superuser'); + expect(getUsernameById(db, id)).toBe('carla'); + expect(getUsernameById(db, 99999)).toBeNull(); + expect(listUsers(db).some((u) => u.username === 'carla' && u.role === 'superuser')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Esegui e verifica il fallimento** + +Run: `npm test -- tests/posts.test.ts` +Expected: FAIL — `listByAuthor`/`getUsernameById`/`listUsers` non esistono, `author_id` non nell'input. + +- [ ] **Step 3: Aggiorna `posts.ts`** + +Nell'`interface Post` aggiungi `author_id: number | null;`. Nell'`interface PostInput` aggiungi `author_id?: number | null;`. Sostituisci `createPost`: + +```typescript +export function createPost(db: Database.Database, input: PostInput): number { + const res = db.prepare( + `INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, author_id, published_at) + VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft, @author_id, + CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)` + ).run({ ...input, cover: input.cover ?? null, author_id: input.author_id ?? null, draft: input.draft ? 1 : 0 }); + return Number(res.lastInsertRowid); +} +``` + +`updatePost` resta invariato (non tocca `author_id`: l'autore non cambia in modifica). Aggiungi in fondo: + +```typescript +export function listByAuthor(db: Database.Database, authorId: number): Post[] { + return db.prepare('SELECT * FROM posts WHERE author_id = ? ORDER BY updated_at DESC').all(authorId) as Post[]; +} +``` + +- [ ] **Step 4: Aggiungi gli helper utenti in `auth.ts`** + +In `src/lib/auth.ts` aggiungi: + +```typescript +export function getUsernameById(db: Database.Database, id: number): string | null { + const row = db.prepare('SELECT username FROM users WHERE id = ?').get(id) as { username: string } | undefined; + return row?.username ?? null; +} + +export function listUsers(db: Database.Database): { id: number; username: string; role: string }[] { + return db.prepare('SELECT id, username, role FROM users ORDER BY username').all() as + { id: number; username: string; role: string }[]; +} +``` + +- [ ] **Step 5: Esegui e verifica il successo** + +Run: `npm test -- tests/posts.test.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/posts.ts src/lib/auth.ts tests/posts.test.ts +git commit -m "feat(posts): author_id nel repository + listByAuthor + helper utenti" +``` + +--- + +### Task 5: API posts — ownership e assegnazione autore + +**Files:** +- Modify: `src/pages/api/admin/posts/index.ts` +- Modify: `src/pages/api/admin/posts/[id].ts` +- Test: `tests/posts-ownership.test.ts` (nuovo, a livello logico via funzione estratta) + +**Interfaces:** +- Consumes: `createPost`, `getPostById`, `updatePost`, `deletePost` (Task 4), `locals.user` dal middleware. +- Produces: helper `canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean` in `src/lib/posts.ts`. + +- [ ] **Step 1: Scrivi il test dell'ownership (livello logico)** + +Crea `tests/posts-ownership.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { canModifyPost } from '../src/lib/posts'; + +describe('canModifyPost', () => { + const own = { author_id: 7 }; + const other = { author_id: 8 }; + const orphan = { author_id: null }; + + it('admin e superuser modificano qualsiasi post', () => { + for (const role of ['admin', 'superuser']) { + expect(canModifyPost(role, 7, other)).toBe(true); + expect(canModifyPost(role, 7, orphan)).toBe(true); + } + }); + + it('user modifica solo i propri', () => { + expect(canModifyPost('user', 7, own)).toBe(true); + expect(canModifyPost('user', 7, other)).toBe(false); + expect(canModifyPost('user', 7, orphan)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Esegui e verifica il fallimento** + +Run: `npm test -- tests/posts-ownership.test.ts` +Expected: FAIL — `canModifyPost` non esiste. + +- [ ] **Step 3: Aggiungi `canModifyPost` in `posts.ts`** + +```typescript +export function canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean { + if (role === 'admin' || role === 'superuser') return true; + return post.author_id === userId; +} +``` + +- [ ] **Step 4: Esegui e verifica il successo** + +Run: `npm test -- tests/posts-ownership.test.ts` +Expected: PASS. + +- [ ] **Step 5: Applica l'autore nel POST** + +In `src/pages/api/admin/posts/index.ts`, cambia la firma per leggere `locals` e passa `author_id`: + +```typescript +export const POST: APIRoute = async ({ request, locals }) => { + let data: Record; + try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } + const parsed = parsePostBody(data); + if ('error' in parsed) return json(400, { error: parsed.error }); + try { + const id = createPost(getDb(), { ...parsed.value, author_id: locals.user!.id }); + return json(201, { id, slug: parsed.value.slug }); + } catch (err: any) { + if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' }); + throw err; + } +}; +``` + +- [ ] **Step 6: Applica l'ownership su PUT e DELETE** + +In `src/pages/api/admin/posts/[id].ts`, aggiorna gli import e i due handler: + +```typescript +import { getPostById, updatePost, deletePost, canModifyPost } from '../../../../lib/posts'; + +export const PUT: APIRoute = async ({ params, request, locals }) => { + const id = Number(params.id); + const existing = getPostById(getDb(), id); + if (!existing) return json(404, { error: 'Articolo non trovato.' }); + if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi modificare questo articolo.' }); + let data: Record; + try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } + const parsed = parsePostBody(data); + if ('error' in parsed) return json(400, { error: parsed.error }); + try { + updatePost(getDb(), id, parsed.value); + return json(200, { ok: true }); + } catch (err: any) { + if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' }); + throw err; + } +}; + +export const DELETE: APIRoute = ({ params, locals }) => { + const id = Number(params.id); + const existing = getPostById(getDb(), id); + if (!existing) return json(404, { error: 'Articolo non trovato.' }); + if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi eliminare questo articolo.' }); + deletePost(getDb(), id); + return json(200, { ok: true }); +}; +``` + +- [ ] **Step 7: Verifica build + test** + +Run: `npm run build && npm test` +Expected: build OK, tutti i test verdi. + +- [ ] **Step 8: Commit** + +```bash +git add src/pages/api/admin/posts/index.ts src/pages/api/admin/posts/[id].ts src/lib/posts.ts tests/posts-ownership.test.ts +git commit -m "feat(posts-api): assegna autore in POST, ownership su PUT/DELETE" +``` + +--- + +### Task 6: GUID esposto — resolve, componenti e GET singolo + +**Files:** +- Modify: `src/lib/content.ts` +- Modify: `src/components/content/T.astro` +- Modify: `src/components/content/TImg.astro` +- Create: `src/pages/api/admin/content/[tag]/index.ts` +- Test: `tests/content.test.ts`, `tests/content-api.test.ts` + +**Interfaces:** +- Consumes: `content_blocks.guid` (Task 1), `seedByTag` da `content-seed.ts`. +- Produces: + - `resolveContent(tag)` ritorna `{ value, type, classes, guid }` (`guid: string` — stringa vuota se seed-only) + - `GET /api/admin/content/[tag]` → `{ tag, guid, type, value, styleOptions }` + +- [ ] **Step 1: Test su resolve + GET singolo** + +In `tests/content.test.ts` aggiungi: + +```typescript +it('resolveContent restituisce il guid dal DB', () => { + const { resolveContent } = require('../src/lib/content'); + const r = resolveContent('home.hero.title'); + expect(typeof r.guid).toBe('string'); + expect(r.guid.length).toBeGreaterThan(10); +}); +``` + +In `tests/content-api.test.ts` aggiungi un test per il GET singolo seguendo il pattern già usato nel file per gli altri endpoint (stessa costruzione di `context`/`locals.user` degli altri test del file). Struttura: + +```typescript +it('GET /api/admin/content/[tag] ritorna tag, guid, type, value, styleOptions', async () => { + const { GET } = await import('../src/pages/api/admin/content/[tag]/index'); + const res = await GET({ params: { tag: 'home.hero.title' } } as any); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.tag).toBe('home.hero.title'); + expect(typeof body.guid).toBe('string'); + expect(body.type).toBeDefined(); + expect(Array.isArray(body.styleOptions)).toBe(true); +}); +``` + +Se `content-api.test.ts` usa un DB di test isolato via `getDb`, riusa lo stesso setup già presente nel file (non reinventarlo). + +- [ ] **Step 2: Esegui e verifica il fallimento** + +Run: `npm test -- tests/content.test.ts tests/content-api.test.ts` +Expected: FAIL — `guid` assente / modulo `[tag]/index` inesistente. + +- [ ] **Step 3: Espandi lo store in `content.ts`** + +Aggiorna `interface Entry` e la `resolve`: + +```typescript +interface Entry { value: string; type: ContentType; styles: Record; guid: string } +``` + +Nel `load()`, cambia la query e la costruzione: + +```typescript + const rows = dbGetter().prepare('SELECT tag, type, value, styles, guid FROM content_blocks').all() as + { tag: string; type: ContentType; value: string; styles: string; guid: string | null }[]; + cache = new Map(rows.map((r) => { + let styles: Record = {}; + try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ } + if (typeof styles !== 'object' || styles === null) styles = {}; + return [r.tag, { value: r.value, type: r.type, styles, guid: r.guid ?? '' }]; + })); +``` + +Nel metodo `resolve`, aggiorna i tre `return`: + +```typescript + resolve(tag: string): { value: string; type: ContentType; classes: string; guid: string } { + const hit = load().get(tag); + if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles), guid: hit.guid }; + const seed = seedByTag.get(tag); + if (seed) return { value: seed.value, type: seed.type, classes: '', guid: '' }; + console.warn(`[content] tag sconosciuto: ${tag}`); + return { value: '', type: 'text', classes: '', guid: '' }; + }, +``` + +- [ ] **Step 4: Emetti `data-guid` nei componenti** + +In `src/components/content/T.astro`, dopo `const c = resolveContent(tag);` usa `c.guid`: + +```astro +{c.type === 'html' + ? + : {c.value}} +``` + +In `src/components/content/TImg.astro`, aggiungi `data-guid` in entrambi i rami. Il ramo override usa `getImageOverride` che non dà il guid; recupera il guid da `resolveContent`: + +```astro +--- +import { Image } from 'astro:assets'; +import type { ImageMetadata } from 'astro'; +import { resolveContent } from '../../lib/content'; +interface Props { + tag: string; src: ImageMetadata; alt: string; + class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; height?: number; +} +const { tag, src, alt, class: cls, ...rest } = Astro.props; +const c = resolveContent(tag); +const override = c.type === 'image' && c.value ? `/uploads/${c.value}` : null; +const guid = c.guid || undefined; +--- +{override + ? {alt} + : {alt}} +``` + +- [ ] **Step 5: Crea l'endpoint GET singolo** + +Crea `src/pages/api/admin/content/[tag]/index.ts`: + +```typescript +import type { APIRoute } from 'astro'; +import { getDb } from '../../../../../lib/db'; +import { seedByTag } from '../../../../../data/content-seed'; +export const prerender = false; + +const json = (status: number, body: object) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +export const GET: APIRoute = ({ params }) => { + const tag = params.tag ?? ''; + const row = getDb().prepare('SELECT tag, type, value, guid FROM content_blocks WHERE tag = ?').get(tag) as + { tag: string; type: string; value: string; guid: string | null } | undefined; + if (!row) return json(404, { error: 'Tag inesistente.' }); + return json(200, { + tag: row.tag, + guid: row.guid ?? '', + type: row.type, + value: row.value, + styleOptions: seedByTag.get(row.tag)?.styleOptions ?? [], + }); +}; +``` + +Nota: convive con `PUT` di `src/pages/api/admin/content/[tag].ts` e con `POST/DELETE` di `[tag]/image.ts` — Astro risolve `/api/admin/content/` sull'endpoint `[tag].ts` (GET qui aggiunto come `[tag]/index.ts` risponde allo stesso path). **Verifica in Step 6** che il GET risponda; se il routing preferisce `[tag].ts`, sposta l'handler `GET` dentro `src/pages/api/admin/content/[tag].ts` accanto al `PUT` (stessa logica) ed elimina il file index. Decidi in base all'esito del test. + +- [ ] **Step 6: Esegui e verifica il successo** + +Run: `npm test -- tests/content.test.ts tests/content-api.test.ts && npm run build` +Expected: PASS + build OK. Se il test del GET fallisce per routing, applica la nota dello Step 5 (sposta `GET` in `[tag].ts`) e rilancia. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/content.ts src/components/content/T.astro src/components/content/TImg.astro src/pages/api/admin/content tests/content.test.ts tests/content-api.test.ts +git commit -m "feat(content): guid in resolve/componenti + GET /api/admin/content/[tag]" +``` + +--- + +### Task 7: Pagina di login pubblica `/login` + +**Files:** +- Create: `src/pages/login.astro` +- Create: `src/lib/safe-next.ts` +- Test: `tests/safe-next.test.ts` + +**Interfaces:** +- Consumes: `login`, `SESSION_COOKIE` (auth.ts), `rateLimit` (rate-limit.ts). +- Produces: `safeNext(next: string | null): string` — ritorna un path interno sicuro o `'/'`. + +- [ ] **Step 1: Test di `safeNext`** + +Crea `tests/safe-next.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { safeNext } from '../src/lib/safe-next'; + +describe('safeNext', () => { + it('accetta path interni', () => { + expect(safeNext('/about')).toBe('/about'); + expect(safeNext('/blog/x?y=1')).toBe('/blog/x?y=1'); + }); + it('rifiuta esterni, protocol-relative, api e vuoti', () => { + expect(safeNext(null)).toBe('/'); + expect(safeNext('')).toBe('/'); + expect(safeNext('//evil.com')).toBe('/'); + expect(safeNext('https://evil.com')).toBe('/'); + expect(safeNext('/api/admin/posts')).toBe('/'); + expect(safeNext('javascript:alert(1)')).toBe('/'); + expect(safeNext('not-a-path')).toBe('/'); + }); +}); +``` + +- [ ] **Step 2: Esegui e verifica il fallimento** + +Run: `npm test -- tests/safe-next.test.ts` +Expected: FAIL — modulo inesistente. + +- [ ] **Step 3: Implementa `safeNext`** + +Crea `src/lib/safe-next.ts`: + +```typescript +// Path interno sicuro per il redirect post-login: deve iniziare con "/", non essere +// protocol-relative ("//"), non puntare alle API. Tutto il resto → home. +export function safeNext(next: string | null): string { + if (!next || !next.startsWith('/') || next.startsWith('//')) return '/'; + if (next.startsWith('/api/')) return '/'; + return next; +} +``` + +- [ ] **Step 4: Esegui e verifica il successo** + +Run: `npm test -- tests/safe-next.test.ts` +Expected: PASS. + +- [ ] **Step 5: Crea la pagina `/login`** + +Crea `src/pages/login.astro`: + +```astro +--- +import Base from '../layouts/Base.astro'; +import { getDb } from '../lib/db'; +import { login, getSessionUser, SESSION_COOKIE } from '../lib/auth'; +import { rateLimit } from '../lib/rate-limit'; +import { safeNext } from '../lib/safe-next'; +export const prerender = false; + +const dest = safeNext(Astro.url.searchParams.get('next')); + +// Già loggato → via dal login. +const existing = Astro.cookies.get(SESSION_COOKIE)?.value; +if (existing && getSessionUser(getDb(), existing)) return Astro.redirect(dest); + +let error = ''; +if (Astro.request.method === 'POST') { + if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) { + error = 'Troppi tentativi: riprova tra 15 minuti.'; + } else { + const form = await Astro.request.formData(); + const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? '')); + if (token) { + Astro.cookies.set(SESSION_COOKIE, token, { + httpOnly: true, sameSite: 'lax', path: '/', + secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600, + }); + return Astro.redirect(safeNext(String(form.get('next') ?? '') || dest)); + } + error = 'Credenziali non valide.'; + } +} +--- + +
+ +
+ + + +``` + +- [ ] **Step 6: Verifica build** + +Run: `npm run build` +Expected: build OK. + +- [ ] **Step 7: Commit** + +```bash +git add src/pages/login.astro src/lib/safe-next.ts tests/safe-next.test.ts +git commit -m "feat(login): pagina pubblica /login con next sicuro" +``` + +--- + +### Task 8: Header SSR con identità utente + +**Files:** +- Modify: `src/layouts/Base.astro` +- Modify: `src/components/Header.astro` + +**Interfaces:** +- Consumes: `getSessionUser`, `SESSION_COOKIE` (auth.ts). +- Produces: `Header` accetta `Props { user: { username: string; role: string } | null; showTags: boolean; canTags: boolean }`. In questo task `showTags`/`canTags` sono passati ma il toggle è aggiunto nel Task 9 (qui il menu mostra solo identità + area riservata + esci). + +- [ ] **Step 1: Calcola la sessione in `Base.astro` e passala all'header** + +In `src/layouts/Base.astro`, sostituisci il blocco `// Overlay annotate...` con: + +```typescript +const tok = Astro.cookies.get(SESSION_COOKIE)?.value; +const user = tok ? getSessionUser(getDb(), tok) : null; +const canTags = !!user && (user.role === 'superuser' || user.role === 'admin'); +const wantTags = Astro.cookies.get('showtags')?.value === '1' || Astro.url.searchParams.get('annotate') === '1'; +const showTags = canTags && wantTags; +``` + +E cambia `
` in: + +```astro +
+``` + +(La sostituzione della variabile `annotate` con `showTags` nell'overlay avviene nel Task 10; per ora l'overlay resta gestito da `showTags` — aggiorna il blocco `{annotate && (` in `{showTags && (` già qui, così la pagina compila.) + +- [ ] **Step 2: Rendi l'header consapevole della sessione** + +Sostituisci il frontmatter e il markup di `src/components/Header.astro`: + +```astro +--- +import { site } from '../data/site'; +import { t, contentImageUrl } from '../lib/content'; +interface Props { + user: { username: string; role: string } | null; + showTags: boolean; + canTags: boolean; +} +const { user, canTags } = Astro.props; +const path = Astro.url.pathname; +const loginHref = `/login?next=${encodeURIComponent(path)}`; +const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin'; +--- + +``` + +Aggiungi in fondo al blocco ` + + + )} +``` + +- [ ] **Step 2: Verifica build** + +Run: `npm run build` +Expected: build OK. + +- [ ] **Step 3: Verifica manuale in dev (superuser)** + +Crea un utente superuser locale: `npm run create-user -- super prova1234 superuser`. Avvia dev (`fuser -k 4321/tcp; npm run dev &`), fai login su `/login`, attiva "Mostra tag", verifica i badge con guid e apri il modal su un tag testo: modifica → salva → il testo cambia in pagina senza reload. Ferma il server. Documenta l'esito nel report. + +- [ ] **Step 4: Commit** + +```bash +git add src/layouts/Base.astro +git commit -m "feat(inline): badge con guid + modal di modifica contenuti in pagina" +``` + +--- + +### Task 11: Nav admin per 3 ruoli e blog filtrato per autore + +**Files:** +- Modify: `src/layouts/Admin.astro` +- Modify: `src/pages/admin/index.astro` + +**Interfaces:** +- Consumes: `listAllPosts`, `listByAuthor` (posts.ts), `listUsers` (auth.ts), `Astro.locals.user`. + +- [ ] **Step 1: Nav admin per ruolo** + +In `src/layouts/Admin.astro`, sostituisci le due righe dentro `` (righe ~34-35) con: + +```astro + {user && ( + <> + {`Ciao, ${user.username}`} + Articoli + {(user.role === 'superuser' || user.role === 'admin') && Contenuti} + {user.role === 'admin' && Utenti} + Nuovo + Esci + Vedi sito ↗ + + )} +``` + +- [ ] **Step 2: Blog admin filtrato + colonna autore** + +Sostituisci il frontmatter di `src/pages/admin/index.astro`: + +```astro +--- +import Admin from '../../layouts/Admin.astro'; +import { getDb } from '../../lib/db'; +import { listAllPosts, listByAuthor } from '../../lib/posts'; +import { listUsers } from '../../lib/auth'; +export const prerender = false; +const user = Astro.locals.user!; +const isManager = user.role === 'superuser' || user.role === 'admin'; +const posts = isManager ? listAllPosts(getDb()) : listByAuthor(getDb(), user.id); +const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username])); +const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' }); +--- +``` + +Nella `` aggiungi la colonna Autore solo per i manager, e nella riga il valore. Sostituisci la tabella: + +```astro + + {isManager && } + + {posts.map((p) => ( + + + + {isManager && } + + + + + ))} + +
TitoloCategoriaAutoreStatoAggiornato
{p.title}{p.category}{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}{p.draft ? 'Bozza' : 'Pubblicato'}{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}
+``` + +- [ ] **Step 3: Verifica build** + +Run: `npm run build` +Expected: build OK. + +- [ ] **Step 4: Commit** + +```bash +git add src/layouts/Admin.astro src/pages/admin/index.astro +git commit -m "feat(admin): nav per 3 ruoli, blog filtrato per autore + colonna autore" +``` + +--- + +### Task 12: Firma autore nel blog pubblico + +**Files:** +- Modify: `src/pages/blog/[slug].astro` + +**Interfaces:** +- Consumes: `getUsernameById` (auth.ts), `post.author_id`. + +- [ ] **Step 1: Aggiungi la firma** + +In `src/pages/blog/[slug].astro`, aggiungi l'import e calcola l'autore nel frontmatter: + +```astro +import { getUsernameById } from '../../lib/auth'; +``` + +Dopo `const post = getPublishedBySlug(...)` e il check 404, aggiungi: + +```typescript +const author = post.author_id ? getUsernameById(getDb(), post.author_id) : null; +const byline = author ?? 'Staff InsanityLab'; +``` + +Nel markup, cambia la riga meta per includere la firma: + +```astro +

{post.published_at && fmt.format(new Date(post.published_at))} · {post.category} · di {byline}

+``` + +- [ ] **Step 2: Verifica build** + +Run: `npm run build` +Expected: build OK. + +- [ ] **Step 3: Commit** + +```bash +git add src/pages/blog/[slug].astro +git commit -m "feat(blog): firma autore sotto il titolo, fallback Staff InsanityLab" +``` + +--- + +### Task 13: Gestione utenti — pagina e API + +**Files:** +- Modify: `src/lib/auth.ts` (helper gestione) +- Create: `src/pages/api/admin/users/index.ts` +- Create: `src/pages/api/admin/users/[id]/role.ts` +- Create: `src/pages/api/admin/users/[id]/reset-password.ts` +- Create: `src/pages/api/admin/users/[id]/index.ts` +- Create: `src/pages/admin/users.astro` +- Test: `tests/users.test.ts` + +**Interfaces:** +- Consumes: `createUser`, `hashPassword`, `listUsers` (auth.ts), `Astro.locals.user`. +- Produces in `auth.ts`: + - `countAdmins(db): number` + - `getUserById(db, id): { id: number; username: string; role: string } | null` + - `updateUserRole(db, id, role: Role): void` + - `updateUserPassword(db, id, hash: string): void` + - `deleteUser(db, id): void` + - `randomPassword(): string` + - `isRole(v: unknown): v is Role` + +- [ ] **Step 1: Test degli helper gestione** + +Crea `tests/users.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { createDb } from '../src/lib/db'; +import { + createUser, countAdmins, getUserById, updateUserRole, + updateUserPassword, deleteUser, randomPassword, isRole, hashPassword, verifyPassword, +} from '../src/lib/auth'; + +let db: Database.Database; +beforeEach(() => { db = createDb(':memory:'); }); + +describe('gestione utenti', () => { + it('countAdmins conta i soli admin', () => { + createUser(db, 'a1', 'segreta123', 'admin'); + createUser(db, 's1', 'segreta123', 'superuser'); + expect(countAdmins(db)).toBe(1); + }); + + it('updateUserRole e getUserById', () => { + const id = createUser(db, 'u1', 'segreta123', 'user'); + updateUserRole(db, id, 'superuser'); + expect(getUserById(db, id)!.role).toBe('superuser'); + }); + + it('updateUserPassword cambia l’hash', () => { + const id = createUser(db, 'u2', 'vecchia123', 'user'); + updateUserPassword(db, id, hashPassword('nuova12345')); + const hash = (db.prepare('SELECT password_hash AS h FROM users WHERE id = ?').get(id) as { h: string }).h; + expect(verifyPassword('nuova12345', hash)).toBe(true); + }); + + it('deleteUser rimuove', () => { + const id = createUser(db, 'u3', 'segreta123', 'user'); + deleteUser(db, id); + expect(getUserById(db, id)).toBeNull(); + }); + + it('randomPassword genera almeno 12 caratteri', () => { + expect(randomPassword().length).toBeGreaterThanOrEqual(12); + }); + + it('isRole valida i ruoli', () => { + expect(isRole('admin')).toBe(true); + expect(isRole('superuser')).toBe(true); + expect(isRole('user')).toBe(true); + expect(isRole('editor')).toBe(false); + expect(isRole(3)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Esegui e verifica il fallimento** + +Run: `npm test -- tests/users.test.ts` +Expected: FAIL — helper non esistono. + +- [ ] **Step 3: Aggiungi gli helper in `auth.ts`** + +```typescript +export function countAdmins(db: Database.Database): number { + return (db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get() as { n: number }).n; +} + +export function getUserById(db: Database.Database, id: number): { id: number; username: string; role: string } | null { + const row = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(id) as + { id: number; username: string; role: string } | undefined; + return row ?? null; +} + +export function updateUserRole(db: Database.Database, id: number, role: Role): void { + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id); +} + +export function updateUserPassword(db: Database.Database, id: number, hash: string): void { + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, id); +} + +export function deleteUser(db: Database.Database, id: number): void { + db.prepare('DELETE FROM users WHERE id = ?').run(id); +} + +export function randomPassword(): string { + return randomBytes(9).toString('base64url'); // ~12 caratteri +} + +export function isRole(v: unknown): v is Role { + return v === 'admin' || v === 'superuser' || v === 'user'; +} +``` + +- [ ] **Step 4: Esegui e verifica il successo** + +Run: `npm test -- tests/users.test.ts` +Expected: PASS. + +- [ ] **Step 5: Crea l'API di creazione/lista** + +Crea `src/pages/api/admin/users/index.ts`: + +```typescript +import type { APIRoute } from 'astro'; +import { getDb } from '../../../../lib/db'; +import { createUser, listUsers, isRole } from '../../../../lib/auth'; +export const prerender = false; + +const json = (status: number, body: object) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +export const GET: APIRoute = () => json(200, { users: listUsers(getDb()) }); + +export const POST: APIRoute = async ({ request }) => { + let data: { username?: unknown; password?: unknown; role?: unknown }; + try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } + const username = String(data.username ?? '').trim(); + const password = String(data.password ?? ''); + if (username.length < 2) return json(400, { error: 'Nome utente troppo corto.' }); + if (password.length < 8) return json(400, { error: 'Password troppo corta (min 8).' }); + if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' }); + try { + const id = createUser(getDb(), username, password, data.role); + return json(201, { id }); + } catch (err: any) { + if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Nome utente già esistente.' }); + throw err; + } +}; +``` + +- [ ] **Step 6: Crea l'API cambio ruolo (con guardia ultimo admin)** + +Crea `src/pages/api/admin/users/[id]/role.ts`: + +```typescript +import type { APIRoute } from 'astro'; +import { getDb } from '../../../../../lib/db'; +import { getUserById, updateUserRole, countAdmins, isRole } from '../../../../../lib/auth'; +export const prerender = false; + +const json = (status: number, body: object) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +export const PUT: APIRoute = async ({ params, request }) => { + const id = Number(params.id); + const target = getUserById(getDb(), id); + if (!target) return json(404, { error: 'Utente inesistente.' }); + let data: { role?: unknown }; + try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } + if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' }); + if (target.role === 'admin' && data.role !== 'admin' && countAdmins(getDb()) <= 1) { + return json(400, { error: 'Deve restare almeno un admin.' }); + } + updateUserRole(getDb(), id, data.role); + return json(200, { ok: true }); +}; +``` + +- [ ] **Step 7: Crea l'API reset password** + +Crea `src/pages/api/admin/users/[id]/reset-password.ts`: + +```typescript +import type { APIRoute } from 'astro'; +import { getDb } from '../../../../../lib/db'; +import { getUserById, updateUserPassword, hashPassword, randomPassword } from '../../../../../lib/auth'; +export const prerender = false; + +const json = (status: number, body: object) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +export const POST: APIRoute = ({ params }) => { + const id = Number(params.id); + if (!getUserById(getDb(), id)) return json(404, { error: 'Utente inesistente.' }); + const password = randomPassword(); + updateUserPassword(getDb(), id, hashPassword(password)); + // La password in chiaro è restituita UNA sola volta: l'admin la copia e la consegna. + return json(200, { password }); +}; +``` + +- [ ] **Step 8: Crea l'API elimina (guardie self + ultimo admin)** + +Crea `src/pages/api/admin/users/[id]/index.ts`: + +```typescript +import type { APIRoute } from 'astro'; +import { getDb } from '../../../../../lib/db'; +import { getUserById, deleteUser, countAdmins } from '../../../../../lib/auth'; +export const prerender = false; + +const json = (status: number, body: object) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +export const DELETE: APIRoute = ({ params, locals }) => { + const id = Number(params.id); + const target = getUserById(getDb(), id); + if (!target) return json(404, { error: 'Utente inesistente.' }); + if (id === locals.user!.id) return json(400, { error: 'Non puoi eliminare te stesso.' }); + if (target.role === 'admin' && countAdmins(getDb()) <= 1) { + return json(400, { error: 'Deve restare almeno un admin.' }); + } + deleteUser(getDb(), id); + return json(200, { ok: true }); +}; +``` + +- [ ] **Step 9: Crea la pagina `/admin/users`** + +Crea `src/pages/admin/users.astro`: + +```astro +--- +import Admin from '../../layouts/Admin.astro'; +import { getDb } from '../../lib/db'; +import { listUsers } from '../../lib/auth'; +export const prerender = false; +const users = listUsers(getDb()); +const me = Astro.locals.user!; +--- + +

Utenti

+ +

Nuovo utente

+
+ + + + + +
+ +

Elenco

+ + + + {users.map((u) => ( + + + + + + ))} + +
UtenteRuolo
{u.username}{u.id === me.id && ' (tu)'} + + + + {u.id !== me.id && } +
+

+
+ + +``` + +- [ ] **Step 10: Verifica build + test** + +Run: `npm run build && npm test` +Expected: build OK, tutti i test verdi. + +- [ ] **Step 11: Commit** + +```bash +git add src/lib/auth.ts src/pages/api/admin/users src/pages/admin/users.astro tests/users.test.ts +git commit -m "feat(users): gestione utenti /admin/users con guardie self/ultimo-admin" +``` + +--- + +### Task 14: Smoke esteso e verifica end-to-end + +**Files:** +- Modify: `scripts/smoke.sh` + +**Interfaces:** +- Consumes: build di produzione, utenti locali di prova. + +- [ ] **Step 1: Estendi lo smoke** + +In `scripts/smoke.sh`, aggiungi controlli (adeguandoti allo stile e alle variabili già presenti nel file — porta, `BASE`, helper di asserzione esistenti). Aggiungi: + +```bash +# /login pubblico raggiungibile +code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/login") +[ "$code" = "200" ] || { echo "FAIL: /login $code"; exit 1; } + +# Header mostra "Accedi" da anonimo +curl -s "$BASE/" | grep -q 'Accedi' || { echo "FAIL: link Accedi assente"; exit 1; } + +# Nessun badge/modal da anonimo +if curl -s "$BASE/" | grep -q 'tag-badge'; then echo "FAIL: badge visibile da anonimo"; exit 1; fi + +# Rotta gestione utenti protetta (redirect al login da anonimo) +code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/admin/users") +[ "$code" = "302" ] || { echo "FAIL: /admin/users non protetta ($code)"; exit 1; } + +echo "smoke ruoli/login/inline OK" +``` + +- [ ] **Step 2: Esegui lo smoke sulla build di produzione** + +Run: `fuser -k 4399/tcp 2>/dev/null; npm run build && bash scripts/smoke.sh` +Expected: exit 0, riga finale `smoke ruoli/login/inline OK`. (Ricorda: `fuser -k 4399/tcp` prima, per evitare il falso positivo del server stantio.) + +- [ ] **Step 3: Verifica funzionale locale completa** + +Crea tre utenti: `npm run create-user -- u1 prova1234 user`, `npm run create-user -- s1 prova1234 superuser` (l'admin esiste già). Con dev server, verifica in browser: +- `user`: vede solo i propri articoli, non `/admin/content` (redirect), non `/admin/users`; crea un articolo → compare firmato. +- `superuser`: vede tutti gli articoli con colonna Autore, apre `/admin/content`, attiva "Mostra tag" e modifica un tag inline; non vede `/admin/users`. +- `admin`: apre `/admin/users`, crea un utente, reset password (mostra la password una volta), cambia ruolo, prova a eliminare se stesso (bloccato) e prova a declassare l'unico admin (bloccato). +Documenta gli esiti nel report del task. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/smoke.sh +git commit -m "test(smoke): login pubblico, badge, protezione /admin/users" +``` + +--- + +### Task 15: Deploy in produzione e verifica + +**Files:** nessuna modifica di codice. + +**Interfaces:** +- Consumes: repo Gitea, VPS `/opt/docker/insanitylab/src`. + +- [ ] **Step 1: Push del branch/merge in main** + +Segui la convenzione del progetto (merge ff in `main` come nei piani precedenti) e push su Gitea. Verifica albero pulito con `git status`. + +- [ ] **Step 2: Deploy sul VPS** + +Run (via ssh `HOSTINGER_Tielogic_XYZ`): `cd /opt/docker/insanitylab/src && git pull && docker compose up -d --build` +Expected: build immagine OK, container `insanitylab` up. + +- [ ] **Step 3: Verifica la migrazione dei ruoli in produzione** + +L'utente `editor` di produzione deve risultare `superuser` dopo il boot (la migrazione gira in `createDb`). Verifica: +Run: `docker compose exec -T insanitylab node -e "const D=require('better-sqlite3');const db=new D(process.env.DB_PATH||'data/insanitylab.db');console.log(db.prepare('SELECT username, role FROM users').all())"` +Expected: nessun ruolo `editor`; l'ex editor è `superuser`. + +- [ ] **Step 4: Verifica funzionale in produzione** + +- `https://insanitylab.tielogic.xyz/login` → 200, form stile sito. +- Login con l'ex editor (ora superuser): header mostra il nome, "Mostra tag" attivabile, modal inline salva un tag e il sito riflette la modifica; poi ripristina il valore. +- `https://insanitylab.tielogic.xyz/admin/users` con l'admin: crea/reset/ruolo/elimina funzionano; guardie attive. +- Un GUID è presente come `data-guid` nel sorgente della home. +- Blog pubblico: un articolo con autore mostra la firma. +Documenta gli esiti. + +- [ ] **Step 5: Aggiorna la memoria di progetto** + +Aggiorna `~/.claude/projects/-home-adriano-Documenti-Clienti-InsanityLab/memory/insanitylab-stato-progetto.md` con: sistema 3 ruoli, login pubblico, GUID contenuti, modifica inline, gestione utenti — deployati. Nessuna credenziale nei file di memoria. + +--- + +## Self-Review + +**Spec coverage:** +- Ruoli + matrice → Task 1 (migrazione), Task 2 (matrice), Task 3 (enforcement). ✓ +- GUID interno + nome leggibile → Task 1 (colonna/backfill), Task 6 (resolve/componenti/GET). ✓ +- editor → superuser → Task 1. ✓ +- Pagina `/login` + `?next` sicuro + alias `/admin/login` → Task 7 (alias resta: `/admin/login.astro` non rimosso). ✓ +- Nome nel header + menu → Task 8. ✓ +- Blog ownership (solo propri per user, tutti per superuser/admin, firma autore) → Task 4/5 (repo+API), Task 11 (lista), Task 12 (firma pubblica). ✓ +- Toggle "Mostra tag" persistente + badge + modifica inline modal → Task 9 (toggle/cookie), Task 10 (badge+modal). ✓ +- Gestione utenti completa (crea, reset psw una-volta, ruolo, elimina; guardie self/ultimo-admin) → Task 13. ✓ +- Testing (unit matrice, ownership, migrazioni, gestione utenti; smoke) → Task 1/2/4/5/6/13 + Task 14. ✓ +- Fuori scope (no framework, no tabella permessi, no self-registration) → rispettato: tutto vanilla, ruoli fissi. ✓ + +**Placeholder scan:** nessun TBD/TODO; ogni step ha codice o comando reale. Le due note di contingenza (routing GET in Task 6, stato pulsante in Task 9) contengono entrambe la correzione esatta da applicare, non un rimando generico. + +**Type consistency:** `Role` unica definizione (Task 2), riusata da `createUser`/`updateUserRole`/`isRole`. `canModifyPost(role, userId, post)` firma coerente tra Task 5 (def) e API. `resolveContent` ritorna `guid` (Task 6) consumato da T/TImg e — indirettamente via query — dal GET singolo. `listUsers`/`getUsernameById`/`getUserById`/`countAdmins` definiti in Task 4/13 e consumati da Task 11/12/13 con le stesse firme. `safeNext` (Task 7) usato solo in `/login`. + +## Execution Handoff + +Salvo qui sotto. Due opzioni di esecuzione: + +1. **Subagent-Driven (consigliata)** — un subagent fresco per task + review in due fasi tra un task e l'altro, iterazione rapida. +2. **Inline** — eseguo i task in questa sessione con checkpoint di review a batch.