# Aplicación de referencia de Taskboard con sesiones, propiedad y tareas persistentes

> Construye y pruebe una pequeña aplicación usable con sesiones firmadas, contraseñas de escritura, CSRF, dos roles, NoSQL creado por propietario y una interfaz de navegador.

Framework: total5
Product: Total.js 5 (total5@0.0.19)
Language: es
Content-SHA256: 07f8a9a6742ee396f9b2653d3b173225ab67156fd1ba62e9ee2652f109d1e730
Snapshot: c0700e321579725c5cf4f5ace1ad7d2866d1b4f93c24a63c0b41b6b7998e976c
Sources: total5-installed@total5@0.0.19
Review: not-recorded

Esta referencia une las piezas de una aplicación de negocio: un visitante anónimo abre la interfaz, inicia sesión, crea tareas, las marca como hechas y cierra sesión. Los miembros ven solo sus propias tareas; un administrador puede listar y borrar cualquier tarea. El servidor es dueño de cada decisión de autorización, con independencia de qué botones muestre el navegador.

La aplicación usa rutas, acciones, esquemas, autenticación de sesión y NoSQL embebido nativos de Total.js. Las contraseñas usan scrypt de Node a través de `Total.Crypto`. La interfaz usa formularios ordinarios del navegador y nodos de texto del DOM; no hace falta ningún otro framework de frontend ni dependencia en tiempo de ejecución. Los controladores directos gestionan el transporte de la sesión; las acciones de tareas reutilizables usan identificadores estables `Namespace|action`.

## Ejecuta el ejemplo completo

Copia los archivos indicados abajo en un directorio nuevo, añade `total5@0.0.19` a package.json y arranca con `require('total5'); Total.run({ ip: '127.0.0.1', port: 18400, release: false, watcher: false });`. Abre localhost:18400. Usa un directorio y un puerto distintos de los de Forge. La herramienta de exportación del repositorio también puede materializar este ejemplo exacto como un proyecto.

`demo: true` permite los tres usuarios locales `alice`, `bob` y `admin`, con las contraseñas `alice-demo-password`, `bob-demo-password` y `admin-demo-password`. El modo demo se niega a arrancar cuando se pasa `--release` o `NODE_ENV=production`. Mantén su listener ligado al loopback. El framework establece `DEBUG=false` con `watcher:false` incluso cuando se pasa `release:false`; por eso este ejemplo no usa `DEBUG` como guarda de la demo. Para release, define `demo: false`, proporciona `TASKBOARD_SECRET` (al menos 32 caracteres) y `TASKBOARD_ALICE_PASSWORD`, `TASKBOARD_BOB_PASSWORD`, `TASKBOARD_ADMIN_PASSWORD` (cada una de al menos 12 caracteres). Las credenciales fijas de la demo no deben usarse en un despliegue público.

Los usuarios son un directorio fijo deliberadamente pequeño; las sesiones y las tareas persisten en `databases/`. Un secreto estable permite recargar una sesión válida tras reiniciar. El logout espera al callback de eliminación del almacén de sesiones antes de devolver éxito. No compartas ese directorio entre instancias en ejecución, no lo borres al desplegar ni lo pongas en git. Un despliegue público necesita además HTTPS, un proxy inverso de confianza, copias de seguridad y una política real de alta de usuarios. Esto es una referencia ejecutable, no un producto completo de gestión de identidades.

## Archivos del servidor

```text config
name : Taskboard reference
demo : true
```

```js definitions/taskboard.js
var crypto = Total.Crypto;
var demo = CONF.demo === true;
if (demo && (process.argv.includes('--release') || process.env.NODE_ENV === 'production'))
	throw new Error('Disable demo mode before a release deployment');
var secret = process.env.TASKBOARD_SECRET || (demo ? 'local-taskboard-demo-secret-not-for-public-use' : '');
if (secret.length < 32)
	throw new Error('TASKBOARD_SECRET needs at least 32 characters');
CONF.secret_csrf = secret;

MAIN.taskUsers = Object.create(null);
for (var name of ['alice', 'bob', 'admin']) {
	var password = process.env['TASKBOARD_' + name.toUpperCase() + '_PASSWORD'] || (demo ? name + '-demo-password' : '');
	if (password.length < 12)
		throw new Error('Configure a password of at least 12 characters for ' + name);
	var salt = crypto.randomBytes(16).toString('hex');
	MAIN.taskUsers[name] = { id: name, name: name, role: name === 'admin' ? 'admin' : 'member', salt: salt, hash: crypto.scryptSync(password, salt, 32) };
}

MAIN.taskAuth = {
	secret: secret,
	cookie: 'taskboard_session',
	expire: '30 minutes',
	options: { httponly: true, samesite: 'Strict', secure: !demo },
	onread: function(meta, next) {
		NOSQL('task-sessions').read().where('id', meta.sessionid).callback(function(err, session) {
			var user = !err && session && session.expire > Date.now() && MAIN.taskUsers[session.userid];
			user ? next(null, { id: user.id, name: user.name, role: user.role }) : next('Session not found');
		});
	},
	onlogout: function(session) {
		NOSQL('task-sessions').remove().where('id', session.sessionid).callback(NOOP);
	},
	onremove: function(id) {
		NOSQL('task-sessions').remove().where('id', id).callback(NOOP);
	}
};
AUTH(MAIN.taskAuth);

MIDDLEWARE('taskcsrf', function($, next) {
	DEF.onCSRFcheck($) ? next() : $.invalid(403);
});
```

```js actions/tasks.js
function owned(builder, user) {
	return user.role === 'admin' ? builder : builder.where('owner', user.id);
}

NEWACTION('Tasks|list', {
	user: true,
	action: function($) {
		owned(NOSQL('tasks').find(), $.user).sort('title').callback(function(err, tasks) {
			err ? $.invalid(500) : $.callback(tasks);
		});
	}
});

NEWACTION('Tasks|read', {
	user: true,
	params: '*id:UID',
	action: function($) {
		owned(NOSQL('tasks').read().where('id', $.params.id), $.user).callback(function(err, task) {
			if (err) return $.invalid(500);
			task ? $.callback(task) : $.invalid(404);
		});
	}
});

NEWACTION('Tasks|create', {
	user: true,
	input: '*title:String(80)',
	action: function($, model) {
		var task = { id: UID(), title: model.title, done: false, owner: $.user.id };
		NOSQL('tasks').insert(task).callback(function(err) {
			err ? $.invalid(500) : $.callback(task);
		});
	}
});

NEWACTION('Tasks|update', {
	user: true,
	params: '*id:UID',
	input: '*done:Boolean',
	action: function($, model) {
		owned(NOSQL('tasks').update({ done: model.done }).where('id', $.params.id), $.user).callback(function(err, count) {
			if (err) return $.invalid(500);
			count ? $.success() : $.invalid(404);
		});
	}
});

NEWACTION('Tasks|remove', {
	user: true,
	params: '*id:UID',
	action: function($) {
		if ($.user.role !== 'admin') return $.invalid(403);
		NOSQL('tasks').remove().where('id', $.params.id).callback(function(err, count) {
			if (err) return $.invalid(500);
			count ? $.success() : $.invalid(404);
		});
	}
});
```

```js controllers/taskboard.js
var attempts = Object.create(null);

exports.install = function() {
	ROUTE('GET /', function($) { $.file(PATH.public('index.html')); });
	ROUTE('GET /session/', session);
	ROUTE('POST /login/ @json <2kb #taskcsrf', login);
	ROUTE('+POST /logout/ @json #taskcsrf', logout);
	ROUTE('+GET /tasks/ --> *Tasks|list');
	ROUTE('+GET /tasks/{id}/ --> *Tasks|read');
	ROUTE('+POST /tasks/ <2kb #taskcsrf --> *Tasks|create');
	ROUTE('+PATCH /tasks/{id}/ <2kb #taskcsrf --> *Tasks|update');
	ROUTE('+DELETE /tasks/{id}/ #taskcsrf --> *Tasks|remove');
};

function session($) {
	$.response.headers['cache-control'] = 'no-store';
	$.json({ user: $.user || null, csrf: $.csrf() });
}

function login($) {
	var key = $.ip;
	var recent = (attempts[key] || []).filter(time => time > Date.now() - 600000);
	attempts[key] = recent;
	if (recent.length >= 5) return $.invalid(429);
	var user = MAIN.taskUsers[String($.body.name || '').toLowerCase()];
	var salt = user ? user.salt : 'dummy-salt';
	var actual = Total.Crypto.scryptSync(String($.body.password || ''), salt, 32);
	var valid = user && Total.Crypto.timingSafeEqual(actual, user.hash);
	if (!valid) {
		recent.push(Date.now());
		return $.invalid(401);
	}
	var id = UID();
	NOSQL('task-sessions').insert({ id: id, userid: user.id, expire: Date.now() + 1800000 }).callback(function(err) {
		if (err) return $.invalid(500);
		MAIN.taskAuth.authcookie($, id, user.id, '30 minutes');
		$.json({ user: { id: user.id, name: user.name, role: user.role }, csrf: $.csrf() });
	});
}

function logout($) {
	NOSQL('task-sessions').remove().where('id', $.sessionid).callback(function(err) {
		if (err) return $.invalid(500);
		MAIN.taskAuth.logout($);
		$.cookie(MAIN.taskAuth.cookie, '', '-1 year', MAIN.taskAuth.options);
		$.json({ ok: true });
	});
}
```

```html public/index.html
<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Taskboard · Total.js</title>
<style>
body{font:16px system-ui;background:#f4f5f7;color:#172033;margin:0}main{max-width:720px;margin:8vh auto;padding:30px;background:white;border-radius:18px}header{display:flex;align-items:center;justify-content:space-between;gap:15px}h1{margin:0}p{color:#536078}label{display:block;margin-top:14px}input,button{font:inherit;padding:10px;border:1px solid #bbc4d1;border-radius:8px}input[type=text],input[type=password]{box-sizing:border-box;width:100%;margin-top:5px}button{cursor:pointer;background:#152d57;color:white}form button{margin-top:15px}ul{padding:0;list-style:none}li{display:flex;align-items:center;gap:10px;padding:14px 0;border-bottom:1px solid #eee}.task-title{flex:1;overflow-wrap:anywhere}.muted{font-size:14px;color:#536078}#message{min-height:24px;color:#9d251e}[hidden]{display:none!important}
</style></head><body><main>
<header><h1>Taskboard</h1><span class="muted">Total.js reference</span></header>
<p>A small workspace with private tasks and a shared administrator view.</p>
<p id="message" role="status" aria-live="polite"></p>
<form id="login"><h2>Sign in</h2><label for="name">User</label><input id="name" autocomplete="username" required><label for="password">Password</label><input id="password" type="password" autocomplete="current-password" required><button>Sign in</button></form>
<section id="workspace" hidden><header><p id="identity"></p><button id="logout" type="button">Sign out</button></header>
<form id="create"><label for="title">New task</label><input id="title" type="text" maxlength="80" required><button>Add task</button></form>
<ul id="tasks" aria-label="Tasks"></ul><p id="empty">No tasks yet.</p></section>
</main><script src="/app.js"></script></body></html>
```

```js public/app.js
(function() {
	var user = null;
	var csrf = '';
	var busy = false;
	var byid = id => document.getElementById(id);
	async function request(path, method, body) {
		var response = await fetch(path, { method: method || 'GET', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'x-csrf-token': csrf }, body: body === undefined ? undefined : JSON.stringify(body) });
		var data = await response.json().catch(() => null);
		if (!response.ok) {
			if (response.status === 401) { user = null; show(); }
			throw new Error('Request refused (' + response.status + ')');
		}
		if (data === null) throw new Error('Unexpected server response');
		return data;
	}
	function show() {
		byid('login').hidden = !!user;
		byid('workspace').hidden = !user;
		byid('identity').textContent = user ? user.name + ' · ' + user.role : '';
	}
	async function load() {
		var tasks = await request('/tasks/');
		byid('tasks').replaceChildren();
		byid('empty').hidden = tasks.length > 0;
		tasks.forEach(function(task) {
			var row = document.createElement('li');
			var checkbox = document.createElement('input');
			checkbox.type = 'checkbox'; checkbox.checked = task.done; checkbox.setAttribute('aria-label', 'Complete ' + task.title);
			checkbox.addEventListener('change', () => work(async () => { await request('/tasks/' + task.id + '/', 'PATCH', { done: checkbox.checked }); await load(); }));
			var title = document.createElement('span'); title.className = 'task-title'; title.textContent = task.title;
			row.append(checkbox, title);
			if (user.role === 'admin') {
				var remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Delete';
				remove.addEventListener('click', () => work(async () => { await request('/tasks/' + task.id + '/', 'DELETE'); await load(); }));
				row.append(remove);
			}
			byid('tasks').append(row);
		});
	}
	async function work(fn) {
		if (busy) return;
		busy = true; byid('message').textContent = '';
		document.querySelectorAll('input,button').forEach(control => control.disabled = true);
		try { await fn(); } catch (err) { byid('message').textContent = err.message; }
		finally { busy = false; document.querySelectorAll('input,button').forEach(control => control.disabled = false); }
	}
	byid('login').addEventListener('submit', function(event) {
		event.preventDefault(); work(async function() {
			var result = await request('/login/', 'POST', { name: byid('name').value, password: byid('password').value });
			user = result.user; csrf = result.csrf; byid('password').value = ''; show(); await load();
		});
	});
	byid('create').addEventListener('submit', function(event) {
		event.preventDefault(); work(async function() { await request('/tasks/', 'POST', { title: byid('title').value }); byid('title').value = ''; await load(); });
	});
	byid('logout').addEventListener('click', () => work(async function() { await request('/logout/', 'POST', {}); user = null; byid('tasks').replaceChildren(); show(); }));
	work(async function() { var result = await request('/session/'); user = result.user; csrf = result.csrf; show(); if (user) await load(); });
})();
```

## Qué se ejercita y qué queda pendiente


Los adjuntos de archivos y las actualizaciones en tiempo real continúan en [Taskboard Live](/en/docs/taskboard-live/). El registro, el restablecimiento de contraseña, varias instancias de la aplicación y un driver SQL son ejemplos posteriores con sus propios contratos, no funciones implícitas de esta aplicación. Recetas relacionadas: [autenticación de sesión](/en/total5/sessions-with-built-in-auth/), [CSRF](/en/total5/csrf-protection/), [acciones CRUD](/en/total5/actions-crud/) y [WebSocket](/en/total5/websocket-chat/).
