Files
InsanityLab-web/docs/superpowers/plans/2026-07-05-utenti-ruoli-login-inline.md
T

1826 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<string, unknown>;
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<string, unknown>;
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<string, string>; 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<string, string> = {};
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'
? <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes} set:html={c.value} />
: <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes}>{c.value}</Tag>}
```
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
? <img src={override} alt={alt} class={cls} data-tag={tag} data-guid={guid} loading={rest.loading} />
: <Image src={src} alt={alt} class={cls} data-tag={tag} data-guid={guid} {...rest} />}
```
- [ ] **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/<tag>` 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.';
}
}
---
<Base title="Accedi" description="Area riservata InsanityLab">
<section class="section">
<div class="container login-wrap">
<h1 class="section-heading">Accedi</h1>
{error && <p class="login-err">{error}</p>}
<form method="post" class="login-form">
<input type="hidden" name="next" value={dest} />
<input class="login-field" name="username" placeholder="Utente" required autocomplete="username" />
<input class="login-field" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
<button class="btn btn--dark" type="submit">Entra</button>
</form>
</div>
</section>
</Base>
<style>
.login-wrap { max-width: 380px; margin: 0 auto; }
.login-form { display: grid; gap: 14px; margin-top: 20px; }
.login-field { padding: 12px; border: 1px solid #ccc; font: inherit; }
.login-err { color: #a33; }
</style>
```
- [ ] **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 `<Header />` in:
```astro
<Header user={user} showTags={showTags} canTags={canTags} />
```
(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';
---
<header class="hdr" id="site-header">
<div class="container hdr__in">
<a href="/" class="hdr__logo"><img src={contentImageUrl('header.logo.image', '/img/logo-dark.png')} alt={t('header.logo.alt')} width="150" /></a>
<nav class="hdr__nav" id="site-nav" aria-label="principale">
{site.navigation.map((item, i) => (
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
))}
</nav>
<div class="hdr__user">
{user ? (
<details class="umenu">
<summary class="umenu__btn">{user.username} ▾</summary>
<div class="umenu__panel">
<a href={areaHref}>Area riservata</a>
{canTags && (
<button type="button" class="umenu__tags" id="toggle-tags" data-slot="tags">Mostra tag</button>
)}
<a href="/admin/logout">Esci</a>
</div>
</details>
) : (
<a class="hdr__login" href={loginHref}>Accedi</a>
)}
</div>
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
<span></span><span></span><span></span>
</button>
</div>
</header>
```
Aggiungi in fondo al blocco `<style>` esistente dell'header:
```css
.hdr__user { display: flex; align-items: center; }
.hdr__login { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
.hdr__login:hover { color: var(--c-accent-dark); }
.umenu { position: relative; }
.umenu__btn { cursor: pointer; list-style: none; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); }
.umenu__btn::-webkit-details-marker { display: none; }
.umenu__panel { position: absolute; right: 0; top: calc(100% + 8px); background: #fff; box-shadow: 0 8px 22px rgba(0,0,0,.12); min-width: 180px; display: flex; flex-direction: column; padding: 8px 0; z-index: 200; }
.umenu__panel a, .umenu__tags { padding: 10px 16px; text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
.umenu__panel a:hover, .umenu__tags:hover { background: #f4f2ef; }
```
(Il pulsante `#toggle-tags` è reso qui ma il suo comportamento è cablato nel Task 9.)
- [ ] **Step 3: Verifica build**
Run: `npm run build`
Expected: build OK.
- [ ] **Step 4: Verifica manuale in dev**
Run: `fuser -k 4321/tcp 2>/dev/null; npm run dev &` poi `curl -s localhost:4321/ | grep -o 'Accedi'`
Expected: `Accedi` presente da anonimo. Ferma il server dopo la verifica (`fuser -k 4321/tcp`).
- [ ] **Step 5: Commit**
```bash
git add src/layouts/Base.astro src/components/Header.astro
git commit -m "feat(header): identità utente SSR, link Accedi / menu utente"
```
---
### Task 9: Toggle "Mostra tag" — endpoint cookie e cablaggio
**Files:**
- Create: `src/pages/api/admin/showtags.ts`
- Modify: `src/components/Header.astro` (script del pulsante)
**Interfaces:**
- Consumes: `locals.user` dal middleware (rotta sotto `/api/admin`, quindi login garantito).
- Produces: `POST /api/admin/showtags` con body `{ on: boolean }`; imposta/rimuove il cookie `showtags`; `403` se il ruolo non è `superuser`/`admin`.
- [ ] **Step 1: Crea l'endpoint**
Crea `src/pages/api/admin/showtags.ts`:
```typescript
import type { APIRoute } from 'astro';
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 = async ({ request, cookies, locals }) => {
const role = locals.user!.role;
if (role !== 'superuser' && role !== 'admin') return json(403, { error: 'Permessi insufficienti' });
let data: { on?: unknown };
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
if (data.on) {
cookies.set('showtags', '1', { httpOnly: true, sameSite: 'lax', path: '/', secure: import.meta.env.PROD });
} else {
cookies.delete('showtags', { path: '/' });
}
return json(200, { on: Boolean(data.on) });
};
```
- [ ] **Step 2: Cabla il pulsante nell'header**
In `src/components/Header.astro`, dentro il blocco `<script>` esistente (dopo il codice del burger), aggiungi:
```javascript
const tagsBtn = document.getElementById('toggle-tags');
if (tagsBtn) {
// Lo stato attuale è riflesso server-side; il testo lo deriviamo dal cookie.
const on = document.cookie.split('; ').some((c) => c === 'showtags=1');
tagsBtn.textContent = on ? 'Nascondi tag' : 'Mostra tag';
tagsBtn.addEventListener('click', async () => {
const res = await fetch('/api/admin/showtags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on: !on }),
});
if (res.ok) location.reload();
else alert('Operazione non riuscita.');
});
}
```
Nota: il cookie `showtags` è `httpOnly`, quindi `document.cookie` non lo espone. Per riflettere lo stato senza reload, l'header riceve già `showTags` come prop: passa lo stato al pulsante via attributo. Correzione da applicare in questo step — nel markup del Task 8 il pulsante diventa:
```astro
<button type="button" class="umenu__tags" id="toggle-tags" data-on={Astro.props.showTags ? '1' : '0'}>{Astro.props.showTags ? 'Nascondi tag' : 'Mostra tag'}</button>
```
e lo script usa `data-on`:
```javascript
const tagsBtn = document.getElementById('toggle-tags');
if (tagsBtn) {
const on = tagsBtn.dataset.on === '1';
tagsBtn.addEventListener('click', async () => {
const res = await fetch('/api/admin/showtags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on: !on }),
});
if (res.ok) location.reload();
else alert('Operazione non riuscita.');
});
}
```
- [ ] **Step 3: Verifica build**
Run: `npm run build`
Expected: build OK.
- [ ] **Step 4: Commit**
```bash
git add src/pages/api/admin/showtags.ts src/components/Header.astro
git commit -m "feat(showtags): endpoint cookie + toggle nel menu utente"
```
---
### Task 10: Badge con GUID e modal di modifica inline
**Files:**
- Modify: `src/layouts/Base.astro` (blocco overlay `{showTags && (...)}`)
**Interfaces:**
- Consumes: elementi con `data-tag`/`data-guid` nel DOM; `GET /api/admin/content/[tag]` (Task 6); `PUT /api/admin/content/[tag]` e `POST/DELETE /api/admin/content/[tag]/image` (esistenti); `showTags` (Task 8).
- Produces: overlay con badge (nome tag + guid abbreviato + matita) e modal di modifica in pagina.
- [ ] **Step 1: Sostituisci l'overlay in `Base.astro`**
Rimpiazza l'intero blocco `{showTags && ( ... )}` (ex `{annotate && (...)}`, righe ~77-102) con:
```astro
{showTags && (
<style is:global>
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
.tag-badge { position: absolute; z-index: 9999; display: inline-flex; align-items: center; gap: 4px;
background: #3a2929; color: #fff; font: 600 10px/1.6 monospace; padding: 0 4px 0 6px; opacity: .85; }
.tag-badge:hover { opacity: 1; }
.tag-badge__edit { background: #9c8b70; color: #fff; border: 0; cursor: pointer; font: inherit; padding: 0 5px; }
.tag-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; }
.tag-modal[hidden] { display: none; }
.tag-modal__box { background: #fff; color: #222; width: min(560px, 92vw); max-height: 88vh; overflow: auto; padding: 20px; border-radius: 4px; }
.tag-modal__box h3 { margin: 0 0 4px; font: 600 14px monospace; }
.tag-modal__box .guid { font: 11px monospace; color: #888; margin: 0 0 14px; }
.tag-modal__box textarea { width: 100%; min-height: 140px; font: inherit; padding: 8px; box-sizing: border-box; }
.tag-modal__box select { margin: 6px 8px 6px 0; }
.tag-modal__row { display: flex; gap: 10px; margin-top: 14px; }
.tag-modal__row button { padding: 9px 16px; border: 0; cursor: pointer; }
.tag-modal__save { background: #3a2d26; color: #fff; }
.tag-modal__cancel { background: #ddd; }
.tag-modal__msg { color: #a33; margin-top: 8px; min-height: 18px; }
</style>
<div class="tag-modal" id="tag-modal" hidden>
<div class="tag-modal__box">
<h3 id="tm-tag"></h3>
<p class="guid" id="tm-guid"></p>
<div id="tm-body"></div>
<div class="tag-modal__msg" id="tm-msg"></div>
<div class="tag-modal__row">
<button type="button" class="tag-modal__save" id="tm-save">Salva</button>
<button type="button" class="tag-modal__cancel" id="tm-cancel">Annulla</button>
</div>
</div>
</div>
<script is:inline>
(() => {
const STYLE_GROUPS = { size: ['s','m','l'], color: ['accent','accent-dark','dark','heading','text','text-light','white'], weight: ['normal','bold'], style: ['normal','italic'], align: ['left','center','right'] };
const modal = document.getElementById('tag-modal');
const tmTag = document.getElementById('tm-tag');
const tmGuid = document.getElementById('tm-guid');
const tmBody = document.getElementById('tm-body');
const tmMsg = document.getElementById('tm-msg');
const tmSave = document.getElementById('tm-save');
let current = null; // { tag, type, el }
function closeModal() { modal.hidden = true; tmBody.innerHTML = ''; tmMsg.textContent = ''; current = null; }
document.getElementById('tm-cancel').addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
async function openModal(tag, el) {
tmMsg.textContent = '';
let data;
try {
const res = await fetch('/api/admin/content/' + encodeURIComponent(tag));
if (!res.ok) { alert('Impossibile caricare il contenuto.'); return; }
data = await res.json();
} catch { alert('Errore di rete.'); return; }
current = { tag: data.tag, type: data.type, el };
tmTag.textContent = data.tag;
tmGuid.textContent = data.guid || '';
tmBody.innerHTML = '';
if (data.type === 'image') {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'tm-file';
tmBody.appendChild(inp);
const del = document.createElement('button');
del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del';
del.style.cssText = 'margin-top:10px;padding:8px 14px;border:0;background:#a33;color:#fff;cursor:pointer';
tmBody.appendChild(del);
del.addEventListener('click', async () => {
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'DELETE' });
if (r.ok) { location.reload(); } else { tmMsg.textContent = 'Rimozione non riuscita.'; }
});
} else {
const ta = document.createElement('textarea');
ta.id = 'tm-value'; ta.value = data.value;
tmBody.appendChild(ta);
(data.styleOptions || []).forEach((group) => {
const opts = STYLE_GROUPS[group];
if (!opts) return;
const sel = document.createElement('select');
sel.dataset.group = group;
sel.className = 'tm-style';
const none = document.createElement('option');
none.value = ''; none.textContent = group + ': —';
sel.appendChild(none);
opts.forEach((v) => { const o = document.createElement('option'); o.value = v; o.textContent = group + ': ' + v; sel.appendChild(o); });
tmBody.appendChild(sel);
});
}
modal.hidden = false;
}
tmSave.addEventListener('click', async () => {
if (!current) return;
tmMsg.textContent = '';
if (current.type === 'image') {
const file = document.getElementById('tm-file').files[0];
if (!file) { tmMsg.textContent = 'Scegli un file.'; return; }
const fd = new FormData(); fd.append('file', file);
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'POST', body: fd });
if (r.ok) { location.reload(); } else { tmMsg.textContent = (await r.json()).error || 'Upload non riuscito.'; }
return;
}
const value = document.getElementById('tm-value').value;
const styles = {};
document.querySelectorAll('.tm-style').forEach((s) => { if (s.value) styles[s.dataset.group] = s.value; });
const body = { value };
if (Object.keys(styles).length) body.styles = styles;
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!r.ok) { tmMsg.textContent = (await r.json()).error || 'Salvataggio non riuscito.'; return; }
const saved = await r.json();
// Aggiorna in-place il contenuto nel DOM.
if (current.type === 'html') current.el.innerHTML = saved.value;
else current.el.textContent = saved.value;
closeModal();
});
document.querySelectorAll('[data-tag]').forEach((el) => {
const tag = el.dataset.tag;
const guid = el.dataset.guid || '';
const badge = document.createElement('span');
badge.className = 'tag-badge';
const label = document.createElement('span');
label.textContent = guid ? tag + ' · ' + guid.slice(0, 8) : tag;
const edit = document.createElement('button');
edit.type = 'button'; edit.className = 'tag-badge__edit'; edit.textContent = '✎';
edit.addEventListener('click', () => openModal(tag, el));
badge.appendChild(label); badge.appendChild(edit);
document.body.appendChild(badge);
const place = () => {
const r = el.getBoundingClientRect();
badge.style.top = window.scrollY + r.top - 16 + 'px';
badge.style.left = window.scrollX + r.left + 'px';
};
place();
addEventListener('scroll', place, { passive: true });
addEventListener('resize', place);
});
})();
</script>
)}
```
- [ ] **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 `<span>` (righe ~34-35) con:
```astro
{user && (
<>
{`Ciao, ${user.username}`}
<a href="/admin">Articoli</a>
{(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</a>}
{user.role === 'admin' && <a href="/admin/users">Utenti</a>}
<a href="/admin/new">Nuovo</a>
<a href="/admin/logout">Esci</a>
<a href="/blog" target="_blank">Vedi sito ↗</a>
</>
)}
```
- [ ] **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 `<thead>` aggiungi la colonna Autore solo per i manager, e nella riga il valore. Sostituisci la tabella:
```astro
<table>
<thead><tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
<tbody>
{posts.map((p) => (
<tr>
<td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
<td>{p.category}</td>
{isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
<td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
<td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
<td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td>
</tr>
))}
</tbody>
</table>
```
- [ ] **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
<p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category} · di {byline}</p>
```
- [ ] **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 lhash', () => {
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!;
---
<Admin title="Utenti">
<h1>Utenti</h1>
<h2>Nuovo utente</h2>
<form id="new-user" style="max-width:420px">
<input class="afield" name="username" placeholder="Nome utente" required />
<input class="afield" name="password" type="text" placeholder="Password (min 8)" required />
<select class="afield" name="role">
<option value="user">user</option>
<option value="superuser">superuser</option>
<option value="admin">admin</option>
</select>
<button class="abtn" type="submit">Crea</button>
<span class="form-msg" id="new-msg"></span>
</form>
<h2 style="margin-top:30px">Elenco</h2>
<table>
<thead><tr><th>Utente</th><th>Ruolo</th><th></th></tr></thead>
<tbody>
{users.map((u) => (
<tr data-id={u.id}>
<td>{u.username}{u.id === me.id && ' (tu)'}</td>
<td>
<select class="role-sel" data-id={u.id}>
{['user', 'superuser', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
</select>
</td>
<td>
<button class="abtn" data-reset={u.id}>Reset password</button>
{u.id !== me.id && <button class="abtn abtn--danger" data-del={u.id}>Elimina</button>}
</td>
</tr>
))}
</tbody>
</table>
<p class="form-msg" id="row-msg"></p>
</Admin>
<script>
const rowMsg = document.getElementById('row-msg')!;
const show = (el: HTMLElement, text: string, ok = false) => {
el.textContent = text;
el.className = 'form-msg ' + (ok ? 'form-msg--ok' : 'form-msg--err');
};
document.getElementById('new-user')!.addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target as HTMLFormElement;
const body = {
username: (f.elements.namedItem('username') as HTMLInputElement).value,
password: (f.elements.namedItem('password') as HTMLInputElement).value,
role: (f.elements.namedItem('role') as HTMLSelectElement).value,
};
const res = await fetch('/api/admin/users', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
const msg = document.getElementById('new-msg') as HTMLElement;
if (res.ok) { show(msg, 'Creato ✓', true); setTimeout(() => location.reload(), 600); }
else show(msg, (await res.json()).error ?? 'Errore');
});
document.querySelectorAll<HTMLSelectElement>('.role-sel').forEach((sel) =>
sel.addEventListener('change', async () => {
const res = await fetch(`/api/admin/users/${sel.dataset.id}/role`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: sel.value }),
});
if (res.ok) show(rowMsg, 'Ruolo aggiornato ✓', true);
else { show(rowMsg, (await res.json()).error ?? 'Errore'); location.reload(); }
}));
document.querySelectorAll<HTMLButtonElement>('[data-reset]').forEach((b) =>
b.addEventListener('click', async () => {
if (!confirm('Generare una nuova password per questo utente?')) return;
const res = await fetch(`/api/admin/users/${b.dataset.reset}/reset-password`, { method: 'POST' });
if (res.ok) { const { password } = await res.json(); prompt('Nuova password (copiala e consegnala ora):', password); }
else show(rowMsg, (await res.json()).error ?? 'Errore');
}));
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
b.addEventListener('click', async () => {
if (!confirm('Eliminare definitivamente questo utente?')) return;
const res = await fetch(`/api/admin/users/${b.dataset.del}`, { method: 'DELETE' });
if (res.ok) location.reload();
else show(rowMsg, (await res.json()).error ?? 'Errore');
}));
</script>
```
- [ ] **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.