evlet 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
evlet/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """Public APIs for models, persistence, queries, and explicit migrations."""
2
+
3
+ from .app import Application
4
+ from .database import ConflictError, Database, NotFoundError
5
+ from .fields import (Boolean, Currency, Date, DateTime, Decimal, Email, Integer,
6
+ Link, LongText, Select, Text, URL)
7
+ from .migrations import AddField, AddIndex, CreateModel, Migration, RenameField, SchemaError
8
+ from .models import Model, Registry, ValidationError
9
+
10
+ __all__ = [
11
+ "AddField", "AddIndex", "Application", "Boolean", "ConflictError", "CreateModel",
12
+ "Currency", "Database", "Date", "DateTime", "Decimal", "Email", "Integer", "Link",
13
+ "LongText", "Migration", "Model", "NotFoundError", "Registry", "RenameField",
14
+ "SchemaError", "Select", "Text", "URL", "ValidationError",
15
+ ]
16
+
17
+ from .auth import LocalAuth, User
18
+ from .permissions import Policy, Rule
19
+ from .business import Action, ChildTable
20
+
21
+ __all__ += ["Action", "ChildTable", "LocalAuth", "Policy", "Rule", "User"]
22
+
23
+ from .storage import LocalFileStorage, SQLiteStorage, StorageError
24
+
25
+ __all__ += ["LocalFileStorage", "SQLiteStorage", "StorageError"]
26
+
27
+ from .tenancy import Tenant, TenantApplication, TenantRegistry
28
+
29
+ __all__ += ["Tenant", "TenantApplication", "TenantRegistry"]
30
+
31
+ from .events import EventStore
32
+ from .jobs import JobContext, JobQueue, JobReceipt, Task
33
+
34
+ __all__ += ["EventStore", "JobContext", "JobQueue", "JobReceipt", "Task"]
35
+
36
+ from .object_storage import S3Storage
37
+ from .serverless import LambdaHTTP
38
+
39
+ __all__ += ["LambdaHTTP", "S3Storage"]
40
+
41
+ from .connector import ConnectorError, HTTPClient
42
+ from .mcp import MCPServer
43
+
44
+ __all__ += ["ConnectorError", "HTTPClient", "MCPServer"]
45
+
46
+ from .postgres import PostgreSQLDatabase
47
+
48
+ __all__ += ["PostgreSQLDatabase"]
evlet/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == '__main__':
4
+ main()
evlet/app.py ADDED
@@ -0,0 +1,221 @@
1
+ """Portable ASGI HTTP and lifespan support without a web framework dependency."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import sqlite3
7
+ from urllib.parse import parse_qs
8
+
9
+ from .database import ConflictError, NotFoundError
10
+ from .models import ValidationError
11
+ from .auth import AccessError
12
+ from .business import Business, Download
13
+ from .storage import StorageError
14
+
15
+ logger = logging.getLogger(__name__)
16
+ MAX_BODY_BYTES = 64 * 1024
17
+
18
+
19
+ class HTTPError(Exception):
20
+ def __init__(self, status, message, headers=()):
21
+ self.status = status
22
+ self.message = message
23
+ self.headers = headers
24
+
25
+
26
+ class Application:
27
+ def __init__(self, database, *, auth=None, policy=None, actions=(), children=(), attachments=(), storage=None, jobs=None, events=None):
28
+ self.database = database
29
+ self.registry = database.registry
30
+ if auth is None and (policy is not None or actions or children or attachments or storage is not None or jobs is not None or events is not None):
31
+ raise ValueError("Business features require authentication")
32
+ self.business = None if auth is None else Business(database, auth, policy=policy, actions=actions, children=children, attachments=attachments, storage=storage, jobs=jobs, events=events)
33
+
34
+ async def __call__(self, scope, receive, send):
35
+ if scope["type"] == "lifespan":
36
+ await self._lifespan(receive, send)
37
+ return
38
+ if scope["type"] == "websocket":
39
+ await send({"type": "websocket.close", "code": 1008})
40
+ return
41
+ if scope["type"] != "http":
42
+ raise ValueError("Unsupported ASGI scope")
43
+ headers = []
44
+ try:
45
+ if scope['path'] == '/api/_events/stream' and self.business:
46
+ await self._event_stream(scope, receive, send)
47
+ return
48
+ body = None
49
+ if scope["method"] in ("POST", "PATCH"):
50
+ content_type = dict(scope.get("headers", [])).get(b"content-type", b"")
51
+ if content_type.split(b";", 1)[0].strip().lower() != b"application/json":
52
+ raise HTTPError(415, "Send application/json")
53
+ data = bytearray()
54
+ while True:
55
+ event = await receive()
56
+ if event["type"] == "http.disconnect":
57
+ return
58
+ data.extend(event.get("body", b""))
59
+ maximum = 1500000 if self.business and scope['path'].endswith('/attachments') else MAX_BODY_BYTES
60
+ if len(data) > maximum:
61
+ raise HTTPError(413, "Request body exceeds the allowed size")
62
+ if not event.get("more_body", False):
63
+ break
64
+ try:
65
+ body = json.loads(data, parse_constant=self._invalid_constant)
66
+ except (ValueError, UnicodeError, RecursionError) as exc:
67
+ raise HTTPError(400, "Malformed JSON") from exc
68
+ if not isinstance(body, dict):
69
+ raise HTTPError(422, "Expected a JSON object")
70
+ status, payload, headers = await asyncio.to_thread(self._dispatch, scope, body)
71
+ except AccessError as exc:
72
+ status, payload = exc.status, {"error": exc.message}
73
+ except HTTPError as exc:
74
+ status, payload, headers = exc.status, {"error": exc.message}, list(exc.headers)
75
+ except ValidationError as exc:
76
+ status, payload = 422, {"error": "Validation failed", "fields": exc.errors}
77
+ except NotFoundError:
78
+ status, payload = 404, {"error": "Record not found"}
79
+ except ConflictError as exc:
80
+ status, payload = 409, {"error": str(exc)}
81
+ except StorageError:
82
+ logger.exception("Attachment storage failed")
83
+ status, payload = 503, {"error": "Attachment temporarily unavailable"}
84
+ except sqlite3.OperationalError:
85
+ logger.exception("Database operation failed")
86
+ status, payload = 503, {"error": "Database temporarily unavailable"}
87
+ except Exception:
88
+ logger.exception("Unhandled request failure")
89
+ status, payload = 500, {"error": "Internal server error"}
90
+ download = isinstance(payload, Download)
91
+ encoded = payload.content if download else b"" if status == 204 else json.dumps(payload, ensure_ascii=True, allow_nan=False).encode("utf-8")
92
+ response_headers = [(b"x-content-type-options", b"nosniff"), (b"cache-control", b"no-store"), *headers]
93
+ if status != 204:
94
+ if not download:
95
+ response_headers.append((b"content-type", b"application/json; charset=utf-8"))
96
+ response_headers.append((b"content-length", str(len(encoded)).encode()))
97
+ await send({"type": "http.response.start", "status": status, "headers": response_headers})
98
+ await send({"type": "http.response.body", "body": encoded})
99
+
100
+ async def _event_stream(self, scope, receive, send):
101
+ page = await asyncio.to_thread(self.business.event_page, scope)
102
+ await send({'type': 'http.response.start', 'status': 200, 'headers': [
103
+ (b'content-type', b'text/event-stream'), (b'cache-control', b'no-store'),
104
+ (b'x-accel-buffering', b'no'), (b'x-content-type-options', b'nosniff')]})
105
+ async def disconnected():
106
+ while (await receive())['type'] != 'http.disconnect':
107
+ pass
108
+ disconnect = asyncio.create_task(disconnected())
109
+ try:
110
+ for iteration in range(25):
111
+ encoded = ('id: ' + page['cursor'] + '\nevent: evlet\ndata: ' + json.dumps(page) + '\n\n').encode()
112
+ await send({'type': 'http.response.body', 'body': encoded, 'more_body': True})
113
+ done, _ = await asyncio.wait({disconnect}, timeout=0.05 if page['more'] else 1)
114
+ if done:
115
+ return
116
+ headers = [(key, value) for key, value in scope.get('headers', []) if key != b'last-event-id']
117
+ headers.append((b'last-event-id', page['cursor'].encode()))
118
+ try:
119
+ page = await asyncio.to_thread(self.business.event_page, {**scope, 'headers': headers})
120
+ except AccessError:
121
+ await send({'type': 'http.response.body', 'body': b'event: unavailable\ndata: {}\n\n', 'more_body': True})
122
+ return
123
+ except Exception:
124
+ logger.exception('Event stream failed')
125
+ return
126
+ except OSError:
127
+ return
128
+ finally:
129
+ disconnect.cancel()
130
+ await asyncio.gather(disconnect, return_exceptions=True)
131
+ try:
132
+ await send({'type': 'http.response.body', 'body': b'', 'more_body': False})
133
+ except OSError:
134
+ pass
135
+
136
+ @staticmethod
137
+ def _invalid_constant(value):
138
+ raise ValueError(f"Non-finite JSON number: {value}")
139
+
140
+ @staticmethod
141
+ def _method(method, allowed):
142
+ if method not in allowed:
143
+ raise HTTPError(405, "Method not allowed", [
144
+ (b"allow", ", ".join(allowed).encode())
145
+ ])
146
+
147
+ def _dispatch(self, scope, body):
148
+ path, method = scope["path"], scope["method"]
149
+ if path == "/health":
150
+ self._method(method, ["GET"])
151
+ self.database.check()
152
+ return 200, {"status": "ok"}, []
153
+ if self.business:
154
+ return self.business.dispatch(scope, body)
155
+ if path == "/api/schema":
156
+ self._method(method, ["GET"])
157
+ return 200, self.registry.ui_schema(), []
158
+ parts = path.split("/")
159
+ if len(parts) not in (3, 4) or parts[:2] != ["", "api"]:
160
+ raise HTTPError(404, "Route not found")
161
+ model = self.registry.models.get(parts[2])
162
+ if model is None:
163
+ raise HTTPError(404, "Model not found")
164
+ if method in ('POST', 'PATCH'):
165
+ errors = {name: 'This field is read-only' for name in body
166
+ if name in model.__fields__ and model.__fields__[name].read_only}
167
+ if errors:
168
+ raise ValidationError(errors)
169
+ if len(parts) == 4:
170
+ self._method(method, ["GET", "PATCH", "DELETE"])
171
+ if not parts[3].isascii() or not parts[3].isdigit() or len(parts[3]) > 19:
172
+ raise HTTPError(404, "Record not found")
173
+ record_id = int(parts[3])
174
+ if method == "DELETE":
175
+ self.database.delete(model, record_id)
176
+ return 204, None, []
177
+ record = (self.database.update(model, record_id, **body) if method == "PATCH"
178
+ else self.database.get(model, record_id))
179
+ return 200, record.to_dict(), []
180
+ self._method(method, ["GET", "POST"])
181
+ if method == "POST":
182
+ record = self.database.create(model, **body)
183
+ location = f"/api/{parts[2]}/{record.id}".encode()
184
+ return 201, record.to_dict(), [(b"location", location)]
185
+ try:
186
+ if len(scope.get("query_string", b"")) > 8192:
187
+ raise ValueError("Query exceeds 8 KiB")
188
+ query = parse_qs(scope.get("query_string", b"").decode("ascii"),
189
+ keep_blank_values=True, max_num_fields=10)
190
+ if set(query) - {"limit", "offset", "filters", "order_by", "q"} or any(len(v) != 1 for v in query.values()):
191
+ raise ValueError("Unknown or repeated query parameters")
192
+ limit = int(query.get("limit", ["50"])[0])
193
+ offset = int(query.get("offset", ["0"])[0])
194
+ filters = json.loads(query["filters"][0], parse_constant=self._invalid_constant) if "filters" in query else None
195
+ order_by = query["order_by"][0].split(",") if "order_by" in query else None
196
+ except (ValueError, UnicodeError, RecursionError) as exc:
197
+ raise HTTPError(422, "Use valid limit, offset, filters JSON, and order_by parameters") from exc
198
+ records, total = self.database.page(model, limit=limit, offset=offset, filters=filters, order_by=order_by, search=query.get("q", [""])[0])
199
+ return 200, {"items": [record.to_dict() for record in records],
200
+ "limit": limit, "offset": offset, "total": total}, []
201
+
202
+ def initialize(self):
203
+ self.database.initialize()
204
+ if self.business:
205
+ self.business.initialize()
206
+
207
+ async def _lifespan(self, receive, send):
208
+ while True:
209
+ event = await receive()
210
+ if event["type"] == "lifespan.startup":
211
+ try:
212
+ await asyncio.to_thread(self.initialize)
213
+ except Exception:
214
+ logger.exception("Database initialization failed")
215
+ await send({"type": "lifespan.startup.failed", "message": "Database initialization failed"})
216
+ return
217
+ await send({"type": "lifespan.startup.complete"})
218
+ elif event["type"] == "lifespan.shutdown":
219
+ await asyncio.to_thread(self.database.close)
220
+ await send({"type": "lifespan.shutdown.complete"})
221
+ return
evlet/auth.py ADDED
@@ -0,0 +1,224 @@
1
+ """Local accounts and revocable cookie sessions for a single application database."""
2
+
3
+ from dataclasses import dataclass
4
+ import hashlib
5
+ import hmac
6
+ from http.cookies import SimpleCookie, CookieError
7
+ import ipaddress
8
+ import re
9
+ import secrets
10
+ import time
11
+ from urllib.parse import urlsplit
12
+
13
+ from .models import ValidationError
14
+
15
+ ROLES = ('admin', 'editor', 'viewer')
16
+
17
+
18
+ class AccessError(Exception):
19
+ def __init__(self, status, message):
20
+ self.status, self.message = status, message
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class User:
25
+ id: int
26
+ username: str
27
+ role: str
28
+
29
+ def public(self):
30
+ return {'id': self.id, 'username': self.username, 'role': self.role}
31
+
32
+
33
+ def password_hash(password, salt=None):
34
+ if not isinstance(password, str) or not 15 <= len(password) <= 128:
35
+ raise ValidationError({'password': 'Use between 15 and 128 characters'})
36
+ try:
37
+ encoded = password.encode('utf-8')
38
+ except UnicodeError:
39
+ raise ValidationError({'password': 'Use valid Unicode text'})
40
+ salt = secrets.token_hex(16) if salt is None else salt
41
+ digest = hashlib.scrypt(encoded, salt=bytes.fromhex(salt),
42
+ n=2**17, r=8, p=1, maxmem=256 * 1024 * 1024).hex()
43
+ return f'scrypt:{salt}:{digest}'
44
+
45
+
46
+ class LocalAuth:
47
+ def __init__(self, database, *, origins, secure_cookies=True, allow_setup=False, cookie_name='evlet_session'):
48
+ if cookie_name not in ('evlet_session', '__Host-evlet_session') or (cookie_name.startswith('__Host-') and not secure_cookies):
49
+ raise ValueError('Invalid session cookie configuration')
50
+ self.cookie_name = cookie_name
51
+ self.database = database
52
+ self.origins = frozenset(origins)
53
+ if not self.origins:
54
+ raise ValueError('Configure explicit browser origins')
55
+ for origin in self.origins:
56
+ url = urlsplit(origin)
57
+ if url.scheme not in ('http', 'https') or not url.netloc or url.path or url.query or url.fragment or url.username:
58
+ raise ValueError('Use exact HTTP origins without paths')
59
+ if not secure_cookies and url.hostname not in ('localhost', '127.0.0.1', '::1') and not (url.hostname or '').endswith('.localhost'):
60
+ raise ValueError('Insecure cookies are limited to loopback development origins')
61
+ self.hosts = {urlsplit(origin).netloc.encode() for origin in self.origins}
62
+ self.secure_cookies = secure_cookies
63
+ self.allow_setup = allow_setup
64
+
65
+ def initialize(self):
66
+ with self.database.transaction() as tx:
67
+ tx.connection.execute('CREATE TABLE IF NOT EXISTS evlet_users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, role TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1)')
68
+ tx.connection.execute('CREATE TABLE IF NOT EXISTS evlet_sessions (token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES evlet_users(id) ON DELETE CASCADE, csrf TEXT NOT NULL, expires INTEGER NOT NULL)')
69
+ tx.connection.execute('CREATE TABLE IF NOT EXISTS evlet_login_limits (client TEXT PRIMARY KEY, attempts INTEGER NOT NULL, resets INTEGER NOT NULL)')
70
+
71
+ tx.connection.execute('CREATE INDEX IF NOT EXISTS evlet_session_user ON evlet_sessions(user_id)')
72
+ tx.connection.execute('CREATE INDEX IF NOT EXISTS evlet_session_expiry ON evlet_sessions(expires)')
73
+ tx.connection.execute('CREATE INDEX IF NOT EXISTS evlet_login_reset ON evlet_login_limits(resets)')
74
+
75
+ def guard(self, scope):
76
+ protected = {b'host', b'origin', b'cookie', b'x-csrf-token', b'x-evlet-request', b'sec-fetch-site'}
77
+ seen = set()
78
+ for key, _ in scope.get('headers', []):
79
+ key = key.lower()
80
+ if key in protected and key in seen:
81
+ raise AccessError(403, 'Repeated security header')
82
+ seen.add(key)
83
+ headers = dict(scope.get('headers', []))
84
+ if headers.get(b'host') not in self.hosts:
85
+ raise AccessError(403, 'Unrecognized host')
86
+ origin = headers.get(b'origin')
87
+ if origin is not None and origin.decode('latin1') not in self.origins:
88
+ raise AccessError(403, 'Unrecognized origin')
89
+ if headers.get(b'sec-fetch-site') == b'cross-site':
90
+ raise AccessError(403, 'Cross-site request rejected')
91
+ if scope['method'] not in ('GET', 'HEAD') and headers.get(b'x-evlet-request') != b'1':
92
+ raise AccessError(403, 'Missing request protection header')
93
+
94
+ def token(self, scope):
95
+ cookie = SimpleCookie()
96
+ try:
97
+ cookie.load(dict(scope.get('headers', [])).get(b'cookie', b'').decode('latin1'))
98
+ value = cookie.get(self.cookie_name)
99
+ token = value.value if value else ''
100
+ return hashlib.sha256(token.encode()).hexdigest()
101
+ except (CookieError, UnicodeError):
102
+ return ''
103
+
104
+ def session(self, scope):
105
+ with self.database._connection() as connection:
106
+ row = connection.execute('SELECT u.id, u.username, u.role, s.csrf FROM evlet_sessions s JOIN evlet_users u ON u.id=s.user_id WHERE s.token=? AND s.expires>? AND u.active=1',
107
+ (self.token(scope), int(time.time()))).fetchone()
108
+ if row is None:
109
+ raise AccessError(401, 'Sign in to continue')
110
+ if scope['method'] not in ('GET', 'HEAD'):
111
+ supplied = dict(scope.get('headers', [])).get(b'x-csrf-token', b'')
112
+ if not hmac.compare_digest(supplied, row['csrf'].encode('ascii')):
113
+ raise AccessError(403, 'Invalid request token')
114
+ return User(row['id'], row['username'], row['role']), row['csrf']
115
+
116
+ @staticmethod
117
+ def account_values(body):
118
+ if set(body) - {'username', 'password', 'role'}:
119
+ raise ValidationError({'_form': 'Unknown account field'})
120
+ name = body.get('username', '')
121
+ role = body.get('role', 'viewer')
122
+ if not isinstance(name, str) or not re.fullmatch(r'[a-zA-Z0-9_.-]{3,64}', name):
123
+ raise ValidationError({'username': 'Use 3 to 64 letters, numbers, dots, hyphens, or underscores'})
124
+ if role not in ROLES:
125
+ raise ValidationError({'role': 'Choose admin, editor, or viewer'})
126
+ return name.lower(), password_hash(body.get('password')), role
127
+
128
+ def create_user(self, **values):
129
+ name, password, role = self.account_values(values)
130
+ with self.database.transaction() as tx:
131
+ user_id = tx.connection.execute('INSERT INTO evlet_users(username,password,role) VALUES (?,?,?)', (name, password, role)).lastrowid
132
+ return User(user_id, name, role)
133
+
134
+ def setup_needed(self):
135
+ with self.database._connection() as connection:
136
+ return not connection.execute('SELECT 1 FROM evlet_users LIMIT 1').fetchone()
137
+
138
+ def setup(self, scope, body):
139
+ try:
140
+ loopback = ipaddress.ip_address(scope.get('client', ('',))[0]).is_loopback
141
+ except ValueError:
142
+ loopback = False
143
+ if not self.allow_setup or not loopback or not self.setup_needed():
144
+ raise AccessError(403, 'Initial setup is unavailable')
145
+ name, password, _ = self.account_values({**body, 'role': 'admin'})
146
+ with self.database.transaction() as tx:
147
+ if tx.connection.execute('SELECT 1 FROM evlet_users LIMIT 1').fetchone():
148
+ raise AccessError(409, 'An administrator already exists')
149
+ tx.connection.execute('INSERT INTO evlet_users(username,password,role) VALUES (?,?,?)', (name, password, 'admin'))
150
+
151
+ def login(self, scope, body):
152
+ if set(body) != {'username', 'password'} or not all(isinstance(body[k], str) for k in body):
153
+ raise AccessError(401, 'Invalid username or password')
154
+ now = int(time.time())
155
+ client = hashlib.sha256(scope.get('client', ('unknown',))[0].encode()).hexdigest()
156
+ with self.database.transaction() as tx:
157
+ tx.connection.execute('DELETE FROM evlet_login_limits WHERE resets<=?', (now,))
158
+ tx.connection.execute('DELETE FROM evlet_sessions WHERE expires<=?', (now,))
159
+ row = tx.connection.execute('SELECT attempts FROM evlet_login_limits WHERE client=?', (client,)).fetchone()
160
+ if row and row['attempts'] >= 10:
161
+ raise AccessError(429, 'Too many sign-in attempts. Try again in 15 minutes.')
162
+ tx.connection.execute('INSERT INTO evlet_login_limits VALUES (?,1,?) ON CONFLICT(client) DO UPDATE SET attempts=evlet_login_limits.attempts+1', (client, now + 900))
163
+ account = tx.connection.execute('SELECT * FROM evlet_users WHERE username=?', (body['username'].lower(),)).fetchone()
164
+ stored = account['password'] if account else 'scrypt:' + '00' * 16 + ':' + '00' * 64
165
+ try:
166
+ candidate = password_hash(body['password'], stored.split(':')[1])
167
+ except (ValueError, UnicodeError):
168
+ candidate = ''
169
+ if not hmac.compare_digest(candidate, stored) or not account or not account['active']:
170
+ raise AccessError(401, 'Invalid username or password')
171
+ token, csrf = secrets.token_urlsafe(32), secrets.token_hex(32)
172
+ with self.database.transaction() as tx:
173
+ # A password/role change during hashing must not revive an old login.
174
+ current = tx.connection.execute('SELECT * FROM evlet_users WHERE id=?', (account['id'],)).fetchone()
175
+ if not current or not current['active'] or current['password'] != stored or current['role'] != account['role']:
176
+ raise AccessError(401, 'Invalid username or password')
177
+ tx.connection.execute('DELETE FROM evlet_login_limits WHERE client=?', (client,))
178
+ tx.connection.execute('DELETE FROM evlet_sessions WHERE token=?', (self.token(scope),))
179
+ tx.connection.execute('INSERT INTO evlet_sessions VALUES (?,?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), account['id'], csrf, now + 28800))
180
+ return {'user': User(account['id'], account['username'], account['role']).public(), 'csrf': csrf}, [self.cookie(token)]
181
+
182
+ def cookie(self, token):
183
+ value = f'{self.cookie_name}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={28800 if token else 0}'
184
+ if self.secure_cookies:
185
+ value += '; Secure'
186
+ return b'set-cookie', value.encode()
187
+
188
+ def logout(self, scope):
189
+ with self.database.transaction() as tx:
190
+ tx.connection.execute('DELETE FROM evlet_sessions WHERE token=?', (self.token(scope),))
191
+ return [self.cookie('')]
192
+
193
+ def users(self):
194
+ return self.user_page()['items']
195
+
196
+ def user_page(self, offset=0):
197
+ if type(offset) is not int or not 0 <= offset <= 2**63-1:
198
+ raise ValidationError({'offset': 'Use a nonnegative offset'})
199
+ with self.database._connection() as connection:
200
+ connection.execute('BEGIN')
201
+ items = [dict(row) for row in connection.execute('SELECT id,username,role,active FROM evlet_users ORDER BY id LIMIT 100 OFFSET ?', (offset,))]
202
+ total = connection.execute('SELECT count(*) FROM evlet_users').fetchone()[0]
203
+ return {'items': items, 'total': total, 'offset': offset, 'limit': 100}
204
+
205
+ def update_user(self, user_id, body):
206
+ if not body or set(body) - {'role', 'active', 'password'}:
207
+ raise ValidationError({'_form': 'Use role, active, or password'})
208
+ if 'role' in body and body['role'] not in ROLES:
209
+ raise ValidationError({'role': 'Unknown role'})
210
+ if 'active' in body and type(body['active']) is not bool:
211
+ raise ValidationError({'active': 'Use true or false'})
212
+ changes = dict(body)
213
+ if 'password' in changes:
214
+ changes['password'] = password_hash(changes['password'])
215
+ with self.database.transaction() as tx:
216
+ row = tx.connection.execute('SELECT * FROM evlet_users WHERE id=?', (user_id,)).fetchone()
217
+ if not row:
218
+ raise AccessError(404, 'Account not found')
219
+ if row['role'] == 'admin' and row['active'] and (changes.get('role', 'admin') != 'admin' or not changes.get('active', True)):
220
+ if tx.connection.execute("SELECT count(*) FROM evlet_users WHERE role='admin' AND active=1").fetchone()[0] == 1:
221
+ raise AccessError(409, 'Keep at least one active administrator')
222
+ for key, value in changes.items():
223
+ tx.connection.execute(f'UPDATE evlet_users SET {key}=? WHERE id=?', (value, user_id))
224
+ tx.connection.execute('DELETE FROM evlet_sessions WHERE user_id=?', (user_id,))