From 9c60cf7fc4b0390c32c084799117ff1de031d625 Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Fri, 10 Jul 2026 20:53:56 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(users):=20phase-1=20identity=20substra?= =?UTF-8?q?te=20=E2=80=94=20users=20table,=20owner=20row,=20terminal=20use?= =?UTF-8?q?r=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the users table (id doubles as the future OIDC sub) and binds every terminal session to a user. The owner user is derived from the default identity: same id (single source of truth for identity), email taken from the identity or synthesized as owner@ since OIDC clients require an email-shaped identifier to auto-provision accounts. ensure_owner_user() runs on every startup after init_default_identity(): creates the owner row if missing and backfills user_id on any terminal that predates this migration. New pairings bind to the owner directly. No behavior or API change otherwise; existing sessions stay valid. Phase 1 of the multi-user identity rollout (embedded OIDC provider). Co-Authored-By: Claude Fable 5 --- migrations/shard-core-0002-users.sql | 15 ++++++ shard_core/app_factory.py | 2 + shard_core/data_model/terminal.py | 4 +- shard_core/database/terminals.py | 9 +++- shard_core/database/users.py | 34 +++++++++++++ shard_core/service/user.py | 34 +++++++++++++ shard_core/web/public/pair.py | 6 ++- tests/conftest.py | 3 +- tests/test_users.py | 71 ++++++++++++++++++++++++++++ 9 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 migrations/shard-core-0002-users.sql create mode 100644 shard_core/database/users.py create mode 100644 shard_core/service/user.py create mode 100644 tests/test_users.py diff --git a/migrations/shard-core-0002-users.sql b/migrations/shard-core-0002-users.sql new file mode 100644 index 00000000..b4533aec --- /dev/null +++ b/migrations/shard-core-0002-users.sql @@ -0,0 +1,15 @@ +-- shard-core-0002-users +-- depends: shard-core-0001-init + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + email TEXT, + role TEXT NOT NULL DEFAULT 'member', + password_hash TEXT, + disabled BOOLEAN NOT NULL DEFAULT FALSE, + created TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE terminals ADD COLUMN IF NOT EXISTS user_id TEXT REFERENCES users (id); diff --git a/shard_core/app_factory.py b/shard_core/app_factory.py index 8db51cb0..2fbd6eb0 100644 --- a/shard_core/app_factory.py +++ b/shard_core/app_factory.py @@ -25,6 +25,7 @@ backup, disk, telemetry, + user, ) from .service.app_installation.util import ( write_traefik_dyn_config, @@ -79,6 +80,7 @@ def configure_logging(): async def lifespan(_): await database.init_database() await identity.init_default_identity() + await user.ensure_owner_user() await write_traefik_dyn_config() await render_all_docker_compose_templates() diff --git a/shard_core/data_model/terminal.py b/shard_core/data_model/terminal.py index e57e1c85..5011d906 100644 --- a/shard_core/data_model/terminal.py +++ b/shard_core/data_model/terminal.py @@ -21,16 +21,18 @@ class Terminal(BaseModel): name: str icon: Icon = Icon.UNKNOWN last_connection: Optional[datetime] = None + user_id: Optional[str] = None def __str__(self): return f"Terminal[{self.id}, {self.name}]" @classmethod - def create(cls, name: str) -> "Terminal": + def create(cls, name: str, user_id: Optional[str] = None) -> "Terminal": return Terminal( id=human_encoding.random_string(6), name=name, last_connection=datetime.now(timezone.utc), + user_id=user_id, ) diff --git a/shard_core/database/terminals.py b/shard_core/database/terminals.py index e7e5b0f8..76c3def1 100644 --- a/shard_core/database/terminals.py +++ b/shard_core/database/terminals.py @@ -28,14 +28,19 @@ async def get_by_name(conn: AsyncConnection, name: str) -> dict | None: async def insert(conn: AsyncConnection, terminal: dict) -> dict: - sql: LiteralString = """INSERT INTO terminals (id, name, icon, last_connection) - VALUES (%(id)s, %(name)s, %(icon)s, %(last_connection)s) + sql: LiteralString = """INSERT INTO terminals (id, name, icon, last_connection, user_id) + VALUES (%(id)s, %(name)s, %(icon)s, %(last_connection)s, %(user_id)s) RETURNING *""" async with conn.cursor(row_factory=dict_row) as cur: await cur.execute(sql, terminal) return await cur.fetchone() +async def set_user_id_where_null(conn: AsyncConnection, user_id: str): + sql: LiteralString = "UPDATE terminals SET user_id = %s WHERE user_id IS NULL" + await conn.execute(sql, (user_id,)) + + async def update(conn: AsyncConnection, id: str, data: dict) -> dict | None: set_clauses = [] params = {"_id": id} diff --git a/shard_core/database/users.py b/shard_core/database/users.py new file mode 100644 index 00000000..f6cdf23a --- /dev/null +++ b/shard_core/database/users.py @@ -0,0 +1,34 @@ +from typing import LiteralString + +from psycopg import AsyncConnection +from psycopg.rows import dict_row + + +async def get_by_id(conn: AsyncConnection, id: str) -> dict | None: + sql: LiteralString = "SELECT * FROM users WHERE id = %s" + async with conn.cursor(row_factory=dict_row) as cur: + await cur.execute(sql, (id,)) + return await cur.fetchone() + + +async def get_owner(conn: AsyncConnection) -> dict | None: + sql: LiteralString = "SELECT * FROM users WHERE role = 'owner'" + async with conn.cursor(row_factory=dict_row) as cur: + await cur.execute(sql) + return await cur.fetchone() + + +async def insert(conn: AsyncConnection, user: dict) -> dict: + sql: LiteralString = """INSERT INTO users (id, username, display_name, email, role) + VALUES (%(id)s, %(username)s, %(display_name)s, %(email)s, %(role)s) + RETURNING *""" + async with conn.cursor(row_factory=dict_row) as cur: + await cur.execute(sql, user) + return await cur.fetchone() + + +async def count(conn: AsyncConnection) -> int: + sql: LiteralString = "SELECT COUNT(*) FROM users" + async with conn.cursor() as cur: + await cur.execute(sql) + return (await cur.fetchone())[0] diff --git a/shard_core/service/user.py b/shard_core/service/user.py new file mode 100644 index 00000000..0df36a76 --- /dev/null +++ b/shard_core/service/user.py @@ -0,0 +1,34 @@ +import logging + +from shard_core.data_model.identity import Identity +from shard_core.database.connection import db_conn +from shard_core.database import identities as db_identities +from shard_core.database import terminals as db_terminals +from shard_core.database import users as db_users + +log = logging.getLogger(__name__) + + +async def ensure_owner_user(): + """Ensure the shard owner exists as a user and all terminals are bound to a user. + + The owner user is derived from the default identity; its id doubles as the + OIDC subject. Idempotent — called on every startup. + """ + async with db_conn() as conn: + owner = await db_users.get_owner(conn) + if owner is None: + identity_row = await db_identities.get_default(conn) + identity = Identity(**identity_row) + owner = await db_users.insert( + conn, + { + "id": identity.id, + "username": "owner", + "display_name": identity.name, + "email": identity.email or f"owner@{identity.domain}", + "role": "owner", + }, + ) + log.info(f"created owner user {owner['id']}") + await db_terminals.set_user_id_where_null(conn, owner["id"]) diff --git a/shard_core/web/public/pair.py b/shard_core/web/public/pair.py index 296d07d8..4a1b0b1f 100644 --- a/shard_core/web/public/pair.py +++ b/shard_core/web/public/pair.py @@ -5,6 +5,7 @@ from shard_core.database.connection import db_conn from shard_core.database import terminals as db_terminals from shard_core.database import identities as db_identities +from shard_core.database import users as db_users from shard_core.data_model.identity import Identity from shard_core.data_model.terminal import Terminal, InputTerminal from shard_core.service import pairing @@ -38,8 +39,11 @@ async def add_terminal(code: str, terminal: InputTerminal, response: Response): detail="This pairing code is not valid.", ) from e - new_terminal = Terminal.create(terminal.name) async with db_conn() as conn: + owner = await db_users.get_owner(conn) + new_terminal = Terminal.create( + terminal.name, user_id=owner["id"] if owner else None + ) await db_terminals.insert(conn, new_terminal.model_dump()) is_first_terminal = await db_terminals.count(conn) == 1 diff --git a/tests/conftest.py b/tests/conftest.py index 1271bedd..c6eab9cf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -218,9 +218,10 @@ async def app_client(mocker) -> AsyncGenerator[AsyncClient]: # Initialize the database (migrations + pool) and create default identity await database.init_database() try: - from shard_core.service import identity + from shard_core.service import identity, user await identity.init_default_identity() + await user.ensure_owner_user() app = app_factory.create_app() diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 00000000..089327c1 --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,71 @@ +from httpx import AsyncClient + +from shard_core.data_model.terminal import Terminal +from shard_core.database.connection import db_conn +from shard_core.database import identities as db_identities +from shard_core.database import terminals as db_terminals +from shard_core.database import users as db_users +from shard_core.service import identity, user +from tests.util import pair_new_terminal + + +async def test_ensure_owner_user_creates_owner_from_default_identity(db): + default_identity = await identity.init_default_identity() + + await user.ensure_owner_user() + + async with db_conn() as conn: + owner = await db_users.get_owner(conn) + assert owner["id"] == default_identity.id + assert owner["role"] == "owner" + assert owner["username"] == "owner" + assert owner["display_name"] == default_identity.name + assert owner["email"] == f"owner@{default_identity.domain}" + assert owner["disabled"] is False + + +async def test_ensure_owner_user_keeps_identity_email(db): + default_identity = await identity.init_default_identity() + async with db_conn() as conn: + await db_identities.update( + conn, default_identity.id, {"email": "max@freeshard.net"} + ) + + await user.ensure_owner_user() + + async with db_conn() as conn: + owner = await db_users.get_owner(conn) + assert owner["email"] == "max@freeshard.net" + + +async def test_ensure_owner_user_is_idempotent(db): + await identity.init_default_identity() + + await user.ensure_owner_user() + await user.ensure_owner_user() + + async with db_conn() as conn: + assert await db_users.count(conn) == 1 + + +async def test_ensure_owner_user_backfills_terminal_user_id(db): + await identity.init_default_identity() + legacy_terminal = Terminal.create("legacy") + async with db_conn() as conn: + await db_terminals.insert(conn, legacy_terminal.model_dump()) + + await user.ensure_owner_user() + + async with db_conn() as conn: + owner = await db_users.get_owner(conn) + row = await db_terminals.get_by_id(conn, legacy_terminal.id) + assert row["user_id"] == owner["id"] + + +async def test_pairing_binds_terminal_to_owner(app_client: AsyncClient): + await pair_new_terminal(app_client, "T1") + + async with db_conn() as conn: + owner = await db_users.get_owner(conn) + terminal = await db_terminals.get_by_name(conn, "T1") + assert terminal["user_id"] == owner["id"] From 8d09e3b99e0ca85c5aa6abf5970050edc094583c Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Sat, 11 Jul 2026 10:41:26 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(users):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20numeric=20user=20ids,=20role=20enum,=20NOT=20NULL=20binding,?= =?UTF-8?q?=20User=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - users.id is now BIGSERIAL: the owner user is its own entity, decoupled from the shard's identity hash id (which identifies the shard, not the person). The OIDC sub becomes the stringified numeric id in phase 2a. - role is a proper Postgres enum (user_role), mirrored by Role in the new data_model/user.py; ensure_owner_user returns a User model. - terminals.user_id is NOT NULL: the 0002 migration itself creates the owner user and binds existing terminals (existing shards have their identity at migration time; fresh shards have empty tables and get the owner at startup before any pairing). The Python-side backfill helper is gone; ensure_owner_user only creates the owner on fresh shards and backfills the synthesized email for migration-created owners. - pair.py drops the owner-missing fallback — the owner always exists once the app serves requests. Co-Authored-By: Claude Fable 5 --- migrations/shard-core-0002-users.sql | 21 ++++++++--- shard_core/data_model/terminal.py | 4 +-- shard_core/data_model/user.py | 24 +++++++++++++ shard_core/database/terminals.py | 5 --- shard_core/database/users.py | 24 +++++++++++-- shard_core/service/user.py | 24 ++++++++----- shard_core/web/public/pair.py | 5 ++- tests/test_users.py | 54 +++++++++++++++------------- 8 files changed, 111 insertions(+), 50 deletions(-) create mode 100644 shard_core/data_model/user.py diff --git a/migrations/shard-core-0002-users.sql b/migrations/shard-core-0002-users.sql index b4533aec..5ccb3326 100644 --- a/migrations/shard-core-0002-users.sql +++ b/migrations/shard-core-0002-users.sql @@ -1,15 +1,28 @@ -- shard-core-0002-users -- depends: shard-core-0001-init -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, +-- Keep in sync with Role in shard_core/data_model/user.py +CREATE TYPE user_role AS ENUM ('owner', 'member'); + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, username TEXT UNIQUE NOT NULL, display_name TEXT NOT NULL, email TEXT, - role TEXT NOT NULL DEFAULT 'member', + role user_role NOT NULL DEFAULT 'member', password_hash TEXT, disabled BOOLEAN NOT NULL DEFAULT FALSE, created TIMESTAMPTZ NOT NULL DEFAULT now() ); -ALTER TABLE terminals ADD COLUMN IF NOT EXISTS user_id TEXT REFERENCES users (id); +-- Existing shards have their default identity at migration time; create the +-- owner user and bind existing terminals right here so user_id can be +-- NOT NULL from the start. Fresh shards have empty tables — the owner user +-- is created at startup (service.user.ensure_owner_user) before any pairing. +INSERT INTO users (username, display_name, email, role) +SELECT 'owner', COALESCE(name, 'Shard Owner'), email, 'owner' +FROM identities WHERE is_default = TRUE; + +ALTER TABLE terminals ADD COLUMN user_id BIGINT REFERENCES users (id); +UPDATE terminals SET user_id = (SELECT id FROM users WHERE role = 'owner'); +ALTER TABLE terminals ALTER COLUMN user_id SET NOT NULL; diff --git a/shard_core/data_model/terminal.py b/shard_core/data_model/terminal.py index 5011d906..58218818 100644 --- a/shard_core/data_model/terminal.py +++ b/shard_core/data_model/terminal.py @@ -21,13 +21,13 @@ class Terminal(BaseModel): name: str icon: Icon = Icon.UNKNOWN last_connection: Optional[datetime] = None - user_id: Optional[str] = None + user_id: Optional[int] = None def __str__(self): return f"Terminal[{self.id}, {self.name}]" @classmethod - def create(cls, name: str, user_id: Optional[str] = None) -> "Terminal": + def create(cls, name: str, user_id: Optional[int] = None) -> "Terminal": return Terminal( id=human_encoding.random_string(6), name=name, diff --git a/shard_core/data_model/user.py b/shard_core/data_model/user.py new file mode 100644 index 00000000..3f37b867 --- /dev/null +++ b/shard_core/data_model/user.py @@ -0,0 +1,24 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + + +class Role(str, Enum): + # Keep in sync with the user_role enum in migrations/shard-core-0002-users.sql + OWNER = "owner" + MEMBER = "member" + + +class User(BaseModel): + id: int + username: str + display_name: str + email: Optional[str] = None + role: Role = Role.MEMBER + disabled: bool = False + created: Optional[datetime] = None + + def __str__(self): + return f"User[{self.id}, {self.username}]" diff --git a/shard_core/database/terminals.py b/shard_core/database/terminals.py index 76c3def1..cf2d495d 100644 --- a/shard_core/database/terminals.py +++ b/shard_core/database/terminals.py @@ -36,11 +36,6 @@ async def insert(conn: AsyncConnection, terminal: dict) -> dict: return await cur.fetchone() -async def set_user_id_where_null(conn: AsyncConnection, user_id: str): - sql: LiteralString = "UPDATE terminals SET user_id = %s WHERE user_id IS NULL" - await conn.execute(sql, (user_id,)) - - async def update(conn: AsyncConnection, id: str, data: dict) -> dict | None: set_clauses = [] params = {"_id": id} diff --git a/shard_core/database/users.py b/shard_core/database/users.py index f6cdf23a..aaa8e6fa 100644 --- a/shard_core/database/users.py +++ b/shard_core/database/users.py @@ -3,8 +3,10 @@ from psycopg import AsyncConnection from psycopg.rows import dict_row +_UPDATABLE_COLUMNS = {"username", "display_name", "email", "role", "disabled"} -async def get_by_id(conn: AsyncConnection, id: str) -> dict | None: + +async def get_by_id(conn: AsyncConnection, id: int) -> dict | None: sql: LiteralString = "SELECT * FROM users WHERE id = %s" async with conn.cursor(row_factory=dict_row) as cur: await cur.execute(sql, (id,)) @@ -19,14 +21,30 @@ async def get_owner(conn: AsyncConnection) -> dict | None: async def insert(conn: AsyncConnection, user: dict) -> dict: - sql: LiteralString = """INSERT INTO users (id, username, display_name, email, role) - VALUES (%(id)s, %(username)s, %(display_name)s, %(email)s, %(role)s) + sql: LiteralString = """INSERT INTO users (username, display_name, email, role) + VALUES (%(username)s, %(display_name)s, %(email)s, %(role)s) RETURNING *""" async with conn.cursor(row_factory=dict_row) as cur: await cur.execute(sql, user) return await cur.fetchone() +async def update(conn: AsyncConnection, id: int, data: dict) -> dict | None: + set_clauses = [] + params = {"_id": id} + for key, value in data.items(): + if key not in _UPDATABLE_COLUMNS: + raise ValueError(f"Invalid column: {key}") + set_clauses.append(f"{key} = %({key})s") + params[key] = value + if not set_clauses: + return await get_by_id(conn, id) + sql = f"UPDATE users SET {', '.join(set_clauses)} WHERE id = %(_id)s RETURNING *" + async with conn.cursor(row_factory=dict_row) as cur: + await cur.execute(sql, params) + return await cur.fetchone() + + async def count(conn: AsyncConnection) -> int: sql: LiteralString = "SELECT COUNT(*) FROM users" async with conn.cursor() as cur: diff --git a/shard_core/service/user.py b/shard_core/service/user.py index 0df36a76..c1b8e83f 100644 --- a/shard_core/service/user.py +++ b/shard_core/service/user.py @@ -1,19 +1,22 @@ import logging from shard_core.data_model.identity import Identity +from shard_core.data_model.user import Role, User from shard_core.database.connection import db_conn from shard_core.database import identities as db_identities -from shard_core.database import terminals as db_terminals from shard_core.database import users as db_users log = logging.getLogger(__name__) -async def ensure_owner_user(): - """Ensure the shard owner exists as a user and all terminals are bound to a user. +async def ensure_owner_user() -> User: + """Ensure the shard owner exists as a user. - The owner user is derived from the default identity; its id doubles as the - OIDC subject. Idempotent — called on every startup. + The owner user is created by the 0002 migration on shards that already + have an identity; on fresh shards it is created here, right after the + default identity. Also backfills the email for migration-created owners — + OIDC clients need an email-shaped identifier to auto-provision accounts. + Idempotent — called on every startup, before any pairing can happen. """ async with db_conn() as conn: owner = await db_users.get_owner(conn) @@ -23,12 +26,17 @@ async def ensure_owner_user(): owner = await db_users.insert( conn, { - "id": identity.id, "username": "owner", "display_name": identity.name, "email": identity.email or f"owner@{identity.domain}", - "role": "owner", + "role": Role.OWNER.value, }, ) log.info(f"created owner user {owner['id']}") - await db_terminals.set_user_id_where_null(conn, owner["id"]) + elif owner["email"] is None: + identity_row = await db_identities.get_default(conn) + identity = Identity(**identity_row) + owner = await db_users.update( + conn, owner["id"], {"email": f"owner@{identity.domain}"} + ) + return User(**owner) diff --git a/shard_core/web/public/pair.py b/shard_core/web/public/pair.py index 4a1b0b1f..a5dcc96a 100644 --- a/shard_core/web/public/pair.py +++ b/shard_core/web/public/pair.py @@ -40,10 +40,9 @@ async def add_terminal(code: str, terminal: InputTerminal, response: Response): ) from e async with db_conn() as conn: + # the owner user always exists here — created at startup, before pairing owner = await db_users.get_owner(conn) - new_terminal = Terminal.create( - terminal.name, user_id=owner["id"] if owner else None - ) + new_terminal = Terminal.create(terminal.name, user_id=owner["id"]) await db_terminals.insert(conn, new_terminal.model_dump()) is_first_terminal = await db_terminals.count(conn) == 1 diff --git a/tests/test_users.py b/tests/test_users.py index 089327c1..3e973bbb 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -1,6 +1,6 @@ from httpx import AsyncClient -from shard_core.data_model.terminal import Terminal +from shard_core.data_model.user import Role, User from shard_core.database.connection import db_conn from shard_core.database import identities as db_identities from shard_core.database import terminals as db_terminals @@ -12,16 +12,15 @@ async def test_ensure_owner_user_creates_owner_from_default_identity(db): default_identity = await identity.init_default_identity() - await user.ensure_owner_user() + owner = await user.ensure_owner_user() - async with db_conn() as conn: - owner = await db_users.get_owner(conn) - assert owner["id"] == default_identity.id - assert owner["role"] == "owner" - assert owner["username"] == "owner" - assert owner["display_name"] == default_identity.name - assert owner["email"] == f"owner@{default_identity.domain}" - assert owner["disabled"] is False + assert isinstance(owner, User) + assert isinstance(owner.id, int) + assert owner.role == Role.OWNER + assert owner.username == "owner" + assert owner.display_name == default_identity.name + assert owner.email == f"owner@{default_identity.domain}" + assert owner.disabled is False async def test_ensure_owner_user_keeps_identity_email(db): @@ -31,35 +30,40 @@ async def test_ensure_owner_user_keeps_identity_email(db): conn, default_identity.id, {"email": "max@freeshard.net"} ) - await user.ensure_owner_user() + owner = await user.ensure_owner_user() - async with db_conn() as conn: - owner = await db_users.get_owner(conn) - assert owner["email"] == "max@freeshard.net" + assert owner.email == "max@freeshard.net" async def test_ensure_owner_user_is_idempotent(db): await identity.init_default_identity() - await user.ensure_owner_user() - await user.ensure_owner_user() + first = await user.ensure_owner_user() + second = await user.ensure_owner_user() + assert first.id == second.id async with db_conn() as conn: assert await db_users.count(conn) == 1 -async def test_ensure_owner_user_backfills_terminal_user_id(db): - await identity.init_default_identity() - legacy_terminal = Terminal.create("legacy") +async def test_ensure_owner_user_backfills_missing_email(db): + """Migration-created owners (existing shards) start without a synthesized + email; ensure_owner_user fills it on the next startup.""" + default_identity = await identity.init_default_identity() async with db_conn() as conn: - await db_terminals.insert(conn, legacy_terminal.model_dump()) + await db_users.insert( + conn, + { + "username": "owner", + "display_name": default_identity.name, + "email": None, + "role": Role.OWNER.value, + }, + ) - await user.ensure_owner_user() + owner = await user.ensure_owner_user() - async with db_conn() as conn: - owner = await db_users.get_owner(conn) - row = await db_terminals.get_by_id(conn, legacy_terminal.id) - assert row["user_id"] == owner["id"] + assert owner.email == f"owner@{default_identity.domain}" async def test_pairing_binds_terminal_to_owner(app_client: AsyncClient): From 69233784a5373b881708b4d2361c573cb96aae07 Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Sat, 11 Jul 2026 18:08:30 +0000 Subject: [PATCH 3/4] fix(users): db module returns User objects via class_row Co-Authored-By: Claude Fable 5 --- shard_core/database/users.py | 20 +++++++++++--------- shard_core/service/user.py | 8 ++++---- shard_core/web/public/pair.py | 2 +- tests/test_users.py | 4 ++-- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/shard_core/database/users.py b/shard_core/database/users.py index aaa8e6fa..83843e74 100644 --- a/shard_core/database/users.py +++ b/shard_core/database/users.py @@ -1,35 +1,37 @@ from typing import LiteralString from psycopg import AsyncConnection -from psycopg.rows import dict_row +from psycopg.rows import class_row + +from shard_core.data_model.user import User _UPDATABLE_COLUMNS = {"username", "display_name", "email", "role", "disabled"} -async def get_by_id(conn: AsyncConnection, id: int) -> dict | None: +async def get_by_id(conn: AsyncConnection, id: int) -> User | None: sql: LiteralString = "SELECT * FROM users WHERE id = %s" - async with conn.cursor(row_factory=dict_row) as cur: + async with conn.cursor(row_factory=class_row(User)) as cur: await cur.execute(sql, (id,)) return await cur.fetchone() -async def get_owner(conn: AsyncConnection) -> dict | None: +async def get_owner(conn: AsyncConnection) -> User | None: sql: LiteralString = "SELECT * FROM users WHERE role = 'owner'" - async with conn.cursor(row_factory=dict_row) as cur: + async with conn.cursor(row_factory=class_row(User)) as cur: await cur.execute(sql) return await cur.fetchone() -async def insert(conn: AsyncConnection, user: dict) -> dict: +async def insert(conn: AsyncConnection, user: dict) -> User: sql: LiteralString = """INSERT INTO users (username, display_name, email, role) VALUES (%(username)s, %(display_name)s, %(email)s, %(role)s) RETURNING *""" - async with conn.cursor(row_factory=dict_row) as cur: + async with conn.cursor(row_factory=class_row(User)) as cur: await cur.execute(sql, user) return await cur.fetchone() -async def update(conn: AsyncConnection, id: int, data: dict) -> dict | None: +async def update(conn: AsyncConnection, id: int, data: dict) -> User | None: set_clauses = [] params = {"_id": id} for key, value in data.items(): @@ -40,7 +42,7 @@ async def update(conn: AsyncConnection, id: int, data: dict) -> dict | None: if not set_clauses: return await get_by_id(conn, id) sql = f"UPDATE users SET {', '.join(set_clauses)} WHERE id = %(_id)s RETURNING *" - async with conn.cursor(row_factory=dict_row) as cur: + async with conn.cursor(row_factory=class_row(User)) as cur: await cur.execute(sql, params) return await cur.fetchone() diff --git a/shard_core/service/user.py b/shard_core/service/user.py index c1b8e83f..b08c22f7 100644 --- a/shard_core/service/user.py +++ b/shard_core/service/user.py @@ -32,11 +32,11 @@ async def ensure_owner_user() -> User: "role": Role.OWNER.value, }, ) - log.info(f"created owner user {owner['id']}") - elif owner["email"] is None: + log.info(f"created owner user {owner.id}") + elif owner.email is None: identity_row = await db_identities.get_default(conn) identity = Identity(**identity_row) owner = await db_users.update( - conn, owner["id"], {"email": f"owner@{identity.domain}"} + conn, owner.id, {"email": f"owner@{identity.domain}"} ) - return User(**owner) + return owner diff --git a/shard_core/web/public/pair.py b/shard_core/web/public/pair.py index a5dcc96a..0b55974b 100644 --- a/shard_core/web/public/pair.py +++ b/shard_core/web/public/pair.py @@ -42,7 +42,7 @@ async def add_terminal(code: str, terminal: InputTerminal, response: Response): async with db_conn() as conn: # the owner user always exists here — created at startup, before pairing owner = await db_users.get_owner(conn) - new_terminal = Terminal.create(terminal.name, user_id=owner["id"]) + new_terminal = Terminal.create(terminal.name, user_id=owner.id) await db_terminals.insert(conn, new_terminal.model_dump()) is_first_terminal = await db_terminals.count(conn) == 1 diff --git a/tests/test_users.py b/tests/test_users.py index 3e973bbb..de6075d3 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -59,7 +59,7 @@ async def test_ensure_owner_user_backfills_missing_email(db): "email": None, "role": Role.OWNER.value, }, - ) + ) # simulates the 0002 SQL backfill (no synthesized email) owner = await user.ensure_owner_user() @@ -72,4 +72,4 @@ async def test_pairing_binds_terminal_to_owner(app_client: AsyncClient): async with db_conn() as conn: owner = await db_users.get_owner(conn) terminal = await db_terminals.get_by_name(conn, "T1") - assert terminal["user_id"] == owner["id"] + assert terminal["user_id"] == owner.id From 7d761f7ad883176d1ee74df1ccf1f9af9d81ef7a Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Sat, 11 Jul 2026 18:24:54 +0000 Subject: [PATCH 4/4] fix(users): bind TinyDB-migrated terminals to the owner user The legacy-JSON migration inserted terminals without user_id, violating the new NOT NULL constraint (caught by CI). The owner user is created from the just-migrated default identity, mirroring the 0002 backfill. Co-Authored-By: Claude Fable 5 --- shard_core/database/tinydb_migration.py | 28 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/shard_core/database/tinydb_migration.py b/shard_core/database/tinydb_migration.py index 1330558f..226b7df5 100644 --- a/shard_core/database/tinydb_migration.py +++ b/shard_core/database/tinydb_migration.py @@ -72,8 +72,9 @@ async def migrate_tinydb_data(): async with db_conn() as conn: await _migrate_kv_store(conn, data.get("_default", {})) await _migrate_identities(conn, data.get("identities", {})) + owner_id = await _ensure_owner_user(conn) await _migrate_installed_apps(conn, data.get("installed_apps", {})) - await _migrate_terminals(conn, data.get("terminals", {})) + await _migrate_terminals(conn, data.get("terminals", {}), owner_id) await _migrate_peers(conn, data.get("peers", {})) await _migrate_backups(conn, data.get("backups", {})) await _migrate_tours(conn, data.get("tours", {})) @@ -121,15 +122,32 @@ async def _migrate_installed_apps(conn: AsyncConnection, records: dict): log.info(f"migrated {len(records)} installed apps") -async def _migrate_terminals(conn: AsyncConnection, records: dict): +async def _ensure_owner_user(conn: AsyncConnection) -> int | None: + """Terminals require a user (NOT NULL); create the owner from the just- + migrated default identity, mirroring the 0002 migration's backfill.""" + cur = await conn.execute("SELECT id FROM users WHERE role = 'owner'") + row = await cur.fetchone() + if row: + return row[0] + cur = await conn.execute("""INSERT INTO users (username, display_name, email, role) + SELECT 'owner', COALESCE(name, 'Shard Owner'), email, 'owner' + FROM identities WHERE is_default = TRUE + RETURNING id""") + row = await cur.fetchone() + return row[0] if row else None + + +async def _migrate_terminals( + conn: AsyncConnection, records: dict, owner_id: int | None +): for record in records.values(): filtered = _filter_keys(record, _TERMINAL_COLUMNS) filtered.setdefault("icon", "unknown") await conn.execute( - """INSERT INTO terminals (id, name, icon, last_connection) - VALUES (%(id)s, %(name)s, %(icon)s, %(last_connection)s) + """INSERT INTO terminals (id, name, icon, last_connection, user_id) + VALUES (%(id)s, %(name)s, %(icon)s, %(last_connection)s, %(user_id)s) ON CONFLICT (id) DO NOTHING""", - filtered, + {**filtered, "user_id": owner_id}, ) log.info(f"migrated {len(records)} terminals")